auto-git:

[unlink] playwright.config.js
 [unlink] src/app/App.js
 [unlink] src/app/editor-store.js
 [unlink] src/app/use-editor-store.js
 [unlink] src/assets/audio-assets.js
 [unlink] src/assets/gltf-model-import.js
 [unlink] src/assets/image-assets.js
 [unlink] src/assets/model-instance-labels.js
 [unlink] src/assets/model-instance-rendering.js
 [unlink] src/assets/model-instances.js
 [unlink] src/assets/project-asset-storage.js
 [unlink] src/assets/project-assets.js
 [unlink] src/commands/brush-command-helpers.js
 [unlink] src/commands/command-history.js
 [unlink] src/commands/command.js
 [unlink] src/commands/commit-transform-session-command.js
 [unlink] src/commands/create-box-brush-command.js
 [unlink] src/commands/delete-box-brush-command.js
 [unlink] src/commands/delete-entity-command.js
 [unlink] src/commands/delete-interaction-link-command.js
 [unlink] src/commands/delete-model-instance-command.js
 [unlink] src/commands/duplicate-selection-command.js
 [unlink] src/commands/import-audio-asset-command.js
 [unlink] src/commands/import-background-image-asset-command.js
 [unlink] src/commands/import-model-asset-command.js
 [unlink] src/commands/move-box-brush-command.js
 [unlink] src/commands/resize-box-brush-command.js
 [unlink] src/commands/rotate-box-brush-command.js
 [unlink] src/commands/set-box-brush-face-material-command.js
 [unlink] src/commands/set-box-brush-face-uv-state-command.js
 [unlink] src/commands/set-box-brush-name-command.js
 [unlink] src/commands/set-box-brush-transform-command.js
 [unlink] src/commands/set-box-brush-volume-settings-command.js
 [unlink] src/commands/set-entity-name-command.js
 [unlink] src/commands/set-model-instance-name-command.js
 [unlink] src/commands/set-player-start-command.js
 [unlink] src/commands/set-scene-name-command.js
 [unlink] src/commands/set-world-settings-command.js
 [unlink] src/commands/upsert-entity-command.js
 [unlink] src/commands/upsert-interaction-link-command.js
 [unlink] src/commands/upsert-model-instance-command.js
 [unlink] src/core/ids.js
 [unlink] src/core/selection.js
 [unlink] src/core/tool-mode.js
 [unlink] src/core/transform-session.js
 [unlink] src/core/vector.js
 [unlink] src/core/whitebox-selection-feedback.js
 [unlink] src/core/whitebox-selection-mode.js
 [unlink] src/document/brushes.js
 [unlink] src/document/migrate-scene-document.js
 [unlink] src/document/scene-document-validation.js
 [unlink] src/document/scene-document.js
 [unlink] src/document/world-settings.js
 [unlink] src/entities/entity-instances.js
 [unlink] src/entities/entity-labels.js
 [unlink] src/geometry/box-brush-components.js
 [unlink] src/geometry/box-brush-mesh.js
 [unlink] src/geometry/box-brush.js
 [unlink] src/geometry/box-face-uvs.js
 [unlink] src/geometry/grid-snapping.js
 [unlink] src/geometry/model-instance-collider-debug-mesh.js
 [unlink] src/geometry/model-instance-collider-generation.js
 [unlink] src/interactions/interaction-links.js
 [unlink] src/main.js
 [unlink] src/materials/starter-material-library.js
 [unlink] src/materials/starter-material-textures.js
 [unlink] src/rendering/advanced-rendering.js
 [unlink] src/rendering/fog-material.js
 [unlink] src/rendering/planar-reflection.js
 [unlink] src/rendering/water-material.js
 [unlink] src/runner-web/RunnerCanvas.js
 [unlink] src/runtime-three/first-person-navigation-controller.js
 [unlink] src/runtime-three/navigation-controller.js
 [unlink] src/runtime-three/orbit-visitor-navigation-controller.js
 [unlink] src/runtime-three/player-collision.js
 [unlink] src/runtime-three/rapier-collision-world.js
 [unlink] src/runtime-three/runtime-audio-system.js
 [unlink] src/runtime-three/runtime-host.js
 [unlink] src/runtime-three/runtime-interaction-system.js
 [unlink] src/runtime-three/runtime-scene-build.js
 [unlink] src/runtime-three/runtime-scene-validation.js
 [unlink] src/runtime-three/underwater-fog.js
 [unlink] src/serialization/local-draft-storage.js
 [unlink] src/serialization/scene-document-json.js
 [unlink] src/shared-ui/HierarchicalMenu.js
 [unlink] src/shared-ui/Panel.js
 [unlink] src/shared-ui/world-background-style.js
 [unlink] src/viewport-three/ViewportCanvas.js
 [unlink] src/viewport-three/ViewportPanel.js
 [unlink] src/viewport-three/viewport-entity-markers.js
 [unlink] src/viewport-three/viewport-focus.js
 [unlink] src/viewport-three/viewport-host.js
 [unlink] src/viewport-three/viewport-layout.js
 [unlink] src/viewport-three/viewport-transient-state.js
 [unlink] src/viewport-three/viewport-view-modes.js
 [unlink] vite.config.js
 [unlink] vitest.config.js
This commit is contained in:
2026-04-11 15:48:39 +02:00
parent 277706e950
commit 9b7706bf5b
97 changed files with 0 additions and 21624 deletions

View File

