@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
862 lines (861 loc) • 39 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
Object.defineProperty(exports, "__esModule", { value: true });
exports.isIsometricView = isIsometricView;
/**
* Check if a view direction is isometric (shows 3 faces at once)
*/
function isIsometricView(direction) {
return direction.startsWith("iso-");
}
class ModelGeometryUtilities {
/**
* Calculate the center point of a cube in world coordinates.
* Cube origin is the minimum corner, so center = origin + size/2.
*/
static getCubeCenter(cube) {
const origin = cube.origin || [0, 0, 0];
const size = cube.size || [0, 0, 0];
return [origin[0] + size[0] / 2, origin[1] + size[1] / 2, origin[2] + size[2] / 2];
}
/**
* Calculate the 8 corner vertices of a cube in world coordinates.
* Returns array of [x, y, z] for each corner.
*/
static getCubeVertices(cube) {
const origin = cube.origin || [0, 0, 0];
const size = cube.size || [0, 0, 0];
const inflate = cube.inflate || 0;
const minX = origin[0] - inflate;
const minY = origin[1] - inflate;
const minZ = origin[2] - inflate;
const maxX = origin[0] + size[0] + inflate;
const maxY = origin[1] + size[1] + inflate;
const maxZ = origin[2] + size[2] + inflate;
return [
[minX, minY, minZ],
[maxX, minY, minZ],
[minX, maxY, minZ],
[maxX, maxY, minZ],
[minX, minY, maxZ],
[maxX, minY, maxZ],
[minX, maxY, maxZ],
[maxX, maxY, maxZ],
];
}
/**
* Get the bounding box of a cube (axis-aligned, before rotation).
*/
static getCubeBoundingBox(cube) {
const origin = cube.origin || [0, 0, 0];
const size = cube.size || [0, 0, 0];
const inflate = cube.inflate || 0;
return {
minX: origin[0] - inflate,
maxX: origin[0] + size[0] + inflate,
minY: origin[1] - inflate,
maxY: origin[1] + size[1] + inflate,
minZ: origin[2] - inflate,
maxZ: origin[2] + size[2] + inflate,
};
}
/**
* Get the bounding box of an entire geometry model.
*/
static getGeometryBoundingBox(geometry) {
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
if (!geometry.bones) {
return { minX: 0, maxX: 0, minY: 0, maxY: 0, minZ: 0, maxZ: 0 };
}
// Build bone transforms for rotation handling
const boneTransforms = this.buildBoneTransformMap(geometry);
for (const bone of geometry.bones) {
if (!bone.cubes)
continue;
for (const cube of bone.cubes) {
// Get vertices and apply bone rotations
const vertices = this.getCubeVertices(cube);
const transform = boneTransforms.get(bone.name);
for (let vertex of vertices) {
// Apply cube rotation if present
// Use same Babylon-style coordinate transformation as projectCubeFace
if (cube.rotation && this.hasRotation(cube.rotation)) {
const pivot = cube.pivot || this.getCubeCenter(cube);
// Step 1: Calculate offset from pivot
const offsetX = vertex[0] - pivot[0];
const offsetY = vertex[1] - pivot[1];
const offsetZ = vertex[2] - pivot[2];
// Step 2: Convert to Babylon space (negate Z)
const babylonOffset = [offsetX, offsetY, -offsetZ];
// Step 3: Apply rotation with Y negated
const adjustedRotation = [cube.rotation[0], -cube.rotation[1], cube.rotation[2]];
const rotated = this.rotatePointAroundPivot(babylonOffset, [0, 0, 0], adjustedRotation);
// Step 4: Convert back and add pivot
vertex = [pivot[0] + rotated[0], pivot[1] + rotated[1], pivot[2] - rotated[2]];
}
// Apply bone rotation if present
if (transform && this.hasRotation(transform.rotation)) {
vertex = this.rotatePointAroundPivot(vertex, transform.pivot, transform.rotation);
}
// Apply parent bone rotations
if (transform?.parentName) {
let parentName = transform.parentName;
while (parentName) {
const parentTransform = boneTransforms.get(parentName);
if (parentTransform && this.hasRotation(parentTransform.rotation)) {
vertex = this.rotatePointAroundPivot(vertex, parentTransform.pivot, parentTransform.rotation);
}
parentName = parentTransform?.parentName;
}
}
minX = Math.min(minX, vertex[0]);
maxX = Math.max(maxX, vertex[0]);
minY = Math.min(minY, vertex[1]);
maxY = Math.max(maxY, vertex[1]);
minZ = Math.min(minZ, vertex[2]);
maxZ = Math.max(maxZ, vertex[2]);
}
}
}
// Handle case of no cubes
if (minX === Infinity) {
return { minX: 0, maxX: 0, minY: 0, maxY: 0, minZ: 0, maxZ: 0 };
}
return { minX, maxX, minY, maxY, minZ, maxZ };
}
/**
* Check if a rotation array has any non-zero rotation.
*/
static hasRotation(rotation) {
if (!rotation)
return false;
return rotation[0] !== 0 || rotation[1] !== 0 || rotation[2] !== 0;
}
/**
* Build a map of bone transforms for a geometry.
* Includes bind_pose_rotation and parent relationships.
*/
static buildBoneTransformMap(geometry) {
const transforms = new Map();
if (!geometry.bones)
return transforms;
for (const bone of geometry.bones) {
transforms.set(bone.name, {
pivot: bone.pivot || [0, 0, 0],
rotation: bone.bind_pose_rotation || bone.rotation || [0, 0, 0],
parentName: bone.parent,
});
}
return transforms;
}
/**
* Rotate a point around a pivot by the given rotation (in degrees).
* Applies rotations in Minecraft order: X, then Y, then Z.
*/
static rotatePointAroundPivot(point, pivot, rotationDegrees) {
const rx = (rotationDegrees[0] * Math.PI) / 180;
const ry = (rotationDegrees[1] * Math.PI) / 180;
const rz = (rotationDegrees[2] * Math.PI) / 180;
// Translate to pivot origin
let x = point[0] - pivot[0];
let y = point[1] - pivot[1];
let z = point[2] - pivot[2];
// Apply rotations in order: X, then Y, then Z (Minecraft convention)
// Rotation around X axis
if (rx !== 0) {
const cosX = Math.cos(rx);
const sinX = Math.sin(rx);
const newY = y * cosX - z * sinX;
const newZ = y * sinX + z * cosX;
y = newY;
z = newZ;
}
// Rotation around Y axis
if (ry !== 0) {
const cosY = Math.cos(ry);
const sinY = Math.sin(ry);
const newX = x * cosY + z * sinY;
const newZ = -x * sinY + z * cosY;
x = newX;
z = newZ;
}
// Rotation around Z axis
if (rz !== 0) {
const cosZ = Math.cos(rz);
const sinZ = Math.sin(rz);
const newX = x * cosZ - y * sinZ;
const newY = x * sinZ + y * cosZ;
x = newX;
y = newY;
}
// Translate back from pivot
return [x + pivot[0], y + pivot[1], z + pivot[2]];
}
/**
* Calculate the normal vector of a face from its rotated corner vertices.
* Uses cross product of two edges to get the outward-facing normal.
*
* @param corners Array of 4 corner points [[x,y,z], ...] in counter-clockwise order
* @returns Normalized face normal [nx, ny, nz]
*/
static calculateFaceNormal(corners) {
// Edge vectors: v1 = corner[1] - corner[0], v2 = corner[3] - corner[0]
const v1 = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1], corners[1][2] - corners[0][2]];
const v2 = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1], corners[3][2] - corners[0][2]];
// Cross product v2 × v1 gives outward-facing normal
// (v1 × v2 would give inward-facing normal due to the corner winding order)
const normal = [v2[1] * v1[2] - v2[2] * v1[1], v2[2] * v1[0] - v2[0] * v1[2], v2[0] * v1[1] - v2[1] * v1[0]];
// Normalize
const length = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
if (length > 0) {
normal[0] /= length;
normal[1] /= length;
normal[2] /= length;
}
return normal;
}
/**
* Get the camera/view direction vector for a given view direction.
* This is the direction FROM the camera TO the scene (opposite of camera facing).
*
* For orthographic/isometric rendering, we use a parallel projection, so
* the view direction is constant (not dependent on position).
*/
static getViewDirectionVector(viewDirection) {
// These vectors point FROM the camera TOWARD the scene
switch (viewDirection) {
case "front":
return [0, 0, 1]; // Looking from -Z toward +Z
case "back":
return [0, 0, -1]; // Looking from +Z toward -Z
case "left":
return [1, 0, 0]; // Looking from -X toward +X
case "right":
return [-1, 0, 0]; // Looking from +X toward -X
case "top":
return [0, -1, 0]; // Looking from +Y toward -Y
case "bottom":
return [0, 1, 0]; // Looking from -Y toward +Y
// Isometric views: 45° Y rotation + ~30° X tilt
// These are approximate normalized vectors
case "iso-front-right": {
// Camera sees: north (front), east (+X side), up (top)
// East normal [1,0,0] must have dot < 0 → viewX must be negative
const cos45 = 0.7071;
const sin30 = 0.5;
const cos30 = 0.866;
return [-cos45 * cos30, -sin30, cos45 * cos30];
}
case "iso-front-left": {
// Camera sees: north (front), west (-X side), up (top)
// West normal [-1,0,0] must have dot < 0 → viewX must be positive
const cos45 = 0.7071;
const sin30 = 0.5;
const cos30 = 0.866;
return [cos45 * cos30, -sin30, cos45 * cos30];
}
case "iso-back-right": {
// Camera sees: south (back), east (+X side), up (top)
// East normal [1,0,0] must have dot < 0 → viewX must be negative
const cos45 = 0.7071;
const sin30 = 0.5;
const cos30 = 0.866;
return [-cos45 * cos30, -sin30, -cos45 * cos30];
}
case "iso-back-left": {
// Camera sees: south (back), west (-X side), up (top)
// West normal [-1,0,0] must have dot < 0 → viewX must be positive
const cos45 = 0.7071;
const sin30 = 0.5;
const cos30 = 0.866;
return [cos45 * cos30, -sin30, -cos45 * cos30];
}
default:
return [0, 0, 1];
}
}
/**
* Check if a face should be visible (not backface-culled) given the view direction.
*
* @param faceNormal The normal vector of the face after rotations
* @param viewDirection The current view direction
* @returns true if the face is visible (facing the camera), false if backface-culled
*/
static isFaceVisible(faceNormal, viewDirection) {
const viewVector = this.getViewDirectionVector(viewDirection);
// Dot product of face normal and view direction
// If positive, face normal points toward camera = visible
// If negative, face normal points away from camera = backface, should be culled
const dot = faceNormal[0] * viewVector[0] + faceNormal[1] * viewVector[1] + faceNormal[2] * viewVector[2];
// Face is visible if its normal points somewhat toward the camera
// Use a small negative threshold to handle near-perpendicular faces
return dot < 0.01; // Normal points opposite to view direction = visible
}
/**
* Calculate UV coordinates for a specific face of a cube.
* Handles both legacy [u, v] format and per-face UV format.
*
* @returns [u, v, width, height] in texture pixels
*/
static getCubeFaceUV(cube, face, texWidth, texHeight) {
const size = cube.size || [0, 0, 0];
const w = size[0]; // width (X dimension)
const h = size[1]; // height (Y dimension)
const d = size[2]; // depth (Z dimension)
if (Array.isArray(cube.uv) && cube.uv.length === 2) {
// Legacy UV format - standard Minecraft box unwrap
// u u+d u+d+w u+d+w+d u+2d+2w
// v +-----+----+
// | Up |Down| <- Top row: height = d
// v+d +----+-----+----+----+
// |East|North|West|South| <- Bottom row: height = h
// v+d+h +----+-----+----+----+
// | d | w | d | w |
const u = cube.uv[0];
const v = cube.uv[1];
switch (face) {
case "east":
return { u: u, v: v + d, width: d, height: h };
case "north":
return { u: u + d, v: v + d, width: w, height: h };
case "west":
return { u: u + d + w, v: v + d, width: d, height: h };
case "south":
return { u: u + d + w + d, v: v + d, width: w, height: h };
case "up":
return { u: u + d, v: v, width: w, height: d };
case "down":
return { u: u + d + w, v: v, width: w, height: d };
default:
return { u: 0, v: 0, width: 1, height: 1 };
}
}
else if (cube.uv && typeof cube.uv === "object") {
// Per-face UV format
const faceUV = cube.uv[face];
if (faceUV && faceUV.uv && faceUV.uv_size) {
return {
u: faceUV.uv[0],
v: faceUV.uv[1],
width: Math.abs(faceUV.uv_size[0]),
height: Math.abs(faceUV.uv_size[1]),
};
}
}
// Default - use full texture
return { u: 0, v: 0, width: texWidth, height: texHeight };
}
/**
* Get the faces that would be visible from a given view direction.
* Returns the face names that should be rendered.
*
* In Minecraft, entities face north (-Z) by default. So:
* - "front" view = looking at their face = we see the "north" face
* - "back" view = looking at their back = we see the "south" face
*
* For isometric views, we see 3 faces at once:
* - "iso-front-right" = north + east + up (viewing from the front-right above)
* - "iso-front-left" = north + west + up (viewing from the front-left above)
* - "iso-back-right" = south + east + up (viewing from the back-right above)
* - "iso-back-left" = south + west + up (viewing from the back-left above)
*/
static getVisibleFaces(viewDirection) {
switch (viewDirection) {
case "front":
return ["north"]; // Entity faces north, so their front is the north face
case "back":
return ["south"]; // Entity's back is the south face
case "left":
return ["west"]; // Entity's left side (when they face north)
case "right":
return ["east"]; // Entity's right side (when they face north)
case "top":
return ["up"];
case "bottom":
return ["down"];
// Isometric views show 3 faces
case "iso-front-right":
return ["north", "east", "up"];
case "iso-front-left":
return ["north", "west", "up"];
case "iso-back-right":
return ["south", "east", "up"];
case "iso-back-left":
return ["south", "west", "up"];
default:
return ["north"];
}
}
/**
* Get the secondary faces that would be partially visible from a given view.
* Used for isometric or 3/4 view rendering with depth effects.
* For isometric views, all 3 primary faces are already in getVisibleFaces, so no secondary.
*/
static getSecondaryVisibleFaces(viewDirection) {
switch (viewDirection) {
case "front":
return ["east", "west", "up"]; // Sides and top visible in 3/4 view
case "back":
return ["east", "west", "up"];
case "left":
return ["south", "north", "up"];
case "right":
return ["south", "north", "up"];
case "top":
return ["south", "east", "west", "north"];
case "bottom":
return ["south", "east", "west", "north"];
// Isometric views already show all relevant faces in primary
case "iso-front-right":
case "iso-front-left":
case "iso-back-right":
case "iso-back-left":
return [];
default:
return [];
}
}
/**
* Options for perspective projection in projectPoint.
*/
static perspectiveOptions = {
enabled: false,
strength: 0,
focalLength: 100,
referenceDepth: 0,
};
/**
* Center offset for isometric rotation.
* When set, isometric rotation is applied around this center point instead of the world origin.
* This ensures models with large Z offsets (like cow.v2) render correctly in isometric views.
* Set to null to disable (rotate around world origin).
*/
static isometricCenterOffset = null;
/**
* Apply isometric rotation to a 3D point.
* For classic isometric view: rotate around Y-axis, then ~30° around X-axis.
* This produces the familiar Minecraft inventory-style 3D view.
*
* Rotation angles for each view (entity faces north/-Z by default):
* - iso-front-right: -135° (see north + east + up)
* - iso-front-left: +135° (see north + west + up)
* - iso-back-right: -45° (see south + east + up)
* - iso-back-left: +45° (see south + west + up)
*
* @param point 3D point [x, y, z]
* @param yRotation Y-axis rotation in degrees
* @param centerOffset Optional 3D offset to subtract before rotation (for centering)
* @returns Rotated point [x, y, z]
*/
static applyIsometricRotation(point, yRotation, centerOffset) {
let [x, y, z] = point;
// Store center offset for adding back after rotation
const offsetX = centerOffset ? centerOffset[0] : 0;
const offsetY = centerOffset ? centerOffset[1] : 0;
const offsetZ = centerOffset ? centerOffset[2] : 0;
// Translate to origin (so rotation happens around model center)
x -= offsetX;
y -= offsetY;
z -= offsetZ;
// Rotation angles:
// - Y rotation: varies based on which corner we're viewing from
// - X rotation: ~35.264° (arctan(1/√2)) for true isometric, or ~30° for a gentler view
const yRad = (yRotation * Math.PI) / 180;
const xRad = (30 * Math.PI) / 180; // 30 degrees tilt for a nice viewing angle
// Apply Y rotation first (horizontal rotation around vertical axis)
const cosY = Math.cos(yRad);
const sinY = Math.sin(yRad);
const newX = x * cosY + z * sinY;
const newZ = -x * sinY + z * cosY;
x = newX;
z = newZ;
// Apply X rotation (tilt toward viewer)
const cosX = Math.cos(xRad);
const sinX = Math.sin(xRad);
const newY = y * cosX - z * sinX;
const finalZ = y * sinX + z * cosX;
y = newY;
z = finalZ;
// Translate back (keep model in original position after rotation)
// For screen rendering, we typically want the rotated model centered at the
// same screen position, so we add back the offset transformed to screen coords.
// However, since we're doing orthographic projection after this, the X offset
// should be added back to keep the model horizontally centered.
// We DON'T add back Y and Z offsets since those affect screen Y and depth.
// Actually, for proper centering, we need to think about what happens:
// - The model was at center (X, Y, Z)
// - We translated it to origin
// - We rotated it
// - Now we need it centered at (0, Y, 0) in screen space for proper display
// Let's just not add anything back - the bounding box centering after projection
// should handle the final centering in screen space.
return [x, y, z];
}
/**
* Project a 3D point to 2D screen coordinates.
* When perspectiveOptions.enabled is true, applies perspective projection
* so that points farther from the camera converge toward the center.
*
* @param point 3D point [x, y, z]
* @param viewDirection View direction
* @param scale Scale multiplier
* @returns {x, y, depth} where x/y are screen coordinates and depth is for z-ordering
*/
static projectPoint(point, viewDirection, scale = 1) {
let [x, y, z] = point;
// Handle isometric views by first rotating the point
// In Minecraft, entities face north (-Z). To see their front (north face) from the front-right,
// we rotate the model so the north face is visible along with the east side.
// Positive Y rotation = counterclockwise when viewed from above.
// Use isometricCenterOffset to rotate around model center instead of world origin.
const centerOffset = this.isometricCenterOffset;
if (viewDirection === "iso-front-right") {
// View from front-right: see north + east + up faces
[x, y, z] = this.applyIsometricRotation(point, -135, centerOffset ?? undefined);
}
else if (viewDirection === "iso-front-left") {
// View from front-left: see north + west + up faces
[x, y, z] = this.applyIsometricRotation(point, 135, centerOffset ?? undefined);
}
else if (viewDirection === "iso-back-right") {
// View from back-right: see south + east + up faces
[x, y, z] = this.applyIsometricRotation(point, -45, centerOffset ?? undefined);
}
else if (viewDirection === "iso-back-left") {
// View from back-left: see south + west + up faces
[x, y, z] = this.applyIsometricRotation(point, 45, centerOffset ?? undefined);
}
// First, determine the depth axis value based on view direction
// This is the axis pointing toward/away from camera
let depthValue;
let screenX;
let screenY;
// In Minecraft, entities face north (-Z) by default.
// When looking at their "front", we look from -Z towards +Z to see their face.
switch (viewDirection) {
case "front":
// Looking from -Z towards +Z (looking at entity's face)
// X is mirrored (entity's left appears on our right), Y inverted for SVG
depthValue = -z; // More negative Z = closer to camera = smaller depth value
screenX = -x;
screenY = -y;
break;
case "back":
// Looking from +Z towards -Z (looking at entity's back)
depthValue = z;
screenX = x;
screenY = -y;
break;
case "left":
// Looking from -X towards +X (entity's left side when they face north)
depthValue = -x;
screenX = z;
screenY = -y;
break;
case "right":
// Looking from +X towards -X (entity's right side when they face north)
depthValue = x;
screenX = -z;
screenY = -y;
break;
case "top":
// Looking from +Y towards -Y
depthValue = y;
screenX = x;
screenY = z;
break;
case "bottom":
// Looking from -Y towards +Y
depthValue = -y;
screenX = x;
screenY = -z;
break;
// Isometric views: after rotation, project orthographically along Z
case "iso-front-right":
case "iso-front-left":
case "iso-back-right":
case "iso-back-left":
depthValue = z;
screenX = x;
screenY = -y;
break;
default:
depthValue = z;
screenX = x;
screenY = -y;
break;
}
// Apply perspective if enabled
if (this.perspectiveOptions.enabled && this.perspectiveOptions.strength > 0) {
const { strength, focalLength, referenceDepth } = this.perspectiveOptions;
// Calculate relative depth from reference point
// Points farther than reference (larger depth) will shrink toward center
// Points closer than reference (smaller depth) will expand away from center
const relativeDepth = depthValue - referenceDepth;
// Perspective scale factor: closer objects are larger, farther objects smaller
// focalLength / (focalLength + depth * strength)
// When relativeDepth = 0, scaleFactor = 1 (no change)
// When relativeDepth > 0 (farther), scaleFactor < 1 (smaller)
// When relativeDepth < 0 (closer), scaleFactor > 1 (larger)
const perspectiveScale = focalLength / (focalLength + relativeDepth * strength);
screenX *= perspectiveScale;
screenY *= perspectiveScale;
}
return { x: screenX * scale, y: screenY * scale, depth: depthValue };
}
/**
* Get projected 2D rectangle for a cube face.
* This is the core projection algorithm for 2D rendering.
*/
static projectCubeFace(cube, bone, face, viewDirection, boneTransforms, scale = 1) {
const origin = cube.origin || [0, 0, 0];
const size = cube.size || [0, 0, 0];
const inflate = cube.inflate || 0;
// Get the four corners of the face in 3D
let corners = [];
const minX = origin[0] - inflate;
const minY = origin[1] - inflate;
const minZ = origin[2] - inflate;
const maxX = origin[0] + size[0] + inflate;
const maxY = origin[1] + size[1] + inflate;
const maxZ = origin[2] + size[2] + inflate;
switch (face) {
case "north": // -Z face
corners = [
[minX, minY, minZ],
[maxX, minY, minZ],
[maxX, maxY, minZ],
[minX, maxY, minZ],
];
break;
case "south": // +Z face
corners = [
[maxX, minY, maxZ],
[minX, minY, maxZ],
[minX, maxY, maxZ],
[maxX, maxY, maxZ],
];
break;
case "east": // +X face
corners = [
[maxX, minY, minZ],
[maxX, minY, maxZ],
[maxX, maxY, maxZ],
[maxX, maxY, minZ],
];
break;
case "west": // -X face
corners = [
[minX, minY, maxZ],
[minX, minY, minZ],
[minX, maxY, minZ],
[minX, maxY, maxZ],
];
break;
case "up": // +Y face
corners = [
[minX, maxY, minZ],
[maxX, maxY, minZ],
[maxX, maxY, maxZ],
[minX, maxY, maxZ],
];
break;
case "down": // -Y face
corners = [
[minX, minY, maxZ],
[maxX, minY, maxZ],
[maxX, minY, minZ],
[minX, minY, minZ],
];
break;
default:
return null;
}
// Apply cube rotation if present
// NOTE: Per-cube rotation requires matching Babylon.js coordinate handling exactly.
// ModelMeshFactory applies cube rotation by:
// 1. Creating a rotation node at the pivot with Z negated
// 2. Positioning the mesh offset with Z negated
// 3. Applying rotation with X positive, Y negated, Z positive
// To match this in 2D, we simulate the full coordinate transformation:
if (cube.rotation && this.hasRotation(cube.rotation)) {
const pivot = cube.pivot || this.getCubeCenter(cube);
corners = corners.map((c) => {
// Step 1: Calculate offset from pivot
const offsetX = c[0] - pivot[0];
const offsetY = c[1] - pivot[1];
const offsetZ = c[2] - pivot[2];
// Step 2: Convert offset to Babylon space (negate Z)
const babylonOffset = [offsetX, offsetY, -offsetZ];
// Step 3: Apply rotation with Y negated (as Babylon does)
const adjustedRotation = [cube.rotation[0], -cube.rotation[1], cube.rotation[2]];
const rotated = this.rotatePointAroundPivot(babylonOffset, [0, 0, 0], adjustedRotation);
// Step 4: Convert back from Babylon space (negate Z again) and add pivot
return [pivot[0] + rotated[0], pivot[1] + rotated[1], pivot[2] - rotated[2]];
});
}
// Apply bone rotation if present
const transform = boneTransforms.get(bone.name);
if (transform && this.hasRotation(transform.rotation)) {
corners = corners.map((c) => this.rotatePointAroundPivot(c, transform.pivot, transform.rotation));
}
// Apply parent bone rotations (walk up the hierarchy)
if (transform?.parentName) {
let parentName = transform.parentName;
while (parentName) {
const parentTransform = boneTransforms.get(parentName);
if (parentTransform && this.hasRotation(parentTransform.rotation)) {
corners = corners.map((c) => this.rotatePointAroundPivot(c, parentTransform.pivot, parentTransform.rotation));
}
parentName = parentTransform?.parentName;
}
}
// Backface culling: calculate face normal and check if visible from view direction
const faceNormal = this.calculateFaceNormal(corners);
if (!this.isFaceVisible(faceNormal, viewDirection)) {
return null; // Face is pointing away from camera, cull it
}
// Project corners to 2D
const projected = corners.map((c) => this.projectPoint(c, viewDirection, scale));
// Calculate bounding box in 2D
const xs = projected.map((p) => p.x);
const ys = projected.map((p) => p.y);
const minProjX = Math.min(...xs);
const maxProjX = Math.max(...xs);
const minProjY = Math.min(...ys);
const maxProjY = Math.max(...ys);
// Calculate depth statistics for z-ordering
const depths = projected.map((p) => p.depth);
const minDepth = Math.min(...depths);
const maxDepth = Math.max(...depths);
const avgDepth = depths.reduce((sum, d) => sum + d, 0) / depths.length;
// Store actual vertices for isometric rendering
// Order: 0=bottom-left, 1=bottom-right, 2=top-right, 3=top-left
const vertices = projected.map((p) => ({ x: p.x, y: p.y }));
return {
x: minProjX,
y: minProjY,
width: maxProjX - minProjX,
height: maxProjY - minProjY,
// Use avgDepth for z-ordering (painter's algorithm)
// minDepth and maxDepth are available for more sophisticated sorting if needed
depth: avgDepth,
minDepth,
maxDepth,
avgDepth,
cube,
bone,
face,
uv: [0, 0, size[0], size[1]], // Will be filled in by UV calculation
vertices,
};
}
/**
* Get all visible faces from a geometry for a given view direction.
* Sorted by depth (back to front) for proper occlusion.
*/
static getProjectedFaces(geometry, viewDirection, scale = 1, includeSecondary = false) {
const faces = [];
const boneTransforms = this.buildBoneTransformMap(geometry);
// For isometric views with rotated cubes, we need to try all 6 faces
// and let backface culling determine which are actually visible.
// For orthographic views, we can optimize by pre-selecting likely faces.
const isIsometric = viewDirection === "iso-front-right" ||
viewDirection === "iso-front-left" ||
viewDirection === "iso-back-right" ||
viewDirection === "iso-back-left";
// All possible faces - for isometric we try all, for ortho we pre-select
const allPossibleFaces = ["north", "south", "east", "west", "up", "down"];
const visibleFaceNames = this.getVisibleFaces(viewDirection);
const secondaryFaceNames = includeSecondary ? this.getSecondaryVisibleFaces(viewDirection) : [];
// For isometric views, try all 6 faces to handle rotated cubes correctly
// Backface culling in projectCubeFace will filter out invisible faces
const facesToTry = isIsometric ? allPossibleFaces : [...visibleFaceNames, ...secondaryFaceNames];
if (!geometry.bones)
return faces;
// For isometric views, calculate the model center to use as rotation pivot
// This ensures models with large Z offsets render correctly centered
if (isIsometric) {
const bounds = this.getGeometryBoundingBox(geometry);
this.isometricCenterOffset = [
(bounds.minX + bounds.maxX) / 2,
(bounds.minY + bounds.maxY) / 2,
(bounds.minZ + bounds.maxZ) / 2,
];
}
else {
this.isometricCenterOffset = null;
}
for (const bone of geometry.bones) {
if (!bone.cubes)
continue;
for (const cube of bone.cubes) {
for (const faceName of facesToTry) {
const projected = this.projectCubeFace(cube, bone, faceName, viewDirection, boneTransforms, scale);
if (projected && projected.width > 0 && projected.height > 0) {
faces.push(projected);
}
}
}
}
// Reset isometric center offset after projection
this.isometricCenterOffset = null;
// Calculate bone hierarchy depth for each face to help with sorting ties
const boneHierarchyDepth = new Map();
if (geometry.bones) {
for (const bone of geometry.bones) {
let depth = 0;
let parentName = bone.parent;
while (parentName) {
depth++;
const parentBone = geometry.bones.find((b) => b.name === parentName);
parentName = parentBone?.parent;
}
boneHierarchyDepth.set(bone.name, depth);
}
}
// Sort by depth (back to front - smaller depth values first)
// Use a weighted depth that considers both min and max to handle rotated faces better.
// For faces with wide depth ranges (like 90° rotated cubes), use max depth to ensure
// they're drawn after faces that are entirely behind them.
faces.sort((a, b) => {
// Calculate depth range for each face
const aRange = a.maxDepth - a.minDepth;
const bRange = b.maxDepth - b.minDepth;
// For faces with narrow depth range, use avgDepth
// For faces with wide depth range (rotated), bias toward maxDepth
const aWeight = aRange > 5 ? 0.8 : 0.5; // Bias more toward max for wide ranges
const bWeight = bRange > 5 ? 0.8 : 0.5;
const aDepth = a.minDepth * (1 - aWeight) + a.maxDepth * aWeight;
const bDepth = b.minDepth * (1 - bWeight) + b.maxDepth * bWeight;
const depthDiff = aDepth - bDepth;
if (Math.abs(depthDiff) > 0.5) {
return depthDiff;
}
// For similar depths, sort by bone hierarchy (parents first)
const aHierarchy = boneHierarchyDepth.get(a.bone.name) || 0;
const bHierarchy = boneHierarchyDepth.get(b.bone.name) || 0;
if (aHierarchy !== bHierarchy) {
return aHierarchy - bHierarchy;
}
return depthDiff;
});
return faces;
}
/**
* Calculate the normalized UV coordinates for a face.
* Returns coordinates in range [0, 1].
*/
static getNormalizedUV(u, v, width, height, texWidth, texHeight) {
return {
uMin: u / texWidth,
vMin: v / texHeight,
uMax: (u + width) / texWidth,
vMax: (v + height) / texHeight,
};
}
}
exports.default = ModelGeometryUtilities;