From 8760b210d4401202b27c0776de92761c09d90c69 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 02:33:18 +0200 Subject: [PATCH] Add commands and update brush face handling --- src/commands/brush-command-helpers.ts | 36 +++- .../set-box-brush-face-material-command.ts | 81 +++++++++ .../set-box-brush-face-uv-state-command.ts | 78 +++++++++ src/core/selection.ts | 34 +++- src/core/vector.ts | 5 + src/document/brushes.ts | 68 +++++++- src/document/scene-document.ts | 10 +- src/geometry/box-face-uvs.ts | 154 ++++++++++++++++++ src/materials/starter-material-library.ts | 62 +++++++ 9 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 src/commands/set-box-brush-face-material-command.ts create mode 100644 src/commands/set-box-brush-face-uv-state-command.ts create mode 100644 src/geometry/box-face-uvs.ts create mode 100644 src/materials/starter-material-library.ts diff --git a/src/commands/brush-command-helpers.ts b/src/commands/brush-command-helpers.ts index cd16cd47..d17a16b3 100644 --- a/src/commands/brush-command-helpers.ts +++ b/src/commands/brush-command-helpers.ts @@ -1,5 +1,5 @@ import { cloneEditorSelection, type EditorSelection } from "../core/selection"; -import type { BoxBrush } from "../document/brushes"; +import { cloneFaceUvState, type BoxBrush, type BoxFaceId, type BrushFace } from "../document/brushes"; import type { SceneDocument } from "../document/scene-document"; export function getBoxBrushOrThrow(document: SceneDocument, brushId: string): BoxBrush { @@ -23,6 +23,14 @@ export function setSingleBrushSelection(brushId: string): EditorSelection { }; } +export function setSingleBrushFaceSelection(brushId: string, faceId: BoxFaceId): EditorSelection { + return { + kind: "brushFace", + brushId, + faceId + }; +} + export function cloneSelectionForCommand(selection: EditorSelection): EditorSelection { return cloneEditorSelection(selection); } @@ -48,3 +56,29 @@ export function removeBrush(document: SceneDocument, brushId: string): SceneDocu brushes: remainingBrushes }; } + +export function getBoxBrushFaceOrThrow(document: SceneDocument, brushId: string, faceId: BoxFaceId): BrushFace { + const brush = getBoxBrushOrThrow(document, brushId); + const face = brush.faces[faceId]; + + if (face === undefined) { + throw new Error(`Box brush ${brushId} does not contain face ${faceId}.`); + } + + return face; +} + +export function replaceBoxBrushFace(document: SceneDocument, brushId: string, faceId: BoxFaceId, face: BrushFace): SceneDocument { + const brush = getBoxBrushOrThrow(document, brushId); + + return replaceBrush(document, { + ...brush, + faces: { + ...brush.faces, + [faceId]: { + materialId: face.materialId, + uv: cloneFaceUvState(face.uv) + } + } + }); +} diff --git a/src/commands/set-box-brush-face-material-command.ts b/src/commands/set-box-brush-face-material-command.ts new file mode 100644 index 00000000..0baf0b21 --- /dev/null +++ b/src/commands/set-box-brush-face-material-command.ts @@ -0,0 +1,81 @@ +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 { + cloneSelectionForCommand, + getBoxBrushFaceOrThrow, + replaceBoxBrushFace, + setSingleBrushFaceSelection +} from "./brush-command-helpers"; +import type { EditorCommand } from "./command"; + +interface SetBoxBrushFaceMaterialCommandOptions { + brushId: string; + faceId: BoxFaceId; + materialId: string | null; +} + +export function createSetBoxBrushFaceMaterialCommand(options: SetBoxBrushFaceMaterialCommandOptions): EditorCommand { + let previousMaterialId: string | null | undefined; + let previousSelection: EditorSelection | null = null; + let previousToolMode: ToolMode | null = null; + + return { + id: createOpaqueId("command"), + label: options.materialId === null ? `Clear ${options.faceId} face material` : `Apply material to ${options.faceId} face`, + execute(context) { + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + + if (options.materialId !== null && currentDocument.materials[options.materialId] === undefined) { + throw new Error(`Material ${options.materialId} does not exist in the document registry.`); + } + + if (previousMaterialId === undefined) { + previousMaterialId = currentFace.materialId; + } + + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + + context.setDocument( + replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + materialId: options.materialId + }) + ); + context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousMaterialId === undefined) { + return; + } + + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + + context.setDocument( + replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + materialId: previousMaterialId + }) + ); + + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/set-box-brush-face-uv-state-command.ts b/src/commands/set-box-brush-face-uv-state-command.ts new file mode 100644 index 00000000..59605a6a --- /dev/null +++ b/src/commands/set-box-brush-face-uv-state-command.ts @@ -0,0 +1,78 @@ +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 { + cloneSelectionForCommand, + getBoxBrushFaceOrThrow, + replaceBoxBrushFace, + setSingleBrushFaceSelection +} from "./brush-command-helpers"; +import type { EditorCommand } from "./command"; + +interface SetBoxBrushFaceUvStateCommandOptions { + brushId: string; + faceId: BoxFaceId; + uvState: FaceUvState; + label?: string; +} + +export function createSetBoxBrushFaceUvStateCommand(options: SetBoxBrushFaceUvStateCommandOptions): EditorCommand { + let previousUvState: FaceUvState | null = null; + let previousSelection: EditorSelection | null = null; + let previousToolMode: ToolMode | null = null; + + return { + id: createOpaqueId("command"), + label: options.label ?? `Update ${options.faceId} face UVs`, + execute(context) { + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + + if (previousUvState === null) { + previousUvState = cloneFaceUvState(currentFace.uv); + } + + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + + context.setDocument( + replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + uv: cloneFaceUvState(options.uvState) + }) + ); + context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousUvState === null) { + return; + } + + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + + context.setDocument( + replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + uv: cloneFaceUvState(previousUvState) + }) + ); + + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/core/selection.ts b/src/core/selection.ts index 3bbe69c5..275278e1 100644 --- a/src/core/selection.ts +++ b/src/core/selection.ts @@ -1,6 +1,9 @@ +import type { BoxFaceId } from "../document/brushes"; + export type EditorSelection = | { kind: "none" } | { kind: "brushes"; ids: string[] } + | { kind: "brushFace"; brushId: string; faceId: BoxFaceId } | { kind: "entities"; ids: string[] } | { kind: "modelInstances"; ids: string[] }; @@ -11,6 +14,14 @@ export function cloneEditorSelection(selection: EditorSelection): EditorSelectio }; } + if (selection.kind === "brushFace") { + return { + kind: "brushFace", + brushId: selection.brushId, + faceId: selection.faceId + }; + } + return { kind: selection.kind, ids: [...selection.ids] @@ -18,6 +29,10 @@ export function cloneEditorSelection(selection: EditorSelection): EditorSelectio } export function getSingleSelectedBrushId(selection: EditorSelection): string | null { + if (selection.kind === "brushFace") { + return selection.brushId; + } + if (selection.kind !== "brushes" || selection.ids.length !== 1) { return null; } @@ -25,6 +40,21 @@ export function getSingleSelectedBrushId(selection: EditorSelection): string | n return selection.ids[0]; } -export function isBrushSelected(selection: EditorSelection, brushId: string): boolean { - return selection.kind === "brushes" && selection.ids.includes(brushId); +export function getSelectedBrushFaceId(selection: EditorSelection): BoxFaceId | null { + if (selection.kind !== "brushFace") { + return null; + } + + return selection.faceId; +} + +export function isBrushSelected(selection: EditorSelection, brushId: string): boolean { + return ( + (selection.kind === "brushes" && selection.ids.includes(brushId)) || + (selection.kind === "brushFace" && selection.brushId === brushId) + ); +} + +export function isBrushFaceSelected(selection: EditorSelection, brushId: string, faceId: BoxFaceId): boolean { + return selection.kind === "brushFace" && selection.brushId === brushId && selection.faceId === faceId; } diff --git a/src/core/vector.ts b/src/core/vector.ts index 042ea117..0c8fabf9 100644 --- a/src/core/vector.ts +++ b/src/core/vector.ts @@ -1,3 +1,8 @@ +export interface Vec2 { + x: number; + y: number; +} + export interface Vec3 { x: number; y: number; diff --git a/src/document/brushes.ts b/src/document/brushes.ts index 2728232a..f5970f3a 100644 --- a/src/document/brushes.ts +++ b/src/document/brushes.ts @@ -1,12 +1,23 @@ import { createOpaqueId } from "../core/ids"; -import type { Vec3 } from "../core/vector"; +import type { Vec2, Vec3 } from "../core/vector"; export const BOX_FACE_IDS = ["posX", "negX", "posY", "negY", "posZ", "negZ"] as const; +export const FACE_UV_ROTATION_QUARTER_TURNS = [0, 1, 2, 3] as const; export type BoxFaceId = (typeof BOX_FACE_IDS)[number]; +export type FaceUvRotationQuarterTurns = (typeof FACE_UV_ROTATION_QUARTER_TURNS)[number]; + +export interface FaceUvState { + offset: Vec2; + scale: Vec2; + rotationQuarterTurns: FaceUvRotationQuarterTurns; + flipU: boolean; + flipV: boolean; +} export interface BrushFace { materialId: string | null; + uv: FaceUvState; } export type BoxBrushFaces = Record; @@ -45,7 +56,8 @@ function cloneVec3(vector: Vec3): Vec3 { function cloneBrushFace(face: BrushFace): BrushFace { return { - materialId: face.materialId + materialId: face.materialId, + uv: cloneFaceUvState(face.uv) }; } @@ -53,10 +65,44 @@ export function isBoxFaceId(value: unknown): value is BoxFaceId { return typeof value === "string" && BOX_FACE_IDS.some((faceId) => faceId === value); } +export function isFaceUvRotationQuarterTurns(value: unknown): value is FaceUvRotationQuarterTurns { + return typeof value === "number" && FACE_UV_ROTATION_QUARTER_TURNS.includes(value as FaceUvRotationQuarterTurns); +} + export function hasPositiveBoxSize(size: Vec3): boolean { return size.x > 0 && size.y > 0 && size.z > 0; } +export function createDefaultFaceUvState(): FaceUvState { + return { + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + }; +} + +export function cloneFaceUvState(uv: FaceUvState): FaceUvState { + return { + offset: { + ...uv.offset + }, + scale: { + ...uv.scale + }, + rotationQuarterTurns: uv.rotationQuarterTurns, + flipU: uv.flipU, + flipV: uv.flipV + }; +} + export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces { return { posX: cloneBrushFace(faces.posX), @@ -71,22 +117,28 @@ export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces { export function createDefaultBoxBrushFaces(): BoxBrushFaces { return { posX: { - materialId: null + materialId: null, + uv: createDefaultFaceUvState() }, negX: { - materialId: null + materialId: null, + uv: createDefaultFaceUvState() }, posY: { - materialId: null + materialId: null, + uv: createDefaultFaceUvState() }, negY: { - materialId: null + materialId: null, + uv: createDefaultFaceUvState() }, posZ: { - materialId: null + materialId: null, + uv: createDefaultFaceUvState() }, negZ: { - materialId: null + materialId: null, + uv: createDefaultFaceUvState() } }; } diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 115d3725..2331e4d6 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -1,8 +1,10 @@ import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector"; import type { Brush } from "./brushes"; +import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library"; -export const SCENE_DOCUMENT_VERSION = 2 as const; +export const SCENE_DOCUMENT_VERSION = 3 as const; export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1 as const; +export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2 as const; export interface WorldBackgroundSettings { mode: "solid"; @@ -30,7 +32,7 @@ export interface SceneDocument { version: typeof SCENE_DOCUMENT_VERSION; name: string; world: WorldSettings; - materials: Record; + materials: Record; textures: Record; assets: Record; brushes: Record; @@ -59,12 +61,12 @@ export function createDefaultWorldSettings(): WorldSettings { }; } -export function createEmptySceneDocument(overrides: Partial> = {}): SceneDocument { +export function createEmptySceneDocument(overrides: Partial> = {}): SceneDocument { return { version: SCENE_DOCUMENT_VERSION, name: overrides.name ?? "Untitled Scene", world: overrides.world ?? createDefaultWorldSettings(), - materials: {}, + materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()), textures: {}, assets: {}, brushes: {}, diff --git a/src/geometry/box-face-uvs.ts b/src/geometry/box-face-uvs.ts new file mode 100644 index 00000000..4b467e2e --- /dev/null +++ b/src/geometry/box-face-uvs.ts @@ -0,0 +1,154 @@ +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 { getBoxBrushHalfSize } from "./box-brush"; + +export function getBoxBrushFaceSize(brush: BoxBrush, faceId: BoxFaceId): Vec2 { + 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: BoxBrush, faceId: BoxFaceId): FaceUvState { + const faceSize = getBoxBrushFaceSize(brush, faceId); + + return { + ...createDefaultFaceUvState(), + scale: { + x: 1 / faceSize.x, + y: 1 / faceSize.y + } + }; +} + +export function projectBoxFaceVertexToUv(vertexPosition: Vec3, brush: BoxBrush, faceId: BoxFaceId): Vec2 { + 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: Vec2, faceSize: Vec2, uvState: FaceUvState): Vec2 { + 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: BoxGeometry, brush: BoxBrush): void { + 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; +} diff --git a/src/materials/starter-material-library.ts b/src/materials/starter-material-library.ts new file mode 100644 index 00000000..16172fc1 --- /dev/null +++ b/src/materials/starter-material-library.ts @@ -0,0 +1,62 @@ +export type MaterialPattern = "grid" | "checker" | "stripes" | "diamond"; + +export interface MaterialDef { + id: string; + name: string; + baseColorHex: string; + accentColorHex: string; + pattern: MaterialPattern; + tags: string[]; +} + +export const STARTER_MATERIAL_LIBRARY: readonly MaterialDef[] = [ + { + id: "starter-amber-grid", + name: "Amber Grid", + baseColorHex: "#c79a63", + accentColorHex: "#5f3820", + pattern: "grid", + tags: ["starter", "wall"] + }, + { + id: "starter-concrete-checker", + name: "Concrete Checker", + baseColorHex: "#7d838c", + accentColorHex: "#5a616a", + pattern: "checker", + tags: ["starter", "floor"] + }, + { + id: "starter-hazard-stripe", + name: "Hazard Stripe", + baseColorHex: "#d1a245", + accentColorHex: "#211b16", + pattern: "stripes", + tags: ["starter", "warning"] + }, + { + id: "starter-night-diamond", + name: "Night Diamond", + baseColorHex: "#5a6985", + accentColorHex: "#1f2836", + pattern: "diamond", + tags: ["starter", "trim"] + } +] as const; + +export function cloneMaterialDef(material: MaterialDef): MaterialDef { + return { + ...material, + tags: [...material.tags] + }; +} + +export function cloneMaterialRegistry(materials: Record): Record { + return Object.fromEntries( + Object.entries(materials).map(([materialId, material]) => [materialId, cloneMaterialDef(material)]) + ); +} + +export function createStarterMaterialRegistry(): Record { + return Object.fromEntries(STARTER_MATERIAL_LIBRARY.map((material) => [material.id, cloneMaterialDef(material)])); +}