Enhance App component with validation and UI updates for better user experience

This commit is contained in:
2026-03-31 03:44:17 +02:00
parent 57a4d45692
commit f52cd5d884
3 changed files with 310 additions and 36 deletions

View File

@@ -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<BoxFaceId, string> = {
};
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<RuntimeNavigationMode>(
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
);
@@ -314,6 +343,17 @@ export function App({ store, initialStatusMessage }: AppProps) {
const [runtimeMessage, setRuntimeMessage] = useState<string | null>(null);
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState<FirstPersonTelemetry | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(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) {
<header className="toolbar">
<div className="toolbar__brand">
<div className="toolbar__title">WebEditor3D</div>
<div className="toolbar__subtitle">Slice 1.3 runner v1</div>
<div className="toolbar__subtitle">Slice 1.4 first-room polish</div>
</div>
<div className="toolbar__actions">
@@ -841,7 +916,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
<header className="toolbar">
<div className="toolbar__brand">
<div className="toolbar__title">WebEditor3D</div>
<div className="toolbar__subtitle">Slice 1.3 runner v1</div>
<div className="toolbar__subtitle">Slice 1.4 first-room polish</div>
</div>
<div className="toolbar__actions">
@@ -858,21 +933,32 @@ export function App({ store, initialStatusMessage }: AppProps) {
type="button"
onClick={() => store.setToolMode("box-create")}
>
Box
Box Create
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button toolbar__button--accent" type="button" data-testid="create-box-brush" onClick={handleCreateBoxBrush}>
Create Box Brush
Create Box
</button>
<button className="toolbar__button toolbar__button--accent" type="button" data-testid="enter-run-mode" onClick={handleEnterPlayMode}>
<button className="toolbar__button" type="button" data-testid="place-player-start-toolbar" onClick={handleSelectOrPlacePlayerStart}>
{primaryPlayerStart === null ? "Place Player Start" : "Select Player Start"}
</button>
<button
className={`toolbar__button toolbar__button--accent ${blockingDiagnostics.length > 0 ? "toolbar__button--warn" : ""}`}
type="button"
data-testid="enter-run-mode"
onClick={handleEnterPlayMode}
>
Run Scene
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button" type="button" onClick={handleSaveDraft}>
<button className="toolbar__button" type="button" disabled={!editorState.storageAvailable} onClick={handleSaveDraft}>
Save Draft
</button>
<button className="toolbar__button" type="button" onClick={handleLoadDraft}>
<button className="toolbar__button" type="button" disabled={!editorState.storageAvailable} onClick={handleLoadDraft}>
Load Draft
</button>
<button className="toolbar__button" type="button" onClick={handleExportJson}>
@@ -896,7 +982,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
<div className="workspace">
<aside className="side-column">
<Panel title="Document">
<Panel title="Scene">
<div className="stat-grid">
<div className="stat-card">
<div className="label">Version</div>
@@ -908,7 +994,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
</div>
<div className="stat-card">
<div className="label">Tool Mode</div>
<div className="value">{editorState.toolMode}</div>
<div className="value">{TOOL_LABELS[editorState.toolMode]}</div>
</div>
<div className="stat-card">
<div className="label">Brushes</div>
@@ -937,12 +1023,78 @@ export function App({ store, initialStatusMessage }: AppProps) {
</button>
</div>
<div className="form-section">
<div className="label">Save / Load</div>
<div className="inline-actions">
<button className="toolbar__button" type="button" disabled={!editorState.storageAvailable} onClick={handleSaveDraft}>
Save Draft
</button>
<button className="toolbar__button" type="button" disabled={!editorState.storageAvailable} onClick={handleLoadDraft}>
Load Draft
</button>
<button className="toolbar__button" type="button" onClick={handleExportJson}>
Export JSON
</button>
<button className="toolbar__button" type="button" onClick={handleImportButtonClick}>
Import JSON
</button>
</div>
<div className="info-banner" data-testid="persistence-message">
{persistenceMessage}
</div>
</div>
<ul className="placeholder-list">
<li>Materials live in the canonical document registry and ship with a tiny starter library.</li>
<li>Player Start now persists as a typed scene entity and feeds the built-in runner directly.</li>
<li>Local Draft is the browser persistence path for this slice and auto-loads on refresh when available.</li>
<li>JSON import replaces the current document only after migration and validation succeed.</li>
</ul>
</Panel>
<Panel title="Status">
<div className="stat-grid">
<div className="stat-card" data-testid="document-validation-state">
<div className="label">Document</div>
<div className="value">
{documentValidation.errors.length === 0 ? "Valid" : formatDiagnosticCount(documentValidation.errors.length, "error")}
</div>
</div>
<div className="stat-card" data-testid="run-validation-state">
<div className="label">Run Preflight</div>
<div className="value">{runReadyLabel}</div>
</div>
<div className="stat-card">
<div className="label">Warnings</div>
<div className="value">{warningDiagnostics.length}</div>
</div>
<div className="stat-card">
<div className="label">Last Command</div>
<div className="value">{editorState.lastCommandLabel ?? "No commands yet"}</div>
</div>
</div>
{diagnostics.length === 0 ? (
<ul className="placeholder-list" data-testid="diagnostics-list">
<li>No validation or run-preflight issues are blocking the first-room workflow.</li>
</ul>
) : (
<div className="diagnostic-list" data-testid="diagnostics-list">
{diagnostics.map((diagnostic, index) => (
<div
key={`${diagnostic.scope}-${diagnostic.code}-${diagnostic.path ?? index}`}
className={`diagnostic-item diagnostic-item--${diagnostic.severity}`}
>
<div className="diagnostic-item__header">
<span className={`diagnostic-badge diagnostic-badge--${diagnostic.severity}`}>{diagnostic.severity}</span>
<span className="diagnostic-item__scope">{DIAGNOSTIC_BADGE_LABELS[diagnostic.scope]}</span>
</div>
<div className="diagnostic-item__message">{diagnostic.message}</div>
{diagnostic.path === undefined ? null : <div className="diagnostic-item__path">{diagnostic.path}</div>}
</div>
))}
</div>
)}
</Panel>
<Panel title="Materials">
<div className="material-browser">
{materialList.map((material) => (
@@ -1054,15 +1206,15 @@ export function App({ store, initialStatusMessage }: AppProps) {
<main className="viewport-region">
<div className="viewport-region__header">
<div className="viewport-region__title">Viewport</div>
<div className="viewport-region__caption">
{brushList.length} box brushes loaded. Click a brush face in the viewport or use the face selector to texture it.
</div>
<div className="viewport-region__caption">{getViewportCaption(editorState.toolMode, brushList.length)}</div>
</div>
<ViewportCanvas
world={editorState.document.world}
sceneDocument={editorState.document}
selection={editorState.selection}
onBrushSelectionChange={(selection) => applySelection(selection, "viewport")}
toolMode={editorState.toolMode}
onSelectionChange={(selection) => applySelection(selection, "viewport")}
onCreateBoxBrush={handleCreateBoxBrush}
/>
</main>

View File

@@ -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<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null);
const [viewportMessage, setViewportMessage] = useState<string | null>(null);
const [boxCreatePreview, setBoxCreatePreview] = useState<Vec3 | null>(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 (
<div ref={containerRef} className="viewport-canvas" data-testid="viewport-shell" aria-label="Editor viewport">
<div
ref={containerRef}
className={`viewport-canvas viewport-canvas--${toolMode}`}
data-testid="viewport-shell"
aria-label="Editor viewport"
>
<div className="viewport-canvas__overlay" data-testid="viewport-overlay">
<div className="viewport-canvas__overlay-badge">{toolMode === "box-create" ? "Box Create" : "Select"}</div>
<div className="viewport-canvas__overlay-text">
{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."}
</div>
{toolMode !== "box-create" ? null : (
<div className="viewport-canvas__overlay-preview" data-testid="viewport-snap-preview">
Next box center: {formatVec3(boxCreatePreview)}
</div>
)}
</div>
{viewportMessage === null ? null : (
<div className="viewport-canvas__fallback" role="status">
<div className="viewport-canvas__fallback-title">Viewport Unavailable</div>

View File

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