Add commands and update brush face handling

This commit is contained in:
2026-03-31 02:33:18 +02:00
parent d1ae2499e5
commit 8760b210d4
9 changed files with 513 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
import { cloneEditorSelection, type EditorSelection } from "../core/selection"; 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"; import type { SceneDocument } from "../document/scene-document";
export function getBoxBrushOrThrow(document: SceneDocument, brushId: string): BoxBrush { 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 { export function cloneSelectionForCommand(selection: EditorSelection): EditorSelection {
return cloneEditorSelection(selection); return cloneEditorSelection(selection);
} }
@@ -48,3 +56,29 @@ export function removeBrush(document: SceneDocument, brushId: string): SceneDocu
brushes: remainingBrushes 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)
}
}
});
}

View File

@@ -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);
}
}
};
}

View File

@@ -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);
}
}
};
}

View File

@@ -1,6 +1,9 @@
import type { BoxFaceId } from "../document/brushes";
export type EditorSelection = export type EditorSelection =
| { kind: "none" } | { kind: "none" }
| { kind: "brushes"; ids: string[] } | { kind: "brushes"; ids: string[] }
| { kind: "brushFace"; brushId: string; faceId: BoxFaceId }
| { kind: "entities"; ids: string[] } | { kind: "entities"; ids: string[] }
| { kind: "modelInstances"; 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 { return {
kind: selection.kind, kind: selection.kind,
ids: [...selection.ids] ids: [...selection.ids]
@@ -18,6 +29,10 @@ export function cloneEditorSelection(selection: EditorSelection): EditorSelectio
} }
export function getSingleSelectedBrushId(selection: EditorSelection): string | null { export function getSingleSelectedBrushId(selection: EditorSelection): string | null {
if (selection.kind === "brushFace") {
return selection.brushId;
}
if (selection.kind !== "brushes" || selection.ids.length !== 1) { if (selection.kind !== "brushes" || selection.ids.length !== 1) {
return null; return null;
} }
@@ -25,6 +40,21 @@ export function getSingleSelectedBrushId(selection: EditorSelection): string | n
return selection.ids[0]; return selection.ids[0];
} }
export function isBrushSelected(selection: EditorSelection, brushId: string): boolean { export function getSelectedBrushFaceId(selection: EditorSelection): BoxFaceId | null {
return selection.kind === "brushes" && selection.ids.includes(brushId); 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;
} }

View File

@@ -1,3 +1,8 @@
export interface Vec2 {
x: number;
y: number;
}
export interface Vec3 { export interface Vec3 {
x: number; x: number;
y: number; y: number;

View File

@@ -1,12 +1,23 @@
import { createOpaqueId } from "../core/ids"; 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 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 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 { export interface BrushFace {
materialId: string | null; materialId: string | null;
uv: FaceUvState;
} }
export type BoxBrushFaces = Record<BoxFaceId, BrushFace>; export type BoxBrushFaces = Record<BoxFaceId, BrushFace>;
@@ -45,7 +56,8 @@ function cloneVec3(vector: Vec3): Vec3 {
function cloneBrushFace(face: BrushFace): BrushFace { function cloneBrushFace(face: BrushFace): BrushFace {
return { 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); 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 { export function hasPositiveBoxSize(size: Vec3): boolean {
return size.x > 0 && size.y > 0 && size.z > 0; 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 { export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces {
return { return {
posX: cloneBrushFace(faces.posX), posX: cloneBrushFace(faces.posX),
@@ -71,22 +117,28 @@ export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces {
export function createDefaultBoxBrushFaces(): BoxBrushFaces { export function createDefaultBoxBrushFaces(): BoxBrushFaces {
return { return {
posX: { posX: {
materialId: null materialId: null,
uv: createDefaultFaceUvState()
}, },
negX: { negX: {
materialId: null materialId: null,
uv: createDefaultFaceUvState()
}, },
posY: { posY: {
materialId: null materialId: null,
uv: createDefaultFaceUvState()
}, },
negY: { negY: {
materialId: null materialId: null,
uv: createDefaultFaceUvState()
}, },
posZ: { posZ: {
materialId: null materialId: null,
uv: createDefaultFaceUvState()
}, },
negZ: { negZ: {
materialId: null materialId: null,
uv: createDefaultFaceUvState()
} }
}; };
} }

View File

@@ -1,8 +1,10 @@
import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector"; import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector";
import type { Brush } from "./brushes"; 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 FOUNDATION_SCENE_DOCUMENT_VERSION = 1 as const;
export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2 as const;
export interface WorldBackgroundSettings { export interface WorldBackgroundSettings {
mode: "solid"; mode: "solid";
@@ -30,7 +32,7 @@ export interface SceneDocument {
version: typeof SCENE_DOCUMENT_VERSION; version: typeof SCENE_DOCUMENT_VERSION;
name: string; name: string;
world: WorldSettings; world: WorldSettings;
materials: Record<string, never>; materials: Record<string, MaterialDef>;
textures: Record<string, never>; textures: Record<string, never>;
assets: Record<string, never>; assets: Record<string, never>;
brushes: Record<string, Brush>; brushes: Record<string, Brush>;
@@ -59,12 +61,12 @@ export function createDefaultWorldSettings(): WorldSettings {
}; };
} }
export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world">> = {}): SceneDocument { export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world" | "materials">> = {}): SceneDocument {
return { return {
version: SCENE_DOCUMENT_VERSION, version: SCENE_DOCUMENT_VERSION,
name: overrides.name ?? "Untitled Scene", name: overrides.name ?? "Untitled Scene",
world: overrides.world ?? createDefaultWorldSettings(), world: overrides.world ?? createDefaultWorldSettings(),
materials: {}, materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()),
textures: {}, textures: {},
assets: {}, assets: {},
brushes: {}, brushes: {},

View File

@@ -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<number>();
for (let indexOffset = group.start; indexOffset < group.start + group.count; indexOffset += 1) {
vertexIndices.add(indexAttribute.getX(indexOffset));
}
for (const vertexIndex of vertexIndices) {
const localVertexPosition = {
x: positionAttribute.getX(vertexIndex),
y: positionAttribute.getY(vertexIndex),
z: positionAttribute.getZ(vertexIndex)
};
const projectedUv = projectBoxFaceVertexToUv(localVertexPosition, brush, faceId);
const transformedUv = transformProjectedFaceUv(projectedUv, faceSize, brush.faces[faceId].uv);
uvAttribute.setXY(vertexIndex, transformedUv.x, transformedUv.y);
}
}
uvAttribute.needsUpdate = true;
}

View File

@@ -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<string, MaterialDef>): Record<string, MaterialDef> {
return Object.fromEntries(
Object.entries(materials).map(([materialId, material]) => [materialId, cloneMaterialDef(material)])
);
}
export function createStarterMaterialRegistry(): Record<string, MaterialDef> {
return Object.fromEntries(STARTER_MATERIAL_LIBRARY.map((material) => [material.id, cloneMaterialDef(material)]));
}