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 { createSetPlayerStartCommand } from "../commands/set-player-start-command"; import { createSetSceneNameCommand } from "../commands/set-scene-name-command"; import { getSelectedBrushFaceId, getSingleSelectedBrushId, getSingleSelectedEntityId, isBrushFaceSelected, isBrushSelected, type EditorSelection } from "../core/selection"; import type { Vec2, Vec3 } from "../core/vector"; import { BOX_FACE_IDS, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE, createDefaultFaceUvState, type BoxBrush, type BoxFaceId, type FaceUvRotationQuarterTurns, type FaceUvState } from "../document/brushes"; import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs"; import { DEFAULT_PLAYER_START_POSITION, getPlayerStartEntities, getPrimaryPlayerStartEntity, normalizeYawDegrees, type PlayerStartEntity } from "../entities/entity-instances"; import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library"; import { RunnerCanvas } from "../runner-web/RunnerCanvas"; import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller"; import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; import { Panel } from "../shared-ui/Panel"; import { ViewportCanvas } from "../viewport-three/ViewportCanvas"; 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 readYawDegreesDraft(source: string): number { const yawDegrees = Number(source); if (!Number.isFinite(yawDegrees)) { throw new Error("Player start yaw must be a finite number."); } return normalizeYawDegrees(yawDegrees); } 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 arePlayerStartsEqual(left: PlayerStartEntity, rightPosition: Vec3, rightYawDegrees: number): boolean { return areVec3Equal(left.position, rightPosition) && left.yawDegrees === normalizeYawDegrees(rightYawDegrees); } 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 getSelectedPlayerStart(selection: EditorSelection, playerStarts: PlayerStartEntity[]): PlayerStartEntity | null { const selectedEntityId = getSingleSelectedEntityId(selection); if (selectedEntityId === null) { return null; } return playerStarts.find((entity) => entity.id === selectedEntityId) ?? 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 getPlayerStartLabel(index: number): string { return index === 0 ? "Player Start" : `Player Start ${index + 1}`; } function getPlayerStartLabelById(entityId: string, playerStarts: PlayerStartEntity[]): string { const playerStartIndex = playerStarts.findIndex((playerStart) => playerStart.id === entityId); return playerStartIndex === -1 ? "Player Start" : getPlayerStartLabel(playerStartIndex); } 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[], playerStarts: PlayerStartEntity[]): 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} entity selected (${getPlayerStartLabelById(selection.ids[0], playerStarts)})`; 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; } function formatRunnerFeetPosition(position: Vec3 | null): string { if (position === null) { return "n/a"; } return `${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`; } export function App({ store, initialStatusMessage }: AppProps) { const editorState = useEditorStoreState(store); const brushList = Object.values(editorState.document.brushes); const playerStartList = getPlayerStartEntities(editorState.document.entities); const primaryPlayerStart = getPrimaryPlayerStartEntity(editorState.document.entities); const materialList = sortDocumentMaterials(editorState.document.materials); const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList); const selectedPlayerStart = getSelectedPlayerStart(editorState.selection, playerStartList); 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 editablePlayerStart = selectedPlayerStart ?? primaryPlayerStart; 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 [playerStartPositionDraft, setPlayerStartPositionDraft] = useState(createVec3Draft(DEFAULT_PLAYER_START_POSITION)); const [playerStartYawDraft, setPlayerStartYawDraft] = useState("0"); const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Runner v1 authoring ready."); const [preferredNavigationMode, setPreferredNavigationMode] = useState( primaryPlayerStart === null ? "orbitVisitor" : "firstPerson" ); const [activeNavigationMode, setActiveNavigationMode] = useState( primaryPlayerStart === null ? "orbitVisitor" : "firstPerson" ); const [runtimeScene, setRuntimeScene] = useState(null); const [runtimeMessage, setRuntimeMessage] = useState(null); const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); 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]); useEffect(() => { if (editablePlayerStart === null) { setPlayerStartPositionDraft(createVec3Draft(DEFAULT_PLAYER_START_POSITION)); setPlayerStartYawDraft("0"); return; } setPlayerStartPositionDraft(createVec3Draft(editablePlayerStart.position)); setPlayerStartYawDraft(String(editablePlayerStart.yawDegrees)); }, [editablePlayerStart]); useEffect(() => { if (primaryPlayerStart !== null || preferredNavigationMode !== "firstPerson") { return; } setPreferredNavigationMode("orbitVisitor"); setActiveNavigationMode("orbitVisitor"); }, [preferredNavigationMode, primaryPlayerStart]); 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" | "runner") => { 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; case "entities": setStatusMessage(`Selected ${getPlayerStartLabelById(selection.ids[0], playerStartList)} 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 applyPlayerStartChange = () => { try { const snappedPosition = snapVec3ToGrid(readVec3Draft(playerStartPositionDraft, "Player start position"), DEFAULT_GRID_SIZE); const yawDegrees = readYawDegreesDraft(playerStartYawDraft); if (editablePlayerStart !== null && arePlayerStartsEqual(editablePlayerStart, snappedPosition, yawDegrees)) { setStatusMessage("Player start already uses that authored position and yaw."); return; } store.executeCommand( createSetPlayerStartCommand({ entityId: editablePlayerStart?.id, position: snappedPosition, yawDegrees }) ); setStatusMessage(editablePlayerStart === null ? "Placed Player Start." : "Updated Player Start."); } 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." ); }; const handleSelectOrPlacePlayerStart = () => { if (primaryPlayerStart === null) { applyPlayerStartChange(); return; } applySelection( { kind: "entities", ids: [primaryPlayerStart.id] }, "runner" ); }; const handleEnterPlayMode = () => { try { const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document); const nextNavigationMode = primaryPlayerStart === null && preferredNavigationMode === "firstPerson" ? "orbitVisitor" : preferredNavigationMode; setRuntimeScene(nextRuntimeScene); setRuntimeMessage( nextRuntimeScene.spawn.source === "playerStart" ? "Running from the authored Player Start." : "No Player Start is authored yet. Orbit Visitor opened first, with a fallback FPS spawn still available." ); setFirstPersonTelemetry(null); setActiveNavigationMode(nextNavigationMode); store.enterPlayMode(); setStatusMessage( nextNavigationMode === "firstPerson" ? "Entered run mode with first-person navigation." : "Entered run mode with Orbit Visitor." ); } catch (error) { setStatusMessage(`Run mode could not start: ${getErrorMessage(error)}`); } }; const handleExitPlayMode = () => { setRuntimeScene(null); setRuntimeMessage(null); setFirstPersonTelemetry(null); store.exitPlayMode(); setStatusMessage("Returned to editor mode."); }; const handleSetPreferredNavigationMode = (navigationMode: RuntimeNavigationMode) => { setPreferredNavigationMode(navigationMode); if (editorState.toolMode === "play") { setActiveNavigationMode(navigationMode); setStatusMessage(navigationMode === "firstPerson" ? "Runner switched to first-person navigation." : "Runner switched to Orbit Visitor."); } }; if (editorState.toolMode === "play" && runtimeScene !== null) { return (
WebEditor3D
Slice 1.3 runner v1
Status: {statusMessage}
Spawn:{" "} {runtimeScene.spawn.source === "playerStart" ? "Authored Player Start" : "Fallback runtime spawn"}
); } return (
WebEditor3D
Slice 1.3 runner v1
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"}
); }