@@ -1,168 +0,0 @@
import { Euler, MathUtils, Quaternion, Vector3 } from "three";
import { BOX_FACE_IDS } from "../document/brushes";
import { getBoxBrushFaceVertexIds, getBoxBrushLocalVertexPosition } from "./box-brush-mesh";
const BOX_VERTEX_SIGNS = {
negX_negY_negZ: { x: -1, y: -1, z: -1 },
posX_negY_negZ: { x: 1, y: -1, z: -1 },
negX_posY_negZ: { x: -1, y: 1, z: -1 },
posX_posY_negZ: { x: 1, y: 1, z: -1 },
negX_negY_posZ: { x: -1, y: -1, z: 1 },
posX_negY_posZ: { x: 1, y: -1, z: 1 },
negX_posY_posZ: { x: -1, y: 1, z: 1 },
posX_posY_posZ: { x: 1, y: 1, z: 1 }
};
const BOX_FACE_TRANSFORM_META = {
posX: { axis: "x", sign: 1 },
negX: { axis: "x", sign: -1 },
posY: { axis: "y", sign: 1 },
negY: { axis: "y", sign: -1 },
posZ: { axis: "z", sign: 1 },
negZ: { axis: "z", sign: -1 }
};
const BOX_EDGE_TRANSFORM_META = {
edgeX_negY_negZ: {
axis: "x",
signs: { x: null, y: -1, z: -1 }
},
edgeX_posY_negZ: {
axis: "x",
signs: { x: null, y: 1, z: -1 }
},
edgeX_negY_posZ: {
axis: "x",
signs: { x: null, y: -1, z: 1 }
},
edgeX_posY_posZ: {
axis: "x",
signs: { x: null, y: 1, z: 1 }
},
edgeY_negX_negZ: {
axis: "y",
signs: { x: -1, y: null, z: -1 }
},
edgeY_posX_negZ: {
axis: "y",
signs: { x: 1, y: null, z: -1 }
},
edgeY_negX_posZ: {
axis: "y",
signs: { x: -1, y: null, z: 1 }
},
edgeY_posX_posZ: {
axis: "y",
signs: { x: 1, y: null, z: 1 }
},
edgeZ_negX_negY: {
axis: "z",
signs: { x: -1, y: -1, z: null }
},
edgeZ_posX_negY: {
axis: "z",
signs: { x: 1, y: -1, z: null }
},
edgeZ_negX_posY: {
axis: "z",
signs: { x: -1, y: 1, z: null }
},
edgeZ_posX_posY: {
axis: "z",
signs: { x: 1, y: 1, z: null }
}
};
const BOX_EDGE_VERTEX_IDS = {
edgeX_negY_negZ: { start: "negX_negY_negZ", end: "posX_negY_negZ" },
edgeX_posY_negZ: { start: "negX_posY_negZ", end: "posX_posY_negZ" },
edgeX_negY_posZ: { start: "negX_negY_posZ", end: "posX_negY_posZ" },
edgeX_posY_posZ: { start: "negX_posY_posZ", end: "posX_posY_posZ" },
edgeY_negX_negZ: { start: "negX_negY_negZ", end: "negX_posY_negZ" },
edgeY_posX_negZ: { start: "posX_negY_negZ", end: "posX_posY_negZ" },
edgeY_negX_posZ: { start: "negX_negY_posZ", end: "negX_posY_posZ" },
edgeY_posX_posZ: { start: "posX_negY_posZ", end: "posX_posY_posZ" },
edgeZ_negX_negY: { start: "negX_negY_negZ", end: "negX_negY_posZ" },
edgeZ_posX_negY: { start: "posX_negY_negZ", end: "posX_negY_posZ" },
edgeZ_negX_posY: { start: "negX_posY_negZ", end: "negX_posY_posZ" },
edgeZ_posX_posY: { start: "posX_posY_negZ", end: "posX_posY_posZ" }
};
function createBrushRotationEuler(brush) {
return new Euler(MathUtils.degToRad(brush.rotationDegrees.x), MathUtils.degToRad(brush.rotationDegrees.y), MathUtils.degToRad(brush.rotationDegrees.z), "XYZ");
}
export function transformBoxBrushWorldVectorToLocal(brush, worldVector) {
const rotation = createBrushRotationEuler(brush);
const inverseRotation = new Quaternion().setFromEuler(rotation).invert();
const localVector = new Vector3(worldVector.x, worldVector.y, worldVector.z).applyQuaternion(inverseRotation);
return {
x: localVector.x,
y: localVector.y,
z: localVector.z
};
}
export function transformBoxBrushWorldPointToLocal(brush, worldPoint) {
const rotation = createBrushRotationEuler(brush);
const inverseRotation = new Quaternion().setFromEuler(rotation).invert();
const localPoint = new Vector3(worldPoint.x - brush.center.x, worldPoint.y - brush.center.y, worldPoint.z - brush.center.z).applyQuaternion(inverseRotation);
return {
x: localPoint.x,
y: localPoint.y,
z: localPoint.z
};
}
export function transformBoxBrushLocalPointToWorld(brush, localPoint) {
const rotation = createBrushRotationEuler(brush);
const rotatedOffset = new Vector3(localPoint.x, localPoint.y, localPoint.z).applyEuler(rotation);
return {
x: brush.center.x + rotatedOffset.x,
y: brush.center.y + rotatedOffset.y,
z: brush.center.z + rotatedOffset.z
};
}
export function getBoxBrushFaceTransformMeta(faceId) {
return BOX_FACE_TRANSFORM_META[faceId];
}
export function getBoxBrushEdgeTransformMeta(edgeId) {
return BOX_EDGE_TRANSFORM_META[edgeId];
}
export function getBoxBrushVertexSigns(vertexId) {
return BOX_VERTEX_SIGNS[vertexId];
}
export function getBoxBrushFaceWorldCenter(brush, faceId) {
const faceVertexIds = getBoxBrushFaceVertexIds(faceId);
const localCenter = faceVertexIds.reduce((accumulator, vertexId) => {
const vertex = getBoxBrushLocalVertexPosition(brush, vertexId);
return {
x: accumulator.x + vertex.x * 0.25,
y: accumulator.y + vertex.y * 0.25,
z: accumulator.z + vertex.z * 0.25
};
}, { x: 0, y: 0, z: 0 });
return transformBoxBrushLocalPointToWorld(brush, localCenter);
}
export function getBoxBrushFaceAxis(faceId) {
return BOX_FACE_TRANSFORM_META[faceId].axis;
}
export function getBoxBrushEdgeAxis(edgeId) {
return BOX_EDGE_TRANSFORM_META[edgeId].axis;
}
export function getBoxBrushFaceIdsForAxis(axis) {
return BOX_FACE_IDS.filter((faceId) => BOX_FACE_TRANSFORM_META[faceId].axis === axis);
}
export function getBoxBrushVertexLocalPosition(brush, vertexId) {
return getBoxBrushLocalVertexPosition(brush, vertexId);
}
export function getBoxBrushVertexWorldPosition(brush, vertexId) {
return transformBoxBrushLocalPointToWorld(brush, getBoxBrushVertexLocalPosition(brush, vertexId));
}
export function getBoxBrushEdgeWorldSegment(brush, edgeId) {
const vertexIds = BOX_EDGE_VERTEX_IDS[edgeId];
const start = getBoxBrushVertexWorldPosition(brush, vertexIds.start);
const end = getBoxBrushVertexWorldPosition(brush, vertexIds.end);
return {
id: edgeId,
start,
end,
center: {
x: (start.x + end.x) * 0.5,
y: (start.y + end.y) * 0.5,
z: (start.z + end.z) * 0.5
}
};
}

View File

