diff --git a/src/app/App.tsx b/src/app/App.tsx index fc9d11bf..bb74b870 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,9 +1,16 @@ -import { useEffect, useRef, useState, type ChangeEvent } from "react"; +import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; -import { createSetSceneNameCommand } from "../commands/set-scene-name-command"; -import type { EditorSelection } from "../core/selection"; +import { createCreateBoxBrushCommand } from "../commands/create-box-brush-command"; +import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command"; +import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command"; +import { getSingleSelectedBrushId, isBrushSelected, type EditorSelection } from "../core/selection"; +import type { Vec3 } from "../core/vector"; +import { DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE, type BoxBrush } from "../document/brushes"; +import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; import { Panel } from "../shared-ui/Panel"; import { ViewportCanvas } from "../viewport-three/ViewportCanvas"; + +import { createSetSceneNameCommand } from "../commands/set-scene-name-command"; import type { EditorStore } from "./editor-store"; import { useEditorStoreState } from "./use-editor-store"; @@ -12,16 +19,78 @@ interface AppProps { initialStatusMessage?: string; } -function describeSelection(selectionKind: EditorSelection["kind"]): string { - switch (selectionKind) { +interface Vec3Draft { + x: string; + y: string; + z: string; +} + +function createVec3Draft(vector: Vec3): Vec3Draft { + return { + x: String(vector.x), + y: String(vector.y), + z: String(vector.z) + }; +} + +function readVec3Draft(draft: Vec3Draft, label: string): Vec3 { + const vector = { + x: Number(draft.x), + y: Number(draft.y), + z: Number(draft.z) + }; + + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + throw new Error(`${label} values must be finite numbers.`); + } + + return vector; +} + +function areVec3Equal(left: Vec3, right: Vec3): boolean { + return left.x === right.x && left.y === right.y && left.z === right.z; +} + +function getSelectedBoxBrush(selection: EditorSelection, brushes: BoxBrush[]): BoxBrush | null { + const selectedBrushId = getSingleSelectedBrushId(selection); + + if (selectedBrushId === null) { + return null; + } + + return brushes.find((brush) => brush.id === selectedBrushId) ?? null; +} + +function getBrushLabel(index: number): string { + return `Box Brush ${index + 1}`; +} + +function getSelectedBrushLabel(selection: EditorSelection, brushes: BoxBrush[]): string { + const selectedBrushId = getSingleSelectedBrushId(selection); + + if (selectedBrushId === null) { + return "No brush selected"; + } + + const brushIndex = brushes.findIndex((brush) => brush.id === selectedBrushId); + + if (brushIndex === -1) { + return "Selected brush is missing"; + } + + return getBrushLabel(brushIndex); +} + +function describeSelection(selection: EditorSelection, brushes: BoxBrush[]): string { + switch (selection.kind) { case "none": - return "No authored selection yet"; + return "No authored selection"; case "brushes": - return "Brush selection placeholder"; + return `${selection.ids.length} brush selected (${getSelectedBrushLabel(selection, brushes)})`; case "entities": - return "Entity selection placeholder"; + return `${selection.ids.length} entities selected`; case "modelInstances": - return "Model instance selection placeholder"; + return `${selection.ids.length} model instances selected`; default: return "Unknown selection"; } @@ -37,14 +106,38 @@ function getErrorMessage(error: unknown): string { export function App({ store, initialStatusMessage }: AppProps) { const editorState = useEditorStoreState(store); + const brushList = Object.values(editorState.document.brushes); + const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList); + const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name); - const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Viewport shell ready."); + const [positionDraft, setPositionDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); + const [sizeDraft, setSizeDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); + const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Box brush authoring ready."); const importInputRef = useRef(null); useEffect(() => { setSceneNameDraft(editorState.document.name); }, [editorState.document.name]); + useEffect(() => { + if (selectedBrush === null) { + setPositionDraft(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); + setSizeDraft(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); + return; + } + + setPositionDraft(createVec3Draft(selectedBrush.center)); + setSizeDraft(createVec3Draft(selectedBrush.size)); + }, [ + selectedBrush?.id, + selectedBrush?.center.x, + selectedBrush?.center.y, + selectedBrush?.center.z, + selectedBrush?.size.x, + selectedBrush?.size.y, + selectedBrush?.size.z + ]); + const applySceneName = () => { const normalizedName = sceneNameDraft.trim() || "Untitled Scene"; @@ -57,6 +150,93 @@ export function App({ store, initialStatusMessage }: AppProps) { setStatusMessage(`Scene renamed to ${normalizedName}.`); }; + const handleCreateBoxBrush = () => { + try { + store.executeCommand(createCreateBoxBrushCommand()); + setStatusMessage(`Created a box brush snapped to the ${DEFAULT_GRID_SIZE}m grid.`); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const handleBrushSelection = (brushId: string | null, source: "outliner" | "viewport") => { + if (brushId === null) { + store.setSelection({ + kind: "none" + }); + setStatusMessage(`${source === "viewport" ? "Viewport" : "Outliner"} selection cleared.`); + return; + } + + const brushIndex = brushList.findIndex((brush) => brush.id === brushId); + + store.setSelection({ + kind: "brushes", + ids: [brushId] + }); + + const brushLabel = brushIndex === -1 ? "Box Brush" : getBrushLabel(brushIndex); + setStatusMessage(`Selected ${brushLabel} from the ${source}.`); + }; + + const applyPositionChange = () => { + if (selectedBrush === null) { + setStatusMessage("Select a box brush before moving it."); + return; + } + + try { + const snappedCenter = snapVec3ToGrid(readVec3Draft(positionDraft, "Box brush position"), DEFAULT_GRID_SIZE); + + if (areVec3Equal(snappedCenter, selectedBrush.center)) { + setStatusMessage("Box brush position is already snapped to that grid location."); + return; + } + + store.executeCommand( + createMoveBoxBrushCommand({ + brushId: selectedBrush.id, + center: snappedCenter + }) + ); + setStatusMessage("Moved selected box brush."); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const applySizeChange = () => { + if (selectedBrush === null) { + setStatusMessage("Select a box brush before resizing it."); + return; + } + + try { + const snappedSize = snapPositiveSizeToGrid(readVec3Draft(sizeDraft, "Box brush size"), DEFAULT_GRID_SIZE); + + if (areVec3Equal(snappedSize, selectedBrush.size)) { + setStatusMessage("Box brush size is already snapped to those dimensions."); + return; + } + + store.executeCommand( + createResizeBoxBrushCommand({ + brushId: selectedBrush.id, + size: snappedSize + }) + ); + setStatusMessage("Resized selected box brush."); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const handleDraftVectorKeyDown = (event: KeyboardEvent, applyChange: () => void) => { + if (event.key === "Enter") { + applyChange(); + } + }; + const handleSaveDraft = () => { const result = store.saveDraft(); setStatusMessage(result.message); @@ -108,7 +288,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
WebEditor3D
-
Milestone 0 foundation slice
+
Slice 1.1 box brush authoring
@@ -134,6 +314,9 @@ export function App({ store, initialStatusMessage }: AppProps) { > Play +
@@ -170,10 +353,18 @@ export function App({ store, initialStatusMessage }: AppProps) {
Version
v{editorState.document.version}
+
+
Grid
+
{DEFAULT_GRID_SIZE}m snap
+
Tool Mode
{editorState.toolMode}
+
+
Brushes
+
{brushList.length}
+