Refactor project sequence handling and add whitebox brush geometry support

This commit is contained in:
2026-04-15 07:40:33 +02:00
parent d6c81008fe
commit bf662f76f2
8 changed files with 734 additions and 559 deletions

View File

@@ -1,544 +0,0 @@
import { BufferAttribute, BufferGeometry } from "three";
import type { Vec2, Vec3 } from "../core/vector";
import {
BOX_EDGE_IDS,
BOX_FACE_IDS,
type BoxBrush,
type BoxEdgeId,
type BoxFaceId,
type BoxVertexId,
type FaceUvState
} from "../document/brushes";
import { transformProjectedFaceUv } from "./box-face-uvs";
const FACE_VERTEX_IDS: Record<BoxFaceId, readonly [BoxVertexId, BoxVertexId, BoxVertexId, BoxVertexId]> = {
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: Record<BoxEdgeId, readonly [BoxVertexId, BoxVertexId]> = {
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;
export interface BoxBrushGeometryDiagnostic {
code: string;
message: string;
faceId?: BoxFaceId;
}
export interface DerivedBoxBrushFaceSurface {
faceId: BoxFaceId;
vertexIds: readonly [BoxVertexId, BoxVertexId, BoxVertexId, BoxVertexId];
triangles: Array<readonly [number, number, number]>;
normal: Vec3;
}
export interface DerivedBoxBrushMeshData {
geometry: BufferGeometry;
faceSurfaces: DerivedBoxBrushFaceSurface[];
edgeSegments: Array<{ edgeId: BoxEdgeId; start: Vec3; end: Vec3 }>;
colliderVertices: Float32Array;
colliderIndices: Uint32Array;
localBounds: { min: Vec3; max: Vec3 };
}
function cloneVec3(vector: Vec3): Vec3 {
return { x: vector.x, y: vector.y, z: vector.z };
}
function dotVec3(left: Vec3, right: Vec3): number {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function getVectorLength(vector: Vec3): number {
return Math.sqrt(dotVec3(vector, vector));
}
function normalizeVec3(vector: Vec3): Vec3 {
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: Vec3[]): Vec3 {
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: Vec3): [keyof Vec3, keyof Vec3] {
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: Vec3[], normal: Vec3): Vec2[] {
const [uAxis, vAxis] = chooseProjectionAxes(normal);
return vertices.map((vertex) => ({
x: vertex[uAxis],
y: vertex[vAxis]
}));
}
function computeSignedArea(points: Vec2[]): number {
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: Vec2, triangle: [Vec2, Vec2, Vec2], orientation: number): boolean {
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: Vec3[]): Array<readonly [number, number, number]> {
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: Array<readonly [number, number, number]> = [];
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: [Vec2, Vec2, Vec2] = [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: Vec3, faceId: BoxFaceId, faceBounds: { min: Vec3; max: Vec3 }): Vec2 {
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: BoxFaceId, faceBounds: { min: Vec3; max: Vec3 }): Vec2 {
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: Vec3[]): { min: Vec3; max: Vec3 } {
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: number, end: number, amount: number) {
return start + (end - start) * amount;
}
function lerpVec3(start: Vec3, end: Vec3, amount: number): Vec3 {
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: readonly [Vec3, Vec3, Vec3, Vec3],
u: number,
v: number
): Vec3 {
const topEdge = lerpVec3(corners[0], corners[1], u);
const bottomEdge = lerpVec3(corners[3], corners[2], u);
return lerpVec3(topEdge, bottomEdge, v);
}
function pushRenderedFaceVertex(
positions: number[],
normals: number[],
uvs: number[],
faceUvs: number[],
indices: number[],
vertex: Vec3,
normal: Vec3,
faceId: BoxFaceId,
faceBounds: { min: Vec3; max: Vec3 },
uvSize: Vec2,
uvState: FaceUvState
) {
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);
faceUvs.push(
uvSize.x <= 1e-8 ? 0.5 : projectedUv.x / uvSize.x,
uvSize.y <= 1e-8 ? 0.5 : projectedUv.y / uvSize.y
);
indices.push(indices.length);
}
export function getBoxBrushFaceVertexIds(faceId: BoxFaceId): readonly [BoxVertexId, BoxVertexId, BoxVertexId, BoxVertexId] {
return FACE_VERTEX_IDS[faceId];
}
export function getBoxBrushEdgeVertexIds(edgeId: BoxEdgeId): readonly [BoxVertexId, BoxVertexId] {
return EDGE_VERTEX_IDS[edgeId];
}
export function getBoxBrushLocalVertexPosition(brush: BoxBrush, vertexId: BoxVertexId): Vec3 {
return cloneVec3(brush.geometry.vertices[vertexId]);
}
export function buildBoxBrushDerivedMeshData(brush: BoxBrush): DerivedBoxBrushMeshData {
const diagnostics = validateBoxBrushGeometry(brush);
if (diagnostics.length > 0) {
throw new Error(diagnostics[0].message);
}
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const faceUvs: number[] = [];
const indices: number[] = [];
const colliderVertices: number[] = [];
const colliderIndices: number[] = [];
const faceSurfaces: DerivedBoxBrushFaceSurface[] = [];
const groups: Array<{ start: number; count: number; materialIndex: number }> = [];
const vertexIndexMap = new Map<BoxVertexId, number>();
for (const vertexId of Object.keys(brush.geometry.vertices) as BoxVertexId[]) {
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 as FaceUvState;
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 as [Vec3, Vec3, Vec3, Vec3];
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: [Vec3, Vec3, Vec3, Vec3] = [
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,
faceUvs,
indices,
vertex,
normal,
faceId,
faceBounds,
uvSize,
uvState
);
}
}
}
} else {
for (const triangle of triangles) {
for (const vertexOffset of triangle) {
pushRenderedFaceVertex(
positions,
normals,
uvs,
faceUvs,
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.setAttribute("faceUv", new BufferAttribute(new Float32Array(faceUvs), 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: BoxBrush): BoxBrushGeometryDiagnostic[] {
const diagnostics: BoxBrushGeometryDiagnostic[] = [];
for (const [vertexId, vertex] of Object.entries(brush.geometry.vertices) as Array<[BoxVertexId, Vec3]>) {
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

@@ -0,0 +1,316 @@
import { Euler, MathUtils, Quaternion, Vector3 } from "three";
import type { Vec3 } from "../core/vector";
import type {
Brush,
BrushGeometry,
WhiteboxEdgeId,
WhiteboxFaceId,
WhiteboxVertexId
} from "../document/brushes";
import {
getBrushEdgeVertexIds,
getBrushFaceVertexIds,
getBrushVertexIds
} from "./whitebox-topology";
export interface WhiteboxBounds {
min: Vec3;
max: Vec3;
}
export interface WhiteboxEdgeWorldSegment {
id: WhiteboxEdgeId;
start: Vec3;
end: Vec3;
center: Vec3;
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function createBrushRotationEuler(brush: Brush): Euler {
return new Euler(
MathUtils.degToRad(brush.rotationDegrees.x),
MathUtils.degToRad(brush.rotationDegrees.y),
MathUtils.degToRad(brush.rotationDegrees.z),
"XYZ"
);
}
export function getBrushLocalVertexPosition(
brush: { geometry: BrushGeometry },
vertexId: WhiteboxVertexId
): Vec3 {
const vertex = brush.geometry.vertices[vertexId];
if (vertex === undefined) {
throw new Error(`Whitebox vertex ${vertexId} does not exist on this brush.`);
}
return cloneVec3(vertex);
}
export function transformBrushWorldVectorToLocal(
brush: Brush,
worldVector: Vec3
): Vec3 {
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 transformBrushWorldPointToLocal(
brush: Brush,
worldPoint: Vec3
): Vec3 {
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 transformBrushLocalPointToWorld(
brush: Brush,
localPoint: Vec3
): Vec3 {
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 getBrushVertexWorldPosition(
brush: Brush,
vertexId: WhiteboxVertexId
): Vec3 {
return transformBrushLocalPointToWorld(
brush,
getBrushLocalVertexPosition(brush, vertexId)
);
}
export function getBrushFaceWorldCenter(
brush: Brush,
faceId: WhiteboxFaceId
): Vec3 {
const vertexIds = getBrushFaceVertexIds(brush, faceId);
const weight = 1 / vertexIds.length;
const localCenter = vertexIds.reduce(
(accumulator, vertexId) => {
const vertex = getBrushLocalVertexPosition(brush, vertexId);
return {
x: accumulator.x + vertex.x * weight,
y: accumulator.y + vertex.y * weight,
z: accumulator.z + vertex.z * weight
};
},
{
x: 0,
y: 0,
z: 0
}
);
return transformBrushLocalPointToWorld(brush, localCenter);
}
export function getBrushEdgeWorldSegment(
brush: Brush,
edgeId: WhiteboxEdgeId
): WhiteboxEdgeWorldSegment {
const [startId, endId] = getBrushEdgeVertexIds(brush, edgeId);
const start = getBrushVertexWorldPosition(brush, startId);
const end = getBrushVertexWorldPosition(brush, endId);
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
}
};
}
export function getBrushBounds(brush: Brush): WhiteboxBounds {
const corners = getBrushVertexIds(brush).map((vertexId) =>
getBrushVertexWorldPosition(brush, vertexId)
);
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
};
}
function subtractVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z
};
}
function crossVec3(left: Vec3, right: Vec3): Vec3 {
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: Vec3, right: Vec3): number {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function getVectorLength(vector: Vec3): number {
return Math.sqrt(dotVec3(vector, vector));
}
function normalizeVec3(vector: Vec3): Vec3 {
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 getDominantAxis(vector: Vec3): "x" | "y" | "z" {
const absolute = {
x: Math.abs(vector.x),
y: Math.abs(vector.y),
z: Math.abs(vector.z)
};
if (absolute.x >= absolute.y && absolute.x >= absolute.z) {
return "x";
}
if (absolute.y >= absolute.z) {
return "y";
}
return "z";
}
export function getBrushFaceNormal(
brush: Brush,
faceId: WhiteboxFaceId
): Vec3 {
const vertexIds = getBrushFaceVertexIds(brush, faceId);
const vertices = vertexIds.map((vertexId) =>
getBrushLocalVertexPosition(brush, vertexId)
);
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);
}
export function getBrushFaceAxis(
brush: Brush,
faceId: WhiteboxFaceId
): "x" | "y" | "z" {
return getDominantAxis(getBrushFaceNormal(brush, faceId));
}
export function getBrushEdgeAxis(
brush: Brush,
edgeId: WhiteboxEdgeId
): "x" | "y" | "z" {
const [startId, endId] = getBrushEdgeVertexIds(brush, edgeId);
const start = getBrushLocalVertexPosition(brush, startId);
const end = getBrushLocalVertexPosition(brush, endId);
return getDominantAxis(subtractVec3(end, start));
}
export function getBrushEdgeScaleAxes(
brush: Brush,
edgeId: WhiteboxEdgeId
): Array<"x" | "y" | "z"> {
const edgeAxis = getBrushEdgeAxis(brush, edgeId);
return (["x", "y", "z"] as const).filter((axis) => axis !== edgeAxis);
}
export function getBrushFaceBasis(
brush: Brush,
faceId: WhiteboxFaceId
): { origin: Vec3; uAxis: Vec3; vAxis: Vec3 } {
const vertexIds = getBrushFaceVertexIds(brush, faceId);
const vertices = vertexIds.map((vertexId) =>
getBrushLocalVertexPosition(brush, vertexId)
);
const origin = vertices[0];
const firstEdge = normalizeVec3(subtractVec3(vertices[1], origin));
const normal = getBrushFaceNormal(brush, faceId);
const vAxis = normalizeVec3(crossVec3(normal, firstEdge));
return {
origin,
uAxis: firstEdge,
vAxis
};
}

View File

@@ -0,0 +1,316 @@
import {
BOX_EDGE_IDS,
BOX_EDGE_LABELS,
BOX_FACE_IDS,
BOX_FACE_LABELS,
BOX_VERTEX_IDS,
BOX_VERTEX_LABELS,
WEDGE_EDGE_IDS,
WEDGE_EDGE_LABELS,
WEDGE_FACE_IDS,
WEDGE_FACE_LABELS,
WEDGE_VERTEX_IDS,
WEDGE_VERTEX_LABELS,
getRadialPrismEdgeIds,
getRadialPrismFaceIds,
getRadialPrismVertexIds,
type Brush,
type BoxBrush,
type BoxEdgeId,
type BoxFaceId,
type BoxVertexId,
type RadialPrismBrush,
type RadialPrismEdgeId,
type RadialPrismFaceId,
type RadialPrismVertexId,
type WhiteboxEdgeId,
type WhiteboxFaceId,
type WhiteboxVertexId,
type WedgeBrush,
type WedgeEdgeId,
type WedgeFaceId,
type WedgeVertexId
} from "../document/brushes";
const BOX_FACE_VERTEX_IDS: Record<
BoxFaceId,
readonly [BoxVertexId, BoxVertexId, BoxVertexId, BoxVertexId]
> = {
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 BOX_EDGE_VERTEX_IDS: Record<
BoxEdgeId,
readonly [BoxVertexId, BoxVertexId]
> = {
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 WEDGE_FACE_VERTEX_IDS: Record<WedgeFaceId, readonly WedgeVertexId[]> = {
bottom: [
"negX_negY_negZ",
"posX_negY_negZ",
"posX_negY_posZ",
"negX_negY_posZ"
],
back: ["posX_negY_negZ", "negX_negY_negZ", "negX_posY_negZ", "posX_posY_negZ"],
slope: ["negX_negY_posZ", "posX_negY_posZ", "posX_posY_negZ", "negX_posY_negZ"],
left: ["negX_negY_negZ", "negX_negY_posZ", "negX_posY_negZ"],
right: ["posX_negY_posZ", "posX_negY_negZ", "posX_posY_negZ"]
};
const WEDGE_EDGE_VERTEX_IDS: Record<
WedgeEdgeId,
readonly [WedgeVertexId, WedgeVertexId]
> = {
bottomBack: ["negX_negY_negZ", "posX_negY_negZ"],
bottomFront: ["negX_negY_posZ", "posX_negY_posZ"],
bottomLeft: ["negX_negY_negZ", "negX_negY_posZ"],
bottomRight: ["posX_negY_negZ", "posX_negY_posZ"],
topBack: ["negX_posY_negZ", "posX_posY_negZ"],
leftBack: ["negX_negY_negZ", "negX_posY_negZ"],
rightBack: ["posX_negY_negZ", "posX_posY_negZ"],
leftSlope: ["negX_posY_negZ", "negX_negY_posZ"],
rightSlope: ["posX_posY_negZ", "posX_negY_posZ"]
};
function getRadialPrismFaceLabel(faceId: RadialPrismFaceId): string {
if (faceId === "top") {
return "Top";
}
if (faceId === "bottom") {
return "Bottom";
}
return `Side ${Number(faceId.slice(5)) + 1}`;
}
function getRadialPrismEdgeLabel(edgeId: RadialPrismEdgeId): string {
if (edgeId.startsWith("vertical-")) {
return `Vertical ${Number(edgeId.slice(9)) + 1}`;
}
if (edgeId.startsWith("top-")) {
return `Top Ring ${Number(edgeId.slice(4)) + 1}`;
}
return `Bottom Ring ${Number(edgeId.slice(7)) + 1}`;
}
function getRadialPrismVertexLabel(vertexId: RadialPrismVertexId): string {
if (vertexId.startsWith("top-")) {
return `Top ${Number(vertexId.slice(4)) + 1}`;
}
return `Bottom ${Number(vertexId.slice(7)) + 1}`;
}
function getRadialPrismFaceVertexIds(
brush: RadialPrismBrush,
faceId: RadialPrismFaceId
): WhiteboxVertexId[] {
if (faceId === "top") {
return Array.from({ length: brush.sideCount }, (_, index) => `top-${brush.sideCount - 1 - index}` as const);
}
if (faceId === "bottom") {
return Array.from({ length: brush.sideCount }, (_, index) => `bottom-${index}` as const);
}
const sideIndex = Number(faceId.slice(5));
const nextIndex = (sideIndex + 1) % brush.sideCount;
return [
`bottom-${sideIndex}`,
`bottom-${nextIndex}`,
`top-${nextIndex}`,
`top-${sideIndex}`
];
}
function getRadialPrismEdgeVertexIds(
edgeId: RadialPrismEdgeId,
sideCount: number
): [RadialPrismVertexId, RadialPrismVertexId] {
if (edgeId.startsWith("vertical-")) {
const index = Number(edgeId.slice(9));
return [`bottom-${index}`, `top-${index}`];
}
if (edgeId.startsWith("top-")) {
const index = Number(edgeId.slice(4));
const nextIndex = (index + 1) % sideCount;
return [`top-${index}`, `top-${nextIndex}`];
}
const index = Number(edgeId.slice(7));
const nextIndex = (index + 1) % sideCount;
return [`bottom-${index}`, `bottom-${nextIndex}`];
}
export function getBrushFaceIds(brush: Brush): WhiteboxFaceId[] {
switch (brush.kind) {
case "box":
return [...BOX_FACE_IDS];
case "wedge":
return [...WEDGE_FACE_IDS];
case "radialPrism":
return getRadialPrismFaceIds(brush.sideCount);
}
}
export function getBrushEdgeIds(brush: Brush): WhiteboxEdgeId[] {
switch (brush.kind) {
case "box":
return [...BOX_EDGE_IDS];
case "wedge":
return [...WEDGE_EDGE_IDS];
case "radialPrism":
return getRadialPrismEdgeIds(brush.sideCount);
}
}
export function getBrushVertexIds(brush: Brush): WhiteboxVertexId[] {
switch (brush.kind) {
case "box":
return [...BOX_VERTEX_IDS];
case "wedge":
return [...WEDGE_VERTEX_IDS];
case "radialPrism":
return getRadialPrismVertexIds(brush.sideCount);
}
}
export function getBrushFaceLabel(
brush: Brush,
faceId: WhiteboxFaceId
): string {
switch (brush.kind) {
case "box":
return BOX_FACE_LABELS[faceId as BoxFaceId] ?? faceId;
case "wedge":
return WEDGE_FACE_LABELS[faceId as WedgeFaceId] ?? faceId;
case "radialPrism":
return getRadialPrismFaceLabel(faceId as RadialPrismFaceId);
}
}
export function getBrushEdgeLabel(
brush: Brush,
edgeId: WhiteboxEdgeId
): string {
switch (brush.kind) {
case "box":
return BOX_EDGE_LABELS[edgeId as BoxEdgeId] ?? edgeId;
case "wedge":
return WEDGE_EDGE_LABELS[edgeId as WedgeEdgeId] ?? edgeId;
case "radialPrism":
return getRadialPrismEdgeLabel(edgeId as RadialPrismEdgeId);
}
}
export function getBrushVertexLabel(
brush: Brush,
vertexId: WhiteboxVertexId
): string {
switch (brush.kind) {
case "box":
return BOX_VERTEX_LABELS[vertexId as BoxVertexId] ?? vertexId;
case "wedge":
return WEDGE_VERTEX_LABELS[vertexId as WedgeVertexId] ?? vertexId;
case "radialPrism":
return getRadialPrismVertexLabel(vertexId as RadialPrismVertexId);
}
}
export function getBrushFaceVertexIds(
brush: Brush,
faceId: WhiteboxFaceId
): WhiteboxVertexId[] {
switch (brush.kind) {
case "box":
return [...BOX_FACE_VERTEX_IDS[faceId as BoxFaceId]];
case "wedge":
return [...WEDGE_FACE_VERTEX_IDS[faceId as WedgeFaceId]];
case "radialPrism":
return getRadialPrismFaceVertexIds(brush, faceId as RadialPrismFaceId);
}
}
export function getBrushEdgeVertexIds(
brush: Brush,
edgeId: WhiteboxEdgeId
): [WhiteboxVertexId, WhiteboxVertexId] {
switch (brush.kind) {
case "box":
return [...BOX_EDGE_VERTEX_IDS[edgeId as BoxEdgeId]] as [
WhiteboxVertexId,
WhiteboxVertexId
];
case "wedge":
return [...WEDGE_EDGE_VERTEX_IDS[edgeId as WedgeEdgeId]] as [
WhiteboxVertexId,
WhiteboxVertexId
];
case "radialPrism":
return getRadialPrismEdgeVertexIds(
edgeId as RadialPrismEdgeId,
brush.sideCount
);
}
}
export function getBoxBrushFaceVertexIds(faceId: BoxFaceId): readonly [
BoxVertexId,
BoxVertexId,
BoxVertexId,
BoxVertexId
] {
return BOX_FACE_VERTEX_IDS[faceId];
}
export function getBoxBrushEdgeVertexIds(edgeId: BoxEdgeId): readonly [
BoxVertexId,
BoxVertexId
] {
return BOX_EDGE_VERTEX_IDS[edgeId];
}
export function getBrushDefaultName(brush: Brush, index: number): string {
switch (brush.kind) {
case "box":
return `Whitebox Box ${index + 1}`;
case "wedge":
return `Whitebox Wedge ${index + 1}`;
case "radialPrism":
return `Whitebox Cylinder ${index + 1}`;
}
}
export function getBrushKindLabel(brush: Brush): string {
switch (brush.kind) {
case "box":
return "Whitebox Box";
case "wedge":
return "Whitebox Wedge";
case "radialPrism":
return "Whitebox Cylinder";
}
}