diff --git a/src/app/App.tsx b/src/app/App.tsx index a9a33760..286ea07f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -26,6 +26,7 @@ import { type FaceUvRotationQuarterTurns, type FaceUvState } from "../document/brushes"; +import { formatSceneDiagnosticSummary, validateSceneDocument, type SceneDiagnostic } from "../document/scene-document-validation"; import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs"; import { @@ -39,6 +40,7 @@ import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter 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 { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation"; import { Panel } from "../shared-ui/Panel"; import { ViewportCanvas } from "../viewport-three/ViewportCanvas"; import type { EditorStore } from "./editor-store"; @@ -70,6 +72,32 @@ const FACE_LABELS: Record = { }; const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index])); +const TOOL_LABELS = { + select: "Select", + "box-create": "Box Create", + play: "Play" +} as const; + +const DIAGNOSTIC_BADGE_LABELS = { + document: "Document", + build: "Run" +} as const; + +function formatVec3(vector: Vec3): string { + return `${vector.x}, ${vector.y}, ${vector.z}`; +} + +function formatDiagnosticCount(count: number, label: string): string { + return `${count} ${label}${count === 1 ? "" : "s"}`; +} + +function getViewportCaption(toolMode: "select" | "box-create", brushCount: number): string { + if (toolMode === "box-create") { + return `Box Create is active. Move over the grid to preview snapped placement, then click to place a ${DEFAULT_BOX_BRUSH_SIZE.x} x ${DEFAULT_BOX_BRUSH_SIZE.y} x ${DEFAULT_BOX_BRUSH_SIZE.z} box.`; + } + + return `${brushCount} box brush${brushCount === 1 ? "" : "es"} loaded. Click a brush face in the viewport or use the face selector to texture it.`; +} function createVec2Draft(vector: Vec2): Vec2Draft { return { @@ -303,7 +331,8 @@ export function App({ store, initialStatusMessage }: AppProps) { 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 [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Slice 1.4 room-authoring workflow ready."); + const [persistenceMessage, setPersistenceMessage] = useState("Local Draft is the current browser persistence path. Export JSON creates a portable copy."); const [preferredNavigationMode, setPreferredNavigationMode] = useState( primaryPlayerStart === null ? "orbitVisitor" : "firstPerson" ); @@ -314,6 +343,17 @@ export function App({ store, initialStatusMessage }: AppProps) { const [runtimeMessage, setRuntimeMessage] = useState(null); const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); const importInputRef = useRef(null); + const documentValidation = validateSceneDocument(editorState.document); + const runValidation = validateRuntimeSceneBuild(editorState.document, preferredNavigationMode); + const diagnostics = [...documentValidation.errors, ...documentValidation.warnings, ...runValidation.errors, ...runValidation.warnings]; + const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error"); + const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning"); + const runReadyLabel = + blockingDiagnostics.length > 0 + ? "Blocked" + : preferredNavigationMode === "firstPerson" + ? "Ready for First Person" + : "Ready for Orbit Visitor"; useEffect(() => { setSceneNameDraft(editorState.document.name); @@ -374,10 +414,14 @@ export function App({ store, initialStatusMessage }: AppProps) { setStatusMessage(`Scene renamed to ${normalizedName}.`); }; - const handleCreateBoxBrush = () => { + const handleCreateBoxBrush = (center?: Vec3) => { try { - store.executeCommand(createCreateBoxBrushCommand()); - setStatusMessage(`Created a box brush snapped to the ${DEFAULT_GRID_SIZE}m grid.`); + store.executeCommand(createCreateBoxBrushCommand(center === undefined ? {} : { center })); + setStatusMessage( + center === undefined + ? `Created a box brush snapped to the ${DEFAULT_GRID_SIZE}m grid.` + : `Created a box brush at snapped center ${formatVec3(center)}.` + ); } catch (error) { setStatusMessage(getErrorMessage(error)); } @@ -488,26 +532,43 @@ export function App({ store, initialStatusMessage }: AppProps) { const handleSaveDraft = () => { const result = store.saveDraft(); + setPersistenceMessage( + result.status === "saved" + ? "Local Draft saved. Refresh, reopen, or use Load Draft to restore this exact validated document." + : result.message + ); setStatusMessage(result.message); }; const handleLoadDraft = () => { const result = store.loadDraft(); + setPersistenceMessage( + result.status === "loaded" + ? "Local Draft loaded. The current in-memory document was replaced with the stored browser draft." + : result.message + ); 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"); + try { + 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); + 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."); + setPersistenceMessage("Exported a validated Scene Document JSON file for sharing or backup."); + setStatusMessage("Scene document exported as JSON."); + } catch (error) { + const message = getErrorMessage(error); + setPersistenceMessage(message); + setStatusMessage(message); + } }; const handleImportButtonClick = () => { @@ -524,9 +585,12 @@ export function App({ store, initialStatusMessage }: AppProps) { try { const source = await file.text(); store.importDocumentJson(source); + setPersistenceMessage("Imported JSON replaced the current document after migration and validation. Save Draft to make it the browser draft."); setStatusMessage(`Imported ${file.name}.`); } catch (error) { - setStatusMessage(getErrorMessage(error)); + const message = getErrorMessage(error); + setPersistenceMessage(message); + setStatusMessage(message); } finally { event.currentTarget.value = ""; } @@ -687,9 +751,16 @@ export function App({ store, initialStatusMessage }: AppProps) { }; const handleEnterPlayMode = () => { + if (blockingDiagnostics.length > 0) { + setStatusMessage(`Run mode blocked: ${formatSceneDiagnosticSummary(blockingDiagnostics)}`); + return; + } + try { - const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document); - const nextNavigationMode = primaryPlayerStart === null && preferredNavigationMode === "firstPerson" ? "orbitVisitor" : preferredNavigationMode; + const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document, { + navigationMode: preferredNavigationMode + }); + const nextNavigationMode = preferredNavigationMode; setRuntimeScene(nextRuntimeScene); setRuntimeMessage( @@ -721,6 +792,10 @@ export function App({ store, initialStatusMessage }: AppProps) { const handleSetPreferredNavigationMode = (navigationMode: RuntimeNavigationMode) => { setPreferredNavigationMode(navigationMode); + if (navigationMode === "firstPerson" && primaryPlayerStart === null) { + setStatusMessage("First Person selected. Author a Player Start before running, or switch back to Orbit Visitor."); + } + if (editorState.toolMode === "play") { setActiveNavigationMode(navigationMode); setStatusMessage(navigationMode === "firstPerson" ? "Runner switched to first-person navigation." : "Runner switched to Orbit Visitor."); @@ -733,7 +808,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
WebEditor3D
-
Slice 1.3 runner v1
+
Slice 1.4 first-room polish
@@ -841,7 +916,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
WebEditor3D
-
Slice 1.3 runner v1
+
Slice 1.4 first-room polish
@@ -858,21 +933,32 @@ export function App({ store, initialStatusMessage }: AppProps) { type="button" onClick={() => store.setToolMode("box-create")} > - Box + Box Create +
+ +
- +
- -
+
+
Save / Load
+
+ + + + +
+
+ {persistenceMessage} +
+
+
    -
  • Materials live in the canonical document registry and ship with a tiny starter library.
  • -
  • Player Start now persists as a typed scene entity and feeds the built-in runner directly.
  • +
  • Local Draft is the browser persistence path for this slice and auto-loads on refresh when available.
  • +
  • JSON import replaces the current document only after migration and validation succeed.
+ +
+
+
Document
+
+ {documentValidation.errors.length === 0 ? "Valid" : formatDiagnosticCount(documentValidation.errors.length, "error")} +
+
+
+
Run Preflight
+
{runReadyLabel}
+
+
+
Warnings
+
{warningDiagnostics.length}
+
+
+
Last Command
+
{editorState.lastCommandLabel ?? "No commands yet"}
+
+
+ + {diagnostics.length === 0 ? ( +
    +
  • No validation or run-preflight issues are blocking the first-room workflow.
  • +
+ ) : ( +
+ {diagnostics.map((diagnostic, index) => ( +
+
+ {diagnostic.severity} + {DIAGNOSTIC_BADGE_LABELS[diagnostic.scope]} +
+
{diagnostic.message}
+ {diagnostic.path === undefined ? null :
{diagnostic.path}
} +
+ ))} +
+ )} +
+
{materialList.map((material) => ( @@ -1054,15 +1206,15 @@ export function App({ store, initialStatusMessage }: AppProps) {
Viewport
-
- {brushList.length} box brushes loaded. Click a brush face in the viewport or use the face selector to texture it. -
+
{getViewportCaption(editorState.toolMode, brushList.length)}
applySelection(selection, "viewport")} + toolMode={editorState.toolMode} + onSelectionChange={(selection) => applySelection(selection, "viewport")} + onCreateBoxBrush={handleCreateBoxBrush} />
diff --git a/src/viewport-three/ViewportCanvas.tsx b/src/viewport-three/ViewportCanvas.tsx index 4407009c..8f7fba2f 100644 --- a/src/viewport-three/ViewportCanvas.tsx +++ b/src/viewport-three/ViewportCanvas.tsx @@ -1,7 +1,11 @@ import { useEffect, useRef, useState } from "react"; import type { EditorSelection } from "../core/selection"; +import type { ToolMode } from "../core/tool-mode"; +import type { Vec3 } from "../core/vector"; +import { DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes"; import type { SceneDocument, WorldSettings } from "../document/scene-document"; +import { DEFAULT_GRID_SIZE } from "../geometry/grid-snapping"; import { ViewportHost } from "./viewport-host"; @@ -9,13 +13,24 @@ interface ViewportCanvasProps { world: WorldSettings; sceneDocument: SceneDocument; selection: EditorSelection; - onBrushSelectionChange(selection: EditorSelection): void; + toolMode: ToolMode; + onSelectionChange(selection: EditorSelection): void; + onCreateBoxBrush(center: Vec3): void; } -export function ViewportCanvas({ world, sceneDocument, selection, onBrushSelectionChange }: ViewportCanvasProps) { +function formatVec3(vector: Vec3 | null): string { + if (vector === null) { + return "Move over the grid to preview a snapped placement."; + } + + return `${vector.x}, ${vector.y}, ${vector.z}`; +} + +export function ViewportCanvas({ world, sceneDocument, selection, toolMode, onSelectionChange, onCreateBoxBrush }: ViewportCanvasProps) { const containerRef = useRef(null); const hostRef = useRef(null); const [viewportMessage, setViewportMessage] = useState(null); + const [boxCreatePreview, setBoxCreatePreview] = useState(null); useEffect(() => { const container = containerRef.current; @@ -61,11 +76,46 @@ export function ViewportCanvas({ world, sceneDocument, selection, onBrushSelecti }, [sceneDocument, selection]); useEffect(() => { - hostRef.current?.setBrushSelectionChangeHandler(onBrushSelectionChange); - }, [onBrushSelectionChange]); + hostRef.current?.setBrushSelectionChangeHandler(onSelectionChange); + }, [onSelectionChange]); + + useEffect(() => { + hostRef.current?.setCreateBoxBrushHandler(onCreateBoxBrush); + }, [onCreateBoxBrush]); + + useEffect(() => { + hostRef.current?.setBoxCreatePreviewHandler(setBoxCreatePreview); + }, []); + + useEffect(() => { + hostRef.current?.setToolMode(toolMode); + + if (toolMode !== "box-create") { + setBoxCreatePreview(null); + } + }, [toolMode]); return ( -
+
+
+
{toolMode === "box-create" ? "Box Create" : "Select"}
+
+ {toolMode === "box-create" + ? `Click to place a ${DEFAULT_BOX_BRUSH_SIZE.x} x ${DEFAULT_BOX_BRUSH_SIZE.y} x ${DEFAULT_BOX_BRUSH_SIZE.z} box on the ${DEFAULT_GRID_SIZE}m grid.` + : "Click a brush, face, or Player Start marker to update the authored selection."} +
+ {toolMode !== "box-create" ? null : ( +
+ Next box center: {formatVec3(boxCreatePreview)} +
+ )} +
+ {viewportMessage === null ? null : (
Viewport Unavailable
diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 02e19fb8..81f1c69f 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -404,6 +404,16 @@ export class ViewportHost { } private handlePointerDown = (event: PointerEvent) => { + if (this.toolMode === "box-create") { + const previewCenter = this.getBoxCreatePreviewCenter(event); + + if (previewCenter !== null) { + this.createBoxBrushHandler?.(previewCenter); + } + + return; + } + const bounds = this.renderer.domElement.getBoundingClientRect(); if (bounds.width === 0 || bounds.height === 0) { @@ -464,6 +474,68 @@ export class ViewportHost { }); }; + private handlePointerMove = (event: PointerEvent) => { + if (this.toolMode !== "box-create") { + return; + } + + this.setBoxCreatePreview(this.getBoxCreatePreviewCenter(event)); + }; + + private handlePointerLeave = () => { + if (this.toolMode !== "box-create") { + return; + } + + this.setBoxCreatePreview(null); + }; + + private getBoxCreatePreviewCenter(event: PointerEvent): Vec3 | null { + const bounds = this.renderer.domElement.getBoundingClientRect(); + + if (bounds.width === 0 || bounds.height === 0) { + return null; + } + + this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1); + this.raycaster.setFromCamera(this.pointer, this.camera); + + if (this.raycaster.ray.intersectPlane(this.boxCreatePlane, this.boxCreateIntersection) === null) { + return null; + } + + return { + x: snapValueToGrid(this.boxCreateIntersection.x, DEFAULT_GRID_SIZE), + y: DEFAULT_BOX_BRUSH_SIZE.y * 0.5, + z: snapValueToGrid(this.boxCreateIntersection.z, DEFAULT_GRID_SIZE) + }; + } + + private setBoxCreatePreview(center: Vec3 | null) { + if ( + (center === null && this.lastBoxCreatePreviewCenter === null) || + (center !== null && + this.lastBoxCreatePreviewCenter !== null && + center.x === this.lastBoxCreatePreviewCenter.x && + center.y === this.lastBoxCreatePreviewCenter.y && + center.z === this.lastBoxCreatePreviewCenter.z) + ) { + return; + } + + this.lastBoxCreatePreviewCenter = center === null ? null : { ...center }; + this.boxCreatePreviewMesh.visible = center !== null; + this.boxCreatePreviewEdges.visible = center !== null; + + if (center !== null) { + this.boxCreatePreviewMesh.position.set(center.x, center.y, center.z); + this.boxCreatePreviewEdges.position.set(center.x, center.y, center.z); + } + + this.boxCreatePreviewHandler?.(this.lastBoxCreatePreviewCenter); + } + private render = () => { this.animationFrame = window.requestAnimationFrame(this.render); this.renderer.render(this.scene, this.camera);