@@ -1,401 +0,0 @@
import { BufferAttribute, BufferGeometry } from "three";
import { BOX_EDGE_IDS, BOX_FACE_IDS } from "../document/brushes";
import { transformProjectedFaceUv } from "./box-face-uvs";
const FACE_VERTEX_IDS = {
posX: ["posX_negY_posZ", "posX_negY_negZ", "posX_posY_negZ", "posX_posY_posZ"],
negX: ["negX_negY_negZ", "negX_negY_posZ", "negX_posY_posZ", "negX_posY_negZ"],
posY: ["negX_posY_posZ", "posX_posY_posZ", "posX_posY_negZ", "negX_posY_negZ"],
negY: ["negX_negY_negZ", "posX_negY_negZ", "posX_negY_posZ", "negX_negY_posZ"],
posZ: ["negX_negY_posZ", "posX_negY_posZ", "posX_posY_posZ", "negX_posY_posZ"],
negZ: ["posX_negY_negZ", "negX_negY_negZ", "negX_posY_negZ", "posX_posY_negZ"]
};
const EDGE_VERTEX_IDS = {
edgeX_negY_negZ: ["negX_negY_negZ", "posX_negY_negZ"],
edgeX_posY_negZ: ["negX_posY_negZ", "posX_posY_negZ"],
edgeX_negY_posZ: ["negX_negY_posZ", "posX_negY_posZ"],
edgeX_posY_posZ: ["negX_posY_posZ", "posX_posY_posZ"],
edgeY_negX_negZ: ["negX_negY_negZ", "negX_posY_negZ"],
edgeY_posX_negZ: ["posX_negY_negZ", "posX_posY_negZ"],
edgeY_negX_posZ: ["negX_negY_posZ", "negX_posY_posZ"],
edgeY_posX_posZ: ["posX_negY_posZ", "posX_posY_posZ"],
edgeZ_negX_negY: ["negX_negY_negZ", "negX_negY_posZ"],
edgeZ_posX_negY: ["posX_negY_negZ", "posX_negY_posZ"],
edgeZ_negX_posY: ["negX_posY_negZ", "negX_posY_posZ"],
edgeZ_posX_posY: ["posX_posY_negZ", "posX_posY_posZ"]
};
const WATER_TOP_FACE_RENDER_SEGMENTS = 12;
function cloneVec3(vector) {
return { x: vector.x, y: vector.y, z: vector.z };
}
function subtractVec3(left, right) {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z
};
}
function crossVec3(left, right) {
return {
x: left.y * right.z - left.z * right.y,
y: left.z * right.x - left.x * right.z,
z: left.x * right.y - left.y * right.x
};
}
function dotVec3(left, right) {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function getVectorLength(vector) {
return Math.sqrt(dotVec3(vector, vector));
}
function normalizeVec3(vector) {
const length = getVectorLength(vector);
if (length <= 1e-8) {
return { x: 0, y: 0, z: 0 };
}
return {
x: vector.x / length,
y: vector.y / length,
z: vector.z / length
};
}
function computeNewellNormal(vertices) {
let normal = { x: 0, y: 0, z: 0 };
for (let index = 0; index < vertices.length; index += 1) {
const current = vertices[index];
const next = vertices[(index + 1) % vertices.length];
normal.x += (current.y - next.y) * (current.z + next.z);
normal.y += (current.z - next.z) * (current.x + next.x);
normal.z += (current.x - next.x) * (current.y + next.y);
}
return normalizeVec3(normal);
}
function chooseProjectionAxes(normal) {
const absoluteNormal = {
x: Math.abs(normal.x),
y: Math.abs(normal.y),
z: Math.abs(normal.z)
};
if (absoluteNormal.x >= absoluteNormal.y && absoluteNormal.x >= absoluteNormal.z) {
return ["y", "z"];
}
if (absoluteNormal.y >= absoluteNormal.z) {
return ["x", "z"];
}
return ["x", "y"];
}
function projectVerticesTo2d(vertices, normal) {
const [uAxis, vAxis] = chooseProjectionAxes(normal);
return vertices.map((vertex) => ({
x: vertex[uAxis],
y: vertex[vAxis]
}));
}
function computeSignedArea(points) {
let area = 0;
for (let index = 0; index < points.length; index += 1) {
const current = points[index];
const next = points[(index + 1) % points.length];
area += current.x * next.y - next.x * current.y;
}
return area * 0.5;
}
function isPointInTriangle(point, triangle, orientation) {
const [a, b, c] = triangle;
const edges = [
(b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x),
(c.x - b.x) * (point.y - b.y) - (c.y - b.y) * (point.x - b.x),
(a.x - c.x) * (point.y - c.y) - (a.y - c.y) * (point.x - c.x)
];
return orientation > 0 ? edges.every((value) => value >= -1e-8) : edges.every((value) => value <= 1e-8);
}
function triangulateQuad(vertices) {
const normal = computeNewellNormal(vertices);
const projected = projectVerticesTo2d(vertices, normal);
const orientation = computeSignedArea(projected);
if (Math.abs(orientation) <= 1e-8) {
throw new Error("Face projection is degenerate.");
}
const remaining = [0, 1, 2, 3];
const triangles = [];
while (remaining.length > 3) {
let earFound = false;
for (let offset = 0; offset < remaining.length; offset += 1) {
const previousIndex = remaining[(offset + remaining.length - 1) % remaining.length];
const currentIndex = remaining[offset];
const nextIndex = remaining[(offset + 1) % remaining.length];
const previousPoint = projected[previousIndex];
const currentPoint = projected[currentIndex];
const nextPoint = projected[nextIndex];
const cross = (currentPoint.x - previousPoint.x) * (nextPoint.y - previousPoint.y) -
(currentPoint.y - previousPoint.y) * (nextPoint.x - previousPoint.x);
if ((orientation > 0 && cross <= 1e-8) || (orientation < 0 && cross >= -1e-8)) {
continue;
}
const candidateTriangle = [previousPoint, currentPoint, nextPoint];
const containsOtherPoint = remaining.some((candidateIndex) => {
if (candidateIndex === previousIndex || candidateIndex === currentIndex || candidateIndex === nextIndex) {
return false;
}
return isPointInTriangle(projected[candidateIndex], candidateTriangle, orientation);
});
if (containsOtherPoint) {
continue;
}
triangles.push([previousIndex, currentIndex, nextIndex]);
remaining.splice(offset, 1);
earFound = true;
break;
}
if (!earFound) {
throw new Error("Face triangulation could not find a stable ear.");
}
}
triangles.push([remaining[0], remaining[1], remaining[2]]);
return triangles;
}
function projectLocalVertexToFaceUv(vertexPosition, faceId, faceBounds) {
switch (faceId) {
case "posX":
return {
x: faceBounds.max.z - vertexPosition.z,
y: vertexPosition.y - faceBounds.min.y
};
case "negX":
return {
x: vertexPosition.z - faceBounds.min.z,
y: vertexPosition.y - faceBounds.min.y
};
case "posY":
return {
x: vertexPosition.x - faceBounds.min.x,
y: faceBounds.max.z - vertexPosition.z
};
case "negY":
return {
x: vertexPosition.x - faceBounds.min.x,
y: vertexPosition.z - faceBounds.min.z
};
case "posZ":
return {
x: vertexPosition.x - faceBounds.min.x,
y: vertexPosition.y - faceBounds.min.y
};
case "negZ":
return {
x: faceBounds.max.x - vertexPosition.x,
y: vertexPosition.y - faceBounds.min.y
};
}
}
function getFaceUvSize(faceId, faceBounds) {
switch (faceId) {
case "posX":
case "negX":
return {
x: faceBounds.max.z - faceBounds.min.z,
y: faceBounds.max.y - faceBounds.min.y
};
case "posY":
case "negY":
return {
x: faceBounds.max.x - faceBounds.min.x,
y: faceBounds.max.z - faceBounds.min.z
};
case "posZ":
case "negZ":
return {
x: faceBounds.max.x - faceBounds.min.x,
y: faceBounds.max.y - faceBounds.min.y
};
}
}
function computeFaceBounds(vertices) {
const firstVertex = vertices[0];
const min = { ...firstVertex };
const max = { ...firstVertex };
for (const vertex of vertices.slice(1)) {
min.x = Math.min(min.x, vertex.x);
min.y = Math.min(min.y, vertex.y);
min.z = Math.min(min.z, vertex.z);
max.x = Math.max(max.x, vertex.x);
max.y = Math.max(max.y, vertex.y);
max.z = Math.max(max.z, vertex.z);
}
return { min, max };
}
function lerpNumber(start, end, amount) {
return start + (end - start) * amount;
}
function lerpVec3(start, end, amount) {
return {
x: lerpNumber(start.x, end.x, amount),
y: lerpNumber(start.y, end.y, amount),
z: lerpNumber(start.z, end.z, amount)
};
}
function interpolateQuadSurfaceVertex(corners, u, v) {
const topEdge = lerpVec3(corners[0], corners[1], u);
const bottomEdge = lerpVec3(corners[3], corners[2], u);
return lerpVec3(topEdge, bottomEdge, v);
}
function pushRenderedFaceVertex(positions, normals, uvs, indices, vertex, normal, faceId, faceBounds, uvSize, uvState) {
const projectedUv = projectLocalVertexToFaceUv(vertex, faceId, faceBounds);
const transformedUv = transformProjectedFaceUv(projectedUv, uvSize, uvState);
positions.push(vertex.x, vertex.y, vertex.z);
normals.push(normal.x, normal.y, normal.z);
uvs.push(transformedUv.x, transformedUv.y);
indices.push(indices.length);
}
export function getBoxBrushFaceVertexIds(faceId) {
return FACE_VERTEX_IDS[faceId];
}
export function getBoxBrushEdgeVertexIds(edgeId) {
return EDGE_VERTEX_IDS[edgeId];
}
export function getBoxBrushLocalVertexPosition(brush, vertexId) {
return cloneVec3(brush.geometry.vertices[vertexId]);
}
export function buildBoxBrushDerivedMeshData(brush) {
const diagnostics = validateBoxBrushGeometry(brush);
if (diagnostics.length > 0) {
throw new Error(diagnostics[0].message);
}
const positions = [];
const normals = [];
const uvs = [];
const indices = [];
const colliderVertices = [];
const colliderIndices = [];
const faceSurfaces = [];
const groups = [];
const vertexIndexMap = new Map();
for (const vertexId of Object.keys(brush.geometry.vertices)) {
const vertex = brush.geometry.vertices[vertexId];
vertexIndexMap.set(vertexId, colliderVertices.length / 3);
colliderVertices.push(vertex.x, vertex.y, vertex.z);
}
for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) {
const faceVertexIds = FACE_VERTEX_IDS[faceId];
const faceVertices = faceVertexIds.map((vertexId) => getBoxBrushLocalVertexPosition(brush, vertexId));
const triangles = triangulateQuad(faceVertices);
const normal = computeNewellNormal(faceVertices);
const faceBounds = computeFaceBounds(faceVertices);
const uvSize = getFaceUvSize(faceId, faceBounds);
const uvState = brush.faces[faceId].uv;
const indexStart = indices.length;
faceSurfaces.push({
faceId,
vertexIds: faceVertexIds,
triangles,
normal
});
const useSubdividedWaterTopFace = brush.volume.mode === "water" && faceId === "posY" && brush.volume.water.surfaceDisplacementEnabled;
if (useSubdividedWaterTopFace) {
const faceCorners = faceVertices;
for (let row = 0; row < WATER_TOP_FACE_RENDER_SEGMENTS; row += 1) {
const v0 = row / WATER_TOP_FACE_RENDER_SEGMENTS;
const v1 = (row + 1) / WATER_TOP_FACE_RENDER_SEGMENTS;
for (let column = 0; column < WATER_TOP_FACE_RENDER_SEGMENTS; column += 1) {
const u0 = column / WATER_TOP_FACE_RENDER_SEGMENTS;
const u1 = (column + 1) / WATER_TOP_FACE_RENDER_SEGMENTS;
const quadVertices = [
interpolateQuadSurfaceVertex(faceCorners, u0, v0),
interpolateQuadSurfaceVertex(faceCorners, u1, v0),
interpolateQuadSurfaceVertex(faceCorners, u1, v1),
interpolateQuadSurfaceVertex(faceCorners, u0, v1)
];
for (const vertex of [quadVertices[0], quadVertices[1], quadVertices[2], quadVertices[0], quadVertices[2], quadVertices[3]]) {
pushRenderedFaceVertex(positions, normals, uvs, indices, vertex, normal, faceId, faceBounds, uvSize, uvState);
}
}
}
}
else {
for (const triangle of triangles) {
for (const vertexOffset of triangle) {
pushRenderedFaceVertex(positions, normals, uvs, indices, faceVertices[vertexOffset], normal, faceId, faceBounds, uvSize, uvState);
}
}
}
groups.push({
start: indexStart,
count: indices.length - indexStart,
materialIndex
});
for (const triangle of triangles) {
colliderIndices.push(vertexIndexMap.get(faceVertexIds[triangle[0]]) ?? 0, vertexIndexMap.get(faceVertexIds[triangle[1]]) ?? 0, vertexIndexMap.get(faceVertexIds[triangle[2]]) ?? 0);
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3));
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(uvs), 2));
geometry.setIndex(indices);
for (const group of groups) {
geometry.addGroup(group.start, group.count, group.materialIndex);
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const firstVertex = brush.geometry.vertices.negX_negY_negZ;
const localBounds = {
min: cloneVec3(firstVertex),
max: cloneVec3(firstVertex)
};
for (const vertex of Object.values(brush.geometry.vertices)) {
localBounds.min.x = Math.min(localBounds.min.x, vertex.x);
localBounds.min.y = Math.min(localBounds.min.y, vertex.y);
localBounds.min.z = Math.min(localBounds.min.z, vertex.z);
localBounds.max.x = Math.max(localBounds.max.x, vertex.x);
localBounds.max.y = Math.max(localBounds.max.y, vertex.y);
localBounds.max.z = Math.max(localBounds.max.z, vertex.z);
}
return {
geometry,
faceSurfaces,
edgeSegments: BOX_EDGE_IDS.map((edgeId) => {
const [startId, endId] = EDGE_VERTEX_IDS[edgeId];
return {
edgeId,
start: getBoxBrushLocalVertexPosition(brush, startId),
end: getBoxBrushLocalVertexPosition(brush, endId)
};
}),
colliderVertices: new Float32Array(colliderVertices),
colliderIndices: new Uint32Array(colliderIndices),
localBounds
};
}
export function validateBoxBrushGeometry(brush) {
const diagnostics = [];
for (const [vertexId, vertex] of Object.entries(brush.geometry.vertices)) {
if (!Number.isFinite(vertex.x) || !Number.isFinite(vertex.y) || !Number.isFinite(vertex.z)) {
diagnostics.push({
code: "invalid-box-geometry-vertex",
message: `Whitebox vertex ${vertexId} must remain finite.`
});
}
}
for (const faceId of BOX_FACE_IDS) {
const faceVertices = FACE_VERTEX_IDS[faceId].map((vertexId) => brush.geometry.vertices[vertexId]);
const normal = computeNewellNormal(faceVertices);
if (getVectorLength(normal) <= 1e-8) {
diagnostics.push({
code: "degenerate-box-face",
message: `Whitebox face ${faceId} is degenerate and cannot be triangulated.`,
faceId
});
continue;
}
try {
triangulateQuad(faceVertices);
}
catch (error) {
diagnostics.push({
code: "invalid-box-face-triangulation",
message: error instanceof Error ? `Whitebox face ${faceId} could not be triangulated: ${error.message}` : `Whitebox face ${faceId} could not be triangulated.`,
faceId
});
}
}
return diagnostics;
}

