@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
345 lines (344 loc) • 13.5 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 });
const ste_events_1 = require("ste-events");
const StorageUtilities_1 = __importDefault(require("../storage/StorageUtilities"));
class VoxelShapeDefinition {
_file;
_id;
_isLoaded = false;
_loadedWithComments = false;
_data;
_onLoaded = new ste_events_1.EventDispatcher();
get data() {
return this._data;
}
get isLoaded() {
return this._isLoaded;
}
get file() {
return this._file;
}
set file(newFile) {
this._file = newFile;
}
get onLoaded() {
return this._onLoaded.asEvent();
}
get id() {
if (this._data && this._data["minecraft:voxel_shape"]?.description?.identifier) {
return this._data["minecraft:voxel_shape"].description.identifier;
}
return this._id;
}
set id(newId) {
if (this._data && this._data["minecraft:voxel_shape"]?.description) {
this._data["minecraft:voxel_shape"].description.identifier = newId || "";
}
this._id = newId;
}
get formatVersion() {
return this._data?.format_version;
}
get boxes() {
if (this._data && this._data["minecraft:voxel_shape"]?.shape?.boxes) {
return this._data["minecraft:voxel_shape"].shape.boxes;
}
return [];
}
static async ensureOnFile(file, loadHandler) {
let vsd;
if (file.manager === undefined) {
vsd = new VoxelShapeDefinition();
vsd.file = file;
file.manager = vsd;
}
if (file.manager !== undefined && file.manager instanceof VoxelShapeDefinition) {
vsd = file.manager;
if (!vsd.isLoaded && loadHandler) {
vsd.onLoaded.subscribe(loadHandler);
}
await vsd.load();
}
return vsd;
}
persist() {
if (this._file === undefined) {
return;
}
const content = JSON.stringify(this._data, null, 2);
this._file.setContent(content);
}
async save() {
if (this._file === undefined) {
return;
}
this.persist();
await this._file.saveContent(false);
}
/**
* Loads the definition from the file.
* @param preserveComments If true, uses comment-preserving JSON parsing for edit/save cycles.
* If false (default), uses efficient standard JSON parsing.
* Can be called again with true to "upgrade" a read-only load to read/write.
*/
async load(preserveComments = false) {
// If already loaded with comments, we have the "best" version - nothing more to do
if (this._isLoaded && this._loadedWithComments) {
return;
}
// If already loaded without comments and caller doesn't need comments, we're done
if (this._isLoaded && !preserveComments) {
return;
}
if (this._file === undefined) {
return;
}
await this._file.loadContent();
if (this._file.content === null || this._file.content instanceof Uint8Array) {
this._isLoaded = true;
this._loadedWithComments = preserveComments;
this._onLoaded.dispatch(this, this);
return;
}
// Use comment-preserving parser only when needed for editing
this._data = preserveComments
? StorageUtilities_1.default.getJsonObjectWithComments(this._file)
: StorageUtilities_1.default.getJsonObject(this._file);
this._isLoaded = true;
this._loadedWithComments = preserveComments;
this._onLoaded.dispatch(this, this);
}
/**
* Adds a new box to the voxel shape.
*/
addBox(min, max) {
if (!this._data) {
this._initializeData();
}
if (this._data && this._data["minecraft:voxel_shape"]?.shape?.boxes) {
this._data["minecraft:voxel_shape"].shape.boxes.push({ min, max });
}
}
/**
* Removes a box at the specified index.
*/
removeBox(index) {
if (this._data && this._data["minecraft:voxel_shape"]?.shape?.boxes) {
const boxes = this._data["minecraft:voxel_shape"].shape.boxes;
if (index >= 0 && index < boxes.length) {
boxes.splice(index, 1);
return true;
}
}
return false;
}
/**
* Updates a box at the specified index.
*/
updateBox(index, min, max) {
if (this._data && this._data["minecraft:voxel_shape"]?.shape?.boxes) {
const boxes = this._data["minecraft:voxel_shape"].shape.boxes;
if (index >= 0 && index < boxes.length) {
boxes[index] = { min, max };
return true;
}
}
return false;
}
/**
* Initializes an empty voxel shape data structure.
*/
_initializeData() {
this._data = {
format_version: "1.21.110",
"minecraft:voxel_shape": {
description: {
identifier: this._id || "custom:new_shape",
},
shape: {
boxes: [],
},
},
};
}
/**
* Gets the normalized coordinates for a box (0-1 range for standard block).
*/
static getNormalizedBox(box) {
const minArr = Array.isArray(box.min) ? box.min : [box.min.x, box.min.y, box.min.z];
const maxArr = Array.isArray(box.max) ? box.max : [box.max.x, box.max.y, box.max.z];
return {
min: minArr.map((v) => v / 16),
max: maxArr.map((v) => v / 16),
};
}
/**
* Color palette for box visualization.
* Each box gets a distinct color for easy identification.
*/
static BOX_COLORS = [
{ r: 0x55, g: 0x88, b: 0xdd }, // Blue
{ r: 0xdd, g: 0x55, b: 0x55 }, // Red
{ r: 0x55, g: 0xbb, b: 0x55 }, // Green
{ r: 0xdd, g: 0xaa, b: 0x33 }, // Orange
{ r: 0xaa, g: 0x55, b: 0xcc }, // Purple
{ r: 0x33, g: 0xbb, b: 0xbb }, // Cyan
{ r: 0xdd, g: 0x77, b: 0xaa }, // Pink
{ r: 0x88, g: 0x88, b: 0x55 }, // Olive
];
/**
* Converts the voxel shape to a model geometry definition object.
* Each box gets a different UV region for distinct coloring.
* Texture atlas is 128x16 with 8 colored slots.
*/
toGeometryJson() {
const bones = [];
const cubes = [];
const boxCount = this.boxes.length;
for (let i = 0; i < boxCount; i++) {
const box = this.boxes[i];
const normalized = VoxelShapeDefinition.getNormalizedBox(box);
// Convert to geometry coordinates:
// - Geometry origin is at center-bottom of model
// - Voxel shape origin is at corner (0,0,0 = bottom-southwest)
// - We need to offset by -8 in X and Z to center
const originX = normalized.min[0] * 16 - 8;
const originY = normalized.min[1] * 16;
const originZ = normalized.min[2] * 16 - 8;
const sizeX = (normalized.max[0] - normalized.min[0]) * 16;
const sizeY = (normalized.max[1] - normalized.min[1]) * 16;
const sizeZ = (normalized.max[2] - normalized.min[2]) * 16;
// Each box uses a different 16x16 region in the texture atlas
// Atlas is 128x16 (8 slots of 16x16 each)
// Use per-face UV mode so all faces show the same color/number
const colorIndex = i % 8;
const uvX = colorIndex * 16;
// Per-face UV ensures all faces use the same texture slot
const faceUv = { uv: [uvX, 0], uv_size: [16, 16] };
cubes.push({
origin: [originX, originY, originZ],
size: [sizeX, sizeY, sizeZ],
uv: {
north: faceUv,
south: faceUv,
east: faceUv,
west: faceUv,
up: faceUv,
down: faceUv,
},
});
}
bones.push({
name: "shape",
pivot: [0, 0, 0],
cubes: cubes,
});
// Calculate visible bounds based on actual box extents
let maxExtent = 1;
let maxHeight = 1;
for (const box of this.boxes) {
const normalized = VoxelShapeDefinition.getNormalizedBox(box);
maxExtent = Math.max(maxExtent, Math.abs(normalized.min[0]), Math.abs(normalized.max[0]), Math.abs(normalized.min[2]), Math.abs(normalized.max[2]));
maxHeight = Math.max(maxHeight, normalized.max[1]);
}
// Add some padding and convert to blocks
const boundsWidth = Math.ceil(maxExtent) * 2 + 2;
const boundsHeight = Math.ceil(maxHeight) + 2;
return {
format_version: "1.16.0",
"minecraft:geometry": [
{
description: {
identifier: "geometry.voxelshape_preview",
texture_width: 128,
texture_height: 16,
visible_bounds_width: boundsWidth,
visible_bounds_height: boundsHeight,
visible_bounds_offset: [0, boundsHeight / 2, 0],
},
bones: bones,
},
],
};
}
/**
* Generates a texture atlas for the voxel shape preview.
* Creates a 128x16 image with 8 colored slots (16x16 each).
* Each slot has a number overlay (1-8) for identification.
* Returns raw RGBA pixel data that can be encoded to PNG.
*/
generatePreviewTexturePixels() {
return VoxelShapeDefinition.generatePreviewTexturePixelsStatic(this.boxes.length);
}
/**
* Static version of generatePreviewTexturePixels for use without an instance.
* Creates a 128x16 image with 8 colored slots (16x16 each).
* Each slot has a number overlay (1-8) for identification.
* @param boxCount Number of boxes (only used for future extensions)
*/
static generatePreviewTexturePixelsStatic(_boxCount) {
const width = 128;
const height = 16;
const pixels = new Uint8Array(width * height * 4);
// Simple 3x5 digit patterns (1-8) for number overlays
const digitPatterns = {
1: [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
2: [1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1],
3: [1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1],
4: [1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1],
5: [1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1],
6: [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1],
7: [1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
8: [1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1],
};
// Fill each 16x16 slot with a color and number
for (let slot = 0; slot < 8; slot++) {
const color = VoxelShapeDefinition.BOX_COLORS[slot];
const slotX = slot * 16;
// Fill the slot with the base color (with slight variation for texture)
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
const pixelX = slotX + x;
const idx = (y * width + pixelX) * 4;
// Add slight checkerboard variation for texture
const isLight = (x + y) % 2 === 0;
const variation = isLight ? 0 : -20;
pixels[idx] = Math.max(0, Math.min(255, color.r + variation));
pixels[idx + 1] = Math.max(0, Math.min(255, color.g + variation));
pixels[idx + 2] = Math.max(0, Math.min(255, color.b + variation));
pixels[idx + 3] = 255;
}
}
// Draw the number (centered in the slot)
const digitNum = slot + 1;
const pattern = digitPatterns[digitNum];
if (pattern) {
const digitWidth = 3;
const digitHeight = 5;
const startX = slotX + Math.floor((16 - digitWidth) / 2);
const startY = Math.floor((16 - digitHeight) / 2);
for (let dy = 0; dy < digitHeight; dy++) {
for (let dx = 0; dx < digitWidth; dx++) {
if (pattern[dy * digitWidth + dx] === 1) {
const pixelX = startX + dx;
const pixelY = startY + dy;
const idx = (pixelY * width + pixelX) * 4;
// Draw number in white with dark outline effect
pixels[idx] = 255;
pixels[idx + 1] = 255;
pixels[idx + 2] = 255;
pixels[idx + 3] = 255;
}
}
}
}
}
return { pixels, width, height };
}
}
exports.default = VoxelShapeDefinition;