From d80b32408d288f3c72b07c339e0744d2746dde6a Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Wed, 15 Apr 2026 07:43:30 +0200 Subject: [PATCH] Add box brush mesh and update related files --- src/commands/brush-command-helpers.ts | 35 +- ...et-box-brush-all-face-materials-command.ts | 56 +- .../set-box-brush-face-material-command.ts | 4 +- .../set-box-brush-face-uv-state-command.ts | 8 +- .../update-box-brush-all-face-uvs-command.ts | 61 +- src/core/selection.ts | 42 +- src/core/whitebox-selection-feedback.ts | 46 +- src/document/brushes.ts | 16 +- src/geometry/box-brush-mesh.ts | 659 ++++++++++++++++++ src/geometry/box-brush.ts | 11 +- src/geometry/box-face-uvs.ts | 77 +- 11 files changed, 880 insertions(+), 135 deletions(-) create mode 100644 src/geometry/box-brush-mesh.ts diff --git a/src/commands/brush-command-helpers.ts b/src/commands/brush-command-helpers.ts index 90c447bf..7b795ceb 100644 --- a/src/commands/brush-command-helpers.ts +++ b/src/commands/brush-command-helpers.ts @@ -1,25 +1,25 @@ import { cloneEditorSelection, type EditorSelection } from "../core/selection"; import { + cloneBrush, + cloneBrushGeometry, cloneFaceUvState, - type BoxBrush, - type BoxEdgeId, - type BoxFaceId, - type BoxVertexId, + type Brush, type BrushFace } from "../document/brushes"; import type { SceneDocument } from "../document/scene-document"; +import type { + WhiteboxEdgeId, + WhiteboxFaceId, + WhiteboxVertexId +} from "../document/brushes"; -export function getBoxBrushOrThrow(document: SceneDocument, brushId: string): BoxBrush { +export function getBoxBrushOrThrow(document: SceneDocument, brushId: string): Brush { const brush = document.brushes[brushId]; if (brush === undefined) { throw new Error(`Box brush ${brushId} does not exist.`); } - if (brush.kind !== "box") { - throw new Error(`Brush ${brushId} is not a supported box brush.`); - } - return brush; } @@ -30,7 +30,7 @@ export function setSingleBrushSelection(brushId: string): EditorSelection { }; } -export function setSingleBrushFaceSelection(brushId: string, faceId: BoxFaceId): EditorSelection { +export function setSingleBrushFaceSelection(brushId: string, faceId: WhiteboxFaceId): EditorSelection { return { kind: "brushFace", brushId, @@ -38,7 +38,7 @@ export function setSingleBrushFaceSelection(brushId: string, faceId: BoxFaceId): }; } -export function setSingleBrushEdgeSelection(brushId: string, edgeId: BoxEdgeId): EditorSelection { +export function setSingleBrushEdgeSelection(brushId: string, edgeId: WhiteboxEdgeId): EditorSelection { return { kind: "brushEdge", brushId, @@ -46,7 +46,7 @@ export function setSingleBrushEdgeSelection(brushId: string, edgeId: BoxEdgeId): }; } -export function setSingleBrushVertexSelection(brushId: string, vertexId: BoxVertexId): EditorSelection { +export function setSingleBrushVertexSelection(brushId: string, vertexId: WhiteboxVertexId): EditorSelection { return { kind: "brushVertex", brushId, @@ -58,12 +58,12 @@ export function cloneSelectionForCommand(selection: EditorSelection): EditorSele return cloneEditorSelection(selection); } -export function replaceBrush(document: SceneDocument, brush: BoxBrush): SceneDocument { +export function replaceBrush(document: SceneDocument, brush: Brush): SceneDocument { return { ...document, brushes: { ...document.brushes, - [brush.id]: brush + [brush.id]: cloneBrush(brush) } }; } @@ -80,7 +80,7 @@ export function removeBrush(document: SceneDocument, brushId: string): SceneDocu }; } -export function getBoxBrushFaceOrThrow(document: SceneDocument, brushId: string, faceId: BoxFaceId): BrushFace { +export function getBoxBrushFaceOrThrow(document: SceneDocument, brushId: string, faceId: WhiteboxFaceId): BrushFace { const brush = getBoxBrushOrThrow(document, brushId); const face = brush.faces[faceId]; @@ -91,7 +91,7 @@ export function getBoxBrushFaceOrThrow(document: SceneDocument, brushId: string, return face; } -export function replaceBoxBrushFace(document: SceneDocument, brushId: string, faceId: BoxFaceId, face: BrushFace): SceneDocument { +export function replaceBoxBrushFace(document: SceneDocument, brushId: string, faceId: WhiteboxFaceId, face: BrushFace): SceneDocument { const brush = getBoxBrushOrThrow(document, brushId); return replaceBrush(document, { @@ -102,6 +102,7 @@ export function replaceBoxBrushFace(document: SceneDocument, brushId: string, fa materialId: face.materialId, uv: cloneFaceUvState(face.uv) } - } + }, + geometry: cloneBrushGeometry(brush.geometry) }); } diff --git a/src/commands/set-box-brush-all-face-materials-command.ts b/src/commands/set-box-brush-all-face-materials-command.ts index 762ef692..f04e5ef7 100644 --- a/src/commands/set-box-brush-all-face-materials-command.ts +++ b/src/commands/set-box-brush-all-face-materials-command.ts @@ -1,7 +1,8 @@ import type { ToolMode } from "../core/tool-mode"; import { createOpaqueId } from "../core/ids"; import type { EditorSelection } from "../core/selection"; -import { BOX_FACE_IDS, type BoxFaceId } from "../document/brushes"; +import type { WhiteboxFaceId } from "../document/brushes"; +import { getBrushFaceIds } from "../geometry/whitebox-topology"; import { cloneSelectionForCommand, @@ -18,7 +19,7 @@ interface SetBoxBrushAllFaceMaterialsCommandOptions { export function createSetBoxBrushAllFaceMaterialsCommand( options: SetBoxBrushAllFaceMaterialsCommandOptions ): EditorCommand { - let previousMaterialIds: Record | null = null; + let previousMaterialIds: Record | null = null; let previousSelection: EditorSelection | null = null; let previousToolMode: ToolMode | null = null; @@ -42,14 +43,12 @@ export function createSetBoxBrushAllFaceMaterialsCommand( } if (previousMaterialIds === null) { - previousMaterialIds = { - posX: currentBrush.faces.posX.materialId, - negX: currentBrush.faces.negX.materialId, - posY: currentBrush.faces.posY.materialId, - negY: currentBrush.faces.negY.materialId, - posZ: currentBrush.faces.posZ.materialId, - negZ: currentBrush.faces.negZ.materialId - }; + previousMaterialIds = Object.fromEntries( + getBrushFaceIds(currentBrush).map((faceId) => [ + faceId, + currentBrush.faces[faceId].materialId + ]) + ); } if (previousSelection === null) { @@ -64,7 +63,7 @@ export function createSetBoxBrushAllFaceMaterialsCommand( replaceBrush(currentDocument, { ...currentBrush, faces: Object.fromEntries( - BOX_FACE_IDS.map((faceId) => [ + getBrushFaceIds(currentBrush).map((faceId) => [ faceId, { ...currentBrush.faces[faceId], @@ -87,32 +86,15 @@ export function createSetBoxBrushAllFaceMaterialsCommand( context.setDocument( replaceBrush(currentDocument, { ...currentBrush, - faces: { - posX: { - ...currentBrush.faces.posX, - materialId: previousMaterialIds.posX - }, - negX: { - ...currentBrush.faces.negX, - materialId: previousMaterialIds.negX - }, - posY: { - ...currentBrush.faces.posY, - materialId: previousMaterialIds.posY - }, - negY: { - ...currentBrush.faces.negY, - materialId: previousMaterialIds.negY - }, - posZ: { - ...currentBrush.faces.posZ, - materialId: previousMaterialIds.posZ - }, - negZ: { - ...currentBrush.faces.negZ, - materialId: previousMaterialIds.negZ - } - } + faces: Object.fromEntries( + getBrushFaceIds(currentBrush).map((faceId) => [ + faceId, + { + ...currentBrush.faces[faceId], + materialId: previousMaterialIds[faceId] ?? null + } + ]) + ) as typeof currentBrush.faces }) ); diff --git a/src/commands/set-box-brush-face-material-command.ts b/src/commands/set-box-brush-face-material-command.ts index 0baf0b21..23a4922f 100644 --- a/src/commands/set-box-brush-face-material-command.ts +++ b/src/commands/set-box-brush-face-material-command.ts @@ -1,7 +1,7 @@ import type { ToolMode } from "../core/tool-mode"; import { createOpaqueId } from "../core/ids"; import type { EditorSelection } from "../core/selection"; -import type { BoxFaceId } from "../document/brushes"; +import type { WhiteboxFaceId } from "../document/brushes"; import { cloneSelectionForCommand, @@ -13,7 +13,7 @@ import type { EditorCommand } from "./command"; interface SetBoxBrushFaceMaterialCommandOptions { brushId: string; - faceId: BoxFaceId; + faceId: WhiteboxFaceId; materialId: string | null; } diff --git a/src/commands/set-box-brush-face-uv-state-command.ts b/src/commands/set-box-brush-face-uv-state-command.ts index 59605a6a..59a55ba7 100644 --- a/src/commands/set-box-brush-face-uv-state-command.ts +++ b/src/commands/set-box-brush-face-uv-state-command.ts @@ -1,7 +1,11 @@ import type { ToolMode } from "../core/tool-mode"; import { createOpaqueId } from "../core/ids"; import type { EditorSelection } from "../core/selection"; -import { cloneFaceUvState, type BoxFaceId, type FaceUvState } from "../document/brushes"; +import { + cloneFaceUvState, + type FaceUvState, + type WhiteboxFaceId +} from "../document/brushes"; import { cloneSelectionForCommand, @@ -13,7 +17,7 @@ import type { EditorCommand } from "./command"; interface SetBoxBrushFaceUvStateCommandOptions { brushId: string; - faceId: BoxFaceId; + faceId: WhiteboxFaceId; uvState: FaceUvState; label?: string; } diff --git a/src/commands/update-box-brush-all-face-uvs-command.ts b/src/commands/update-box-brush-all-face-uvs-command.ts index 1027a2d9..f04fdd2f 100644 --- a/src/commands/update-box-brush-all-face-uvs-command.ts +++ b/src/commands/update-box-brush-all-face-uvs-command.ts @@ -2,11 +2,11 @@ import type { ToolMode } from "../core/tool-mode"; import { createOpaqueId } from "../core/ids"; import type { EditorSelection } from "../core/selection"; import { - BOX_FACE_IDS, cloneFaceUvState, - type BoxFaceId, - type FaceUvState + type FaceUvState, + type WhiteboxFaceId } from "../document/brushes"; +import { getBrushFaceIds } from "../geometry/whitebox-topology"; import { cloneSelectionForCommand, @@ -18,13 +18,13 @@ import type { EditorCommand } from "./command"; interface UpdateBoxBrushAllFaceUvsCommandOptions { brushId: string; label: string; - updateUvState(uvState: FaceUvState, faceId: BoxFaceId): FaceUvState; + updateUvState(uvState: FaceUvState, faceId: WhiteboxFaceId): FaceUvState; } export function createUpdateBoxBrushAllFaceUvsCommand( options: UpdateBoxBrushAllFaceUvsCommandOptions ): EditorCommand { - let previousUvStates: Record | null = null; + let previousUvStates: Record | null = null; let previousSelection: EditorSelection | null = null; let previousToolMode: ToolMode | null = null; @@ -36,14 +36,12 @@ export function createUpdateBoxBrushAllFaceUvsCommand( const currentBrush = getBoxBrushOrThrow(currentDocument, options.brushId); if (previousUvStates === null) { - previousUvStates = { - posX: cloneFaceUvState(currentBrush.faces.posX.uv), - negX: cloneFaceUvState(currentBrush.faces.negX.uv), - posY: cloneFaceUvState(currentBrush.faces.posY.uv), - negY: cloneFaceUvState(currentBrush.faces.negY.uv), - posZ: cloneFaceUvState(currentBrush.faces.posZ.uv), - negZ: cloneFaceUvState(currentBrush.faces.negZ.uv) - }; + previousUvStates = Object.fromEntries( + getBrushFaceIds(currentBrush).map((faceId) => [ + faceId, + cloneFaceUvState(currentBrush.faces[faceId].uv) + ]) + ); } if (previousSelection === null) { @@ -58,7 +56,7 @@ export function createUpdateBoxBrushAllFaceUvsCommand( replaceBrush(currentDocument, { ...currentBrush, faces: Object.fromEntries( - BOX_FACE_IDS.map((faceId) => [ + getBrushFaceIds(currentBrush).map((faceId) => [ faceId, { ...currentBrush.faces[faceId], @@ -83,32 +81,15 @@ export function createUpdateBoxBrushAllFaceUvsCommand( context.setDocument( replaceBrush(currentDocument, { ...currentBrush, - faces: { - posX: { - ...currentBrush.faces.posX, - uv: cloneFaceUvState(previousUvStates.posX) - }, - negX: { - ...currentBrush.faces.negX, - uv: cloneFaceUvState(previousUvStates.negX) - }, - posY: { - ...currentBrush.faces.posY, - uv: cloneFaceUvState(previousUvStates.posY) - }, - negY: { - ...currentBrush.faces.negY, - uv: cloneFaceUvState(previousUvStates.negY) - }, - posZ: { - ...currentBrush.faces.posZ, - uv: cloneFaceUvState(previousUvStates.posZ) - }, - negZ: { - ...currentBrush.faces.negZ, - uv: cloneFaceUvState(previousUvStates.negZ) - } - } + faces: Object.fromEntries( + getBrushFaceIds(currentBrush).map((faceId) => [ + faceId, + { + ...currentBrush.faces[faceId], + uv: cloneFaceUvState(previousUvStates[faceId]) + } + ]) + ) as typeof currentBrush.faces }) ); diff --git a/src/core/selection.ts b/src/core/selection.ts index 55ea0170..17a9a723 100644 --- a/src/core/selection.ts +++ b/src/core/selection.ts @@ -1,12 +1,16 @@ import type { WhiteboxSelectionMode } from "./whitebox-selection-mode"; -import type { BoxEdgeId, BoxFaceId, BoxVertexId } from "../document/brushes"; +import type { + WhiteboxEdgeId, + WhiteboxFaceId, + WhiteboxVertexId +} from "../document/brushes"; export type EditorSelection = | { kind: "none" } | { kind: "brushes"; ids: string[] } - | { kind: "brushFace"; brushId: string; faceId: BoxFaceId } - | { kind: "brushEdge"; brushId: string; edgeId: BoxEdgeId } - | { kind: "brushVertex"; brushId: string; vertexId: BoxVertexId } + | { kind: "brushFace"; brushId: string; faceId: WhiteboxFaceId } + | { kind: "brushEdge"; brushId: string; edgeId: WhiteboxEdgeId } + | { kind: "brushVertex"; brushId: string; vertexId: WhiteboxVertexId } | { kind: "paths"; ids: string[] } | { kind: "pathPoint"; pathId: string; pointId: string } | { kind: "entities"; ids: string[] } @@ -93,7 +97,9 @@ export function getSingleSelectedBrushId(selection: EditorSelection): string | n return selection.ids[0]; } -export function getSelectedBrushFaceId(selection: EditorSelection): BoxFaceId | null { +export function getSelectedBrushFaceId( + selection: EditorSelection +): WhiteboxFaceId | null { if (selection.kind !== "brushFace") { return null; } @@ -101,7 +107,9 @@ export function getSelectedBrushFaceId(selection: EditorSelection): BoxFaceId | return selection.faceId; } -export function getSelectedBrushEdgeId(selection: EditorSelection): BoxEdgeId | null { +export function getSelectedBrushEdgeId( + selection: EditorSelection +): WhiteboxEdgeId | null { if (selection.kind !== "brushEdge") { return null; } @@ -109,7 +117,9 @@ export function getSelectedBrushEdgeId(selection: EditorSelection): BoxEdgeId | return selection.edgeId; } -export function getSelectedBrushVertexId(selection: EditorSelection): BoxVertexId | null { +export function getSelectedBrushVertexId( + selection: EditorSelection +): WhiteboxVertexId | null { if (selection.kind !== "brushVertex") { return null; } @@ -168,15 +178,27 @@ export function isBrushSelected(selection: EditorSelection, brushId: string): bo ); } -export function isBrushFaceSelected(selection: EditorSelection, brushId: string, faceId: BoxFaceId): boolean { +export function isBrushFaceSelected( + selection: EditorSelection, + brushId: string, + faceId: WhiteboxFaceId +): boolean { return selection.kind === "brushFace" && selection.brushId === brushId && selection.faceId === faceId; } -export function isBrushEdgeSelected(selection: EditorSelection, brushId: string, edgeId: BoxEdgeId): boolean { +export function isBrushEdgeSelected( + selection: EditorSelection, + brushId: string, + edgeId: WhiteboxEdgeId +): boolean { return selection.kind === "brushEdge" && selection.brushId === brushId && selection.edgeId === edgeId; } -export function isBrushVertexSelected(selection: EditorSelection, brushId: string, vertexId: BoxVertexId): boolean { +export function isBrushVertexSelected( + selection: EditorSelection, + brushId: string, + vertexId: WhiteboxVertexId +): boolean { return selection.kind === "brushVertex" && selection.brushId === brushId && selection.vertexId === vertexId; } diff --git a/src/core/whitebox-selection-feedback.ts b/src/core/whitebox-selection-feedback.ts index fc183fd5..b5b0ab7e 100644 --- a/src/core/whitebox-selection-feedback.ts +++ b/src/core/whitebox-selection-feedback.ts @@ -1,32 +1,56 @@ import type { EditorSelection } from "./selection"; import { - BOX_EDGE_LABELS, - BOX_FACE_LABELS, - BOX_VERTEX_LABELS + type Brush } from "../document/brushes"; import type { SceneDocument } from "../document/scene-document"; +import { + getBrushDefaultName, + getBrushEdgeLabel, + getBrushFaceLabel, + getBrushKindLabel, + getBrushVertexLabel +} from "../geometry/whitebox-topology"; function getBrushDisplayLabel(document: SceneDocument, brushId: string): string { const brushes = Object.values(document.brushes); const brushIndex = brushes.findIndex((brush) => brush.id === brushId); if (brushIndex === -1) { - return "Whitebox Box"; + return "Whitebox Solid"; } - return brushes[brushIndex].name ?? `Whitebox Box ${brushIndex + 1}`; + return brushes[brushIndex].name ?? getBrushDefaultName(brushes[brushIndex], brushIndex); +} + +function getBrushOrNull( + document: SceneDocument, + brushId: string +): Brush | null { + return document.brushes[brushId] ?? null; } export function getWhiteboxSelectionFeedbackLabel(document: SceneDocument, selection: EditorSelection): string | null { switch (selection.kind) { case "brushes": return selection.ids.length === 1 ? `Solid · ${getBrushDisplayLabel(document, selection.ids[0])}` : null; - case "brushFace": - return `Face · ${BOX_FACE_LABELS[selection.faceId]} · ${getBrushDisplayLabel(document, selection.brushId)}`; - case "brushEdge": - return `Edge · ${BOX_EDGE_LABELS[selection.edgeId]} · ${getBrushDisplayLabel(document, selection.brushId)}`; - case "brushVertex": - return `Vertex · ${BOX_VERTEX_LABELS[selection.vertexId]} · ${getBrushDisplayLabel(document, selection.brushId)}`; + case "brushFace": { + const brush = getBrushOrNull(document, selection.brushId); + return brush === null + ? `Face · ${selection.faceId} · ${getBrushDisplayLabel(document, selection.brushId)}` + : `Face · ${getBrushFaceLabel(brush, selection.faceId)} · ${getBrushDisplayLabel(document, selection.brushId)}`; + } + case "brushEdge": { + const brush = getBrushOrNull(document, selection.brushId); + return brush === null + ? `Edge · ${selection.edgeId} · ${getBrushDisplayLabel(document, selection.brushId)}` + : `Edge · ${getBrushEdgeLabel(brush, selection.edgeId)} · ${getBrushDisplayLabel(document, selection.brushId)}`; + } + case "brushVertex": { + const brush = getBrushOrNull(document, selection.brushId); + return brush === null + ? `Vertex · ${selection.vertexId} · ${getBrushDisplayLabel(document, selection.brushId)}` + : `Vertex · ${getBrushVertexLabel(brush, selection.vertexId)} · ${getBrushDisplayLabel(document, selection.brushId)}`; + } default: return null; } diff --git a/src/document/brushes.ts b/src/document/brushes.ts index 795828d9..1c4a2ce5 100644 --- a/src/document/brushes.ts +++ b/src/document/brushes.ts @@ -177,20 +177,24 @@ export interface BrushGeometry { vertices: Record; } -export type BoxBrushFaces = Record; -export type BoxBrushGeometryVertices = Record; +export type BoxBrushFaces = Record & + Record; +export type BoxBrushGeometryVertices = Record & + Record; export interface BoxBrushGeometry extends BrushGeometry { vertices: BoxBrushGeometryVertices; } -export type WedgeBrushFaces = Record; -export type WedgeBrushGeometryVertices = Record; +export type WedgeBrushFaces = Record & + Record; +export type WedgeBrushGeometryVertices = Record & + Record; export interface WedgeBrushGeometry extends BrushGeometry { vertices: WedgeBrushGeometryVertices; } export interface RadialPrismBrushGeometry extends BrushGeometry { - vertices: Record; + vertices: Record & Record; } interface BrushBase { @@ -223,7 +227,7 @@ export interface RadialPrismBrush extends BrushBase { kind: "radialPrism"; sideCount: number; geometry: RadialPrismBrushGeometry; - faces: Record; + faces: Record & Record; volume: BoxBrushVolumeSettings; } diff --git a/src/geometry/box-brush-mesh.ts b/src/geometry/box-brush-mesh.ts new file mode 100644 index 00000000..d534bb46 --- /dev/null +++ b/src/geometry/box-brush-mesh.ts @@ -0,0 +1,659 @@ +import { BufferAttribute, BufferGeometry } from "three"; + +import type { Vec2, Vec3 } from "../core/vector"; +import type { + Brush, + BoxBrush, + BoxEdgeId, + BoxFaceId, + BoxVertexId, + FaceUvState, + WhiteboxEdgeId, + WhiteboxFaceId, + WhiteboxVertexId +} from "../document/brushes"; +import { transformProjectedFaceUv } from "./box-face-uvs"; +import { getBrushFaceBasis, getBrushFaceNormal, getBrushLocalVertexPosition } from "./whitebox-brush"; +import { + getBoxBrushEdgeVertexIds as getTopologyBoxBrushEdgeVertexIds, + getBoxBrushFaceVertexIds as getTopologyBoxBrushFaceVertexIds, + getBrushEdgeIds, + getBrushEdgeVertexIds, + getBrushFaceIds, + getBrushFaceVertexIds, + getBrushVertexIds +} from "./whitebox-topology"; + +const WATER_TOP_FACE_RENDER_SEGMENTS = 12; + +export interface BoxBrushGeometryDiagnostic { + code: string; + message: string; + faceId?: WhiteboxFaceId; +} + +export interface DerivedBoxBrushFaceSurface { + faceId: WhiteboxFaceId; + vertexIds: readonly WhiteboxVertexId[]; + triangles: Array; + normal: Vec3; +} + +export interface DerivedBoxBrushMeshData { + geometry: BufferGeometry; + faceIdsInOrder: WhiteboxFaceId[]; + faceSurfaces: DerivedBoxBrushFaceSurface[]; + edgeSegments: Array<{ edgeId: WhiteboxEdgeId; 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 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 triangulateFace(vertices: Vec3[]): Array { + if (vertices.length < 3) { + throw new Error("Face must contain at least three vertices."); + } + + if (vertices.length === 3) { + return [[0, 1, 2]]; + } + + 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 = vertices.map((_, index) => index); + const triangles: Array = []; + + 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 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 projectBoxLocalVertexToFaceUv( + 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 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 getGenericFaceUvProjection( + brush: Brush, + faceId: WhiteboxFaceId, + vertexPosition: Vec3 +): Vec2 { + const basis = getBrushFaceBasis(brush, faceId); + const relative = { + x: vertexPosition.x - basis.origin.x, + y: vertexPosition.y - basis.origin.y, + z: vertexPosition.z - basis.origin.z + }; + + return { + x: dotVec3(relative, basis.uAxis), + y: dotVec3(relative, basis.vAxis) + }; +} + +function getFaceUvProjection( + brush: Brush, + faceId: WhiteboxFaceId, + vertexPosition: Vec3, + faceBounds: { min: Vec3; max: Vec3 } +): Vec2 { + if (brush.kind === "box") { + return projectBoxLocalVertexToFaceUv(vertexPosition, faceId as BoxFaceId, faceBounds); + } + + return getGenericFaceUvProjection(brush, faceId, vertexPosition); +} + +function computeProjectedFaceBounds(projectedUvs: Vec2[]): { min: Vec2; max: Vec2 } { + const firstUv = projectedUvs[0]; + const min = { ...firstUv }; + const max = { ...firstUv }; + + for (const projectedUv of projectedUvs.slice(1)) { + min.x = Math.min(min.x, projectedUv.x); + min.y = Math.min(min.y, projectedUv.y); + max.x = Math.max(max.x, projectedUv.x); + max.y = Math.max(max.y, projectedUv.y); + } + + return { + min, + max + }; +} + +function pushRenderedFaceVertex( + positions: number[], + normals: number[], + uvs: number[], + faceUvs: number[], + indices: number[], + vertex: Vec3, + normal: Vec3, + projectedUv: Vec2, + projectedBounds: { min: Vec2; max: Vec2 }, + uvSize: Vec2, + uvState: FaceUvState +) { + const baseUv = { + x: projectedUv.x - projectedBounds.min.x, + y: projectedUv.y - projectedBounds.min.y + }; + const transformedUv = transformProjectedFaceUv(baseUv, 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 : baseUv.x / uvSize.x, + uvSize.y <= 1e-8 ? 0.5 : baseUv.y / uvSize.y + ); + indices.push(indices.length); +} + +export function getBoxBrushFaceVertexIds(faceId: BoxFaceId): readonly [ + BoxVertexId, + BoxVertexId, + BoxVertexId, + BoxVertexId +] { + return getTopologyBoxBrushFaceVertexIds(faceId); +} + +export function getBoxBrushEdgeVertexIds(edgeId: BoxEdgeId): readonly [ + BoxVertexId, + BoxVertexId +] { + return getTopologyBoxBrushEdgeVertexIds(edgeId); +} + +export function getBoxBrushLocalVertexPosition( + brush: Brush, + vertexId: WhiteboxVertexId +): Vec3 { + return getBrushLocalVertexPosition(brush, vertexId); +} + +export function buildBoxBrushDerivedMeshData(brush: Brush): DerivedBoxBrushMeshData { + const diagnostics = validateBoxBrushGeometry(brush); + + if (diagnostics.length > 0) { + throw new Error(diagnostics[0].message); + } + + const faceIds = getBrushFaceIds(brush); + const edgeIds = getBrushEdgeIds(brush); + const vertexIds = getBrushVertexIds(brush); + 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(); + + for (const vertexId of vertexIds) { + 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 faceIds.entries()) { + const faceVertexIds = getBrushFaceVertexIds(brush, faceId); + const faceVertices = faceVertexIds.map((vertexId) => + getBrushLocalVertexPosition(brush, vertexId) + ); + const triangles = triangulateFace(faceVertices); + const normal = getBrushFaceNormal(brush, faceId); + const faceBounds = computeFaceBounds(faceVertices); + const projectedUvs = faceVertices.map((vertex) => + getFaceUvProjection(brush, faceId, vertex, faceBounds) + ); + const projectedBounds = computeProjectedFaceBounds(projectedUvs); + const uvSize = { + x: projectedBounds.max.x - projectedBounds.min.x, + y: projectedBounds.max.y - projectedBounds.min.y + }; + const uvState = brush.faces[faceId].uv as FaceUvState; + const indexStart = indices.length; + + faceSurfaces.push({ + faceId, + vertexIds: faceVertexIds, + triangles, + normal + }); + + const useSubdividedWaterTopFace = + brush.kind === "box" && + brush.volume.mode === "water" && + faceId === "posY" && + brush.volume.water.surfaceDisplacementEnabled && + faceVertices.length === 4; + + if (useSubdividedWaterTopFace) { + const faceCorners = faceVertices as [Vec3, Vec3, Vec3, Vec3]; + const projectedCornerUvs = projectedUvs as [Vec2, Vec2, Vec2, Vec2]; + + 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) + ]; + const quadProjectedUvs: [Vec2, Vec2, Vec2, Vec2] = [ + interpolateQuadSurfaceUv(projectedCornerUvs, u0, v0), + interpolateQuadSurfaceUv(projectedCornerUvs, u1, v0), + interpolateQuadSurfaceUv(projectedCornerUvs, u1, v1), + interpolateQuadSurfaceUv(projectedCornerUvs, u0, v1) + ]; + + for (const [vertex, projectedUv] of [ + [quadVertices[0], quadProjectedUvs[0]], + [quadVertices[1], quadProjectedUvs[1]], + [quadVertices[2], quadProjectedUvs[2]], + [quadVertices[0], quadProjectedUvs[0]], + [quadVertices[2], quadProjectedUvs[2]], + [quadVertices[3], quadProjectedUvs[3]] + ] as const) { + pushRenderedFaceVertex( + positions, + normals, + uvs, + faceUvs, + indices, + vertex, + normal, + projectedUv, + projectedBounds, + uvSize, + uvState + ); + } + } + } + } else { + for (const triangle of triangles) { + for (const vertexOffset of triangle) { + pushRenderedFaceVertex( + positions, + normals, + uvs, + faceUvs, + indices, + faceVertices[vertexOffset], + normal, + projectedUvs[vertexOffset], + projectedBounds, + 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[vertexIds[0]]; + const localBounds = { + min: cloneVec3(firstVertex), + max: cloneVec3(firstVertex) + }; + + for (const vertexId of vertexIds) { + const vertex = brush.geometry.vertices[vertexId]; + 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, + faceIdsInOrder: faceIds, + faceSurfaces, + edgeSegments: edgeIds.map((edgeId) => { + const [startId, endId] = getBrushEdgeVertexIds(brush, edgeId); + return { + edgeId, + start: getBrushLocalVertexPosition(brush, startId), + end: getBrushLocalVertexPosition(brush, endId) + }; + }), + colliderVertices: new Float32Array(colliderVertices), + colliderIndices: new Uint32Array(colliderIndices), + localBounds + }; +} + +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 lerpVec2(start: Vec2, end: Vec2, amount: number): Vec2 { + return { + x: lerpNumber(start.x, end.x, amount), + y: lerpNumber(start.y, end.y, 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 interpolateQuadSurfaceUv( + corners: readonly [Vec2, Vec2, Vec2, Vec2], + u: number, + v: number +): Vec2 { + const topEdge = lerpVec2(corners[0], corners[1], u); + const bottomEdge = lerpVec2(corners[3], corners[2], u); + + return lerpVec2(topEdge, bottomEdge, v); +} + +export function validateBoxBrushGeometry(brush: Brush): BoxBrushGeometryDiagnostic[] { + const diagnostics: BoxBrushGeometryDiagnostic[] = []; + + for (const vertexId of getBrushVertexIds(brush)) { + const vertex = brush.geometry.vertices[vertexId]; + 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.`, + faceId: undefined + }); + } + } + + for (const faceId of getBrushFaceIds(brush)) { + const faceVertices = getBrushFaceVertexIds(brush, 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 { + triangulateFace(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; +} diff --git a/src/geometry/box-brush.ts b/src/geometry/box-brush.ts index 773a89b1..f22b0594 100644 --- a/src/geometry/box-brush.ts +++ b/src/geometry/box-brush.ts @@ -1,15 +1,16 @@ import { Euler, MathUtils, Vector3 } from "three"; import type { Vec3 } from "../core/vector"; -import { BOX_VERTEX_IDS, type BoxBrush } from "../document/brushes"; +import type { Brush } from "../document/brushes"; import { getBoxBrushLocalVertexPosition } from "./box-brush-mesh"; +import { getBrushVertexIds } from "./whitebox-topology"; export interface BoxBrushBounds { min: Vec3; max: Vec3; } -export function getBoxBrushHalfSize(brush: BoxBrush): Vec3 { +export function getBoxBrushHalfSize(brush: Brush): Vec3 { return { x: brush.size.x * 0.5, y: brush.size.y * 0.5, @@ -17,7 +18,7 @@ export function getBoxBrushHalfSize(brush: BoxBrush): Vec3 { }; } -export function getBoxBrushBounds(brush: BoxBrush): BoxBrushBounds { +export function getBoxBrushBounds(brush: Brush): BoxBrushBounds { const corners = getBoxBrushCornerPositions(brush); const firstCorner = corners[0]; const min = { ...firstCorner }; @@ -38,14 +39,14 @@ export function getBoxBrushBounds(brush: BoxBrush): BoxBrushBounds { }; } -export function getBoxBrushCornerPositions(brush: BoxBrush): Vec3[] { +export function getBoxBrushCornerPositions(brush: Brush): Vec3[] { 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 offsets = getBrushVertexIds(brush).map((vertexId) => { const localVertex = getBoxBrushLocalVertexPosition(brush, vertexId); return new Vector3(localVertex.x, localVertex.y, localVertex.z); }); diff --git a/src/geometry/box-face-uvs.ts b/src/geometry/box-face-uvs.ts index 1dfa6212..9ad3cba9 100644 --- a/src/geometry/box-face-uvs.ts +++ b/src/geometry/box-face-uvs.ts @@ -1,14 +1,72 @@ import { BoxGeometry } from "three"; import type { Vec2, Vec3 } from "../core/vector"; -import { BOX_FACE_IDS, createDefaultFaceUvState, type BoxBrush, type BoxFaceId, type FaceUvState } from "../document/brushes"; +import { + BOX_FACE_IDS, + createDefaultFaceUvState, + type Brush, + type BoxFaceId, + type FaceUvState, + type WhiteboxFaceId +} from "../document/brushes"; +import { getBrushFaceBasis, getBrushLocalVertexPosition } from "./whitebox-brush"; +import { getBrushFaceVertexIds } from "./whitebox-topology"; interface BoxBrushUvProjectionSource { size: Vec3; faces: Record; } -export function getBoxBrushFaceSize(brush: BoxBrushUvProjectionSource, faceId: BoxFaceId): Vec2 { +function computeGenericBrushFaceSize(brush: Brush, faceId: WhiteboxFaceId): Vec2 { + const basis = getBrushFaceBasis(brush, faceId); + const projectedVertices = getBrushFaceVertexIds(brush, faceId).map( + (vertexId) => { + const vertex = getBrushLocalVertexPosition(brush, vertexId); + const relative = { + x: vertex.x - basis.origin.x, + y: vertex.y - basis.origin.y, + z: vertex.z - basis.origin.z + }; + + return { + x: + relative.x * basis.uAxis.x + + relative.y * basis.uAxis.y + + relative.z * basis.uAxis.z, + y: + relative.x * basis.vAxis.x + + relative.y * basis.vAxis.y + + relative.z * basis.vAxis.z + }; + } + ); + const firstVertex = projectedVertices[0]; + const min = { ...firstVertex }; + const max = { ...firstVertex }; + + for (const projectedVertex of projectedVertices.slice(1)) { + min.x = Math.min(min.x, projectedVertex.x); + min.y = Math.min(min.y, projectedVertex.y); + max.x = Math.max(max.x, projectedVertex.x); + max.y = Math.max(max.y, projectedVertex.y); + } + + return { + x: max.x - min.x, + y: max.y - min.y + }; +} + +export function getBoxBrushFaceSize( + brush: BoxBrushUvProjectionSource | Brush, + faceId: BoxFaceId | WhiteboxFaceId +): Vec2 { + if ("kind" in brush) { + if (brush.kind !== "box") { + return computeGenericBrushFaceSize(brush, faceId); + } + } + switch (faceId) { case "posX": case "negX": @@ -29,9 +87,18 @@ export function getBoxBrushFaceSize(brush: BoxBrushUvProjectionSource, faceId: B y: brush.size.y }; } + + if ("kind" in brush) { + return computeGenericBrushFaceSize(brush, faceId); + } + + throw new Error(`Unsupported box face id ${faceId}.`); } -export function createFitToFaceBoxBrushFaceUvState(brush: BoxBrush, faceId: BoxFaceId): FaceUvState { +export function createFitToFaceBoxBrushFaceUvState( + brush: Brush, + faceId: WhiteboxFaceId +): FaceUvState { const faceSize = getBoxBrushFaceSize(brush, faceId); return { @@ -44,8 +111,8 @@ export function createFitToFaceBoxBrushFaceUvState(brush: BoxBrush, faceId: BoxF } export function createFitToMaterialTileBoxBrushFaceUvState( - brush: BoxBrush, - faceId: BoxFaceId, + brush: Brush, + faceId: WhiteboxFaceId, tileSize: Vec2 ): FaceUvState { const faceSize = getBoxBrushFaceSize(brush, faceId);