View File

@@ -1,43 +0,0 @@
import { Euler, MathUtils, Vector3 } from "three";
import { BOX_VERTEX_IDS } from "../document/brushes";
import { getBoxBrushLocalVertexPosition } from "./box-brush-mesh";
export function getBoxBrushHalfSize(brush) {
return {
x: brush.size.x * 0.5,
y: brush.size.y * 0.5,
z: brush.size.z * 0.5
};
}
export function getBoxBrushBounds(brush) {
const corners = getBoxBrushCornerPositions(brush);
const firstCorner = corners[0];
const min = { ...firstCorner };
const max = { ...firstCorner };
for (const corner of corners.slice(1)) {
min.x = Math.min(min.x, corner.x);
min.y = Math.min(min.y, corner.y);
min.z = Math.min(min.z, corner.z);
max.x = Math.max(max.x, corner.x);
max.y = Math.max(max.y, corner.y);
max.z = Math.max(max.z, corner.z);
}
return {
min,
max
};
}
export function getBoxBrushCornerPositions(brush) {
const rotation = new Euler(MathUtils.degToRad(brush.rotationDegrees.x), MathUtils.degToRad(brush.rotationDegrees.y), MathUtils.degToRad(brush.rotationDegrees.z), "XYZ");
const offsets = BOX_VERTEX_IDS.map((vertexId) => {
const localVertex = getBoxBrushLocalVertexPosition(brush, vertexId);
return new Vector3(localVertex.x, localVertex.y, localVertex.z);
});
return offsets.map((offset) => {
const rotatedOffset = offset.clone().applyEuler(rotation);
return {
x: brush.center.x + rotatedOffset.x,
y: brush.center.y + rotatedOffset.y,
z: brush.center.z + rotatedOffset.z
};
});
}

