Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@ jobs:
node - << END
const m = require('spring-map-parser');
const p = new m.MapParser({ verbose: true, mipmapSize: 4, skipSmt: false });
p.parseMap("source/test/test_maps/barren_2.sd7").then(() => console.log("ok"));
p.parseMap("source/test/test_maps/hooked_1.1.1.sd7").then(() => console.log("ok"));
END
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import { MapParser } from "spring-map-parser";
await map.typeMap!.writeAsync("working-files/type.png");
await map.miniMap!.writeAsync("working-files/mini.png");
await map.textureMap!.scaleToFit(765, 300).quality(80).writeAsync("working-files/test.jpg");

// If the map has a skybox defined in mapinfo.lua (atmosphere.skyBox)
// it will be automatically converted from a DDS cubemap to an equirectangular (2:1) image
if (map.skybox) {
await map.skybox.writeAsync("working-files/skybox.png");
}
})();
```

Expand Down
121 changes: 121 additions & 0 deletions src/cubemap-to-equirectangular.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Jimp from "jimp";

/**
* Represents the six faces of a cubemap in the order:
* [+X (right), -X (left), +Y (top), -Y (bottom), +Z (front), -Z (back)]
*/
export type CubemapFaces = [Jimp, Jimp, Jimp, Jimp, Jimp, Jimp];

/**
* Converts a cubemap (6 square faces) to an equirectangular (2:1) projection.
*
* @param faces - Array of 6 Jimp images representing cubemap faces in order: +X, -X, +Y, -Y, +Z, -Z
* @param outputWidth - Width of output equirectangular image (height will be outputWidth/2)
* @returns Jimp image in equirectangular projection
*/
export function cubemapToEquirectangular(faces: CubemapFaces, outputWidth: number): Jimp {
const outputHeight = Math.floor(outputWidth / 2);

// Correct face orientations - flip faces 0, 1, 4, 5 vertically
const correctedFaces: Jimp[] = faces.map((face, i) => {
if (i === 0 || i === 1 || i === 4 || i === 5) {
return face.clone().flip(false, true);
}
return face;
});

const faceSize = correctedFaces[0].getWidth();
const output = new Jimp(outputWidth, outputHeight);

// For each pixel in the output equirectangular image
for (let y = 0; y < outputHeight; y++) {
for (let x = 0; x < outputWidth; x++) {
// Convert pixel coordinates to normalized coordinates [0, 1]
const u = x / outputWidth;
const v = y / outputHeight;

// Convert to spherical coordinates
const theta = u * 2 * Math.PI; // longitude
const phi = v * Math.PI; // latitude

// Convert spherical to cartesian coordinates
const cartX = -Math.sin(phi) * Math.sin(theta);
const cartY = Math.cos(phi);
const cartZ = -Math.sin(phi) * Math.cos(theta);

// Determine which face to sample from and get UV coordinates on that face
const { faceIndex, faceU, faceV } = cartesianToCubemapUV(cartX, cartY, cartZ);

// Convert face UV [0, 1] to pixel coordinates
const pixelU = Math.min(Math.floor(faceU * faceSize), faceSize - 1);
const pixelV = Math.min(Math.floor(faceV * faceSize), faceSize - 1);

// Sample the color from the appropriate face
const color = correctedFaces[faceIndex].getPixelColor(pixelU, pixelV);
output.setPixelColor(color, x, y);
}
}

return output;
}

/**
* Converts cartesian coordinates to cubemap face index and UV coordinates.
*
* @param x - X coordinate in cartesian space
* @param y - Y coordinate in cartesian space
* @param z - Z coordinate in cartesian space
* @returns Object containing face index (0-5) and UV coordinates on that face
*/
function cartesianToCubemapUV(x: number, y: number, z: number): { faceIndex: number; faceU: number; faceV: number } {
const absX = Math.abs(x);
const absY = Math.abs(y);
const absZ = Math.abs(z);

const isXPositive = x > 0;
const isYPositive = y > 0;
const isZPositive = z > 0;

let faceIndex: number;
let uc: number;
let vc: number;

// Determine which face we're sampling from
if (isXPositive && absX >= absY && absX >= absZ) {
// +X face (right)
faceIndex = 0;
uc = -z / absX;
vc = y / absX;
} else if (!isXPositive && absX >= absY && absX >= absZ) {
// -X face (left)
faceIndex = 1;
uc = z / absX;
vc = y / absX;
} else if (isYPositive && absY >= absX && absY >= absZ) {
// +Y face (top)
faceIndex = 2;
uc = x / absY;
vc = z / absY;
} else if (!isYPositive && absY >= absX && absY >= absZ) {
// -Y face (bottom)
faceIndex = 3;
uc = x / absY;
vc = -z / absY;
} else if (isZPositive && absZ >= absX && absZ >= absY) {
// +Z face (front)
faceIndex = 4;
uc = x / absZ;
vc = y / absZ;
} else {
// -Z face (back)
faceIndex = 5;
uc = -x / absZ;
vc = y / absZ;
}

// Convert from [-1, 1] to [0, 1]
const faceU = 0.5 * (uc + 1.0);
const faceV = 0.5 * (vc + 1.0);

return { faceIndex, faceU, faceV };
}
5 changes: 3 additions & 2 deletions src/map-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export type SpringMap = {
miniMap: Jimp;
metalMap: Jimp;
typeMap: Jimp;
resources?: Record<string, Jimp | undefined>
skybox?: Jimp;
resources?: Record<string, Jimp | undefined>;
}

export type SMD = {
Expand Down Expand Up @@ -268,4 +269,4 @@ export type Custom = {
export const defaultWaterOptions: Optionals<WaterOptions> = {
rgbColor: { r: 33, g: 35, b: 77 },
rgbModifier: { r: 1, g: 1.2, b: 1 }
};
};
55 changes: 53 additions & 2 deletions src/map-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import * as os from "os";
import * as path from "path";

import { BufferStream } from "./buffer-stream";
import { cubemapToEquirectangular } from "./cubemap-to-equirectangular";
import { defaultWaterOptions, MapInfo, SMD, SMF, SpringMap, WaterOptions } from "./map-model";
import { parseDDSCubemap } from "./parse-dds-cubemap";
import { parseDxt } from "./parse-dxt";

/* eslint-disable @typescript-eslint/no-require-imports */
Expand Down Expand Up @@ -48,6 +50,11 @@ export interface MapParserConfig {
* @default true
*/
water: boolean;
/**
* Parse skybox image file from mapinfo->atmosphere->skybox
* @default false
*/
parseSkybox: boolean;
/**
* Parse resource image files from mapinfo->resources
* @default false
Expand All @@ -61,6 +68,7 @@ const mapParserDefaultConfig: Partial<MapParserConfig> = {
skipSmt: false,
path7za: sevenBin.path7za,
water: true,
parseSkybox: false,
parseResources: false
};

Expand Down Expand Up @@ -130,6 +138,11 @@ export class MapParser {
resources = await this.parseResources(tempArchiveDir, mapInfo?.resources);
}

let skybox: Jimp | undefined;
if (this.config.parseSkybox && mapInfo?.atmosphere?.skyBox) {
skybox = await this.parseSkybox(tempArchiveDir, mapInfo.atmosphere.skyBox);
}

await this.cleanup(tempArchiveDir);

return {
Expand All @@ -146,7 +159,8 @@ export class MapParser {
miniMap: smf.miniMap,
typeMap: smf.typeMap,
textureMap: smt,
resources
resources,
skybox
};
} catch (err) {
await this.cleanup(tempArchiveDir);
Expand Down Expand Up @@ -533,7 +547,7 @@ export class MapParser {
for (const key in resourceFiles) {
const value = resourceFiles[key];

if (typeof value !== "string") {
if (["splatDetailTex", "detailTex"].includes(key) || typeof value !== "string") {
continue;
}

Expand Down Expand Up @@ -569,6 +583,43 @@ export class MapParser {
return resources;
}

protected async parseSkybox(mapArchiveDir: string, skyboxPath: string, targetWidth: number = 4096): Promise<Jimp | undefined> {
if (this.config.verbose) {
console.log(`Parsing skybox: ${skyboxPath}`);
}

const filename = path.join(mapArchiveDir, "maps", skyboxPath);

try {
if (!existsSync(filename)) {
console.warn(`Skybox file not found: ${filename}`);
return undefined;
}

if (path.extname(filename) !== ".dds") {
console.warn(`Skybox must be a .dds file, got: ${filename}`);
return undefined;
}

const skyboxBuffer = await fs.readFile(filename);

// Parse the DDS cubemap into 6 faces
const faces = await parseDDSCubemap(skyboxBuffer);

// Convert cubemap to equirectangular projection (2:1 aspect ratio)
const equirectangular = cubemapToEquirectangular(faces, targetWidth);

if (this.config.verbose) {
console.log(`Converted skybox to equirectangular ${equirectangular.getWidth()}x${equirectangular.getHeight()}`);
}

return equirectangular;
} catch (err) {
console.error(`Error parsing skybox: ${filename}`, err);
return undefined;
}
}

protected async cleanup(tmpDir: string) {
if (this.config.verbose) {
console.log(`Cleaning up temp dir: ${tmpDir}`);
Expand Down
Loading