@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
1,021 lines • 51.2 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* ModelDesignUtilities
*
* Converts MCP model design format to Minecraft .geo.json format
* and generates texture atlases from per-face SVG/color specifications.
*/
const IMcpModelDesign_1 = require("./IMcpModelDesign");
const TexturedRectangleGenerator_1 = __importDefault(require("./TexturedRectangleGenerator"));
/**
* Default pixels per Minecraft unit.
* Standard Minecraft textures use 1 pixel per unit (16x16 texture for a 16-unit cube).
* We default to 2 pixels per unit (32x32 texture per block) for HD quality.
*/
const DEFAULT_PIXELS_PER_UNIT = 2;
/**
* Utility class for working with MCP model designs
*/
class ModelDesignUtilities {
/**
* Calculate the bounding box of a model design.
* Iterates through all bones and cubes to find the min/max extents.
*/
static calculateModelBounds(design) {
let minX = Infinity;
let minY = Infinity;
let minZ = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let maxZ = -Infinity;
for (const bone of design.bones) {
for (const cube of bone.cubes) {
const [ox, oy, oz] = cube.origin;
const [sx, sy, sz] = cube.size;
// Cube extends from origin to origin + size
minX = Math.min(minX, ox);
minY = Math.min(minY, oy);
minZ = Math.min(minZ, oz);
maxX = Math.max(maxX, ox + sx);
maxY = Math.max(maxY, oy + sy);
maxZ = Math.max(maxZ, oz + sz);
}
}
// Handle empty models
if (minX === Infinity) {
minX = minY = minZ = 0;
maxX = maxY = maxZ = 1;
}
const width = maxX - minX;
const height = maxY - minY;
const depth = maxZ - minZ;
const maxDimension = Math.max(width, height, depth);
return {
minX,
minY,
minZ,
maxX,
maxY,
maxZ,
maxDimension,
center: {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
z: (minZ + maxZ) / 2,
},
};
}
/**
* Parse a color string or object to RGBA values (0-255)
*/
static parseColor(color) {
if (!color) {
return { r: 255, g: 255, b: 255, a: 255 };
}
if (typeof color === "object") {
return {
r: Math.max(0, Math.min(255, Math.round(color.r))),
g: Math.max(0, Math.min(255, Math.round(color.g))),
b: Math.max(0, Math.min(255, Math.round(color.b))),
a: color.a !== undefined ? Math.max(0, Math.min(255, Math.round(color.a))) : 255,
};
}
// Parse hex color
if (color.startsWith("#")) {
const hex = color.slice(1);
if (hex.length === 3) {
// Short form #RGB
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16),
a: 255,
};
}
else if (hex.length === 6) {
// Full form #RRGGBB
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: 255,
};
}
else if (hex.length === 8) {
// With alpha #RRGGBBAA
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: parseInt(hex.slice(6, 8), 16),
};
}
}
// Parse rgb/rgba format
const rgbaMatch = color.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/i);
if (rgbaMatch) {
return {
r: parseInt(rgbaMatch[1], 10),
g: parseInt(rgbaMatch[2], 10),
b: parseInt(rgbaMatch[3], 10),
a: rgbaMatch[4] ? Math.round(parseFloat(rgbaMatch[4]) * 255) : 255,
};
}
// Default to white if parsing fails
return { r: 255, g: 255, b: 255, a: 255 };
}
/**
* Convert color to hex string
*/
static colorToHex(color) {
const r = color.r.toString(16).padStart(2, "0");
const g = color.g.toString(16).padStart(2, "0");
const b = color.b.toString(16).padStart(2, "0");
return `#${r}${g}${b}`;
}
/**
* Resolve face content by looking up textureId references.
* Returns the actual svg/color content to render.
* Priority: textureId > svg > color
*
* @param faceContent The face content which may contain a textureId reference
* @param textures The texture dictionary from the model design
* @param warnings Array to collect any warnings (e.g., missing texture references)
* @returns Resolved content with svg/color, or undefined if face should be transparent
*/
static resolveFaceContent(faceContent, textures, warnings) {
if (!faceContent) {
return undefined;
}
// If textureId is specified, look it up
if (faceContent.textureId) {
if (!textures) {
warnings.push(`Face references textureId "${faceContent.textureId}" but no textures dictionary is defined in the model.`);
// Fall through to check for inline content
}
else {
const texture = textures[faceContent.textureId];
if (!texture) {
warnings.push(`Face references textureId "${faceContent.textureId}" which is not defined in the textures dictionary.`);
// Fall through to check for inline content
}
else {
// Successfully resolved texture reference
// Priority: background > noise > color (face overrides texture)
const resolvedBackground = faceContent.background || texture.background;
const resolvedNoise = faceContent.noise || texture.noise;
const resolvedColor = texture.color;
// Merge pixelArt: face pixelArt comes after texture pixelArt (renders on top)
const resolvedPixelArt = [...(texture.pixelArt || []), ...(faceContent.pixelArt || [])];
// Merge effects: face effects override texture effects per-property
const resolvedEffects = texture.effects || faceContent.effects
? {
...texture.effects,
...faceContent.effects,
}
: undefined;
// Normalize to background if possible (for consistent handling downstream)
let normalizedBackground = resolvedBackground;
if (!normalizedBackground && resolvedNoise) {
normalizedBackground = (0, IMcpModelDesign_1.convertNoiseConfigToTexturedRectangle)(resolvedNoise);
}
else if (!normalizedBackground && resolvedColor) {
normalizedBackground = (0, IMcpModelDesign_1.colorToTexturedRectangle)(resolvedColor);
}
return {
color: resolvedColor,
svg: texture.svg,
noise: resolvedNoise,
background: normalizedBackground,
pixelArt: resolvedPixelArt.length > 0 ? resolvedPixelArt : undefined,
effects: resolvedEffects,
rotation: faceContent.rotation,
sourceTextureId: faceContent.textureId,
};
}
}
}
// No textureId or failed lookup - use inline content
// Priority: background > noise > color
if (faceContent.svg ||
faceContent.color ||
faceContent.noise ||
faceContent.background ||
faceContent.pixelArt ||
faceContent.effects) {
// Normalize to background if possible
let normalizedBackground = faceContent.background;
if (!normalizedBackground && faceContent.noise) {
normalizedBackground = (0, IMcpModelDesign_1.convertNoiseConfigToTexturedRectangle)(faceContent.noise);
}
else if (!normalizedBackground && faceContent.color) {
normalizedBackground = (0, IMcpModelDesign_1.colorToTexturedRectangle)(faceContent.color);
}
return {
color: faceContent.color,
svg: faceContent.svg,
noise: faceContent.noise,
background: normalizedBackground,
pixelArt: faceContent.pixelArt,
effects: faceContent.effects,
rotation: faceContent.rotation,
};
}
// No content at all
return undefined;
}
/**
* Generate a content hash for a resolved face content.
* Used for texture deduplication - faces with identical content can share atlas regions.
* Note: rotation is NOT included in hash since it's applied at UV time, not texture time.
* Note: backgrounds with undefined/random seed create unique textures per face (not deduplicated).
*/
static getContentHash(content) {
// If there's a sourceTextureId and no face-specific overrides, use that as a quick hash
if (content.sourceTextureId && !content.noise && !content.background) {
return `texref:${content.sourceTextureId}`;
}
// Build hash from all content components
const parts = [];
// Prefer background over legacy noise/color for hashing
if (content.background) {
// Solid fills are deterministic - they don't need seed for deduplication
// Noise-based fills need a seed - if not provided, use random to ensure unique textures per face
const isSolid = content.background.type === "solid";
const bgHashParts = [`type:${content.background.type}`, `colors:${JSON.stringify(content.background.colors)}`];
// Only include noise-related params for non-solid types
if (!isSolid) {
bgHashParts.push(`factor:${content.background.factor ?? 0.2}`);
bgHashParts.push(`pixelSize:${content.background.pixelSize ?? 1}`);
bgHashParts.push(`scale:${content.background.scale ?? 4}`);
// For noise types: explicit seed means deterministic (can deduplicate), no seed means unique per face
bgHashParts.push(content.background.seed !== undefined ? `seed:${content.background.seed}` : `seed:${Math.random()}`);
}
parts.push(`bg:{${bgHashParts.join(",")}}`);
}
else if (content.noise) {
// Legacy noise support
const noiseHash = [
`pattern:${content.noise.pattern || "random"}`,
`colors:${JSON.stringify(content.noise.colors)}`,
`factor:${content.noise.factor ?? 0.5}`,
`pixelSize:${content.noise.pixelSize ?? 1}`,
`scale:${content.noise.scale ?? 4}`,
content.noise.seed !== undefined ? `seed:${content.noise.seed}` : `seed:${Math.random()}`,
].join(",");
parts.push(`noise:{${noiseHash}}`);
}
if (content.svg) {
parts.push(`svg:${content.svg}`);
}
if (content.color && !content.background) {
// Only include color if no background (for legacy support)
if (typeof content.color === "string") {
parts.push(`color:${content.color}`);
}
else {
parts.push(`color:rgba(${content.color.r},${content.color.g},${content.color.b},${content.color.a ?? 255})`);
}
}
// Pixel art is deterministic - include in hash for deduplication
if (content.pixelArt && content.pixelArt.length > 0) {
// Create a stable hash of the pixel art configuration
const pixelArtHash = content.pixelArt
.map((pa, i) => {
const lines = pa.lines.join("|");
const palette = Object.entries(pa.palette || {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v.hex || `${v.r},${v.g},${v.b},${v.a ?? 255}`}`)
.join(";");
return `[${i}:x${pa.x || 0}y${pa.y || 0}:${lines}:${palette}]`;
})
.join("");
parts.push(`pixelArt:${pixelArtHash}`);
}
if (content.sourceTextureId) {
parts.push(`texref:${content.sourceTextureId}`);
}
return parts.length > 0 ? parts.join("|") : "empty";
}
/**
* Get the effective pixels per unit for a design.
* Returns the design's pixelsPerUnit if specified, otherwise DEFAULT_PIXELS_PER_UNIT.
*/
static getPixelsPerUnit(design) {
return design.pixelsPerUnit ?? DEFAULT_PIXELS_PER_UNIT;
}
/**
* Calculate the texture size needed for a face based on cube dimensions.
* @param cubeSize The cube dimensions [width, height, depth] in Minecraft units
* @param faceName The face to calculate texture size for
* @param pixelsPerUnit Pixels per Minecraft unit (default: DEFAULT_PIXELS_PER_UNIT)
*/
static getFaceTextureSize(cubeSize, faceName, pixelsPerUnit = DEFAULT_PIXELS_PER_UNIT) {
// Face dimensions based on cube size
// Pixels = units × pixelsPerUnit
// north/south: width x height
// east/west: depth x height
// up/down: width x depth
const [width, height, depth] = cubeSize;
switch (faceName) {
case "north":
case "south":
return {
width: Math.max(1, Math.round(width * pixelsPerUnit)),
height: Math.max(1, Math.round(height * pixelsPerUnit)),
};
case "east":
case "west":
return {
width: Math.max(1, Math.round(depth * pixelsPerUnit)),
height: Math.max(1, Math.round(height * pixelsPerUnit)),
};
case "up":
case "down":
return {
width: Math.max(1, Math.round(width * pixelsPerUnit)),
height: Math.max(1, Math.round(depth * pixelsPerUnit)),
};
default:
return { width: 1, height: 1 };
}
}
/**
* Check if a texture size is sufficient for a design by doing a dry-run pack.
* Returns true if the texture needs to be larger.
* Takes into account texture deduplication - identical textures at same size share atlas space.
*/
static _checkNeedsLargerTexture(design, textureSize) {
let currentX = 0;
let currentY = 0;
let rowHeight = 0;
const dummyWarnings = [];
const pixelsPerUnit = this.getPixelsPerUnit(design);
// Track already-packed textures for deduplication
const packedTextures = new Set();
for (const bone of design.bones) {
for (const cube of bone.cubes) {
for (const faceName of ["north", "south", "east", "west", "up", "down"]) {
const faceContent = cube.faces[faceName];
if (!faceContent) {
continue;
}
// Resolve texture references
const resolvedContent = this.resolveFaceContent(faceContent, design.textures, dummyWarnings);
if (!resolvedContent) {
continue;
}
const faceSize = this.getFaceTextureSize(cube.size, faceName, pixelsPerUnit);
// Check for deduplication - if we've already packed this exact content at this size, skip
const contentHash = this.getContentHash(resolvedContent);
const dedupeKey = `${contentHash}|${faceSize.width}x${faceSize.height}`;
if (packedTextures.has(dedupeKey)) {
continue; // This texture will be reused, no new space needed
}
packedTextures.add(dedupeKey);
// Check if we need to wrap to next row
if (currentX + faceSize.width > textureSize[0]) {
currentX = 0;
currentY += rowHeight;
rowHeight = 0;
}
// Check if we're out of vertical space
if (currentY + faceSize.height > textureSize[1]) {
return true; // Texture is too small
}
currentX += faceSize.width;
rowHeight = Math.max(rowHeight, faceSize.height);
}
}
}
return false; // Texture size is sufficient
}
/**
* Convert an MCP model design to Minecraft geometry JSON format
*/
static convertToGeometry(design) {
const warnings = [];
const atlasRegions = [];
// Ensure identifier has geometry. prefix
let identifier = design.identifier;
if (!identifier.startsWith("geometry.")) {
identifier = `geometry.${identifier}`;
}
// First pass: calculate the required texture size by measuring unique faces
// (accounting for deduplication - identical content at same size only counts once)
let maxFaceWidth = 0;
let maxFaceHeight = 0;
const uniqueFaces = new Set();
const pixelsPerUnit = this.getPixelsPerUnit(design);
for (const bone of design.bones) {
for (const cube of bone.cubes) {
for (const faceName of ["north", "south", "east", "west", "up", "down"]) {
const faceContent = cube.faces[faceName];
if (faceContent) {
const resolvedContent = this.resolveFaceContent(faceContent, design.textures, warnings);
if (!resolvedContent) {
continue;
}
const faceSize = this.getFaceTextureSize(cube.size, faceName, pixelsPerUnit);
// Check for deduplication
const contentHash = this.getContentHash(resolvedContent);
const dedupeKey = `${contentHash}|${faceSize.width}x${faceSize.height}`;
if (!uniqueFaces.has(dedupeKey)) {
uniqueFaces.add(dedupeKey);
maxFaceWidth = Math.max(maxFaceWidth, faceSize.width);
maxFaceHeight = Math.max(maxFaceHeight, faceSize.height);
}
}
}
}
}
// Calculate optimal texture size based on actual content
// Start with the minimum power of 2 that can fit the largest single face
const minDimension = Math.max(maxFaceWidth, maxFaceHeight);
let optimalSize = Math.pow(2, Math.ceil(Math.log2(Math.max(16, minDimension))));
// Verify optimal size fits all faces, expand if needed
while (this._checkNeedsLargerTexture(design, [optimalSize, optimalSize]) && optimalSize < 4096) {
optimalSize = optimalSize * 2;
}
// Use optimal size if no textureSize specified, or if specified size is larger than optimal
// Only expand beyond optimal if the specified size is too small
let textureSize;
if (design.textureSize) {
// If specified size is too small, expand to fit
if (this._checkNeedsLargerTexture(design, design.textureSize)) {
textureSize = [optimalSize, optimalSize];
warnings.push(`Specified texture size ${design.textureSize[0]}x${design.textureSize[1]} was too small. ` +
`Auto-expanded to ${textureSize[0]}x${textureSize[1]}.`);
}
else {
// Use optimal size (smaller is better for efficiency, but respect user minimum)
textureSize = [
Math.min(design.textureSize[0], optimalSize),
Math.min(design.textureSize[1], optimalSize),
];
// Re-verify the shrunk size works
while (this._checkNeedsLargerTexture(design, textureSize) && textureSize[0] < 4096) {
textureSize = [textureSize[0] * 2, textureSize[1] * 2];
}
}
}
else {
textureSize = [optimalSize, optimalSize];
}
// Collect all face regions and calculate atlas layout
let currentX = 0;
let currentY = 0;
let rowHeight = 0;
const padding = 0; // No padding between faces for clean tiling
// Map for texture deduplication: contentHash+size -> atlas region index
// Only faces with identical content AND identical size can share a region
const textureDeduplicationMap = new Map();
for (let boneIndex = 0; boneIndex < design.bones.length; boneIndex++) {
const bone = design.bones[boneIndex];
for (let cubeIndex = 0; cubeIndex < bone.cubes.length; cubeIndex++) {
const cube = bone.cubes[cubeIndex];
const faces = cube.faces;
for (const faceName of ["north", "south", "east", "west", "up", "down"]) {
const faceContent = faces[faceName];
if (!faceContent) {
continue; // Skip undefined faces
}
// Resolve texture references
const resolvedContent = this.resolveFaceContent(faceContent, design.textures, warnings);
if (!resolvedContent) {
continue; // No content to render
}
const faceSize = this.getFaceTextureSize(cube.size, faceName, pixelsPerUnit);
// Check for deduplication opportunity:
// Faces with identical content AND identical size can share a texture region
const contentHash = this.getContentHash(resolvedContent);
const dedupeKey = `${contentHash}|${faceSize.width}x${faceSize.height}`;
const existingRegionIndex = textureDeduplicationMap.get(dedupeKey);
// Generate context string for deterministic noise seeding
const contextString = `${bone.name}:cube${cubeIndex}:${faceName}`;
if (existingRegionIndex !== undefined) {
// Reuse existing atlas region - create a new region entry that points to same UV coordinates
const existingRegion = atlasRegions[existingRegionIndex];
atlasRegions.push({
x: existingRegion.x,
y: existingRegion.y,
width: faceSize.width,
height: faceSize.height,
content: {
// Store resolved content
color: resolvedContent.color,
svg: resolvedContent.svg,
noise: resolvedContent.noise,
background: resolvedContent.background,
pixelArt: resolvedContent.pixelArt,
effects: resolvedContent.effects,
rotation: resolvedContent.rotation,
},
faceName,
cubeIndex,
boneIndex,
contextString: existingRegion.contextString, // Reuse original context for consistent noise
isDuplicate: true, // Mark as duplicate to skip rendering in atlas SVG
});
continue;
}
// Check if we need to wrap to next row
if (currentX + faceSize.width > textureSize[0]) {
currentX = 0;
currentY += rowHeight + padding;
rowHeight = 0;
}
// Check if we're out of vertical space
if (currentY + faceSize.height > textureSize[1]) {
warnings.push(`Texture atlas overflow: face ${faceName} of cube ${cubeIndex} in bone ${bone.name} ` +
`doesn't fit in ${textureSize[0]}x${textureSize[1]} texture. Consider increasing textureSize.`);
continue;
}
// Store the region index for potential reuse
const regionIndex = atlasRegions.length;
textureDeduplicationMap.set(dedupeKey, regionIndex);
atlasRegions.push({
x: currentX,
y: currentY,
width: faceSize.width,
height: faceSize.height,
content: {
// Store resolved content (textureId has been looked up)
color: resolvedContent.color,
svg: resolvedContent.svg,
noise: resolvedContent.noise,
background: resolvedContent.background,
pixelArt: resolvedContent.pixelArt,
effects: resolvedContent.effects,
rotation: resolvedContent.rotation,
},
faceName,
cubeIndex,
boneIndex,
contextString,
});
currentX += faceSize.width + padding;
rowHeight = Math.max(rowHeight, faceSize.height);
}
}
}
// Crop texture height to actual used content
// Width stays power-of-2 for compatibility, but height is cropped to save space
const actualUsedHeight = currentY + rowHeight;
if (actualUsedHeight > 0 && actualUsedHeight < textureSize[1]) {
textureSize = [textureSize[0], actualUsedHeight];
}
// Convert bones to geometry format
const geoBones = design.bones.map((bone, boneIndex) => {
const cubes = bone.cubes.map((cube, cubeIndex) => {
// Build per-face UV from atlas regions
const uvFaces = {};
for (const faceName of ["north", "south", "east", "west", "up", "down"]) {
const region = atlasRegions.find((r) => r.boneIndex === boneIndex && r.cubeIndex === cubeIndex && r.faceName === faceName);
if (region) {
uvFaces[faceName] = {
uv: [region.x, region.y],
uv_size: [region.width, region.height],
};
}
}
const geoCube = {
origin: cube.origin,
size: cube.size,
uv: uvFaces,
};
// Note: Cube-level pivot/rotation is NOT supported in the MCP schema.
// All rotation is done at the bone level for simplicity.
if (cube.inflate !== undefined) {
geoCube.inflate = cube.inflate;
}
if (cube.mirror !== undefined) {
geoCube.mirror = cube.mirror;
}
return geoCube;
});
const geoBone = {
name: bone.name,
pivot: bone.pivot || [0, 0, 0],
cubes,
};
if (bone.parent) {
geoBone.parent = bone.parent;
}
if (bone.rotation) {
geoBone.rotation = bone.rotation;
}
return geoBone;
});
// Auto-compute visible bounds from cube extents when not explicitly provided.
// The renderer uses visible_bounds_* to decide whether to draw the entity;
// a default of 1×1 culls any model larger than ~1 block, making it invisible.
// Bedrock units are 16 per block, so we convert max(|extent|)/16 → blocks
// and add a small margin. This was a recurring "invisible entity" cause.
let computedBoundsWidth;
let computedBoundsHeight;
let computedBoundsOffsetY;
if (!design.visibleBoundsSize) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const bone of design.bones) {
for (const cube of bone.cubes) {
const [ox, oy, oz] = cube.origin;
const [sx, sy, sz] = cube.size;
minX = Math.min(minX, ox);
maxX = Math.max(maxX, ox + sx);
minY = Math.min(minY, oy);
maxY = Math.max(maxY, oy + sy);
minZ = Math.min(minZ, oz);
maxZ = Math.max(maxZ, oz + sz);
}
}
if (Number.isFinite(minX) && Number.isFinite(maxX)) {
const widthUnits = Math.max(maxX - minX, maxZ - minZ);
const heightUnits = maxY - minY;
// Convert from units (16 per block) to blocks; add 25% margin; round up to >=1.
computedBoundsWidth = Math.max(1, Math.ceil((widthUnits / 16) * 1.25));
computedBoundsHeight = Math.max(1, Math.ceil((heightUnits / 16) * 1.25));
computedBoundsOffsetY = (minY + maxY) / 2 / 16;
}
}
// Build the geometry object
const geometry = {
description: {
identifier,
texture_width: textureSize[0],
texture_height: textureSize[1],
visible_bounds_width: design.visibleBoundsSize ? design.visibleBoundsSize[0] : (computedBoundsWidth ?? 1),
visible_bounds_height: design.visibleBoundsSize ? design.visibleBoundsSize[1] : (computedBoundsHeight ?? 1),
visible_bounds_offset: design.visibleBoundsOffset ||
(computedBoundsOffsetY !== undefined ? [0, computedBoundsOffsetY, 0] : [0, 0.5, 0]),
},
bones: geoBones,
};
const modelGeometry = {
format_version: "1.12.0",
"minecraft:geometry": [geometry],
};
return {
geometry: modelGeometry,
atlasRegions,
textureSize,
pixelsPerUnit,
warnings,
};
}
/**
* Generate SVG for a solid color face
*/
static generateColorSvg(color, width, height) {
const hex = this.colorToHex(color);
const opacity = color.a !== undefined ? (color.a / 255).toFixed(2) : "1";
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="${hex}" fill-opacity="${opacity}"/>
</svg>`;
}
/**
* Get the SVG content for a face, either from explicit SVG, noise, or generating from color.
*
* Priority order:
* 1. If noise is specified, generate noise background
* 2. If svg is specified, overlay it on top of noise (or use as primary if no noise)
* 3. If only color is specified, generate solid color
*
* @param content Face content configuration
* @param width Texture width in pixels
* @param height Texture height in pixels
* @param contextString Optional context for deterministic noise seeding
*/
static getFaceSvg(content, width, height, contextString) {
// Handle modern background property first (takes priority)
if (content.background) {
const bgSvg = TexturedRectangleGenerator_1.default.generateTexturedRectangleSvg(content.background, width, height, contextString);
// If there's also an SVG overlay, combine them
if (content.svg) {
let overlaySvg = content.svg;
if (!overlaySvg.includes("viewBox")) {
overlaySvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${overlaySvg}</svg>`;
}
return TexturedRectangleGenerator_1.default.combineWithOverlay(bgSvg, overlaySvg, width, height);
}
return bgSvg;
}
// Handle legacy noise texture
if (content.noise) {
const noiseSvg = TexturedRectangleGenerator_1.default.generateNoiseSvg(content.noise, width, height, contextString);
// If there's also an SVG overlay, combine them
if (content.svg) {
let overlaySvg = content.svg;
if (!overlaySvg.includes("viewBox")) {
overlaySvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${overlaySvg}</svg>`;
}
return TexturedRectangleGenerator_1.default.combineWithOverlay(noiseSvg, overlaySvg, width, height);
}
return noiseSvg;
}
// Handle explicit SVG
if (content.svg) {
// Return SVG as-is - generateAtlasSvg will handle scaling
return content.svg;
}
// Generate from color (legacy fallback)
const color = this.parseColor(content.color);
return this.generateColorSvg(color, width, height);
}
/**
* Generate a complete SVG document representing the texture atlas
* This can be rasterized to PNG using a rendering engine
*/
static generateAtlasSvg(atlasRegions, textureSize) {
const [width, height] = textureSize;
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
`;
for (const region of atlasRegions) {
// Skip duplicate regions - they share atlas space with another region
if (region.isDuplicate) {
continue;
}
// Pass context string for deterministic noise seeding
const faceSvg = this.getFaceSvg(region.content, region.width, region.height, region.contextString);
// Extract the original SVG's dimensions and content
const viewBoxMatch = faceSvg.match(/viewBox\s*=\s*["']([^"']+)["']/i);
const widthMatch = faceSvg.match(/<svg[^>]*width\s*=\s*["']?(\d+)/i);
const heightMatch = faceSvg.match(/<svg[^>]*height\s*=\s*["']?(\d+)/i);
let originalWidth = region.width;
let originalHeight = region.height;
if (viewBoxMatch) {
const parts = viewBoxMatch[1].trim().split(/\s+/);
if (parts.length >= 4) {
originalWidth = parseFloat(parts[2]) || region.width;
originalHeight = parseFloat(parts[3]) || region.height;
}
}
else if (widthMatch && heightMatch) {
originalWidth = parseInt(widthMatch[1], 10) || region.width;
originalHeight = parseInt(heightMatch[1], 10) || region.height;
}
// Extract the inner content of the SVG (skip the outer svg tags)
let innerContent = faceSvg;
const svgMatch = faceSvg.match(/<svg[^>]*>([\s\S]*)<\/svg>/i);
if (svgMatch) {
innerContent = svgMatch[1];
}
// Calculate scale factors to fit content into region
const scaleX = region.width / originalWidth;
const scaleY = region.height / originalHeight;
// Build transform: translate to position, then scale content to fit
let transform = `translate(${region.x}, ${region.y})`;
if (scaleX !== 1 || scaleY !== 1) {
transform += ` scale(${scaleX}, ${scaleY})`;
}
// Apply rotation if specified - rotation happens around the center of the original content
if (region.content.rotation) {
const cx = originalWidth / 2;
const cy = originalHeight / 2;
transform += ` rotate(${region.content.rotation}, ${cx}, ${cy})`;
}
// Use <g> with transform to position and scale the content directly
// This avoids nested <svg> elements which can have viewport issues
svgContent += ` <g transform="${transform}">${innerContent}</g>
`;
}
svgContent += `</svg>`;
return svgContent;
}
/**
* Check if an SVG string contains non-rect elements that are not Minecraft-style
* Returns array of warning messages about non-Minecraft-style SVG elements
*/
static validateSvgStyle(svg, context) {
const warnings = [];
// Non-blocky elements that don't fit Minecraft's pixel art style
const nonBlockyPatterns = [
{ pattern: /<circle/gi, name: "circle" },
{ pattern: /<ellipse/gi, name: "ellipse" },
{ pattern: /<path/gi, name: "path" },
{ pattern: /<polygon/gi, name: "polygon" },
{ pattern: /<polyline/gi, name: "polyline" },
{ pattern: /<line[^a-z]/gi, name: "line" },
{ pattern: /linearGradient/gi, name: "linearGradient" },
{ pattern: /radialGradient/gi, name: "radialGradient" },
{ pattern: /border-radius/gi, name: "border-radius style" },
{ pattern: /rx\s*=/gi, name: "rounded corners (rx attribute)" },
{ pattern: /ry\s*=/gi, name: "rounded corners (ry attribute)" },
];
for (const { pattern, name } of nonBlockyPatterns) {
if (pattern.test(svg)) {
warnings.push(`WARNING: ${context} uses <${name}> which creates non-blocky shapes. ` +
`For authentic Minecraft style, use only <rect> elements to create pixel-art textures.`);
}
}
return warnings;
}
/**
* Validate pixel art configuration and return any errors or warnings.
* Returns array of error/warning messages about invalid pixel art
*/
static validatePixelArt(pixelArtLayers, context) {
const errors = [];
for (let i = 0; i < pixelArtLayers.length; i++) {
const layer = pixelArtLayers[i];
const layerContext = pixelArtLayers.length > 1 ? `${context} pixelArt[${i}]` : `${context} pixelArt`;
// Validate lines
if (!layer.lines || !Array.isArray(layer.lines)) {
errors.push(`${layerContext} must have a 'lines' array`);
continue;
}
if (layer.lines.length === 0) {
errors.push(`${layerContext} has empty 'lines' array`);
continue;
}
// Validate palette
if (!layer.palette || typeof layer.palette !== "object") {
errors.push(`${layerContext} must have a 'palette' object`);
continue;
}
// Check for space in palette (reserved for transparent)
if (" " in layer.palette) {
errors.push(`${layerContext} palette should not define ' ' (space) - it is reserved for transparency`);
}
// Collect all characters used in lines
const usedChars = new Set();
for (const line of layer.lines) {
if (typeof line !== "string") {
errors.push(`${layerContext} has non-string line: ${JSON.stringify(line)}`);
continue;
}
for (const char of line) {
if (char !== " ") {
usedChars.add(char);
}
}
}
// Check for missing palette entries
const missingChars = [];
for (const char of usedChars) {
if (!(char in layer.palette)) {
missingChars.push(char);
}
}
if (missingChars.length > 0) {
errors.push(`${layerContext} uses characters not in palette: "${missingChars.join('", "')}"`);
}
// Warn about unused palette entries
const paletteKeys = Object.keys(layer.palette).filter((k) => k !== " ");
const unusedChars = paletteKeys.filter((k) => !usedChars.has(k));
if (unusedChars.length > 0) {
errors.push(`WARNING: ${layerContext} palette has unused colors: "${unusedChars.join('", "')}"`);
}
// Validate color values
for (const [char, color] of Object.entries(layer.palette)) {
if (char === " ")
continue;
if (!color || typeof color !== "object") {
errors.push(`${layerContext} palette entry "${char}" must be a color object`);
continue;
}
// Check hex or rgb values
if (!color.hex && (color.r === undefined || color.g === undefined || color.b === undefined)) {
errors.push(`${layerContext} palette entry "${char}" must have either 'hex' or 'r', 'g', 'b' values`);
}
// Validate RGB ranges
if (color.r !== undefined && (color.r < 0 || color.r > 255)) {
errors.push(`${layerContext} palette entry "${char}" has invalid 'r' value (must be 0-255)`);
}
if (color.g !== undefined && (color.g < 0 || color.g > 255)) {
errors.push(`${layerContext} palette entry "${char}" has invalid 'g' value (must be 0-255)`);
}
if (color.b !== undefined && (color.b < 0 || color.b > 255)) {
errors.push(`${layerContext} palette entry "${char}" has invalid 'b' value (must be 0-255)`);
}
if (color.a !== undefined && (color.a < 0 || color.a > 255)) {
errors.push(`${layerContext} palette entry "${char}" has invalid 'a' value (must be 0-255)`);
}
}
}
return errors;
}
/**
* Validate an MCP model design and return any errors or warnings.
* Errors are blocking issues that prevent model generation.
* Warnings (prefixed with "WARNING:") are style suggestions for better Minecraft compatibility.
*/
static validateDesign(design) {
const errors = [];
if (!design.identifier) {
errors.push("Model design must have an identifier");
}
if (!design.bones || design.bones.length === 0) {
errors.push("Model design must have at least one bone");
}
for (let i = 0; i < (design.bones || []).length; i++) {
const bone = design.bones[i];
if (!bone.name) {
errors.push(`Bone at index ${i} must have a name`);
}
// Note: Empty cubes arrays are allowed - bones can serve as parent/pivot bones
// for hierarchy organization without having geometry themselves
for (let j = 0; j < (bone.cubes || []).length; j++) {
const cube = bone.cubes[j];
if (!cube.origin || cube.origin.length !== 3) {
errors.push(`Cube ${j} in bone "${bone.name}" must have a valid origin [x, y, z]`);
}
if (!cube.size || cube.size.length !== 3) {
errors.push(`Cube ${j} in bone "${bone.name}" must have a valid size [w, h, d]`);
}
if (!cube.faces) {
errors.push(`Cube ${j} in bone "${bone.name}" must have a faces object`);
}
// Validate texture references and check SVG style
if (cube.faces) {
for (const faceName of ["north", "south", "east", "west", "up", "down"]) {
const face = cube.faces[faceName];
if (face?.textureId) {
if (!design.textures) {
errors.push(`Face "${faceName}" on cube ${j} in bone "${bone.name}" references textureId "${face.textureId}" ` +
`but no textures dictionary is defined in the model.`);
}
else if (!design.textures[face.textureId]) {
errors.push(`Face "${faceName}" on cube ${j} in bone "${bone.name}" references textureId "${face.textureId}" ` +
`which is not defined in the textures dictionary.`);
}
}
// Check inline SVG for non-Minecraft style elements
if (face?.svg) {
const svgWarnings = this.validateSvgStyle(face.svg, `Face "${faceName}" on cube ${j} in bone "${bone.name}"`);
errors.push(...svgWarnings);
}
}
}
}
}
// Validate texture definitions and check SVG style
if (design.textures) {
for (const [textureId, textureDef] of Object.entries(design.textures)) {
if (!textureDef.svg &&
!textureDef.color &&
!textureDef.noise &&
!textureDef.background &&
!textureDef.pixelArt) {
errors.push(`Texture "${textureId}" must have either a background, noise, svg, color, or pixelArt property.`);
}
// Check texture SVG for non-Minecraft style elements
if (textureDef.svg) {
const svgWarnings = this.validateSvgStyle(textureDef.svg, `Texture "${textureId}"`);
errors.push(...svgWarnings);
}
// Validate pixel art
if (textureDef.pixelArt) {
const pixelArtErrors = this.validatePixelArt(textureDef.pixelArt, `Texture "${textureId}"`);
errors.push(...pixelArtErrors);
}
}
}
// Validate inline pixelArt on faces
for (let i = 0; i < (design.bones || []).length; i++) {
const bone = design.bones[i];
for (let j = 0; j < (bone.cubes || []).length; j++) {
const cube = bone.cubes[j];
if (cube.faces) {
for (const faceName of ["north", "south", "east", "west", "up", "down"]) {
const face = cube.faces[faceName];
if (face?.pixelArt) {
const pixelArtErrors = this.validatePixelArt(face.pixelArt, `Face "${faceName}" on cube ${j} in bone "${bone.name}"`);
errors.push(...pixelArtErrors);
}
}
}
}
}
// Validate model size - warn if too small or too large for Minecraft
if (design.bones && design.bones.length > 0) {
const bounds = this.calculateModelBounds(design);
const height = bounds.maxY - bounds.minY;
const width = bounds.maxX - bounds.minX;
const depth = bounds.maxZ - bounds.minZ;
if (bounds.maxDimension < 0.5) {
errors.push(`WARNING: Model is very small (max dimension: ${bounds.maxDimension.toFixed(2)} units). ` +
`Most Minecraft entities are 16-48 units tall (1-3 blocks). Consider scaling up for visibility.`);
}
else if (bounds.maxDimension > 800) {
errors.push(`WARNING: Model is very large (max dimension: ${bounds.maxDimension.toFixed(2)} units, ~${(bounds.maxDimension / 16).toFixed(1)} blocks). ` +
`This exceeds Minecraft's typical entity rendering limits. Consider scaling down unless creating a mega-structure.`);
}
// Warn about flat models that might not render well
if (height < 0.1 && (width > 1 || depth > 1)) {
errors.push(`WARNING: Model appears very flat (height: ${height.toFixed(2)} units). ` +
`Minecraft mobs typically have 3D volume. Consider adding height.`);
}
}
return errors;
}
/**
* Create a simple unit cube model design (for testing)
*/
static cr