View File

@@ -1,133 +0,0 @@
import { BoxGeometry } from "three";
import { BOX_FACE_IDS, createDefaultFaceUvState } from "../document/brushes";
import { getBoxBrushHalfSize } from "./box-brush";
export function getBoxBrushFaceSize(brush, faceId) {
switch (faceId) {
case "posX":
case "negX":
return {
x: brush.size.z,
y: brush.size.y
};
case "posY":
case "negY":
return {
x: brush.size.x,
y: brush.size.z
};
case "posZ":
case "negZ":
return {
x: brush.size.x,
y: brush.size.y
};
}
}
export function createFitToFaceBoxBrushFaceUvState(brush, faceId) {
const faceSize = getBoxBrushFaceSize(brush, faceId);
return {
...createDefaultFaceUvState(),
scale: {
x: 1 / faceSize.x,
y: 1 / faceSize.y
}
};
}
export function projectBoxFaceVertexToUv(vertexPosition, brush, faceId) {
const halfSize = getBoxBrushHalfSize(brush);
switch (faceId) {
case "posX":
return {
x: halfSize.z - vertexPosition.z,
y: vertexPosition.y + halfSize.y
};
case "negX":
return {
x: vertexPosition.z + halfSize.z,
y: vertexPosition.y + halfSize.y
};
case "posY":
return {
x: vertexPosition.x + halfSize.x,
y: halfSize.z - vertexPosition.z
};
case "negY":
return {
x: vertexPosition.x + halfSize.x,
y: vertexPosition.z + halfSize.z
};
case "posZ":
return {
x: vertexPosition.x + halfSize.x,
y: vertexPosition.y + halfSize.y
};
case "negZ":
return {
x: halfSize.x - vertexPosition.x,
y: vertexPosition.y + halfSize.y
};
}
}
export function transformProjectedFaceUv(baseUv, faceSize, uvState) {
let u = (baseUv.x - faceSize.x * 0.5) * uvState.scale.x;
let v = (baseUv.y - faceSize.y * 0.5) * uvState.scale.y;
if (uvState.flipU) {
u *= -1;
}
if (uvState.flipV) {
v *= -1;
}
switch (uvState.rotationQuarterTurns) {
case 1: {
const nextU = -v;
v = u;
u = nextU;
break;
}
case 2:
u *= -1;
v *= -1;
break;
case 3: {
const nextU = v;
v = -u;
u = nextU;
break;
}
}
return {
x: u + faceSize.x * 0.5 * uvState.scale.x + uvState.offset.x,
y: v + faceSize.y * 0.5 * uvState.scale.y + uvState.offset.y
};
}
export function applyBoxBrushFaceUvsToGeometry(geometry, brush) {
const positionAttribute = geometry.getAttribute("position");
const uvAttribute = geometry.getAttribute("uv");
const indexAttribute = geometry.getIndex();
if (indexAttribute === null) {
throw new Error("BoxGeometry is expected to be indexed for face UV projection.");
}
// BoxGeometry groups follow the same px, nx, py, ny, pz, nz order as the canonical face ids.
for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) {
const group = geometry.groups.find((candidate) => candidate.materialIndex === materialIndex);
if (group === undefined) {
continue;
}
const faceSize = getBoxBrushFaceSize(brush, faceId);
const vertexIndices = new Set();
for (let indexOffset = group.start; indexOffset < group.start + group.count; indexOffset += 1) {
vertexIndices.add(indexAttribute.getX(indexOffset));
}
for (const vertexIndex of vertexIndices) {
const localVertexPosition = {
x: positionAttribute.getX(vertexIndex),
y: positionAttribute.getY(vertexIndex),
z: positionAttribute.getZ(vertexIndex)
};
const projectedUv = projectBoxFaceVertexToUv(localVertexPosition, brush, faceId);
const transformedUv = transformProjectedFaceUv(projectedUv, faceSize, brush.faces[faceId].uv);
uvAttribute.setXY(vertexIndex, transformedUv.x, transformedUv.y);
}
}
uvAttribute.needsUpdate = true;
}

