@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
742 lines (741 loc) • 36.3 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const ModelGeometryUtilities_1 = __importStar(require("./ModelGeometryUtilities"));
const ImageCodec_1 = __importDefault(require("../core/ImageCodec"));
const CreatorToolsHost_1 = __importDefault(require("../app/CreatorToolsHost"));
/**
* Static class for rendering Minecraft geometry models to 2D SVG.
* Pure Node.js implementation - no browser/Playwright required.
*/
class Model2DRenderer {
/**
* Standard unit cube geometry (16x16x16 Minecraft units = 1 block).
* Can be used directly with renderToSvg for unit cube blocks.
*/
static UNIT_CUBE_GEOMETRY = {
description: {
identifier: "geometry.unit_cube",
texture_width: 16,
texture_height: 16,
visible_bounds_width: 1,
visible_bounds_height: 1,
visible_bounds_offset: [0, 0.5, 0],
},
bones: [
{
name: "body",
pivot: [0, 0, 0],
cubes: [
{
origin: [-8, 0, -8],
size: [16, 16, 16],
uv: [0, 0],
},
],
},
],
};
/**
* Render a unit cube block to SVG.
* Convenience method that uses the standard unit cube geometry.
*
* @param options Rendering options (same as renderToSvg, but geometry is provided)
* @returns SVG string
*/
static renderUnitCubeToSvg(options = {}) {
return this.renderToSvg(this.UNIT_CUBE_GEOMETRY, options);
}
/**
* Async version of renderUnitCubeToSvg that supports TGA textures.
*
* @param options Rendering options
* @returns SVG string
*/
static async renderUnitCubeToSvgAsync(options = {}) {
return this.renderToSvgAsync(this.UNIT_CUBE_GEOMETRY, options);
}
/**
* Decode PNG data to RGBA pixels using pngjs.
* This is a synchronous operation - fast and doesn't require a browser.
* Only works in Node.js environment - returns undefined in browser.
*
* @param pngData Raw PNG file bytes
* @returns Decoded texture pixels, or undefined if decoding fails
* @deprecated Use ImageCodec.decodePng() directly instead
*/
static decodePng(pngData) {
// Delegate to CreatorToolsHost's platform-specific PNG decoder
if (CreatorToolsHost_1.default.decodePng) {
try {
return CreatorToolsHost_1.default.decodePng(pngData);
}
catch (e) {
return undefined;
}
}
return undefined;
}
/**
* Decode TGA data to RGBA pixels.
* Uses ImageCodec for decoding.
*
* @param tgaData Raw TGA file bytes
* @returns Decoded texture pixels, or undefined if decoding fails
* @deprecated Use ImageCodec.decodeTga() directly instead
*/
static async decodeTga(tgaData) {
return await ImageCodec_1.default.decodeTga(tgaData);
}
/**
* Decode image data (PNG or TGA) to RGBA pixels.
* Uses ImageCodec for decoding.
*
* @param data Raw image file bytes
* @param fileType File extension ('png' or 'tga')
* @returns Decoded texture pixels, or undefined if decoding fails
* @deprecated Use ImageCodec.decode() directly instead
*/
static async decodeTexture(data, fileType) {
return await ImageCodec_1.default.decode(data, fileType);
}
/**
* Render a geometry model to SVG string.
* Uses average color sampling per face for efficient rendering.
*
* @param geometry The geometry definition to render
* @param options Rendering options
* @returns SVG string
*/
static renderToSvg(geometry, options = {}) {
const viewDirection = options.viewDirection || "front";
const outputWidth = options.outputWidth || 64;
const outputHeight = options.outputHeight || 64;
const padding = options.padding ?? 2;
const depthShading = options.depthShading ?? false;
const depthShadingIntensity = options.depthShadingIntensity ?? 0.3;
const includeSecondary = options.includeSecondaryFaces ?? false;
const fallbackColor = options.fallbackColor || "#808080";
const backgroundColor = options.backgroundColor;
const textureSampleResolution = Math.max(1, Math.min(16, options.textureSampleResolution ?? 1));
// Get texture dimensions from geometry or options
const texWidth = options.textureWidth || geometry.description?.texture_width || geometry.texturewidth || 64;
const texHeight = options.textureHeight || geometry.description?.texture_height || geometry.textureheight || 64;
// Resolve texture pixels - decode PNG or TGA if provided
// Note: TGA decoding is async, so for TGA use renderToSvgAsync or pre-decode with decodeTga()
let texturePixels = options.texturePixels;
if (!texturePixels && options.texturePngData) {
texturePixels = this.decodePng(options.texturePngData);
}
// Calculate geometry bounding box
const bounds = ModelGeometryUtilities_1.default.getGeometryBoundingBox(geometry);
// Calculate scale to fit in output dimensions
let scale = options.scale;
if (!scale) {
scale = this._calculateAutoScale(bounds, viewDirection, outputWidth, outputHeight, padding);
}
// Set up perspective projection if enabled
// This must be done BEFORE getProjectedFaces so perspective is applied at the vertex level
const perspectiveStrength = options.perspectiveStrength ?? 0;
const focalLength = options.focalLength ?? 100;
if (perspectiveStrength > 0) {
// Calculate reference depth (center of the model) based on view direction
let referenceDepth = 0;
switch (viewDirection) {
case "front":
referenceDepth = -(bounds.minZ + bounds.maxZ) / 2; // Front view: depth = -z
break;
case "back":
referenceDepth = (bounds.minZ + bounds.maxZ) / 2;
break;
case "left":
referenceDepth = -(bounds.minX + bounds.maxX) / 2;
break;
case "right":
referenceDepth = (bounds.minX + bounds.maxX) / 2;
break;
case "top":
referenceDepth = (bounds.minY + bounds.maxY) / 2;
break;
case "bottom":
referenceDepth = -(bounds.minY + bounds.maxY) / 2;
break;
}
ModelGeometryUtilities_1.default.perspectiveOptions = {
enabled: true,
strength: perspectiveStrength,
focalLength: focalLength,
referenceDepth: referenceDepth,
};
}
else {
ModelGeometryUtilities_1.default.perspectiveOptions.enabled = false;
}
// Get projected faces sorted by depth (perspective is now applied at vertex level)
const projectedFaces = ModelGeometryUtilities_1.default.getProjectedFaces(geometry, viewDirection, scale, includeSecondary);
// Reset perspective options after projection
ModelGeometryUtilities_1.default.perspectiveOptions.enabled = false;
// Calculate depth range for shading
let minDepth = Infinity;
let maxDepth = -Infinity;
for (const face of projectedFaces) {
minDepth = Math.min(minDepth, face.depth);
maxDepth = Math.max(maxDepth, face.depth);
}
const depthRange = maxDepth - minDepth || 1;
// Calculate centering offset
const projBounds = this._getProjectedBounds(projectedFaces);
const contentWidth = projBounds.maxX - projBounds.minX;
const contentHeight = projBounds.maxY - projBounds.minY;
const offsetX = (outputWidth - contentWidth) / 2 - projBounds.minX;
const offsetY = (outputHeight - contentHeight) / 2 - projBounds.minY;
// Check if this is an isometric view (faces rendered as polygons, not rectangles)
const isIsometric = (0, ModelGeometryUtilities_1.isIsometricView)(viewDirection);
// Generate SVG elements
const elements = [];
// Add background if specified
if (backgroundColor) {
elements.push(`<rect width="${outputWidth}" height="${outputHeight}" fill="${backgroundColor}"/>`);
}
// Render faces back to front
for (const face of projectedFaces) {
const x = face.x + offsetX;
const y = face.y + offsetY;
// Get face color from texture or use fallback
let fillColor = fallbackColor;
if (texturePixels) {
const uv = ModelGeometryUtilities_1.default.getCubeFaceUV(face.cube, face.face, texWidth, texHeight);
fillColor = this._sampleTextureAverageColor(texturePixels, uv.u, uv.v, uv.width, uv.height, texWidth, texHeight);
if (fillColor === "transparent") {
fillColor = fallbackColor;
}
}
// Apply depth shading
if (depthShading) {
// depthFactor: 0 = closest to camera, 1 = farthest from camera
const depthFactor = (face.depth - minDepth) / depthRange;
// shadeFactor: 1 = no darkening (closest), (1-intensity) = max darkening (farthest)
const shadeFactor = 1 - depthFactor * depthShadingIntensity;
fillColor = this._shadeColor(fillColor, shadeFactor);
}
// Add shape for this face
if (face.width > 0.1 && face.height > 0.1) {
if (isIsometric && face.vertices && face.vertices.length >= 4) {
if (textureSampleResolution > 1 && texturePixels) {
// Grid-based texture sampling for isometric views
// Interpolate between vertices to create sub-polygons
const uv = ModelGeometryUtilities_1.default.getCubeFaceUV(face.cube, face.face, texWidth, texHeight);
const uvCellWidth = uv.width / textureSampleResolution;
const uvCellHeight = uv.height / textureSampleResolution;
// Vertices are: 0=bottom-left, 1=bottom-right, 2=top-right, 3=top-left
const v0 = face.vertices[0];
const v1 = face.vertices[1];
const v2 = face.vertices[2];
const v3 = face.vertices[3];
for (let gy = 0; gy < textureSampleResolution; gy++) {
for (let gx = 0; gx < textureSampleResolution; gx++) {
// Calculate normalized coordinates for this cell
// The bilinear parameters must be flipped in both U and V to correctly map
// UV texture space to the face vertex positions.
//
// Why: Face corners are defined with v0/v1 at minY (bottom) and v2/v3 at maxY (top).
// In Minecraft's UV layout, the top of each face's UV region (gy=0) should map to
// the top of the face (maxY = v2/v3), and UV left should map to the face edge
// matching the adjacent face in the UV unwrap (typically maxX for north face).
// Without flipping, gy=0 maps to v0 (bottom) and gx=0 maps to the wrong horizontal
// edge, causing a 180° texture rotation on every face.
const u0 = 1 - (gx + 1) / textureSampleResolution;
const u1 = 1 - gx / textureSampleResolution;
const v0n = 1 - (gy + 1) / textureSampleResolution;
const v1n = 1 - gy / textureSampleResolution;
// Bilinear interpolation to get cell corners
const lerp = (a, b, t) => a + (b - a) * t;
const bilinear = (p0, p1, p2, p3, u, v) => ({
x: lerp(lerp(p0.x, p1.x, u), lerp(p3.x, p2.x, u), v),
y: lerp(lerp(p0.y, p1.y, u), lerp(p3.y, p2.y, u), v),
});
const cellV0 = bilinear(v0, v1, v2, v3, u0, v0n);
const cellV1 = bilinear(v0, v1, v2, v3, u1, v0n);
const cellV2 = bilinear(v0, v1, v2, v3, u1, v1n);
const cellV3 = bilinear(v0, v1, v2, v3, u0, v1n);
const cellU = uv.u + gx * uvCellWidth;
const cellV = uv.v + gy * uvCellHeight;
let cellColor = this._sampleTextureAverageColor(texturePixels, cellU, cellV, uvCellWidth, uvCellHeight, texWidth, texHeight);
if (cellColor === "transparent") {
continue; // Skip transparent cells
}
// Apply depth shading to cell
if (depthShading) {
const depthFactor = (face.depth - minDepth) / depthRange;
const shadeFactor = 1 - depthFactor * depthShadingIntensity;
cellColor = this._shadeColor(cellColor, shadeFactor);
}
const cellPoints = [cellV0, cellV1, cellV2, cellV3]
.map((cv) => `${(cv.x + offsetX).toFixed(2)},${(cv.y + offsetY).toFixed(2)}`)
.join(" ");
elements.push(`<polygon points="${cellPoints}" fill="${cellColor}"/>`);
}
}
}
else {
// Render as single polygon for isometric views (single color for clean appearance)
const points = face.vertices
.map((v) => `${(v.x + offsetX).toFixed(2)},${(v.y + offsetY).toFixed(2)}`)
.join(" ");
elements.push(`<polygon points="${points}" fill="${fillColor}"/>`);
}
}
else if (textureSampleResolution > 1 && texturePixels) {
// Grid-based texture sampling for higher quality orthographic rendering
const uv = ModelGeometryUtilities_1.default.getCubeFaceUV(face.cube, face.face, texWidth, texHeight);
const cellWidth = face.width / textureSampleResolution;
const cellHeight = face.height / textureSampleResolution;
const uvCellWidth = uv.width / textureSampleResolution;
const uvCellHeight = uv.height / textureSampleResolution;
for (let gy = 0; gy < textureSampleResolution; gy++) {
for (let gx = 0; gx < textureSampleResolution; gx++) {
const cellX = x + gx * cellWidth;
const cellY = y + gy * cellHeight;
const cellU = uv.u + gx * uvCellWidth;
const cellV = uv.v + gy * uvCellHeight;
let cellColor = this._sampleTextureAverageColor(texturePixels, cellU, cellV, uvCellWidth, uvCellHeight, texWidth, texHeight);
if (cellColor === "transparent") {
continue; // Skip transparent cells
}
// Apply depth shading to cell
if (depthShading) {
const depthFactor = (face.depth - minDepth) / depthRange;
const shadeFactor = 1 - depthFactor * depthShadingIntensity;
cellColor = this._shadeColor(cellColor, shadeFactor);
}
elements.push(`<rect x="${cellX.toFixed(2)}" y="${cellY.toFixed(2)}" ` +
`width="${(cellWidth + 0.5).toFixed(2)}" height="${(cellHeight + 0.5).toFixed(2)}" ` +
`fill="${cellColor}"/>`);
}
}
}
else {
// Render as rectangle for orthographic views (single color)
elements.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" ` +
`width="${face.width.toFixed(2)}" height="${face.height.toFixed(2)}" ` +
`fill="${fillColor}"/>`);
}
}
}
// Wrap in SVG
return (`<svg xmlns="http://www.w3.org/2000/svg" ` +
`viewBox="0 0 ${outputWidth} ${outputHeight}" ` +
`width="${outputWidth}" height="${outputHeight}">` +
elements.join("") +
`</svg>`);
}
/**
* Async version of renderToSvg that supports TGA textures.
* Use this when you have a TGA texture that needs to be decoded.
*
* @param geometry The geometry definition to render
* @param options Rendering options (can include textureTgaData or textureData+textureFileType)
* @returns SVG string
*/
static async renderToSvgAsync(geometry, options = {}) {
// Pre-decode TGA if provided
let resolvedOptions = { ...options };
if (!resolvedOptions.texturePixels) {
if (resolvedOptions.textureTgaData) {
const decoded = await this.decodeTga(resolvedOptions.textureTgaData);
if (decoded) {
resolvedOptions.texturePixels = decoded;
}
}
else if (resolvedOptions.textureData && resolvedOptions.textureFileType) {
const decoded = await this.decodeTexture(resolvedOptions.textureData, resolvedOptions.textureFileType);
if (decoded) {
resolvedOptions.texturePixels = decoded;
}
}
}
return this.renderToSvg(geometry, resolvedOptions);
}
/**
* Render a geometry model to SVG with per-pixel texture sampling.
* Creates a more detailed rendering by sampling texture for each output pixel.
*
* @param geometry The geometry definition to render
* @param options Rendering options
* @returns SVG string with detailed pixel rendering
*/
static renderToDetailedSvg(geometry, options = {}) {
const viewDirection = options.viewDirection || "front";
const outputWidth = options.outputWidth || 64;
const outputHeight = options.outputHeight || 64;
const padding = options.padding ?? 2;
const backgroundColor = options.backgroundColor;
const fallbackColor = options.fallbackColor || "#808080";
const includeSecondary = options.includeSecondaryFaces ?? false;
const depthShading = options.depthShading ?? false;
const depthShadingIntensity = options.depthShadingIntensity ?? 0.3;
// Get texture dimensions
const texWidth = options.textureWidth || geometry.description?.texture_width || geometry.texturewidth || 64;
const texHeight = options.textureHeight || geometry.description?.texture_height || geometry.textureheight || 64;
// Resolve texture pixels - decode PNG or TGA if provided
// Note: TGA decoding is async, so for TGA use renderToSvgAsync or pre-decode with decodeTga()
let texturePixels = options.texturePixels;
if (!texturePixels && options.texturePngData) {
texturePixels = this.decodePng(options.texturePngData);
}
// Calculate geometry bounding box and scale
const bounds = ModelGeometryUtilities_1.default.getGeometryBoundingBox(geometry);
const scale = options.scale || this._calculateAutoScale(bounds, viewDirection, outputWidth, outputHeight, padding);
// Set up perspective projection if enabled
// This must be done BEFORE getProjectedFaces so perspective is applied at the vertex level
const perspectiveStrength = options.perspectiveStrength ?? 0;
const focalLength = options.focalLength ?? 100;
if (perspectiveStrength > 0) {
// Calculate reference depth (center of the model) based on view direction
let referenceDepth = 0;
switch (viewDirection) {
case "front":
referenceDepth = -(bounds.minZ + bounds.maxZ) / 2;
break;
case "back":
referenceDepth = (bounds.minZ + bounds.maxZ) / 2;
break;
case "left":
referenceDepth = -(bounds.minX + bounds.maxX) / 2;
break;
case "right":
referenceDepth = (bounds.minX + bounds.maxX) / 2;
break;
case "top":
referenceDepth = (bounds.minY + bounds.maxY) / 2;
break;
case "bottom":
referenceDepth = -(bounds.minY + bounds.maxY) / 2;
break;
}
ModelGeometryUtilities_1.default.perspectiveOptions = {
enabled: true,
strength: perspectiveStrength,
focalLength: focalLength,
referenceDepth: referenceDepth,
};
}
else {
ModelGeometryUtilities_1.default.perspectiveOptions.enabled = false;
}
// Get projected faces sorted by depth (perspective is now applied at vertex level)
const projectedFaces = ModelGeometryUtilities_1.default.getProjectedFaces(geometry, viewDirection, scale, includeSecondary);
// Reset perspective options after projection
ModelGeometryUtilities_1.default.perspectiveOptions.enabled = false;
// Calculate depth range for shading
let minDepth = Infinity;
let maxDepth = -Infinity;
for (const face of projectedFaces) {
minDepth = Math.min(minDepth, face.depth);
maxDepth = Math.max(maxDepth, face.depth);
}
const depthRange = maxDepth - minDepth || 1;
// Calculate centering offset
const projBounds = this._getProjectedBounds(projectedFaces);
const contentWidth = projBounds.maxX - projBounds.minX;
const contentHeight = projBounds.maxY - projBounds.minY;
const offsetX = (outputWidth - contentWidth) / 2 - projBounds.minX;
const offsetY = (outputHeight - contentHeight) / 2 - projBounds.minY;
// Create pixel grid for output
const pixels = Array(outputHeight)
.fill(null)
.map(() => Array(outputWidth).fill(""));
// Render faces back to front (painter's algorithm)
for (const face of projectedFaces) {
if (face.width < 0.1 || face.height < 0.1)
continue;
const x = face.x + offsetX;
const y = face.y + offsetY;
const uv = ModelGeometryUtilities_1.default.getCubeFaceUV(face.cube, face.face, texWidth, texHeight);
// Iterate over output pixels covered by this face
const startX = Math.max(0, Math.floor(x));
const endX = Math.min(outputWidth, Math.ceil(x + face.width));
const startY = Math.max(0, Math.floor(y));
const endY = Math.min(outputHeight, Math.ceil(y + face.height));
for (let py = startY; py < endY; py++) {
for (let px = startX; px < endX; px++) {
// Calculate UV coordinate for this pixel
const relX = px - x;
const relY = py - y;
if (relX >= 0 && relX < face.width && relY >= 0 && relY < face.height) {
// Map pixel position to texture UV
const u = uv.u + (relX / face.width) * uv.width;
const v = uv.v + (relY / face.height) * uv.height;
let color = fallbackColor;
if (texturePixels) {
color = this._sampleTexturePixel(texturePixels, u, v, texWidth, texHeight);
}
// Apply depth shading based on face depth
if (depthShading && color !== "transparent") {
// depthFactor: 0 = closest to camera, 1 = farthest from camera
const depthFactor = (face.depth - minDepth) / depthRange;
// shadeFactor: 1 = no darkening (closest), (1-intensity) = max darkening (farthest)
const shadeFactor = 1 - depthFactor * depthShadingIntensity;
color = this._shadeColor(color, shadeFactor);
}
// Only overwrite if color has alpha
if (color !== "transparent") {
pixels[py][px] = color;
}
}
}
}
}
// Generate SVG from pixel grid
const elements = [];
// Add background if specified
if (backgroundColor) {
elements.push(`<rect width="${outputWidth}" height="${outputHeight}" fill="${backgroundColor}"/>`);
}
// Optimize SVG by grouping horizontal runs of same color
for (let y = 0; y < outputHeight; y++) {
let runStart = -1;
let runColor = "";
for (let x = 0; x <= outputWidth; x++) {
const color = x < outputWidth ? pixels[y][x] : "";
if (color !== runColor) {
// End current run
if (runStart >= 0 && runColor && runColor !== "transparent") {
const runWidth = x - runStart;
elements.push(`<rect x="${runStart}" y="${y}" width="${runWidth}" height="1" fill="${runColor}"/>`);
}
// Start new run
runStart = x;
runColor = color;
}
}
}
return (`<svg xmlns="http://www.w3.org/2000/svg" ` +
`viewBox="0 0 ${outputWidth} ${outputHeight}" ` +
`width="${outputWidth}" height="${outputHeight}">` +
elements.join("") +
`</svg>`);
}
/**
* Async version of renderToDetailedSvg that supports TGA textures.
* Use this when you have a TGA texture that needs to be decoded.
*
* @param geometry The geometry definition to render
* @param options Rendering options (can include textureTgaData or textureData+textureFileType)
* @returns SVG string with detailed pixel rendering
*/
static async renderToDetailedSvgAsync(geometry, options = {}) {
// Pre-decode TGA if provided
let resolvedOptions = { ...options };
if (!resolvedOptions.texturePixels) {
if (resolvedOptions.textureTgaData) {
const decoded = await this.decodeTga(resolvedOptions.textureTgaData);
if (decoded) {
resolvedOptions.texturePixels = decoded;
}
}
else if (resolvedOptions.textureData && resolvedOptions.textureFileType) {
const decoded = await this.decodeTexture(resolvedOptions.textureData, resolvedOptions.textureFileType);
if (decoded) {
resolvedOptions.texturePixels = decoded;
}
}
}
return this.renderToDetailedSvg(geometry, resolvedOptions);
}
/**
* Sample a single pixel from the texture.
*/
static _sampleTexturePixel(texture, u, v, texWidth, texHeight) {
// Map UV to texture pixels (nearest neighbor)
const texX = Math.floor((u / texWidth) * texture.width);
const texY = Math.floor((v / texHeight) * texture.height);
// Clamp to texture bounds
const x = Math.max(0, Math.min(texture.width - 1, texX));
const y = Math.max(0, Math.min(texture.height - 1, texY));
// Get pixel RGBA
const idx = (y * texture.width + x) * 4;
const r = texture.pixels[idx];
const g = texture.pixels[idx + 1];
const b = texture.pixels[idx + 2];
const a = texture.pixels[idx + 3];
if (a < 128) {
return "transparent";
}
return `rgb(${r},${g},${b})`;
}
/**
* Sample the average color from a texture region.
*/
static _sampleTextureAverageColor(texture, u, v, width, height, texWidth, texHeight) {
// Calculate texture pixel bounds
const startX = Math.floor((u / texWidth) * texture.width);
const startY = Math.floor((v / texHeight) * texture.height);
const endX = Math.ceil(((u + width) / texWidth) * texture.width);
const endY = Math.ceil(((v + height) / texHeight) * texture.height);
// Clamp to texture bounds
const x1 = Math.max(0, Math.min(texture.width - 1, startX));
const y1 = Math.max(0, Math.min(texture.height - 1, startY));
const x2 = Math.max(0, Math.min(texture.width, endX));
const y2 = Math.max(0, Math.min(texture.height, endY));
let totalR = 0, totalG = 0, totalB = 0, count = 0;
for (let py = y1; py < y2; py++) {
for (let px = x1; px < x2; px++) {
const idx = (py * texture.width + px) * 4;
const a = texture.pixels[idx + 3];
if (a > 128) {
// Only count opaque pixels
totalR += texture.pixels[idx];
totalG += texture.pixels[idx + 1];
totalB += texture.pixels[idx + 2];
count++;
}
}
}
if (count === 0) {
return "transparent";
}
const r = Math.round(totalR / count);
const g = Math.round(totalG / count);
const b = Math.round(totalB / count);
return `rgb(${r},${g},${b})`;
}
/**
* Apply shading to a color.
*/
static _shadeColor(color, factor) {
// Parse rgb(r,g,b) or #rrggbb
let r, g, b;
if (color.startsWith("rgb(")) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!match)
return color;
r = parseInt(match[1]);
g = parseInt(match[2]);
b = parseInt(match[3]);
}
else if (color.startsWith("#")) {
const hex = color.slice(1);
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
}
else {
return color;
}
r = Math.round(r * factor);
g = Math.round(g * factor);
b = Math.round(b * factor);
return `rgb(${r},${g},${b})`;
}
/**
* Calculate auto scale to fit geometry in output dimensions.
*/
static _calculateAutoScale(bounds, viewDirection, outputWidth, outputHeight, padding) {
// Get dimensions based on view direction
let modelWidth, modelHeight;
const xSpan = bounds.maxX - bounds.minX;
const ySpan = bounds.maxY - bounds.minY;
const zSpan = bounds.maxZ - bounds.minZ;
switch (viewDirection) {
case "front":
case "back":
modelWidth = xSpan;
modelHeight = ySpan;
break;
case "left":
case "right":
modelWidth = zSpan;
modelHeight = ySpan;
break;
case "top":
case "bottom":
modelWidth = xSpan;
modelHeight = zSpan;
break;
// Isometric views: calculate approximate projected dimensions
// After Y rotation (±45° or ±135°) and X tilt (30°):
// - Width ≈ |X * cos(Yrot) + Z * sin(Yrot)| - rotated horizontal span
// - Height ≈ Y * cos(30°) + depth * sin(30°) - Y compressed + depth contribution
case "iso-front-right":
case "iso-front-left":
case "iso-back-right":
case "iso-back-left": {
// For 45° Y rotation: cos(45°) = sin(45°) ≈ 0.707
// Projected width is approximately: X * 0.707 + Z * 0.707
const cos45 = 0.707;
modelWidth = xSpan * cos45 + zSpan * cos45;
// For 30° X tilt: cos(30°) ≈ 0.866, sin(30°) = 0.5
// Projected height ≈ Y * 0.866 + Z * 0.5 (Z contributes to visible height)
const cos30 = 0.866;
const sin30 = 0.5;
modelHeight = ySpan * cos30 + zSpan * sin30;
break;
}
default:
modelWidth = xSpan;
modelHeight = ySpan;
}
// Calculate scale to fit with padding
const availWidth = outputWidth - padding * 2;
const availHeight = outputHeight - padding * 2;
const scaleX = modelWidth > 0 ? availWidth / modelWidth : 1;
const scaleY = modelHeight > 0 ? availHeight / modelHeight : 1;
return Math.min(scaleX, scaleY);
}
/**
* Get the 2D bounding box of projected faces.
*/
static _getProjectedBounds(faces) {
if (faces.length === 0) {
return { minX: 0, maxX: 0, minY: 0, maxY: 0 };
}
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const face of faces) {
minX = Math.min(minX, face.x);
maxX = Math.max(maxX, face.x + face.width);
minY = Math.min(minY, face.y);
maxY = Math.max(maxY, face.y + face.height);
}
return { minX, maxX, minY, maxY };
}
}
exports.default = Model2DRenderer;