From 5e89eaeba93798fcf419169af167e2713f27ad86 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 02:37:22 +0200 Subject: [PATCH] Add new App component for the application --- src/app/App.tsx | 1056 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1056 insertions(+) create mode 100644 src/app/App.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 00000000..d69d3a9a --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,1056 @@ +import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type KeyboardEvent } from "react"; + +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 { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command"; +import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command"; +import { createSetSceneNameCommand } from "../commands/set-scene-name-command"; +import { + getSelectedBrushFaceId, + getSingleSelectedBrushId, + isBrushFaceSelected, + isBrushSelected, + type EditorSelection +} from "../core/selection"; +import type { Vec2, Vec3 } from "../core/vector"; +import { + BOX_FACE_IDS, + createDefaultFaceUvState, + type BoxBrush, + type BoxFaceId, + type FaceUvState, + type FaceUvRotationQuarterTurns +} from "../document/brushes"; +import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; +import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs"; +import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library"; +import { Panel } from "../shared-ui/Panel"; +import { ViewportCanvas } from "../viewport-three/ViewportCanvas"; + +import { DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes"; +import type { EditorStore } from "./editor-store"; +import { useEditorStoreState } from "./use-editor-store"; + +interface AppProps { + store: EditorStore; + initialStatusMessage?: string; +} + +interface Vec2Draft { + x: string; + y: string; +} + +interface Vec3Draft { + x: string; + y: string; + z: string; +} + +const FACE_LABELS: Record = { + posX: "+X Right", + negX: "-X Left", + posY: "+Y Top", + negY: "-Y Bottom", + posZ: "+Z Front", + negZ: "-Z Back" +}; + +const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index])); + +function createVec2Draft(vector: Vec2): Vec2Draft { + return { + x: String(vector.x), + y: String(vector.y) + }; +} + +function createVec3Draft(vector: Vec3): Vec3Draft { + return { + x: String(vector.x), + y: String(vector.y), + z: String(vector.z) + }; +} + +function readVec2Draft(draft: Vec2Draft, label: string): Vec2 { + const vector = { + x: Number(draft.x), + y: Number(draft.y) + }; + + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y)) { + throw new Error(`${label} values must be finite numbers.`); + } + + return vector; +} + +function readPositiveVec2Draft(draft: Vec2Draft, label: string): Vec2 { + const vector = readVec2Draft(draft, label); + + if (vector.x <= 0 || vector.y <= 0) { + throw new Error(`${label} values must remain positive.`); + } + + return vector; +} + +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 areVec2Equal(left: Vec2, right: Vec2): boolean { + return left.x === right.x && left.y === right.y; +} + +function areVec3Equal(left: Vec3, right: Vec3): boolean { + return left.x === right.x && left.y === right.y && left.z === right.z; +} + +function areFaceUvStatesEqual(left: FaceUvState, right: FaceUvState): boolean { + return ( + areVec2Equal(left.offset, right.offset) && + areVec2Equal(left.scale, right.scale) && + left.rotationQuarterTurns === right.rotationQuarterTurns && + left.flipU === right.flipU && + left.flipV === right.flipV + ); +} + +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 getBrushLabelById(brushId: string, brushes: BoxBrush[]): string { + const brushIndex = brushes.findIndex((brush) => brush.id === brushId); + return brushIndex === -1 ? "Box Brush" : getBrushLabel(brushIndex); +} + +function getSelectedBrushLabel(selection: EditorSelection, brushes: BoxBrush[]): string { + const selectedBrushId = getSingleSelectedBrushId(selection); + + if (selectedBrushId === null) { + return "No brush selected"; + } + + return getBrushLabelById(selectedBrushId, brushes); +} + +function describeSelection(selection: EditorSelection, brushes: BoxBrush[]): string { + switch (selection.kind) { + case "none": + return "No authored selection"; + case "brushes": + return `${selection.ids.length} brush selected (${getSelectedBrushLabel(selection, brushes)})`; + case "brushFace": + return `1 face selected (${FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushes)})`; + case "entities": + return `${selection.ids.length} entities selected`; + case "modelInstances": + return `${selection.ids.length} model instances selected`; + default: + return "Unknown selection"; + } +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return "An unexpected error occurred."; +} + +function sortDocumentMaterials(materials: Record): MaterialDef[] { + return Object.values(materials).sort((left, right) => { + const leftStarterIndex = STARTER_MATERIAL_ORDER.get(left.id) ?? Number.MAX_SAFE_INTEGER; + const rightStarterIndex = STARTER_MATERIAL_ORDER.get(right.id) ?? Number.MAX_SAFE_INTEGER; + + if (leftStarterIndex !== rightStarterIndex) { + return leftStarterIndex - rightStarterIndex; + } + + return left.name.localeCompare(right.name); + }); +} + +function getMaterialPreviewStyle(material: MaterialDef): CSSProperties { + switch (material.pattern) { + case "grid": + return { + backgroundColor: material.baseColorHex, + backgroundImage: `linear-gradient(${material.accentColorHex} 2px, transparent 2px), linear-gradient(90deg, ${material.accentColorHex} 2px, transparent 2px)`, + backgroundSize: "18px 18px" + }; + case "checker": + return { + backgroundColor: material.baseColorHex, + backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex}), linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex})`, + backgroundPosition: "0 0, 9px 9px", + backgroundSize: "18px 18px" + }; + case "stripes": + return { + backgroundColor: material.baseColorHex, + backgroundImage: `repeating-linear-gradient(135deg, ${material.accentColorHex} 0 9px, transparent 9px 18px)` + }; + case "diamond": + return { + backgroundColor: material.baseColorHex, + backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%), linear-gradient(-45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%)`, + backgroundSize: "22px 22px" + }; + } +} + +function rotateQuarterTurns(rotationQuarterTurns: FaceUvRotationQuarterTurns): FaceUvRotationQuarterTurns { + return ((rotationQuarterTurns + 1) % 4) as FaceUvRotationQuarterTurns; +} + +export function App({ store, initialStatusMessage }: AppProps) { + const editorState = useEditorStoreState(store); + const brushList = Object.values(editorState.document.brushes); + const materialList = sortDocumentMaterials(editorState.document.materials); + const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList); + const selectedFaceId = getSelectedBrushFaceId(editorState.selection); + const selectedFace = selectedBrush !== null && selectedFaceId !== null ? selectedBrush.faces[selectedFaceId] : null; + const selectedFaceMaterial = + selectedFace !== null && selectedFace.materialId !== null ? editorState.document.materials[selectedFace.materialId] ?? null : null; + + const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name); + const [positionDraft, setPositionDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); + const [sizeDraft, setSizeDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); + const [uvOffsetDraft, setUvOffsetDraft] = useState(createVec2Draft(createDefaultFaceUvState().offset)); + const [uvScaleDraft, setUvScaleDraft] = useState(createVec2Draft(createDefaultFaceUvState().scale)); + const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Face material 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]); + + useEffect(() => { + if (selectedFace === null) { + const defaultUvState = createDefaultFaceUvState(); + setUvOffsetDraft(createVec2Draft(defaultUvState.offset)); + setUvScaleDraft(createVec2Draft(defaultUvState.scale)); + return; + } + + setUvOffsetDraft(createVec2Draft(selectedFace.uv.offset)); + setUvScaleDraft(createVec2Draft(selectedFace.uv.scale)); + }, [selectedFace]); + + const applySceneName = () => { + const normalizedName = sceneNameDraft.trim() || "Untitled Scene"; + + if (normalizedName === editorState.document.name) { + setStatusMessage("Scene name is already current."); + return; + } + + store.executeCommand(createSetSceneNameCommand(normalizedName)); + 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 applySelection = (selection: EditorSelection, source: "outliner" | "viewport" | "inspector") => { + store.setSelection(selection); + + switch (selection.kind) { + case "none": + setStatusMessage(`${source === "viewport" ? "Viewport" : "Editor"} selection cleared.`); + break; + case "brushes": + setStatusMessage(`Selected ${getBrushLabelById(selection.ids[0], brushList)} from the ${source}.`); + break; + case "brushFace": + setStatusMessage(`Selected ${FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}.`); + break; + default: + setStatusMessage(`Selection updated from the ${source}.`); + break; + } + }; + + 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); + }; + + const handleLoadDraft = () => { + const result = store.loadDraft(); + setStatusMessage(result.message); + }; + + const handleExportJson = () => { + const exportedJson = store.exportDocumentJson(); + const blob = new Blob([exportedJson], { type: "application/json" }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = objectUrl; + anchor.download = `${editorState.document.name.replace(/\s+/g, "-").toLowerCase() || "scene"}.json`; + anchor.click(); + URL.revokeObjectURL(objectUrl); + + setStatusMessage("Scene document exported as JSON."); + }; + + const handleImportButtonClick = () => { + importInputRef.current?.click(); + }; + + const handleImportChange = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + + if (file === undefined) { + return; + } + + try { + const source = await file.text(); + store.importDocumentJson(source); + setStatusMessage(`Imported ${file.name}.`); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } finally { + event.currentTarget.value = ""; + } + }; + + const applyFaceMaterial = (materialId: string) => { + if (selectedBrush === null || selectedFaceId === null || selectedFace === null) { + setStatusMessage("Select a single box face before applying a material."); + return; + } + + if (selectedFace.materialId === materialId) { + setStatusMessage(`${FACE_LABELS[selectedFaceId]} already uses that material.`); + return; + } + + try { + store.executeCommand( + createSetBoxBrushFaceMaterialCommand({ + brushId: selectedBrush.id, + faceId: selectedFaceId, + materialId + }) + ); + setStatusMessage(`Applied ${editorState.document.materials[materialId]?.name ?? materialId} to ${FACE_LABELS[selectedFaceId]}.`); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const clearFaceMaterial = () => { + if (selectedBrush === null || selectedFaceId === null || selectedFace === null) { + setStatusMessage("Select a single box face before clearing its material."); + return; + } + + if (selectedFace.materialId === null) { + setStatusMessage(`${FACE_LABELS[selectedFaceId]} already uses the fallback face material.`); + return; + } + + store.executeCommand( + createSetBoxBrushFaceMaterialCommand({ + brushId: selectedBrush.id, + faceId: selectedFaceId, + materialId: null + }) + ); + setStatusMessage(`Cleared the authored material on ${FACE_LABELS[selectedFaceId]}.`); + }; + + const applyFaceUvState = (uvState: FaceUvState, label: string, successMessage: string) => { + if (selectedBrush === null || selectedFaceId === null || selectedFace === null) { + setStatusMessage("Select a single box face before editing UVs."); + return; + } + + if (areFaceUvStatesEqual(selectedFace.uv, uvState)) { + setStatusMessage("That face UV state is already current."); + return; + } + + try { + store.executeCommand( + createSetBoxBrushFaceUvStateCommand({ + brushId: selectedBrush.id, + faceId: selectedFaceId, + uvState, + label + }) + ); + setStatusMessage(successMessage); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const handleApplyUvDraft = () => { + if (selectedFace === null) { + setStatusMessage("Select a single box face before editing UVs."); + return; + } + + try { + applyFaceUvState( + { + ...selectedFace.uv, + offset: readVec2Draft(uvOffsetDraft, "Face UV offset"), + scale: readPositiveVec2Draft(uvScaleDraft, "Face UV scale") + }, + "Set face UV offset and scale", + "Updated face UV offset and scale." + ); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const handleRotateUv = () => { + if (selectedFace === null) { + setStatusMessage("Select a single box face before rotating UVs."); + return; + } + + applyFaceUvState( + { + ...selectedFace.uv, + rotationQuarterTurns: rotateQuarterTurns(selectedFace.uv.rotationQuarterTurns) + }, + "Rotate face UV 90 degrees", + "Rotated face UVs 90 degrees." + ); + }; + + const handleFlipUv = (axis: "u" | "v") => { + if (selectedFace === null) { + setStatusMessage("Select a single box face before flipping UVs."); + return; + } + + applyFaceUvState( + { + ...selectedFace.uv, + flipU: axis === "u" ? !selectedFace.uv.flipU : selectedFace.uv.flipU, + flipV: axis === "v" ? !selectedFace.uv.flipV : selectedFace.uv.flipV + }, + axis === "u" ? "Flip face UV U" : "Flip face UV V", + axis === "u" ? "Flipped face UVs on U." : "Flipped face UVs on V." + ); + }; + + const handleFitUvToFace = () => { + if (selectedBrush === null || selectedFaceId === null) { + setStatusMessage("Select a single box face before fitting UVs."); + return; + } + + applyFaceUvState( + createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId), + "Fit face UV to face", + "Fit the selected face UVs to the face bounds." + ); + }; + + return ( +
+
+
+
WebEditor3D
+
Slice 1.2 face materials and UV basics
+
+ +
+
+ + + + +
+ +
+ + + + +
+ +
+ + +
+
+
+ +
+ + +
+
+
Viewport
+
+ {brushList.length} box brushes loaded. Click a brush face in the viewport or use the face selector to texture it. +
+
+ applySelection(selection, "viewport")} + /> +
+ + +
+ +
+
+ Status: {statusMessage} +
+
+ History: {editorState.lastCommandLabel ?? "No commands yet"} +
+
+ + +
+ ); +}