View File

@@ -1,36 +0,0 @@
export const DEFAULT_GRID_SIZE = 1;
function assertGridSize(gridSize) {
if (!Number.isFinite(gridSize) || gridSize <= 0) {
throw new Error("Grid size must be a positive finite number.");
}
return gridSize;
}
export function snapValueToGrid(value, gridSize = DEFAULT_GRID_SIZE) {
const step = assertGridSize(gridSize);
if (!Number.isFinite(value)) {
throw new Error("Grid-snapped values must be finite numbers.");
}
return Math.round(value / step) * step;
}
function snapPositiveSizeValue(value, gridSize) {
if (!Number.isFinite(value)) {
throw new Error("Box brush size values must be finite numbers.");
}
const snappedSize = Math.round(Math.abs(value) / gridSize) * gridSize;
return snappedSize > 0 ? snappedSize : gridSize;
}
export function snapVec3ToGrid(vector, gridSize = DEFAULT_GRID_SIZE) {
return {
x: snapValueToGrid(vector.x, gridSize),
y: snapValueToGrid(vector.y, gridSize),
z: snapValueToGrid(vector.z, gridSize)
};
}
export function snapPositiveSizeToGrid(size, gridSize = DEFAULT_GRID_SIZE) {
const step = assertGridSize(gridSize);
return {
x: snapPositiveSizeValue(size.x, step),
y: snapPositiveSizeValue(size.y, step),
z: snapPositiveSizeValue(size.z, step)
};
}

View File

@@ -1,119 +0,0 @@
import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, Vector3 } from "three";
import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js";
const DEBUG_COLLIDER_COLORS = {
simple: 0x87d2ff,
terrain: 0x7be7b4,
static: 0xffc66d,
dynamic: 0xff8b7a
};
function createWireframeMaterial(color) {
return new MeshBasicMaterial({
color,
wireframe: true,
transparent: true,
opacity: 0.85,
depthWrite: false,
toneMapped: false
});
}
function markDebugMesh(mesh) {
mesh.userData.shadowIgnored = true;
mesh.userData.nonPickable = true;
mesh.renderOrder = 3_500;
}
function createBoxColliderDebugMesh(collider) {
const mesh = new Mesh(new BoxGeometry(collider.size.x, collider.size.y, collider.size.z), createWireframeMaterial(DEBUG_COLLIDER_COLORS.simple));
mesh.position.set(collider.center.x, collider.center.y, collider.center.z);
markDebugMesh(mesh);
return mesh;
}
function createTriMeshColliderDebugMesh(collider) {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(collider.vertices, 3));
geometry.setIndex(Array.from(collider.indices));
const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.static));
markDebugMesh(mesh);
return mesh;
}
function createHeightfieldColliderDebugMesh(collider) {
const vertices = [];
const indices = [];
const width = collider.maxX - collider.minX;
const depth = collider.maxZ - collider.minZ;
for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) {
const zLerp = collider.cols === 1 ? 0 : zIndex / (collider.cols - 1);
const z = collider.minZ + depth * zLerp;
for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) {
const xLerp = collider.rows === 1 ? 0 : xIndex / (collider.rows - 1);
const x = collider.minX + width * xLerp;
const y = collider.heights[xIndex + zIndex * collider.rows];
vertices.push(x, y, z);
}
}
for (let zIndex = 0; zIndex < collider.cols - 1; zIndex += 1) {
for (let xIndex = 0; xIndex < collider.rows - 1; xIndex += 1) {
const topLeft = xIndex + zIndex * collider.rows;
const topRight = topLeft + 1;
const bottomLeft = topLeft + collider.rows;
const bottomRight = bottomLeft + 1;
indices.push(topLeft, bottomLeft, bottomRight, topLeft, bottomRight, topRight);
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
geometry.setIndex(indices);
const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.terrain));
markDebugMesh(mesh);
return mesh;
}
function createCompoundColliderDebugGroup(collider) {
const group = new Group();
for (const piece of collider.pieces) {
const points = [];
for (let index = 0; index < piece.points.length; index += 3) {
points.push(new Vector3(piece.points[index], piece.points[index + 1], piece.points[index + 2]));
}
const mesh = new Mesh(new ConvexGeometry(points), createWireframeMaterial(DEBUG_COLLIDER_COLORS.dynamic));
markDebugMesh(mesh);
group.add(mesh);
}
return group;
}
export function createModelColliderDebugGroup(collider) {
const group = new Group();
switch (collider.kind) {
case "box":
group.add(createBoxColliderDebugMesh(collider));
break;
case "trimesh":
group.add(createTriMeshColliderDebugMesh(collider));
break;
case "heightfield":
group.add(createHeightfieldColliderDebugMesh(collider));
break;
case "compound":
group.add(createCompoundColliderDebugGroup(collider));
break;
}
group.userData.nonPickable = true;
return group;
}
function disposeMaterial(material) {
if (Array.isArray(material)) {
for (const item of material) {
item.dispose();
}
return;
}
material.dispose();
}
export function disposeModelColliderDebugGroup(group) {
group.traverse((object) => {
const maybeMesh = object;
if (maybeMesh.isMesh !== true) {
return;
}
maybeMesh.geometry.dispose();
disposeMaterial(maybeMesh.material);
});
}

View File

