2026-03-31 02:37:22 +02:00
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" ;
2026-03-31 03:09:04 +02:00
import { createSetPlayerStartCommand } from "../commands/set-player-start-command" ;
2026-03-31 02:37:22 +02:00
import { createSetSceneNameCommand } from "../commands/set-scene-name-command" ;
import {
getSelectedBrushFaceId ,
getSingleSelectedBrushId ,
2026-03-31 03:09:04 +02:00
getSingleSelectedEntityId ,
2026-03-31 02:37:22 +02:00
isBrushFaceSelected ,
isBrushSelected ,
type EditorSelection
} from "../core/selection" ;
import type { Vec2 , Vec3 } from "../core/vector" ;
import {
BOX_FACE_IDS ,
2026-03-31 02:40:32 +02:00
DEFAULT_BOX_BRUSH_CENTER ,
DEFAULT_BOX_BRUSH_SIZE ,
2026-03-31 02:37:22 +02:00
createDefaultFaceUvState ,
type BoxBrush ,
type BoxFaceId ,
2026-03-31 03:09:04 +02:00
type FaceUvRotationQuarterTurns ,
type FaceUvState
2026-03-31 02:37:22 +02:00
} from "../document/brushes" ;
2026-03-31 03:44:17 +02:00
import { formatSceneDiagnosticSummary , validateSceneDocument , type SceneDiagnostic } from "../document/scene-document-validation" ;
2026-03-31 02:37:22 +02:00
import { DEFAULT_GRID_SIZE , snapPositiveSizeToGrid , snapVec3ToGrid } from "../geometry/grid-snapping" ;
import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs" ;
2026-03-31 03:09:04 +02:00
import {
DEFAULT_PLAYER_START_POSITION ,
getPlayerStartEntities ,
getPrimaryPlayerStartEntity ,
normalizeYawDegrees ,
type PlayerStartEntity
} from "../entities/entity-instances" ;
2026-03-31 02:37:22 +02:00
import { STARTER_MATERIAL_LIBRARY , type MaterialDef } from "../materials/starter-material-library" ;
2026-03-31 03:09:04 +02:00
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" ;
2026-03-31 03:44:17 +02:00
import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation" ;
2026-03-31 02:37:22 +02:00
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 < BoxFaceId , string > = {
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 ] ) ) ;
2026-03-31 03:44:17 +02:00
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" } ` ;
}
2026-03-31 03:44:55 +02:00
function getViewportCaption ( toolMode : "select" | "box-create" | "play" , brushCount : number ) : string {
if ( toolMode === "play" ) {
return "Runner is active." ;
}
2026-03-31 03:44:17 +02:00
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. ` ;
}
2026-03-31 02:37:22 +02:00
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 ;
}
2026-03-31 03:09:04 +02:00
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 ) ;
}
2026-03-31 02:37:22 +02:00
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
) ;
}
2026-03-31 03:09:04 +02:00
function arePlayerStartsEqual ( left : PlayerStartEntity , rightPosition : Vec3 , rightYawDegrees : number ) : boolean {
return areVec3Equal ( left . position , rightPosition ) && left . yawDegrees === normalizeYawDegrees ( rightYawDegrees ) ;
}
2026-03-31 02:37:22 +02:00
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 ;
}
2026-03-31 03:09:04 +02:00
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 ;
}
2026-03-31 02:37:22 +02:00
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 ) ;
}
2026-03-31 03:09:04 +02:00
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 ) ;
}
2026-03-31 02:37:22 +02:00
function getSelectedBrushLabel ( selection : EditorSelection , brushes : BoxBrush [ ] ) : string {
const selectedBrushId = getSingleSelectedBrushId ( selection ) ;
if ( selectedBrushId === null ) {
return "No brush selected" ;
}
return getBrushLabelById ( selectedBrushId , brushes ) ;
}
2026-03-31 03:09:04 +02:00
function describeSelection ( selection : EditorSelection , brushes : BoxBrush [ ] , playerStarts : PlayerStartEntity [ ] ) : string {
2026-03-31 02:37:22 +02:00
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" :
2026-03-31 03:09:04 +02:00
return ` ${ selection . ids . length } entity selected ( ${ getPlayerStartLabelById ( selection . ids [ 0 ] , playerStarts ) } ) ` ;
2026-03-31 02:37:22 +02:00
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 < string , MaterialDef > ) : 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 ;
}
2026-03-31 03:09:04 +02:00
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 ) } ` ;
}
2026-03-31 02:37:22 +02:00
export function App ( { store , initialStatusMessage } : AppProps ) {
const editorState = useEditorStoreState ( store ) ;
const brushList = Object . values ( editorState . document . brushes ) ;
2026-03-31 03:09:04 +02:00
const playerStartList = getPlayerStartEntities ( editorState . document . entities ) ;
const primaryPlayerStart = getPrimaryPlayerStartEntity ( editorState . document . entities ) ;
2026-03-31 02:37:22 +02:00
const materialList = sortDocumentMaterials ( editorState . document . materials ) ;
const selectedBrush = getSelectedBoxBrush ( editorState . selection , brushList ) ;
2026-03-31 03:09:04 +02:00
const selectedPlayerStart = getSelectedPlayerStart ( editorState . selection , playerStartList ) ;
2026-03-31 02:37:22 +02:00
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 ;
2026-03-31 03:09:04 +02:00
const editablePlayerStart = selectedPlayerStart ? ? primaryPlayerStart ;
2026-03-31 02:37:22 +02:00
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 ) ) ;
2026-03-31 03:09:04 +02:00
const [ playerStartPositionDraft , setPlayerStartPositionDraft ] = useState ( createVec3Draft ( DEFAULT_PLAYER_START_POSITION ) ) ;
const [ playerStartYawDraft , setPlayerStartYawDraft ] = useState ( "0" ) ;
2026-03-31 03:44:17 +02:00
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." ) ;
2026-03-31 03:09:04 +02:00
const [ preferredNavigationMode , setPreferredNavigationMode ] = useState < RuntimeNavigationMode > (
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
) ;
const [ activeNavigationMode , setActiveNavigationMode ] = useState < RuntimeNavigationMode > (
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
) ;
const [ runtimeScene , setRuntimeScene ] = useState < RuntimeSceneDefinition | null > ( null ) ;
const [ runtimeMessage , setRuntimeMessage ] = useState < string | null > ( null ) ;
const [ firstPersonTelemetry , setFirstPersonTelemetry ] = useState < FirstPersonTelemetry | null > ( null ) ;
2026-03-31 02:37:22 +02:00
const importInputRef = useRef < HTMLInputElement | null > ( null ) ;
2026-03-31 03:44:17 +02:00
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" ;
2026-03-31 02:37:22 +02:00
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 ] ) ;
2026-03-31 03:09:04 +02:00
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 ] ) ;
2026-03-31 02:37:22 +02:00
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 } . ` ) ;
} ;
2026-03-31 03:44:17 +02:00
const handleCreateBoxBrush = ( center? : Vec3 ) = > {
2026-03-31 02:37:22 +02:00
try {
2026-03-31 03:44:17 +02:00
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 ) } . `
) ;
2026-03-31 02:37:22 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 03:09:04 +02:00
const applySelection = ( selection : EditorSelection , source : "outliner" | "viewport" | "inspector" | "runner" ) = > {
2026-03-31 02:37:22 +02:00
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 ;
2026-03-31 03:09:04 +02:00
case "entities" :
setStatusMessage ( ` Selected ${ getPlayerStartLabelById ( selection . ids [ 0 ] , playerStartList ) } from the ${ source } . ` ) ;
break ;
2026-03-31 02:37:22 +02:00
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 ) ) ;
}
} ;
2026-03-31 03:09:04 +02:00
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 ) ) ;
}
} ;
2026-03-31 02:37:22 +02:00
const handleDraftVectorKeyDown = ( event : KeyboardEvent < HTMLInputElement > , applyChange : ( ) = > void ) = > {
if ( event . key === "Enter" ) {
applyChange ( ) ;
}
} ;
const handleSaveDraft = ( ) = > {
const result = store . saveDraft ( ) ;
2026-03-31 03:44:17 +02:00
setPersistenceMessage (
result . status === "saved"
? "Local Draft saved. Refresh, reopen, or use Load Draft to restore this exact validated document."
: result . message
) ;
2026-03-31 02:37:22 +02:00
setStatusMessage ( result . message ) ;
} ;
const handleLoadDraft = ( ) = > {
const result = store . loadDraft ( ) ;
2026-03-31 03:44:17 +02:00
setPersistenceMessage (
result . status === "loaded"
? "Local Draft loaded. The current in-memory document was replaced with the stored browser draft."
: result . message
) ;
2026-03-31 02:37:22 +02:00
setStatusMessage ( result . message ) ;
} ;
const handleExportJson = ( ) = > {
2026-03-31 03:44:17 +02:00
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 ) ;
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 ) ;
}
2026-03-31 02:37:22 +02:00
} ;
const handleImportButtonClick = ( ) = > {
importInputRef . current ? . click ( ) ;
} ;
const handleImportChange = async ( event : ChangeEvent < HTMLInputElement > ) = > {
const file = event . currentTarget . files ? . [ 0 ] ;
if ( file === undefined ) {
return ;
}
try {
const source = await file . text ( ) ;
store . importDocumentJson ( source ) ;
2026-03-31 03:44:17 +02:00
setPersistenceMessage ( "Imported JSON replaced the current document after migration and validation. Save Draft to make it the browser draft." ) ;
2026-03-31 02:37:22 +02:00
setStatusMessage ( ` Imported ${ file . name } . ` ) ;
} catch ( error ) {
2026-03-31 03:44:17 +02:00
const message = getErrorMessage ( error ) ;
setPersistenceMessage ( message ) ;
setStatusMessage ( message ) ;
2026-03-31 02:37:22 +02:00
} 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."
) ;
} ;
2026-03-31 03:09:04 +02:00
const handleSelectOrPlacePlayerStart = ( ) = > {
if ( primaryPlayerStart === null ) {
applyPlayerStartChange ( ) ;
return ;
}
applySelection (
{
kind : "entities" ,
ids : [ primaryPlayerStart . id ]
} ,
"runner"
) ;
} ;
const handleEnterPlayMode = ( ) = > {
2026-03-31 03:44:17 +02:00
if ( blockingDiagnostics . length > 0 ) {
setStatusMessage ( ` Run mode blocked: ${ formatSceneDiagnosticSummary ( blockingDiagnostics ) } ` ) ;
return ;
}
2026-03-31 03:09:04 +02:00
try {
2026-03-31 03:44:17 +02:00
const nextRuntimeScene = buildRuntimeSceneFromDocument ( editorState . document , {
navigationMode : preferredNavigationMode
} ) ;
const nextNavigationMode = preferredNavigationMode ;
2026-03-31 03:09:04 +02:00
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 ) ;
2026-03-31 03:44:17 +02:00
if ( navigationMode === "firstPerson" && primaryPlayerStart === null ) {
setStatusMessage ( "First Person selected. Author a Player Start before running, or switch back to Orbit Visitor." ) ;
}
2026-03-31 03:09:04 +02:00
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 (
< div className = "app-shell app-shell--play" >
< header className = "toolbar" >
< div className = "toolbar__brand" >
< div className = "toolbar__title" > WebEditor3D < / div >
2026-03-31 03:44:17 +02:00
< div className = "toolbar__subtitle" > Slice 1.4 first - room polish < / div >
2026-03-31 03:09:04 +02:00
< / div >
< div className = "toolbar__actions" >
< div className = "toolbar__group" >
< button
className = { ` toolbar__button ${ activeNavigationMode === "firstPerson" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "runner-mode-first-person"
onClick = { ( ) = > handleSetPreferredNavigationMode ( "firstPerson" ) }
>
First Person
< / button >
< button
className = { ` toolbar__button ${ activeNavigationMode === "orbitVisitor" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "runner-mode-orbit-visitor"
onClick = { ( ) = > handleSetPreferredNavigationMode ( "orbitVisitor" ) }
>
Orbit Visitor
< / button >
< / div >
< div className = "toolbar__group" >
< button className = "toolbar__button toolbar__button--accent" type = "button" data-testid = "exit-run-mode" onClick = { handleExitPlayMode } >
Return To Editor
< / button >
< / div >
< / div >
< / header >
< div className = "runner-workspace" >
< main className = "runner-region" >
< RunnerCanvas
runtimeScene = { runtimeScene }
navigationMode = { activeNavigationMode }
onRuntimeMessageChange = { setRuntimeMessage }
onFirstPersonTelemetryChange = { setFirstPersonTelemetry }
/ >
< / main >
< aside className = "side-column" >
< Panel title = "Runner" >
< div className = "stat-grid" >
< div className = "stat-card" >
< div className = "label" > Navigation < / div >
< div className = "value" > { activeNavigationMode === "firstPerson" ? "First Person" : "Orbit Visitor" } < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Spawn Source < / div >
< div className = "value" > { runtimeScene . spawn . source === "playerStart" ? "Player Start" : "Fallback" } < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Pointer Lock < / div >
< div className = "value" >
{ activeNavigationMode === "firstPerson" ? ( firstPersonTelemetry ? . pointerLocked ? "active" : "idle" ) : "not used" }
< / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Grounded < / div >
< div className = "value" > { firstPersonTelemetry ? . grounded ? "yes" : activeNavigationMode === "firstPerson" ? "no" : "n/a" } < / div >
< / div >
< / div >
< div className = "stat-card" >
< div className = "label" > FPS Feet Position < / div >
< div className = "value" data-testid = "runner-player-position" >
{ formatRunnerFeetPosition ( firstPersonTelemetry ? . feetPosition ? ? runtimeScene . spawn . position ) }
< / div >
< div className = "material-summary" data-testid = "runner-spawn-state" >
Spawn : { runtimeScene . spawn . source === "playerStart" ? "Player Start" : "Fallback" } at { " " }
{ formatRunnerFeetPosition ( runtimeScene . spawn . position ) }
< / div >
< / div >
{ runtimeMessage === null ? null : (
< ul className = "placeholder-list" >
< li > { runtimeMessage } < / li >
< / ul >
) }
< ul className = "placeholder-list" >
< li > First - person uses ` WASD ` plus mouse - look after pointer lock is captured . < / li >
< li > Orbit Visitor is the browser - safe fallback when pointer lock is unavailable or undesirable . < / li >
< li > Collision is axis - aligned box collision only in this slice . < / li >
< / ul >
< / Panel >
< / aside >
< / div >
< footer className = "status-bar" >
< div >
< span className = "status-bar__strong" > Status : < / span > { statusMessage }
< / div >
< div >
< span className = "status-bar__strong" > Spawn : < / span > { " " }
{ runtimeScene . spawn . source === "playerStart" ? "Authored Player Start" : "Fallback runtime spawn" }
< / div >
< / footer >
< / div >
) ;
}
2026-03-31 02:37:22 +02:00
return (
< div className = "app-shell" >
< header className = "toolbar" >
< div className = "toolbar__brand" >
< div className = "toolbar__title" > WebEditor3D < / div >
2026-03-31 03:44:17 +02:00
< div className = "toolbar__subtitle" > Slice 1.4 first - room polish < / div >
2026-03-31 02:37:22 +02:00
< / div >
< div className = "toolbar__actions" >
< div className = "toolbar__group" >
< button
className = { ` toolbar__button ${ editorState . toolMode === "select" ? "toolbar__button--active" : "" } ` }
type = "button"
onClick = { ( ) = > store . setToolMode ( "select" ) }
>
Select
< / button >
< button
className = { ` toolbar__button ${ editorState . toolMode === "box-create" ? "toolbar__button--active" : "" } ` }
type = "button"
onClick = { ( ) = > store . setToolMode ( "box-create" ) }
>
2026-03-31 03:44:17 +02:00
Box Create
2026-03-31 02:37:22 +02:00
< / button >
2026-03-31 03:44:17 +02:00
< / div >
< div className = "toolbar__group" >
2026-03-31 03:45:53 +02:00
< button
className = "toolbar__button toolbar__button--accent"
type = "button"
data - testid = "create-box-brush"
onClick = { ( ) = > handleCreateBoxBrush ( ) }
>
2026-03-31 03:44:17 +02:00
Create Box
< / button >
< button className = "toolbar__button" type = "button" data-testid = "place-player-start-toolbar" onClick = { handleSelectOrPlacePlayerStart } >
{ primaryPlayerStart === null ? "Place Player Start" : "Select Player Start" }
2026-03-31 02:37:22 +02:00
< / button >
2026-03-31 03:44:17 +02:00
< button
className = { ` toolbar__button toolbar__button--accent ${ blockingDiagnostics . length > 0 ? "toolbar__button--warn" : "" } ` }
type = "button"
data - testid = "enter-run-mode"
onClick = { handleEnterPlayMode }
>
2026-03-31 03:09:04 +02:00
Run Scene
< / button >
2026-03-31 02:37:22 +02:00
< / div >
< div className = "toolbar__group" >
< button className = "toolbar__button" type = "button" disabled = { ! editorState . canUndo } onClick = { ( ) = > store . undo ( ) } >
Undo
< / button >
< button className = "toolbar__button" type = "button" disabled = { ! editorState . canRedo } onClick = { ( ) = > store . redo ( ) } >
Redo
< / button >
< / div >
< / div >
< / header >
< div className = "workspace" >
< aside className = "side-column" >
2026-03-31 03:44:17 +02:00
< Panel title = "Scene" >
2026-03-31 02:37:22 +02:00
< div className = "stat-grid" >
< div className = "stat-card" >
< div className = "label" > Version < / div >
< div className = "value" > v { editorState . document . version } < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Grid < / div >
< div className = "value" > { DEFAULT_GRID_SIZE } m snap < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Tool Mode < / div >
2026-03-31 03:44:17 +02:00
< div className = "value" > { TOOL_LABELS [ editorState . toolMode ] } < / div >
2026-03-31 02:37:22 +02:00
< / div >
< div className = "stat-card" >
< div className = "label" > Brushes < / div >
< div className = "value" > { brushList . length } < / div >
< / div >
< / div >
< label className = "form-field" >
< span className = "label" > Scene Name < / span >
< input
className = "text-input"
type = "text"
value = { sceneNameDraft }
onChange = { ( event ) = > setSceneNameDraft ( event . currentTarget . value ) }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applySceneName ( ) ;
}
} }
/ >
< / label >
< div className = "inline-actions" >
< button className = "toolbar__button toolbar__button--accent" type = "button" onClick = { applySceneName } >
Apply Scene Name Command
< / button >
< / div >
2026-03-31 03:44:17 +02:00
< 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 >
2026-03-31 02:37:22 +02:00
< ul className = "placeholder-list" >
2026-03-31 03:44:17 +02:00
< 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 >
2026-03-31 02:37:22 +02:00
< / ul >
< / Panel >
2026-03-31 03:44:17 +02:00
< 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 >
2026-03-31 02:37:22 +02:00
< Panel title = "Materials" >
< div className = "material-browser" >
{ materialList . map ( ( material ) = > (
< button
key = { material . id }
type = "button"
data - testid = { ` material-button- ${ material . id } ` }
className = { ` material-item ${ selectedFace ? . materialId === material . id ? "material-item--active" : "" } ` }
disabled = { selectedFace === null }
onClick = { ( ) = > applyFaceMaterial ( material . id ) }
>
< span className = "material-item__preview" style = { getMaterialPreviewStyle ( material ) } aria-hidden = "true" / >
< span className = "material-item__text" >
< span className = "material-item__title" > { material . name } < / span >
< span className = "material-item__meta" > { material . tags . join ( " • " ) } < / span >
< / span >
< / button >
) ) }
< / div >
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" disabled = { selectedFace === null } onClick = { clearFaceMaterial } >
Clear Face Material
< / button >
< / div >
< ul className = "placeholder-list" >
< li > Select a face in the viewport or inspector , then click a starter material to apply it . < / li >
< / ul >
< / Panel >
< Panel title = "Outliner" >
2026-03-31 03:09:04 +02:00
< div className = "outliner-section" >
< div className = "label" > Brushes < / div >
{ brushList . length === 0 ? (
< ul className = "placeholder-list" >
< li > No authored brushes yet . Use Create Box Brush to place the first brush . < / li >
< / ul >
) : (
< div className = "outliner-list" data-testid = "outliner-brush-list" >
{ brushList . map ( ( brush , brushIndex ) = > (
< button
key = { brush . id }
className = { ` outliner-item ${ isBrushSelected ( editorState . selection , brush . id ) ? "outliner-item--selected" : "" } ` }
type = "button"
onClick = { ( ) = >
applySelection (
{
kind : "brushes" ,
ids : [ brush . id ]
} ,
"outliner"
)
}
>
< span className = "outliner-item__title" > { getBrushLabel ( brushIndex ) } < / span >
< span className = "outliner-item__meta" >
center { brush . center . x } , { brush . center . y } , { brush . center . z }
< / span >
< span className = "outliner-item__meta" >
size { brush . size . x } , { brush . size . y } , { brush . size . z }
< / span >
< / button >
) ) }
< / div >
) }
< / div >
< div className = "outliner-section" >
< div className = "label" > Entities < / div >
{ playerStartList . length === 0 ? (
< ul className = "placeholder-list" >
< li > No Player Start authored yet . Place one before relying on first - person spawn . < / li >
< / ul >
) : (
< div className = "outliner-list" >
{ playerStartList . map ( ( playerStart , index ) = > (
< button
key = { playerStart . id }
className = { ` outliner-item ${
editorState . selection . kind === "entities" && editorState . selection . ids . includes ( playerStart . id )
? "outliner-item--selected"
: ""
} ` }
type = "button"
onClick = { ( ) = >
applySelection (
{
kind : "entities" ,
ids : [ playerStart . id ]
} ,
"outliner"
)
}
>
< span className = "outliner-item__title" > { getPlayerStartLabel ( index ) } < / span >
< span className = "outliner-item__meta" >
position { playerStart . position . x } , { playerStart . position . y } , { playerStart . position . z }
< / span >
< span className = "outliner-item__meta" > yaw { playerStart . yawDegrees } ° < / span >
< / button >
) ) }
< / div >
) }
< / div >
2026-03-31 02:37:22 +02:00
< / Panel >
< / aside >
< main className = "viewport-region" >
< div className = "viewport-region__header" >
< div className = "viewport-region__title" > Viewport < / div >
2026-03-31 03:44:17 +02:00
< div className = "viewport-region__caption" > { getViewportCaption ( editorState . toolMode , brushList . length ) } < / div >
2026-03-31 02:37:22 +02:00
< / div >
< ViewportCanvas
world = { editorState . document . world }
sceneDocument = { editorState . document }
selection = { editorState . selection }
2026-03-31 03:44:17 +02:00
toolMode = { editorState . toolMode }
onSelectionChange = { ( selection ) = > applySelection ( selection , "viewport" ) }
onCreateBoxBrush = { handleCreateBoxBrush }
2026-03-31 02:37:22 +02:00
/ >
< / main >
< aside className = "side-column" >
< Panel title = "Inspector" >
< div className = "stat-card" >
< div className = "label" > Selection < / div >
2026-03-31 03:09:04 +02:00
< div className = "value" > { describeSelection ( editorState . selection , brushList , playerStartList ) } < / div >
2026-03-31 02:37:22 +02:00
< / div >
2026-03-31 03:09:04 +02:00
{ selectedPlayerStart !== null ? (
< >
< div className = "stat-card" >
< div className = "label" > Entity Kind < / div >
< div className = "value" > Player Start < / div >
< div className = "material-summary" > Used by first - person run mode as the authored spawn transform . < / div >
< / div >
< div className = "form-section" >
< div className = "label" > Position < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
data - testid = "player-start-position-x"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { playerStartPositionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPlayerStartPositionDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "player-start-position-y"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { playerStartPositionDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPlayerStartPositionDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "player-start-position-z"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { playerStartPositionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPlayerStartPositionDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Yaw < / div >
< label className = "form-field" >
< span className = "label" > Degrees < / span >
< input
data - testid = "player-start-yaw"
className = "text-input"
type = "number"
step = "1"
value = { playerStartYawDraft }
onChange = { ( event ) = > setPlayerStartYawDraft ( event . currentTarget . value ) }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
/ >
< / label >
< button className = "toolbar__button" type = "button" data-testid = "apply-player-start" onClick = { applyPlayerStartChange } >
Apply Player Start Command
< / button >
< / div >
< / >
) : selectedBrush === null ? (
2026-03-31 02:37:22 +02:00
< ul className = "placeholder-list" >
< li > Select a box brush to edit transforms and choose individual faces . < / li >
2026-03-31 03:09:04 +02:00
< li > Select Player Start to author the first - person spawn transform . < / li >
2026-03-31 02:37:22 +02:00
< / ul >
) : (
< >
< div className = "stat-card" >
< div className = "label" > Brush Kind < / div >
< div className = "value" > box < / div >
< / div >
< div className = "form-section" >
< div className = "label" > Center < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
data - testid = "brush-center-x"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { positionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPositionDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPositionChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "brush-center-y"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { positionDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPositionDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPositionChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "brush-center-z"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { positionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPositionDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPositionChange ) }
/ >
< / label >
< / div >
< button className = "toolbar__button" type = "button" data-testid = "apply-brush-position" onClick = { applyPositionChange } >
Apply Move Command
< / button >
< / div >
< div className = "form-section" >
< div className = "label" > Size < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
data - testid = "brush-size-x"
className = "text-input"
type = "number"
min = { DEFAULT_GRID_SIZE }
step = { DEFAULT_GRID_SIZE }
value = { sizeDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSizeDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySizeChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "brush-size-y"
className = "text-input"
type = "number"
min = { DEFAULT_GRID_SIZE }
step = { DEFAULT_GRID_SIZE }
value = { sizeDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSizeDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySizeChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "brush-size-z"
className = "text-input"
type = "number"
min = { DEFAULT_GRID_SIZE }
step = { DEFAULT_GRID_SIZE }
value = { sizeDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSizeDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySizeChange ) }
/ >
< / label >
< / div >
< button className = "toolbar__button" type = "button" data-testid = "apply-brush-size" onClick = { applySizeChange } >
Apply Resize Command
< / button >
< / div >
< div className = "form-section" >
< div className = "label" > Faces < / div >
< div className = "face-grid" >
{ BOX_FACE_IDS . map ( ( faceId ) = > (
< button
key = { faceId }
type = "button"
data - testid = { ` face-button- ${ faceId } ` }
className = { ` face-chip ${ isBrushFaceSelected ( editorState . selection , selectedBrush . id , faceId ) ? "face-chip--active" : "" } ` }
onClick = { ( ) = >
applySelection (
{
kind : "brushFace" ,
brushId : selectedBrush.id ,
faceId
} ,
"inspector"
)
}
>
< span className = "face-chip__title" > { FACE_LABELS [ faceId ] } < / span >
< span className = "face-chip__meta" > { faceId } < / span >
< / button >
) ) }
< / div >
< / div >
{ selectedFace === null || selectedFaceId === null ? (
< ul className = "placeholder-list" >
< li > Stable face ids are : posX , negX , posY , negY , posZ , negZ . < / li >
< li > Select one face to apply a material and edit its UV transform . < / li >
< / ul >
) : (
< >
< div className = "stat-card" >
< div className = "label" > Active Face < / div >
< div className = "value" > { FACE_LABELS [ selectedFaceId ] } < / div >
< div className = "material-summary" data-testid = "selected-face-material-name" >
Material : { selectedFaceMaterial ? . name ? ? "Fallback face color" }
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > UV Offset < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > U < / span >
< input
data - testid = "face-uv-offset-x"
className = "text-input"
type = "number"
step = "0.125"
value = { uvOffsetDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setUvOffsetDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , handleApplyUvDraft ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > V < / span >
< input
data - testid = "face-uv-offset-y"
className = "text-input"
type = "number"
step = "0.125"
value = { uvOffsetDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setUvOffsetDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , handleApplyUvDraft ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > UV Scale < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > U < / span >
< input
data - testid = "face-uv-scale-x"
className = "text-input"
type = "number"
min = "0.001"
step = "0.125"
value = { uvScaleDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setUvScaleDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , handleApplyUvDraft ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > V < / span >
< input
data - testid = "face-uv-scale-y"
className = "text-input"
type = "number"
min = "0.001"
step = "0.125"
value = { uvScaleDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setUvScaleDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , handleApplyUvDraft ) }
/ >
< / label >
< / div >
< / div >
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" data-testid = "apply-face-uv" onClick = { handleApplyUvDraft } >
Apply UV Offset / Scale
< / button >
< button className = "toolbar__button" type = "button" onClick = { handleRotateUv } >
Rotate 90
< / button >
< button className = "toolbar__button" type = "button" onClick = { ( ) = > handleFlipUv ( "u" ) } >
Flip U
< / button >
< button className = "toolbar__button" type = "button" onClick = { ( ) = > handleFlipUv ( "v" ) } >
Flip V
< / button >
< button className = "toolbar__button" type = "button" onClick = { handleFitUvToFace } >
Fit To Face
< / button >
< / div >
< ul className = "placeholder-list" >
< li > Rotation : { selectedFace . uv . rotationQuarterTurns * 90 } ° < / li >
< li >
Flip flags : U { selectedFace . uv . flipU ? "on" : "off" } , V { selectedFace . uv . flipV ? "on" : "off" }
< / li >
< / ul >
< / >
) }
< / >
) }
< / Panel >
< Panel title = "Runner" >
2026-03-31 03:09:04 +02:00
< div className = "stat-grid" >
< div className = "stat-card" >
< div className = "label" > Player Start < / div >
< div className = "value" > { primaryPlayerStart === null ? "Missing" : "Authored" } < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Default Run Mode < / div >
< div className = "value" > { preferredNavigationMode === "firstPerson" ? "First Person" : "Orbit Visitor" } < / div >
< / div >
< / div >
{ primaryPlayerStart === null ? (
< ul className = "placeholder-list" >
2026-03-31 03:44:55 +02:00
< li > No Player Start is authored yet . Orbit Visitor can still run , but first - person run is blocked until you place one . < / li >
2026-03-31 03:09:04 +02:00
< / ul >
) : (
< div className = "stat-card" >
< div className = "label" > Authored Spawn < / div >
< div className = "value" >
{ primaryPlayerStart . position . x } , { primaryPlayerStart . position . y } , { primaryPlayerStart . position . z }
< / div >
< div className = "material-summary" > yaw { primaryPlayerStart . yawDegrees } ° < / div >
< / div >
) }
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" data-testid = "place-player-start" onClick = { handleSelectOrPlacePlayerStart } >
{ primaryPlayerStart === null ? "Place Player Start" : "Select Player Start" }
< / button >
< / div >
< div className = "inline-actions" >
< button
className = { ` toolbar__button ${ preferredNavigationMode === "firstPerson" ? "toolbar__button--active" : "" } ` }
type = "button"
onClick = { ( ) = > handleSetPreferredNavigationMode ( "firstPerson" ) }
>
First Person
< / button >
< button
className = { ` toolbar__button ${ preferredNavigationMode === "orbitVisitor" ? "toolbar__button--active" : "" } ` }
type = "button"
onClick = { ( ) = > handleSetPreferredNavigationMode ( "orbitVisitor" ) }
>
Orbit Visitor
< / button >
< / div >
< div className = "inline-actions" >
2026-03-31 03:44:55 +02:00
< button
className = { ` toolbar__button toolbar__button--accent ${ blockingDiagnostics . length > 0 ? "toolbar__button--warn" : "" } ` }
type = "button"
onClick = { handleEnterPlayMode }
>
2026-03-31 03:09:04 +02:00
Enter Run Mode
< / button >
< / div >
2026-03-31 02:37:22 +02:00
< ul className = "placeholder-list" >
2026-03-31 03:09:04 +02:00
< li > First - person supports ` WASD ` movement plus mouse - look after pointer lock is active . < / li >
< li > Orbit Visitor provides the non - FPS fallback for browsers or users that do not want pointer lock . < / li >
< li > Collision is deterministic AABB collision against box - brush runtime colliders in this slice . < / li >
2026-03-31 02:37:22 +02:00
< / ul >
< / Panel >
< / aside >
< / div >
< footer className = "status-bar" >
< div >
< span className = "status-bar__strong" > Status : < / span > { statusMessage }
< / div >
< div >
2026-03-31 03:44:55 +02:00
< span className = "status-bar__strong" > Diagnostics : < / span > { " " }
{ diagnostics . length === 0
? "Ready"
: ` ${ formatDiagnosticCount ( blockingDiagnostics . length , "error" ) } , ${ formatDiagnosticCount ( warningDiagnostics . length , "warning" ) } ` }
2026-03-31 02:37:22 +02:00
< / div >
< / footer >
< input
ref = { importInputRef }
className = "visually-hidden"
type = "file"
accept = ".json,application/json"
onChange = { handleImportChange }
/ >
< / div >
) ;
}