@@ -1,419 +0,0 @@
import { Euler, Group, MathUtils, Matrix4, Mesh, Quaternion, Vector3 } from "three";
const TERRAIN_GRID_EPSILON = 1e-4;
const DYNAMIC_TRIANGLE_TARGET = 48;
const DYNAMIC_SPLIT_DEPTH_LIMIT = 3;
export class ModelColliderGenerationError extends Error {
code;
constructor(code, message) {
super(message);
this.name = "ModelColliderGenerationError";
this.code = code;
}
}
function cloneVec3(vector) {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function vector3ToVec3(vector) {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function createBounds(min, max) {
return {
min: vector3ToVec3(min),
max: vector3ToVec3(max)
};
}
function createModelTransform(modelInstance) {
return {
position: cloneVec3(modelInstance.position),
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
scale: cloneVec3(modelInstance.scale)
};
}
function createModelTransformMatrix(modelInstance) {
const rotation = new Euler(MathUtils.degToRad(modelInstance.rotationDegrees.x), MathUtils.degToRad(modelInstance.rotationDegrees.y), MathUtils.degToRad(modelInstance.rotationDegrees.z), "XYZ");
const quaternion = new Quaternion().setFromEuler(rotation);
return new Matrix4().compose(new Vector3(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z), quaternion, new Vector3(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z));
}
function computeBoundsFromPoints(points) {
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
let hasPoint = false;
for (const point of points) {
hasPoint = true;
min.min(point);
max.max(point);
}
if (!hasPoint) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry.");
}
return createBounds(min, max);
}
function computeBoundsFromFloat32Points(points) {
if (points.length < 3) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry.");
}
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
for (let index = 0; index < points.length; index += 3) {
min.x = Math.min(min.x, points[index]);
min.y = Math.min(min.y, points[index + 1]);
min.z = Math.min(min.z, points[index + 2]);
max.x = Math.max(max.x, points[index]);
max.y = Math.max(max.y, points[index + 1]);
max.z = Math.max(max.z, points[index + 2]);
}
return createBounds(min, max);
}
function computeWorldBoundsFromLocalBox(localBounds, modelMatrix) {
const min = localBounds.min;
const max = localBounds.max;
const corners = [
new Vector3(min.x, min.y, min.z),
new Vector3(min.x, min.y, max.z),
new Vector3(min.x, max.y, min.z),
new Vector3(min.x, max.y, max.z),
new Vector3(max.x, min.y, min.z),
new Vector3(max.x, min.y, max.z),
new Vector3(max.x, max.y, min.z),
new Vector3(max.x, max.y, max.z)
];
return computeBoundsFromPoints(corners.map((corner) => corner.applyMatrix4(modelMatrix)));
}
function readIndexedVertex(position, index, matrix) {
return new Vector3(position.getX(index), position.getY(index), position.getZ(index)).applyMatrix4(matrix);
}
function getMeshGeometry(object) {
const maybeMesh = object;
if (maybeMesh.isMesh !== true) {
return null;
}
return maybeMesh.geometry;
}
function collectMeshTriangleClusters(template) {
template.updateMatrixWorld(true);
const clusters = [];
template.traverse((object) => {
const geometry = getMeshGeometry(object);
if (geometry === null) {
return;
}
const position = geometry.getAttribute("position");
if (position === undefined || position.itemSize < 3 || position.count < 3) {
return;
}
const matrix = object.matrixWorld;
const index = geometry.getIndex();
const triangles = [];
if (index === null) {
for (let vertexIndex = 0; vertexIndex <= position.count - 3; vertexIndex += 3) {
triangles.push({
a: readIndexedVertex(position, vertexIndex, matrix),
b: readIndexedVertex(position, vertexIndex + 1, matrix),
c: readIndexedVertex(position, vertexIndex + 2, matrix)
});
}
}
else {
for (let triangleIndex = 0; triangleIndex <= index.count - 3; triangleIndex += 3) {
triangles.push({
a: readIndexedVertex(position, index.getX(triangleIndex), matrix),
b: readIndexedVertex(position, index.getX(triangleIndex + 1), matrix),
c: readIndexedVertex(position, index.getX(triangleIndex + 2), matrix)
});
}
}
if (triangles.length > 0) {
clusters.push({
triangles
});
}
});
return clusters;
}
function flattenTriangleClusters(clusters) {
return clusters.flatMap((cluster) => cluster.triangles);
}
function buildTriMeshBuffers(triangles) {
const vertices = new Float32Array(triangles.length * 9);
const indices = new Uint32Array(triangles.length * 3);
let vertexOffset = 0;
for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) {
const triangle = triangles[triangleIndex];
vertices[vertexOffset] = triangle.a.x;
vertices[vertexOffset + 1] = triangle.a.y;
vertices[vertexOffset + 2] = triangle.a.z;
vertices[vertexOffset + 3] = triangle.b.x;
vertices[vertexOffset + 4] = triangle.b.y;
vertices[vertexOffset + 5] = triangle.b.z;
vertices[vertexOffset + 6] = triangle.c.x;
vertices[vertexOffset + 7] = triangle.c.y;
vertices[vertexOffset + 8] = triangle.c.z;
indices[triangleIndex * 3] = triangleIndex * 3;
indices[triangleIndex * 3 + 1] = triangleIndex * 3 + 1;
indices[triangleIndex * 3 + 2] = triangleIndex * 3 + 2;
vertexOffset += 9;
}
return {
vertices,
indices
};
}
function computeClusterCentroid(triangles) {
const centroid = {
x: 0,
y: 0,
z: 0
};
let pointCount = 0;
for (const triangle of triangles) {
centroid.x += triangle.a.x + triangle.b.x + triangle.c.x;
centroid.y += triangle.a.y + triangle.b.y + triangle.c.y;
centroid.z += triangle.a.z + triangle.b.z + triangle.c.z;
pointCount += 3;
}
return {
x: centroid.x / pointCount,
y: centroid.y / pointCount,
z: centroid.z / pointCount
};
}
function getTriangleBounds(triangles) {
return computeBoundsFromPoints(triangles.flatMap((triangle) => [triangle.a, triangle.b, triangle.c]));
}
function splitTriangleCluster(triangles, depth) {
if (triangles.length <= DYNAMIC_TRIANGLE_TARGET || depth >= DYNAMIC_SPLIT_DEPTH_LIMIT) {
return {
kind: "leaf",
triangles
};
}
const bounds = getTriangleBounds(triangles);
const size = {
x: bounds.max.x - bounds.min.x,
y: bounds.max.y - bounds.min.y,
z: bounds.max.z - bounds.min.z
};
const splitAxis = size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z";
const sortedTriangles = [...triangles].sort((left, right) => computeClusterCentroid([left])[splitAxis] - computeClusterCentroid([right])[splitAxis]);
const splitIndex = Math.floor(sortedTriangles.length * 0.5);
if (splitIndex <= 0 || splitIndex >= sortedTriangles.length) {
return {
kind: "leaf",
triangles
};
}
return {
kind: "split",
left: sortedTriangles.slice(0, splitIndex),
right: sortedTriangles.slice(splitIndex)
};
}
function collectConvexHullPointClouds(cluster, depth = 0) {
const split = splitTriangleCluster(cluster, depth);
if (split.kind === "leaf") {
return [dedupeTriangleClusterPoints(split.triangles)];
}
return [...collectConvexHullPointClouds(split.left, depth + 1), ...collectConvexHullPointClouds(split.right, depth + 1)];
}
function quantizeCoordinate(value) {
return (Math.round(value / TERRAIN_GRID_EPSILON) * TERRAIN_GRID_EPSILON).toFixed(4);
}
function dedupeTriangleClusterPoints(triangles) {
const pointLookup = new Map();
for (const triangle of triangles) {
for (const point of [triangle.a, triangle.b, triangle.c]) {
const key = `${quantizeCoordinate(point.x)}:${quantizeCoordinate(point.y)}:${quantizeCoordinate(point.z)}`;
if (!pointLookup.has(key)) {
pointLookup.set(key, {
x: point.x,
y: point.y,
z: point.z
});
}
}
}
if (pointLookup.size < 4) {
throw new ModelColliderGenerationError("unsupported-dynamic-model-collider", "Dynamic collision requires volumetric geometry that can form at least one convex hull.");
}
return new Float32Array(Array.from(pointLookup.values()).flatMap((point) => [point.x, point.y, point.z]));
}
function buildSimpleBoxCollider(modelInstance, asset) {
const boundingBox = asset.metadata.boundingBox;
if (boundingBox === null) {
throw new ModelColliderGenerationError("missing-model-collider-bounds", `Model instance ${modelInstance.id} cannot use simple collision because the asset does not have a measurable bounding box.`);
}
const localBounds = createBounds(new Vector3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z), new Vector3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z));
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: modelInstance.assetId,
mode: "simple",
kind: "box",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
center: {
x: (boundingBox.min.x + boundingBox.max.x) * 0.5,
y: (boundingBox.min.y + boundingBox.max.y) * 0.5,
z: (boundingBox.min.z + boundingBox.max.z) * 0.5
},
size: cloneVec3(boundingBox.size),
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
function buildTriMeshCollider(modelInstance, asset, loadedAsset) {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build ${modelInstance.collision.mode} collision until asset geometry has loaded.`);
}
const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template));
if (triangles.length === 0) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use ${modelInstance.collision.mode} collision because the asset has no mesh triangles.`);
}
const buffers = buildTriMeshBuffers(triangles);
const localBounds = computeBoundsFromFloat32Points(buffers.vertices);
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "static",
kind: "trimesh",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
vertices: buffers.vertices,
indices: buffers.indices,
triangleCount: triangles.length,
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
function buildTerrainCollider(modelInstance, asset, loadedAsset) {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build terrain collision until asset geometry has loaded.`);
}
const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template));
if (triangles.length === 0) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use terrain collision because the asset has no mesh triangles.`);
}
const heightLookup = new Map();
const xValues = new Map();
const zValues = new Map();
for (const triangle of triangles) {
for (const point of [triangle.a, triangle.b, triangle.c]) {
const xKey = quantizeCoordinate(point.x);
const zKey = quantizeCoordinate(point.z);
const key = `${xKey}:${zKey}`;
const previousPoint = heightLookup.get(key);
if (previousPoint !== undefined && Math.abs(previousPoint.y - point.y) > TERRAIN_GRID_EPSILON) {
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a single-valued heightfield over X/Z.`);
}
heightLookup.set(key, {
x: point.x,
y: point.y,
z: point.z
});
xValues.set(xKey, point.x);
zValues.set(zKey, point.z);
}
}
const sortedX = Array.from(xValues.values()).sort((left, right) => left - right);
const sortedZ = Array.from(zValues.values()).sort((left, right) => left - right);
if (sortedX.length < 2 || sortedZ.length < 2) {
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh does not form a regular X/Z grid.`);
}
const expectedTriangleCount = (sortedX.length - 1) * (sortedZ.length - 1) * 2;
if (triangles.length !== expectedTriangleCount) {
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a clean regular-grid terrain surface.`);
}
const heights = new Float32Array(sortedX.length * sortedZ.length);
for (let zIndex = 0; zIndex < sortedZ.length; zIndex += 1) {
for (let xIndex = 0; xIndex < sortedX.length; xIndex += 1) {
const key = `${quantizeCoordinate(sortedX[xIndex])}:${quantizeCoordinate(sortedZ[zIndex])}`;
const point = heightLookup.get(key);
if (point === undefined) {
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is missing one or more regular-grid height samples.`);
}
heights[xIndex + zIndex * sortedX.length] = point.y;
}
}
const localBounds = computeBoundsFromPoints(Array.from(heightLookup.values(), (point) => new Vector3(point.x, point.y, point.z)));
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "terrain",
kind: "heightfield",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
rows: sortedX.length,
cols: sortedZ.length,
heights,
minX: sortedX[0],
maxX: sortedX.at(-1) ?? sortedX[0],
minZ: sortedZ[0],
maxZ: sortedZ.at(-1) ?? sortedZ[0],
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
function buildDynamicCollider(modelInstance, asset, loadedAsset) {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build dynamic collision until asset geometry has loaded.`);
}
const triangleClusters = collectMeshTriangleClusters(loadedAsset.template);
if (triangleClusters.length === 0) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use dynamic collision because the asset has no mesh triangles.`);
}
const pieces = triangleClusters
.flatMap((cluster) => collectConvexHullPointClouds(cluster.triangles))
.map((points, index) => ({
id: `${modelInstance.id}-piece-${index + 1}`,
points,
localBounds: computeBoundsFromFloat32Points(points)
}));
if (pieces.length === 0) {
throw new ModelColliderGenerationError("unsupported-dynamic-model-collider", `Model instance ${modelInstance.id} could not derive any convex pieces for dynamic collision.`);
}
const localBounds = computeBoundsFromPoints(pieces.flatMap((piece) => {
const points = [];
for (let pointIndex = 0; pointIndex < piece.points.length; pointIndex += 3) {
points.push(new Vector3(piece.points[pointIndex], piece.points[pointIndex + 1], piece.points[pointIndex + 2]));
}
return points;
}));
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "dynamic",
kind: "compound",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
pieces,
decomposition: "spatial-bisect",
runtimeBehavior: "fixedQueryOnly",
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
export function buildGeneratedModelCollider(modelInstance, asset, loadedAsset) {
switch (modelInstance.collision.mode) {
case "none":
return null;
case "simple":
return buildSimpleBoxCollider(modelInstance, asset);
case "static":
return buildTriMeshCollider(modelInstance, asset, loadedAsset);
case "terrain":
return buildTerrainCollider(modelInstance, asset, loadedAsset);
case "dynamic":
return buildDynamicCollider(modelInstance, asset, loadedAsset);
}
}