2026-03-31 04:37:44 +02:00
import {
useEffect ,
useRef ,
useState ,
type CSSProperties ,
type ChangeEvent ,
type KeyboardEvent as ReactKeyboardEvent ,
type PointerEvent as ReactPointerEvent
} from "react" ;
2026-03-31 02:37:22 +02:00
import { createCreateBoxBrushCommand } from "../commands/create-box-brush-command" ;
2026-03-31 20:09:06 +02:00
import { createImportBackgroundImageAssetCommand } from "../commands/import-background-image-asset-command" ;
2026-03-31 17:41:12 +02:00
import { createImportModelAssetCommand } from "../commands/import-model-asset-command" ;
2026-03-31 02:37:22 +02:00
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" ;
2026-03-31 04:24:53 +02:00
import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command" ;
2026-03-31 02:37:22 +02:00
import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command" ;
2026-03-31 06:17:43 +02:00
import { createDeleteInteractionLinkCommand } from "../commands/delete-interaction-link-command" ;
2026-03-31 02:37:22 +02:00
import { createSetSceneNameCommand } from "../commands/set-scene-name-command" ;
2026-03-31 05:11:32 +02:00
import { createSetWorldSettingsCommand } from "../commands/set-world-settings-command" ;
2026-03-31 05:54:29 +02:00
import { createUpsertEntityCommand } from "../commands/upsert-entity-command" ;
2026-03-31 17:41:12 +02:00
import { createUpsertModelInstanceCommand } from "../commands/upsert-model-instance-command" ;
2026-03-31 06:17:43 +02:00
import { createUpsertInteractionLinkCommand } from "../commands/upsert-interaction-link-command" ;
2026-03-31 02:37:22 +02:00
import {
getSelectedBrushFaceId ,
getSingleSelectedBrushId ,
2026-03-31 03:09:04 +02:00
getSingleSelectedEntityId ,
2026-03-31 17:41:12 +02:00
getSingleSelectedModelInstanceId ,
2026-03-31 02:37:22 +02:00
isBrushFaceSelected ,
isBrushSelected ,
type EditorSelection
} from "../core/selection" ;
import type { Vec2 , Vec3 } from "../core/vector" ;
2026-03-31 17:41:12 +02:00
import {
areModelInstancesEqual ,
createModelInstance ,
DEFAULT_MODEL_INSTANCE_POSITION ,
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES ,
DEFAULT_MODEL_INSTANCE_SCALE ,
type ModelInstance
} from "../assets/model-instances" ;
import {
getModelInstanceDisplayLabelById ,
getSortedModelInstanceDisplayLabels
} from "../assets/model-instance-labels" ;
import {
importModelAssetFromFile ,
2026-03-31 18:45:19 +02:00
importModelAssetFromFiles ,
2026-03-31 17:41:12 +02:00
loadModelAssetFromStorage ,
2026-03-31 17:42:04 +02:00
disposeModelTemplate ,
2026-03-31 17:56:29 +02:00
type ImportedModelAssetResult ,
2026-03-31 17:41:12 +02:00
type LoadedModelAsset
} from "../assets/gltf-model-import" ;
2026-03-31 20:09:09 +02:00
import {
importBackgroundImageAssetFromFile ,
loadImageAssetFromStorage ,
disposeLoadedImageAsset ,
type ImportedImageAssetResult ,
type LoadedImageAsset
} from "../assets/image-assets" ;
import type { ImageAssetRecord , ModelAssetRecord , ProjectAssetRecord } from "../assets/project-assets" ;
2026-03-31 17:46:18 +02:00
import { getProjectAssetKindLabel } from "../assets/project-assets" ;
2026-03-31 02:37:22 +02:00
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 ,
2026-03-31 04:24:53 +02:00
normalizeBrushName ,
2026-03-31 02:37:22 +02:00
type BoxBrush ,
type BoxFaceId ,
2026-03-31 03:09:04 +02:00
type FaceUvRotationQuarterTurns ,
2026-03-31 05:11:41 +02:00
type FaceUvState
2026-03-31 02:37:22 +02:00
} from "../document/brushes" ;
2026-03-31 05:11:41 +02:00
import { areWorldSettingsEqual , changeWorldBackgroundMode , type WorldBackgroundMode , type WorldSettings } from "../document/scene-document" ;
2026-03-31 03:46:51 +02:00
import { formatSceneDiagnosticSummary , validateSceneDocument } from "../document/scene-document-validation" ;
2026-03-31 17:41:12 +02:00
import { getBrowserProjectAssetStorageAccess , type ProjectAssetStorage } from "../assets/project-asset-storage" ;
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 {
2026-03-31 05:54:29 +02:00
DEFAULT_ENTITY_POSITION ,
DEFAULT_INTERACTABLE_PROMPT ,
DEFAULT_INTERACTABLE_RADIUS ,
2026-03-31 20:09:14 +02:00
DEFAULT_POINT_LIGHT_COLOR_HEX ,
DEFAULT_POINT_LIGHT_DISTANCE ,
DEFAULT_POINT_LIGHT_INTENSITY ,
DEFAULT_POINT_LIGHT_POSITION ,
2026-03-31 05:54:29 +02:00
DEFAULT_SOUND_EMITTER_GAIN ,
DEFAULT_SOUND_EMITTER_RADIUS ,
DEFAULT_TELEPORT_TARGET_YAW_DEGREES ,
2026-03-31 20:09:14 +02:00
DEFAULT_SPOT_LIGHT_ANGLE_DEGREES ,
DEFAULT_SPOT_LIGHT_COLOR_HEX ,
DEFAULT_SPOT_LIGHT_DISTANCE ,
DEFAULT_SPOT_LIGHT_DIRECTION ,
DEFAULT_SPOT_LIGHT_INTENSITY ,
DEFAULT_SPOT_LIGHT_POSITION ,
2026-03-31 05:54:29 +02:00
DEFAULT_TRIGGER_VOLUME_SIZE ,
areEntityInstancesEqual ,
createInteractableEntity ,
2026-03-31 20:09:14 +02:00
createPointLightEntity ,
2026-03-31 05:54:29 +02:00
createPlayerStartEntity ,
createSoundEmitterEntity ,
2026-03-31 20:09:14 +02:00
createSpotLightEntity ,
2026-03-31 05:54:29 +02:00
createTeleportTargetEntity ,
createTriggerVolumeEntity ,
getEntityInstances ,
getEntityKindLabel ,
2026-03-31 03:09:04 +02:00
getPrimaryPlayerStartEntity ,
normalizeYawDegrees ,
2026-03-31 05:54:29 +02:00
normalizeInteractablePrompt ,
type EntityInstance ,
type EntityKind
2026-03-31 03:09:04 +02:00
} from "../entities/entity-instances" ;
2026-03-31 05:54:29 +02:00
import { getEntityDisplayLabelById , getSortedEntityDisplayLabels } from "../entities/entity-labels" ;
2026-03-31 06:17:43 +02:00
import {
areInteractionLinksEqual ,
createTeleportPlayerInteractionLink ,
createToggleVisibilityInteractionLink ,
getInteractionLinksForSource ,
type InteractionLink ,
type InteractionTriggerKind
} from "../interactions/interaction-links" ;
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" ;
2026-03-31 06:47:21 +02:00
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system" ;
2026-03-31 03:09:04 +02:00
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" ;
2026-03-31 05:11:32 +02:00
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style" ;
2026-03-31 02:37:22 +02:00
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 ;
}
2026-03-31 06:47:21 +02:00
type InteractionSourceEntity = Extract < EntityInstance , { kind : "triggerVolume" | "interactable" } > ;
2026-03-31 02:37:22 +02:00
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
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" ) {
2026-03-31 04:24:53 +02:00
return ` Box Create is active. Click the grid to place a ${ DEFAULT_BOX_BRUSH_SIZE . x } x ${ DEFAULT_BOX_BRUSH_SIZE . y } x ${ DEFAULT_BOX_BRUSH_SIZE . z } box. ` ;
2026-03-31 03:44:17 +02:00
}
2026-03-31 04:24:53 +02:00
return ` ${ brushCount } box brush ${ brushCount === 1 ? "" : "es" } loaded. Middle-drag orbits, Shift + middle-drag pans, wheel zooms, and Numpad Comma frames the selection. ` ;
2026-03-31 03:44:17 +02:00
}
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 ;
}
2026-03-31 17:42:48 +02:00
function readPositiveVec3Draft ( draft : Vec3Draft , label : string ) : Vec3 {
const vector = readVec3Draft ( draft , label ) ;
if ( vector . x <= 0 || vector . y <= 0 || vector . z <= 0 ) {
throw new Error ( ` ${ label } values must remain positive. ` ) ;
}
return vector ;
}
2026-03-31 02:37:22 +02:00
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 05:54:41 +02:00
function readInteractablePromptDraft ( source : string ) : string {
return normalizeInteractablePrompt ( source ) ;
}
2026-03-31 05:12:01 +02:00
function readNonNegativeNumberDraft ( source : string , label : string ) : number {
const value = Number ( source ) ;
if ( ! Number . isFinite ( value ) || value < 0 ) {
throw new Error ( ` ${ label } must be a finite number greater than or equal to zero. ` ) ;
}
return value ;
}
2026-03-31 05:55:18 +02:00
function readPositiveNumberDraft ( source : string , label : string ) : number {
const value = Number ( source ) ;
if ( ! Number . isFinite ( value ) || value <= 0 ) {
throw new Error ( ` ${ label } must be a finite number greater than zero. ` ) ;
}
return value ;
}
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
) ;
}
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 05:54:41 +02:00
function getSelectedEntity ( selection : EditorSelection , entities : EntityInstance [ ] ) : EntityInstance | null {
2026-03-31 03:09:04 +02:00
const selectedEntityId = getSingleSelectedEntityId ( selection ) ;
if ( selectedEntityId === null ) {
return null ;
}
2026-03-31 05:54:41 +02:00
return entities . find ( ( entity ) = > entity . id === selectedEntityId ) ? ? null ;
2026-03-31 03:09:04 +02:00
}
2026-03-31 17:41:18 +02:00
function getSelectedModelInstance ( selection : EditorSelection , modelInstances : ModelInstance [ ] ) : ModelInstance | null {
const selectedModelInstanceId = getSingleSelectedModelInstanceId ( selection ) ;
if ( selectedModelInstanceId === null ) {
return null ;
}
return modelInstances . find ( ( modelInstance ) = > modelInstance . id === selectedModelInstanceId ) ? ? null ;
}
function isModelAsset ( asset : ProjectAssetRecord ) : asset is ModelAssetRecord {
return asset . kind === "model" ;
}
2026-03-31 20:09:18 +02:00
function isImageAsset ( asset : ProjectAssetRecord ) : asset is ImageAssetRecord {
return asset . kind === "image" ;
}
2026-03-31 17:41:18 +02:00
function formatByteLength ( byteLength : number ) : string {
if ( byteLength < 1024 ) {
return ` ${ byteLength } B ` ;
}
const kilobytes = byteLength / 1024 ;
if ( kilobytes < 1024 ) {
return ` ${ kilobytes . toFixed ( kilobytes >= 10 ? 0 : 1 ) } KB ` ;
}
return ` ${ ( kilobytes / 1024 ) . toFixed ( 1 ) } MB ` ;
}
function formatModelBoundingBoxLabel ( asset : ModelAssetRecord ) : string {
if ( asset . metadata . boundingBox === null ) {
return "Bounds unavailable" ;
}
const { size } = asset . metadata . boundingBox ;
2026-03-31 17:44:10 +02:00
return ` Bounds ${ size . x . toFixed ( 2 ) } x ${ size . y . toFixed ( 2 ) } x ${ size . z . toFixed ( 2 ) } m ` ;
2026-03-31 17:41:18 +02:00
}
function formatModelAssetSummary ( asset : ModelAssetRecord ) : string {
const details = [
asset . metadata . format . toUpperCase ( ) ,
formatByteLength ( asset . byteLength ) ,
` ${ asset . metadata . meshCount } mesh ${ asset . metadata . meshCount === 1 ? "" : "es" } ` ,
2026-03-31 17:44:11 +02:00
` ${ asset . metadata . materialNames . length } material ${ asset . metadata . materialNames . length === 1 ? "" : "s" } ` ,
` ${ asset . metadata . textureNames . length } texture ${ asset . metadata . textureNames . length === 1 ? "" : "s" } `
2026-03-31 17:41:18 +02:00
] ;
if ( asset . metadata . animationNames . length > 0 ) {
details . push ( ` ${ asset . metadata . animationNames . length } animation ${ asset . metadata . animationNames . length === 1 ? "" : "s" } ` ) ;
}
2026-03-31 17:44:10 +02:00
return details . join ( " | " ) ;
2026-03-31 17:41:18 +02:00
}
2026-03-31 20:09:18 +02:00
function formatImageAssetSummary ( asset : ImageAssetRecord ) : string {
const details = [
` ${ asset . metadata . width } x ${ asset . metadata . height } ` ,
asset . metadata . hasAlpha ? "alpha" : "opaque" ,
formatByteLength ( asset . byteLength )
] ;
return details . join ( " | " ) ;
}
2026-03-31 17:41:18 +02:00
function createModelInstancePlacementPosition ( asset : ModelAssetRecord , anchor : Vec3 | null ) : Vec3 {
const boundingBox = asset . metadata . boundingBox ;
if ( anchor !== null ) {
const floorOffset = boundingBox === null ? 0 : - boundingBox . min . y ;
return {
x : anchor.x ,
y : anchor.y + floorOffset ,
z : anchor.z
} ;
}
return {
x : DEFAULT_MODEL_INSTANCE_POSITION.x ,
y : boundingBox === null ? DEFAULT_MODEL_INSTANCE_POSITION.y : Math.max ( DEFAULT_MODEL_INSTANCE_POSITION . y , - boundingBox . min . y ) ,
z : DEFAULT_MODEL_INSTANCE_POSITION.z
} ;
}
2026-03-31 04:24:53 +02:00
function getBrushLabel ( brush : BoxBrush , index : number ) : string {
return brush . name ? ? ` Box Brush ${ index + 1 } ` ;
2026-03-31 02:37:22 +02:00
}
function getBrushLabelById ( brushId : string , brushes : BoxBrush [ ] ) : string {
const brushIndex = brushes . findIndex ( ( brush ) = > brush . id === brushId ) ;
2026-03-31 04:24:53 +02:00
return brushIndex === - 1 ? "Box Brush" : getBrushLabel ( brushes [ brushIndex ] , brushIndex ) ;
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 17:41:24 +02:00
function describeSelection (
selection : EditorSelection ,
brushes : BoxBrush [ ] ,
modelInstances : Record < string , ModelInstance > ,
assets : Record < string , ProjectAssetRecord > ,
entities : Record < string , EntityInstance >
) : 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 05:54:41 +02:00
return ` ${ selection . ids . length } entity selected ( ${ getEntityDisplayLabelById ( selection . ids [ 0 ] , entities ) } ) ` ;
2026-03-31 02:37:22 +02:00
case "modelInstances" :
2026-03-31 17:45:37 +02:00
return ` ${ selection . ids . length } model instance ${ selection . ids . length === 1 ? "" : "s" } selected ( ${ getModelInstanceDisplayLabelById ( selection . ids [ 0 ] , modelInstances , assets ) } ) ` ;
2026-03-31 17:41:29 +02:00
default :
return "Unknown selection" ;
2026-03-31 02:37:22 +02:00
}
}
2026-03-31 06:17:51 +02:00
function getInteractionTriggerLabel ( trigger : InteractionTriggerKind ) : string {
2026-03-31 06:47:21 +02:00
switch ( trigger ) {
case "enter" :
return "On Enter" ;
case "exit" :
return "On Exit" ;
case "click" :
return "On Click" ;
}
2026-03-31 06:17:51 +02:00
}
function getInteractionActionLabel ( link : InteractionLink ) : string {
switch ( link . action . type ) {
case "teleportPlayer" :
return "Teleport Player" ;
case "toggleVisibility" :
return "Toggle Visibility" ;
}
}
function getVisibilityModeSelectValue ( visible : boolean | undefined ) : "toggle" | "show" | "hide" {
if ( visible === true ) {
return "show" ;
}
if ( visible === false ) {
return "hide" ;
}
return "toggle" ;
}
function readVisibilityModeSelectValue ( value : "toggle" | "show" | "hide" ) : boolean | undefined {
switch ( value ) {
case "toggle" :
return undefined ;
case "show" :
return true ;
case "hide" :
return false ;
}
}
2026-03-31 06:19:09 +02:00
function getDefaultTriggerVolumeLinkTrigger ( triggerOnEnter : boolean , triggerOnExit : boolean ) : InteractionTriggerKind {
if ( triggerOnEnter ) {
return "enter" ;
}
if ( triggerOnExit ) {
return "exit" ;
}
return "enter" ;
}
2026-03-31 06:47:21 +02:00
function isInteractionSourceEntity ( entity : EntityInstance | null ) : entity is InteractionSourceEntity {
return entity !== null && ( entity . kind === "triggerVolume" || entity . kind === "interactable" ) ;
}
function getDefaultInteractionLinkTrigger ( sourceEntity : InteractionSourceEntity ) : InteractionTriggerKind {
return sourceEntity . kind === "triggerVolume"
? getDefaultTriggerVolumeLinkTrigger ( sourceEntity . triggerOnEnter , sourceEntity . triggerOnExit )
: "click" ;
}
2026-03-31 02:37:22 +02:00
function getErrorMessage ( error : unknown ) : string {
if ( error instanceof Error ) {
return error . message ;
}
return "An unexpected error occurred." ;
}
2026-03-31 04:24:53 +02:00
function isTextEntryTarget ( target : EventTarget | null ) : boolean {
if ( ! ( target instanceof HTMLElement ) ) {
return false ;
}
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target . isContentEditable
) ;
}
2026-03-31 04:37:44 +02:00
function isCommitIncrementKey ( key : string ) : boolean {
return key === "ArrowUp" || key === "ArrowDown" || key === "PageUp" || key === "PageDown" ;
}
2026-03-31 04:41:03 +02:00
function blurActiveTextEntry() {
const activeElement = document . activeElement ;
2026-03-31 04:41:45 +02:00
if ( ! ( activeElement instanceof HTMLElement ) || ! isTextEntryTarget ( activeElement ) ) {
2026-03-31 04:41:03 +02:00
return ;
}
activeElement . blur ( ) ;
}
2026-03-31 02:37:22 +02:00
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 05:11:32 +02:00
function formatWorldBackgroundLabel ( world : WorldSettings ) : string {
2026-03-31 20:09:21 +02:00
if ( world . background . mode === "solid" ) {
return "Solid" ;
}
if ( world . background . mode === "verticalGradient" ) {
return "Vertical Gradient" ;
}
return "Image" ;
2026-03-31 05:11:32 +02:00
}
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 05:54:57 +02:00
const entityList = getEntityInstances ( editorState . document . entities ) ;
const entityDisplayList = getSortedEntityDisplayLabels ( editorState . document . entities ) ;
2026-03-31 03:09:04 +02:00
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 05:54:57 +02:00
const selectedEntity = getSelectedEntity ( editorState . selection , entityList ) ;
2026-03-31 17:41:42 +02:00
const selectedModelInstance = getSelectedModelInstance ( editorState . selection , Object . values ( editorState . document . modelInstances ) ) ;
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 17:41:42 +02:00
const selectedModelAsset =
selectedModelInstance !== null ? ( editorState . document . assets [ selectedModelInstance . assetId ] ? ? null ) : null ;
2026-03-31 17:47:34 +02:00
const selectedModelAssetRecord = selectedModelAsset !== null && selectedModelAsset . kind === "model" ? selectedModelAsset : null ;
2026-03-31 05:54:57 +02:00
const selectedPlayerStart = selectedEntity ? . kind === "playerStart" ? selectedEntity : null ;
const selectedSoundEmitter = selectedEntity ? . kind === "soundEmitter" ? selectedEntity : null ;
const selectedTriggerVolume = selectedEntity ? . kind === "triggerVolume" ? selectedEntity : null ;
const selectedTeleportTarget = selectedEntity ? . kind === "teleportTarget" ? selectedEntity : null ;
const selectedInteractable = selectedEntity ? . kind === "interactable" ? selectedEntity : null ;
2026-03-31 17:46:22 +02:00
const projectAssetList = Object . values ( editorState . document . assets ) ;
2026-03-31 20:13:17 +02:00
const modelAssetList = projectAssetList . filter ( isModelAsset ) ;
2026-03-31 20:09:36 +02:00
const imageAssetList = projectAssetList . filter ( isImageAsset ) ;
const selectedPointLight = selectedEntity ? . kind === "pointLight" ? selectedEntity : null ;
const selectedSpotLight = selectedEntity ? . kind === "spotLight" ? selectedEntity : null ;
2026-03-31 17:41:42 +02:00
const modelInstanceDisplayList = getSortedModelInstanceDisplayLabels ( editorState . document . modelInstances , editorState . document . assets ) ;
2026-03-31 06:47:28 +02:00
const selectedInteractionSource = isInteractionSourceEntity ( selectedEntity ) ? selectedEntity : null ;
2026-03-31 06:17:55 +02:00
const selectedTriggerVolumeLinks =
selectedTriggerVolume === null
? [ ]
: getInteractionLinksForSource ( editorState . document . interactionLinks , selectedTriggerVolume . id ) ;
2026-03-31 06:47:28 +02:00
const selectedInteractableLinks =
selectedInteractable === null ? [ ] : getInteractionLinksForSource ( editorState . document . interactionLinks , selectedInteractable . id ) ;
2026-03-31 06:17:55 +02:00
const teleportTargetOptions = entityDisplayList . filter ( ( { entity } ) = > entity . kind === "teleportTarget" ) ;
const visibilityBrushOptions = brushList . map ( ( brush , brushIndex ) = > ( {
brush ,
label : getBrushLabel ( brush , brushIndex )
} ) ) ;
2026-03-31 02:37:22 +02:00
const [ sceneNameDraft , setSceneNameDraft ] = useState ( editorState . document . name ) ;
2026-03-31 04:25:02 +02:00
const [ brushNameDraft , setBrushNameDraft ] = useState ( "" ) ;
2026-03-31 02:37:22 +02:00
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 05:54:57 +02:00
const [ entityPositionDraft , setEntityPositionDraft ] = useState ( createVec3Draft ( DEFAULT_ENTITY_POSITION ) ) ;
2026-03-31 20:13:17 +02:00
const [ pointLightColorDraft , setPointLightColorDraft ] = useState ( DEFAULT_POINT_LIGHT_COLOR_HEX ) ;
const [ pointLightIntensityDraft , setPointLightIntensityDraft ] = useState ( String ( DEFAULT_POINT_LIGHT_INTENSITY ) ) ;
const [ pointLightDistanceDraft , setPointLightDistanceDraft ] = useState ( String ( DEFAULT_POINT_LIGHT_DISTANCE ) ) ;
const [ spotLightColorDraft , setSpotLightColorDraft ] = useState ( DEFAULT_SPOT_LIGHT_COLOR_HEX ) ;
const [ spotLightIntensityDraft , setSpotLightIntensityDraft ] = useState ( String ( DEFAULT_SPOT_LIGHT_INTENSITY ) ) ;
const [ spotLightDistanceDraft , setSpotLightDistanceDraft ] = useState ( String ( DEFAULT_SPOT_LIGHT_DISTANCE ) ) ;
const [ spotLightAngleDraft , setSpotLightAngleDraft ] = useState ( String ( DEFAULT_SPOT_LIGHT_ANGLE_DEGREES ) ) ;
const [ spotLightDirectionDraft , setSpotLightDirectionDraft ] = useState ( createVec3Draft ( DEFAULT_SPOT_LIGHT_DIRECTION ) ) ;
2026-03-31 03:09:04 +02:00
const [ playerStartYawDraft , setPlayerStartYawDraft ] = useState ( "0" ) ;
2026-03-31 05:54:57 +02:00
const [ soundEmitterRadiusDraft , setSoundEmitterRadiusDraft ] = useState ( String ( DEFAULT_SOUND_EMITTER_RADIUS ) ) ;
const [ soundEmitterGainDraft , setSoundEmitterGainDraft ] = useState ( String ( DEFAULT_SOUND_EMITTER_GAIN ) ) ;
const [ soundEmitterAutoplayDraft , setSoundEmitterAutoplayDraft ] = useState ( false ) ;
const [ soundEmitterLoopDraft , setSoundEmitterLoopDraft ] = useState ( false ) ;
const [ triggerVolumeSizeDraft , setTriggerVolumeSizeDraft ] = useState ( createVec3Draft ( DEFAULT_TRIGGER_VOLUME_SIZE ) ) ;
const [ triggerOnEnterDraft , setTriggerOnEnterDraft ] = useState ( true ) ;
const [ triggerOnExitDraft , setTriggerOnExitDraft ] = useState ( false ) ;
const [ teleportTargetYawDraft , setTeleportTargetYawDraft ] = useState ( String ( DEFAULT_TELEPORT_TARGET_YAW_DEGREES ) ) ;
const [ interactableRadiusDraft , setInteractableRadiusDraft ] = useState ( String ( DEFAULT_INTERACTABLE_RADIUS ) ) ;
const [ interactablePromptDraft , setInteractablePromptDraft ] = useState ( DEFAULT_INTERACTABLE_PROMPT ) ;
const [ interactableEnabledDraft , setInteractableEnabledDraft ] = useState ( true ) ;
2026-03-31 17:41:42 +02:00
const [ modelPositionDraft , setModelPositionDraft ] = useState ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_POSITION ) ) ;
const [ modelRotationDraft , setModelRotationDraft ] = useState ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES ) ) ;
const [ modelScaleDraft , setModelScaleDraft ] = useState ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_SCALE ) ) ;
2026-03-31 05:11:52 +02:00
const [ ambientLightIntensityDraft , setAmbientLightIntensityDraft ] = useState ( String ( editorState . document . world . ambientLight . intensity ) ) ;
const [ sunLightIntensityDraft , setSunLightIntensityDraft ] = useState ( String ( editorState . document . world . sunLight . intensity ) ) ;
const [ sunDirectionDraft , setSunDirectionDraft ] = useState ( createVec3Draft ( editorState . document . world . sunLight . direction ) ) ;
2026-03-31 20:09:24 +02:00
const [ statusMessage , setStatusMessage ] = useState ( initialStatusMessage ? ? "Slice 3.2 local lights and skyboxes ready." ) ;
2026-03-31 17:41:42 +02:00
const [ assetStatusMessage , setAssetStatusMessage ] = useState < string | null > ( null ) ;
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"
) ;
2026-03-31 17:41:42 +02:00
const [ projectAssetStorage , setProjectAssetStorage ] = useState < ProjectAssetStorage | null > ( null ) ;
2026-03-31 17:41:51 +02:00
const [ projectAssetStorageReady , setProjectAssetStorageReady ] = useState ( false ) ;
2026-03-31 03:09:04 +02:00
const [ runtimeScene , setRuntimeScene ] = useState < RuntimeSceneDefinition | null > ( null ) ;
const [ runtimeMessage , setRuntimeMessage ] = useState < string | null > ( null ) ;
const [ firstPersonTelemetry , setFirstPersonTelemetry ] = useState < FirstPersonTelemetry | null > ( null ) ;
2026-03-31 06:47:28 +02:00
const [ runtimeInteractionPrompt , setRuntimeInteractionPrompt ] = useState < RuntimeInteractionPrompt | null > ( null ) ;
2026-03-31 17:41:42 +02:00
const [ loadedModelAssets , setLoadedModelAssets ] = useState < Record < string , LoadedModelAsset > > ( { } ) ;
2026-03-31 20:09:24 +02:00
const [ loadedImageAssets , setLoadedImageAssets ] = useState < Record < string , LoadedImageAsset > > ( { } ) ;
2026-03-31 04:25:02 +02:00
const [ focusRequest , setFocusRequest ] = useState < { id : number ; selection : EditorSelection } > ( {
id : 0 ,
selection : {
kind : "none"
}
} ) ;
2026-03-31 02:37:22 +02:00
const importInputRef = useRef < HTMLInputElement | null > ( null ) ;
2026-03-31 17:41:42 +02:00
const importModelInputRef = useRef < HTMLInputElement | null > ( null ) ;
2026-03-31 20:09:24 +02:00
const importBackgroundImageInputRef = useRef < HTMLInputElement | null > ( null ) ;
2026-03-31 17:41:42 +02:00
const loadedModelAssetsRef = useRef < Record < string , LoadedModelAsset > > ( { } ) ;
2026-03-31 20:09:24 +02:00
const loadedImageAssetsRef = useRef < Record < string , LoadedImageAsset > > ( { } ) ;
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" ) ;
2026-03-31 04:53:26 +02:00
const documentStatusLabel =
documentValidation . errors . length === 0 ? "Valid" : formatDiagnosticCount ( documentValidation . errors . length , "error" ) ;
const lastCommandLabel = editorState . lastCommandLabel ? ? "No commands yet" ;
2026-03-31 03:44:17 +02:00
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 ] ) ;
2026-03-31 04:25:02 +02:00
useEffect ( ( ) = > {
setBrushNameDraft ( selectedBrush ? . name ? ? "" ) ;
} , [ selectedBrush ] ) ;
2026-03-31 02:37:22 +02:00
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 ( ( ) = > {
2026-03-31 05:55:08 +02:00
if ( selectedEntity === null ) {
setEntityPositionDraft ( createVec3Draft ( DEFAULT_ENTITY_POSITION ) ) ;
2026-03-31 20:13:17 +02:00
setPointLightColorDraft ( DEFAULT_POINT_LIGHT_COLOR_HEX ) ;
setPointLightIntensityDraft ( String ( DEFAULT_POINT_LIGHT_INTENSITY ) ) ;
setPointLightDistanceDraft ( String ( DEFAULT_POINT_LIGHT_DISTANCE ) ) ;
setSpotLightColorDraft ( DEFAULT_SPOT_LIGHT_COLOR_HEX ) ;
setSpotLightIntensityDraft ( String ( DEFAULT_SPOT_LIGHT_INTENSITY ) ) ;
setSpotLightDistanceDraft ( String ( DEFAULT_SPOT_LIGHT_DISTANCE ) ) ;
setSpotLightAngleDraft ( String ( DEFAULT_SPOT_LIGHT_ANGLE_DEGREES ) ) ;
setSpotLightDirectionDraft ( createVec3Draft ( DEFAULT_SPOT_LIGHT_DIRECTION ) ) ;
2026-03-31 03:09:04 +02:00
setPlayerStartYawDraft ( "0" ) ;
2026-03-31 05:55:08 +02:00
setSoundEmitterRadiusDraft ( String ( DEFAULT_SOUND_EMITTER_RADIUS ) ) ;
setSoundEmitterGainDraft ( String ( DEFAULT_SOUND_EMITTER_GAIN ) ) ;
setSoundEmitterAutoplayDraft ( false ) ;
setSoundEmitterLoopDraft ( false ) ;
setTriggerVolumeSizeDraft ( createVec3Draft ( DEFAULT_TRIGGER_VOLUME_SIZE ) ) ;
setTriggerOnEnterDraft ( true ) ;
setTriggerOnExitDraft ( false ) ;
setTeleportTargetYawDraft ( String ( DEFAULT_TELEPORT_TARGET_YAW_DEGREES ) ) ;
setInteractableRadiusDraft ( String ( DEFAULT_INTERACTABLE_RADIUS ) ) ;
setInteractablePromptDraft ( DEFAULT_INTERACTABLE_PROMPT ) ;
setInteractableEnabledDraft ( true ) ;
2026-03-31 03:09:04 +02:00
return ;
}
2026-03-31 05:55:08 +02:00
setEntityPositionDraft ( createVec3Draft ( selectedEntity . position ) ) ;
switch ( selectedEntity . kind ) {
2026-03-31 20:13:17 +02:00
case "pointLight" :
setPointLightColorDraft ( selectedEntity . colorHex ) ;
setPointLightIntensityDraft ( String ( selectedEntity . intensity ) ) ;
setPointLightDistanceDraft ( String ( selectedEntity . distance ) ) ;
break ;
case "spotLight" :
setSpotLightColorDraft ( selectedEntity . colorHex ) ;
setSpotLightIntensityDraft ( String ( selectedEntity . intensity ) ) ;
setSpotLightDistanceDraft ( String ( selectedEntity . distance ) ) ;
setSpotLightAngleDraft ( String ( selectedEntity . angleDegrees ) ) ;
setSpotLightDirectionDraft ( createVec3Draft ( selectedEntity . direction ) ) ;
break ;
2026-03-31 05:55:08 +02:00
case "playerStart" :
setPlayerStartYawDraft ( String ( selectedEntity . yawDegrees ) ) ;
break ;
case "soundEmitter" :
setSoundEmitterRadiusDraft ( String ( selectedEntity . radius ) ) ;
setSoundEmitterGainDraft ( String ( selectedEntity . gain ) ) ;
setSoundEmitterAutoplayDraft ( selectedEntity . autoplay ) ;
setSoundEmitterLoopDraft ( selectedEntity . loop ) ;
break ;
case "triggerVolume" :
setTriggerVolumeSizeDraft ( createVec3Draft ( selectedEntity . size ) ) ;
setTriggerOnEnterDraft ( selectedEntity . triggerOnEnter ) ;
setTriggerOnExitDraft ( selectedEntity . triggerOnExit ) ;
break ;
case "teleportTarget" :
setTeleportTargetYawDraft ( String ( selectedEntity . yawDegrees ) ) ;
break ;
case "interactable" :
setInteractableRadiusDraft ( String ( selectedEntity . radius ) ) ;
setInteractablePromptDraft ( selectedEntity . prompt ) ;
setInteractableEnabledDraft ( selectedEntity . enabled ) ;
break ;
}
} , [ selectedEntity ] ) ;
2026-03-31 03:09:04 +02:00
2026-03-31 17:42:23 +02:00
useEffect ( ( ) = > {
if ( selectedModelInstance === null ) {
setModelPositionDraft ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_POSITION ) ) ;
setModelRotationDraft ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES ) ) ;
setModelScaleDraft ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_SCALE ) ) ;
return ;
}
setModelPositionDraft ( createVec3Draft ( selectedModelInstance . position ) ) ;
setModelRotationDraft ( createVec3Draft ( selectedModelInstance . rotationDegrees ) ) ;
setModelScaleDraft ( createVec3Draft ( selectedModelInstance . scale ) ) ;
} , [ selectedModelInstance ] ) ;
2026-03-31 05:11:52 +02:00
useEffect ( ( ) = > {
setAmbientLightIntensityDraft ( String ( editorState . document . world . ambientLight . intensity ) ) ;
} , [ editorState . document . world . ambientLight . intensity ] ) ;
useEffect ( ( ) = > {
setSunLightIntensityDraft ( String ( editorState . document . world . sunLight . intensity ) ) ;
} , [ editorState . document . world . sunLight . intensity ] ) ;
useEffect ( ( ) = > {
setSunDirectionDraft ( createVec3Draft ( editorState . document . world . sunLight . direction ) ) ;
} , [ editorState . document . world . sunLight . direction ] ) ;
2026-03-31 20:13:17 +02:00
useEffect ( ( ) = > {
loadedImageAssetsRef . current = loadedImageAssets ;
} , [ loadedImageAssets ] ) ;
2026-03-31 17:41:56 +02:00
useEffect ( ( ) = > {
loadedModelAssetsRef . current = loadedModelAssets ;
} , [ loadedModelAssets ] ) ;
useEffect ( ( ) = > {
let cancelled = false ;
void ( async ( ) = > {
const access = await getBrowserProjectAssetStorageAccess ( ) ;
if ( cancelled ) {
return ;
}
setProjectAssetStorage ( access . storage ) ;
setAssetStatusMessage ( access . diagnostic ) ;
setProjectAssetStorageReady ( true ) ;
} ) ( ) . catch ( ( error ) = > {
if ( cancelled ) {
return ;
}
setProjectAssetStorage ( null ) ;
setProjectAssetStorageReady ( true ) ;
setAssetStatusMessage ( getErrorMessage ( error ) ) ;
} ) ;
return ( ) = > {
cancelled = true ;
} ;
} , [ ] ) ;
useEffect ( ( ) = > {
if ( ! projectAssetStorageReady ) {
return ;
}
let cancelled = false ;
const currentAssets = editorState . document . assets ;
2026-03-31 20:13:17 +02:00
const previousLoadedModelAssets = loadedModelAssetsRef . current ;
const previousLoadedImageAssets = loadedImageAssetsRef . current ;
const previousLoadedModelAssetIds = new Set ( Object . keys ( previousLoadedModelAssets ) ) ;
const previousLoadedImageAssetIds = new Set ( Object . keys ( previousLoadedImageAssets ) ) ;
const nextLoadedModelAssets : Record < string , LoadedModelAsset > = { } ;
const nextLoadedImageAssets : Record < string , LoadedImageAsset > = { } ;
2026-03-31 17:45:30 +02:00
const syncErrorMessages : string [ ] = [ ] ;
2026-03-31 17:41:56 +02:00
2026-03-31 20:13:17 +02:00
const syncAssets = async ( ) = > {
2026-03-31 17:41:56 +02:00
if ( projectAssetStorage === null ) {
2026-03-31 20:13:17 +02:00
for ( const loadedAsset of Object . values ( previousLoadedModelAssets ) ) {
2026-03-31 17:41:56 +02:00
disposeModelTemplate ( loadedAsset . template ) ;
}
2026-03-31 20:13:17 +02:00
for ( const loadedAsset of Object . values ( previousLoadedImageAssets ) ) {
disposeLoadedImageAsset ( loadedAsset ) ;
}
2026-03-31 17:41:56 +02:00
if ( ! cancelled ) {
2026-03-31 17:45:18 +02:00
loadedModelAssetsRef . current = { } ;
2026-03-31 20:13:17 +02:00
loadedImageAssetsRef . current = { } ;
2026-03-31 17:41:56 +02:00
setLoadedModelAssets ( { } ) ;
2026-03-31 20:13:17 +02:00
setLoadedImageAssets ( { } ) ;
2026-03-31 17:41:56 +02:00
}
return ;
}
for ( const asset of Object . values ( currentAssets ) ) {
2026-03-31 20:13:17 +02:00
if ( isModelAsset ( asset ) ) {
previousLoadedModelAssetIds . delete ( asset . id ) ;
const cachedLoadedAsset = previousLoadedModelAssets [ asset . id ] ;
2026-03-31 17:41:56 +02:00
2026-03-31 20:13:17 +02:00
if ( cachedLoadedAsset !== undefined && cachedLoadedAsset . storageKey === asset . storageKey ) {
nextLoadedModelAssets [ asset . id ] = cachedLoadedAsset ;
continue ;
}
2026-03-31 17:41:56 +02:00
2026-03-31 20:13:17 +02:00
try {
nextLoadedModelAssets [ asset . id ] = await loadModelAssetFromStorage ( projectAssetStorage , asset ) ;
} catch ( error ) {
syncErrorMessages . push ( ` Model asset ${ asset . sourceName } could not be restored: ${ getErrorMessage ( error ) } ` ) ;
}
2026-03-31 17:41:56 +02:00
continue ;
}
2026-03-31 20:13:17 +02:00
if ( isImageAsset ( asset ) ) {
previousLoadedImageAssetIds . delete ( asset . id ) ;
const cachedLoadedAsset = previousLoadedImageAssets [ asset . id ] ;
if ( cachedLoadedAsset !== undefined && cachedLoadedAsset . storageKey === asset . storageKey ) {
nextLoadedImageAssets [ asset . id ] = cachedLoadedAsset ;
continue ;
}
try {
nextLoadedImageAssets [ asset . id ] = await loadImageAssetFromStorage ( projectAssetStorage , asset ) ;
} catch ( error ) {
syncErrorMessages . push ( ` Image asset ${ asset . sourceName } could not be restored: ${ getErrorMessage ( error ) } ` ) ;
}
2026-03-31 17:41:56 +02:00
}
}
if ( cancelled ) {
2026-03-31 20:13:17 +02:00
for ( const loadedAsset of Object . values ( nextLoadedModelAssets ) ) {
if ( previousLoadedModelAssets [ loadedAsset . assetId ] !== loadedAsset ) {
2026-03-31 17:41:56 +02:00
disposeModelTemplate ( loadedAsset . template ) ;
}
}
2026-03-31 20:13:17 +02:00
for ( const loadedAsset of Object . values ( nextLoadedImageAssets ) ) {
if ( previousLoadedImageAssets [ loadedAsset . assetId ] !== loadedAsset ) {
disposeLoadedImageAsset ( loadedAsset ) ;
}
}
2026-03-31 17:41:56 +02:00
return ;
}
2026-03-31 20:13:17 +02:00
for ( const assetId of previousLoadedModelAssetIds ) {
const removedAsset = previousLoadedModelAssets [ assetId ] ;
2026-03-31 17:41:56 +02:00
if ( removedAsset !== undefined ) {
disposeModelTemplate ( removedAsset . template ) ;
}
}
2026-03-31 20:13:17 +02:00
for ( const assetId of previousLoadedImageAssetIds ) {
const removedAsset = previousLoadedImageAssets [ assetId ] ;
if ( removedAsset !== undefined ) {
disposeLoadedImageAsset ( removedAsset ) ;
}
}
loadedModelAssetsRef . current = nextLoadedModelAssets ;
loadedImageAssetsRef . current = nextLoadedImageAssets ;
setLoadedModelAssets ( nextLoadedModelAssets ) ;
setLoadedImageAssets ( nextLoadedImageAssets ) ;
2026-03-31 17:45:30 +02:00
setAssetStatusMessage ( syncErrorMessages . length === 0 ? null : syncErrorMessages . join ( " | " ) ) ;
2026-03-31 17:41:56 +02:00
} ;
2026-03-31 20:13:17 +02:00
void syncAssets ( ) ;
2026-03-31 17:41:56 +02:00
return ( ) = > {
cancelled = true ;
} ;
} , [ editorState . document . assets , projectAssetStorage , projectAssetStorageReady ] ) ;
2026-03-31 04:25:02 +02:00
useEffect ( ( ) = > {
if ( editorState . toolMode === "play" ) {
return ;
}
2026-03-31 04:29:05 +02:00
const handleWindowKeyDown = ( event : globalThis.KeyboardEvent ) = > {
2026-03-31 04:25:02 +02:00
if ( isTextEntryTarget ( event . target ) ) {
return ;
}
2026-03-31 04:26:39 +02:00
if (
event . code !== "NumpadComma" &&
! ( event . key === "," && event . location === globalThis . KeyboardEvent . DOM_KEY_LOCATION_NUMPAD )
) {
2026-03-31 04:25:02 +02:00
return ;
}
event . preventDefault ( ) ;
2026-03-31 05:55:08 +02:00
if ( editorState . selection . kind === "none" && brushList . length === 0 && entityList . length === 0 ) {
2026-03-31 04:25:02 +02:00
setStatusMessage ( "Nothing authored yet to frame in the viewport." ) ;
return ;
}
setFocusRequest ( ( current ) = > ( {
id : current.id + 1 ,
selection : editorState.selection
} ) ) ;
setStatusMessage ( editorState . selection . kind === "none" ? "Framed the authored scene in the viewport." : "Framed the current selection." ) ;
} ;
window . addEventListener ( "keydown" , handleWindowKeyDown ) ;
return ( ) = > {
window . removeEventListener ( "keydown" , handleWindowKeyDown ) ;
} ;
2026-03-31 05:55:08 +02:00
} , [ editorState . selection , editorState . toolMode , brushList . length , entityList . length ] ) ;
2026-03-31 04:25:02 +02:00
2026-03-31 02:37:22 +02:00
const applySceneName = ( ) = > {
const normalizedName = sceneNameDraft . trim ( ) || "Untitled Scene" ;
if ( normalizedName === editorState . document . name ) {
return ;
}
store . executeCommand ( createSetSceneNameCommand ( normalizedName ) ) ;
setStatusMessage ( ` Scene renamed to ${ normalizedName } . ` ) ;
} ;
2026-03-31 04:25:13 +02:00
const requestViewportFocus = ( selection : EditorSelection , status? : string ) = > {
setFocusRequest ( ( current ) = > ( {
id : current.id + 1 ,
selection
} ) ) ;
if ( status !== undefined ) {
setStatusMessage ( status ) ;
}
} ;
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 04:25:13 +02:00
const applySelection = (
selection : EditorSelection ,
source : "outliner" | "viewport" | "inspector" | "runner" ,
options : { focusViewport? : boolean } = { }
) = > {
2026-03-31 04:41:08 +02:00
blurActiveTextEntry ( ) ;
2026-03-31 02:37:22 +02:00
store . setSelection ( selection ) ;
2026-03-31 04:25:13 +02:00
const suffix = source === "outliner" && options . focusViewport ? " and framed it in the viewport" : "" ;
2026-03-31 02:37:22 +02:00
switch ( selection . kind ) {
case "none" :
2026-03-31 04:25:13 +02:00
setStatusMessage ( ` ${ source === "viewport" ? "Viewport" : "Editor" } selection cleared ${ suffix } . ` ) ;
2026-03-31 02:37:22 +02:00
break ;
case "brushes" :
2026-03-31 04:25:13 +02:00
setStatusMessage ( ` Selected ${ getBrushLabelById ( selection . ids [ 0 ] , brushList ) } from the ${ source } ${ suffix } . ` ) ;
2026-03-31 02:37:22 +02:00
break ;
case "brushFace" :
2026-03-31 04:25:13 +02:00
setStatusMessage (
` Selected ${ FACE_LABELS [ selection . faceId ] } on ${ getBrushLabelById ( selection . brushId , brushList ) } from the ${ source } ${ suffix } . `
) ;
2026-03-31 02:37:22 +02:00
break ;
2026-03-31 03:09:04 +02:00
case "entities" :
2026-03-31 05:55:14 +02:00
setStatusMessage ( ` Selected ${ getEntityDisplayLabelById ( selection . ids [ 0 ] , editorState . document . entities ) } from the ${ source } ${ suffix } . ` ) ;
2026-03-31 03:09:04 +02:00
break ;
2026-03-31 17:42:30 +02:00
case "modelInstances" :
setStatusMessage (
` Selected ${ getModelInstanceDisplayLabelById ( selection . ids [ 0 ] , editorState . document . modelInstances , editorState . document . assets ) } from the ${ source } ${ suffix } . `
) ;
break ;
2026-03-31 02:37:22 +02:00
default :
2026-03-31 04:25:13 +02:00
setStatusMessage ( ` Selection updated from the ${ source } ${ suffix } . ` ) ;
2026-03-31 02:37:22 +02:00
break ;
}
2026-03-31 04:25:13 +02:00
if ( options . focusViewport ) {
requestViewportFocus ( selection ) ;
}
2026-03-31 02:37:22 +02:00
} ;
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 ) ) {
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 ) ) {
return ;
}
store . executeCommand (
createResizeBoxBrushCommand ( {
brushId : selectedBrush.id ,
size : snappedSize
} )
) ;
setStatusMessage ( "Resized selected box brush." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 05:55:51 +02:00
const commitEntityChange = ( currentEntity : EntityInstance , nextEntity : EntityInstance , successMessage : string ) = > {
if ( areEntityInstancesEqual ( currentEntity , nextEntity ) ) {
return ;
}
2026-03-31 03:09:04 +02:00
2026-03-31 05:55:51 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : nextEntity ,
label : ` Update ${ getEntityKindLabel ( nextEntity . kind ) . toLowerCase ( ) } `
} )
) ;
setStatusMessage ( successMessage ) ;
} ;
const resolveNewEntityPosition = ( kind : EntityKind ) : Vec3 = > {
if ( selectedBrush !== null ) {
if ( kind === "triggerVolume" ) {
return snapVec3ToGrid ( selectedBrush . center , DEFAULT_GRID_SIZE ) ;
2026-03-31 03:09:04 +02:00
}
2026-03-31 05:55:51 +02:00
return snapVec3ToGrid (
{
x : selectedBrush.center.x ,
y : selectedBrush.center.y + selectedBrush . size . y * 0.5 ,
z : selectedBrush.center.z
} ,
DEFAULT_GRID_SIZE
) ;
}
if ( selectedEntity !== null ) {
return snapVec3ToGrid ( selectedEntity . position , DEFAULT_GRID_SIZE ) ;
}
return snapVec3ToGrid ( DEFAULT_ENTITY_POSITION , DEFAULT_GRID_SIZE ) ;
} ;
const handlePlaceEntity = ( kind : EntityKind ) = > {
try {
const basePosition = resolveNewEntityPosition ( kind ) ;
2026-03-31 05:58:29 +02:00
let nextEntity : EntityInstance ;
switch ( kind ) {
2026-03-31 20:13:54 +02:00
case "pointLight" :
nextEntity = createPointLightEntity ( {
position : basePosition
} ) ;
break ;
case "spotLight" :
nextEntity = createSpotLightEntity ( {
position : basePosition
} ) ;
break ;
2026-03-31 05:58:29 +02:00
case "playerStart" :
nextEntity = createPlayerStartEntity ( {
position : basePosition
} ) ;
break ;
case "soundEmitter" :
nextEntity = createSoundEmitterEntity ( {
position : basePosition
} ) ;
break ;
case "triggerVolume" :
nextEntity =
selectedBrush === null
? createTriggerVolumeEntity ( {
position : basePosition
} )
: createTriggerVolumeEntity ( {
position : basePosition ,
size : selectedBrush.size
} ) ;
break ;
case "teleportTarget" :
nextEntity = createTeleportTargetEntity ( {
position : basePosition
} ) ;
break ;
case "interactable" :
nextEntity = createInteractableEntity ( {
position : basePosition
} ) ;
break ;
}
2026-03-31 05:55:51 +02:00
2026-03-31 03:09:04 +02:00
store . executeCommand (
2026-03-31 05:55:51 +02:00
createUpsertEntityCommand ( {
entity : nextEntity ,
label : ` Place ${ getEntityKindLabel ( kind ) . toLowerCase ( ) } `
2026-03-31 03:09:04 +02:00
} )
) ;
2026-03-31 05:55:51 +02:00
setStatusMessage ( ` Placed ${ getEntityKindLabel ( kind ) } . ` ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 17:42:35 +02:00
const commitModelInstanceChange = ( currentModelInstance : ModelInstance , nextModelInstance : ModelInstance , successMessage : string ) = > {
if ( areModelInstancesEqual ( currentModelInstance , nextModelInstance ) ) {
return ;
}
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : nextModelInstance ,
label : ` Update ${ getModelInstanceDisplayLabelById ( currentModelInstance . id , editorState . document . modelInstances , editorState . document . assets ) . toLowerCase ( ) } `
} )
) ;
setStatusMessage ( successMessage ) ;
} ;
const handlePlaceModelInstance = ( assetId : string ) = > {
const asset = editorState . document . assets [ assetId ] ;
if ( asset === undefined || asset . kind !== "model" ) {
setStatusMessage ( "Select a model asset before placing a model instance." ) ;
return ;
}
try {
2026-03-31 17:42:55 +02:00
const anchorPosition =
selectedBrush !== null
? {
x : selectedBrush.center.x ,
y : selectedBrush.center.y + selectedBrush . size . y * 0.5 ,
z : selectedBrush.center.z
}
: selectedModelInstance ? . position ? ? null ;
2026-03-31 17:42:35 +02:00
const nextModelInstance = createModelInstance ( {
assetId : asset.id ,
2026-03-31 17:42:55 +02:00
position : createModelInstancePlacementPosition ( asset , anchorPosition ) ,
2026-03-31 17:42:35 +02:00
rotationDegrees : DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES ,
scale : DEFAULT_MODEL_INSTANCE_SCALE
} ) ;
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : nextModelInstance ,
label : ` Place ${ asset . sourceName } `
} )
) ;
setStatusMessage ( ` Placed ${ asset . sourceName } . ` ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 17:42:52 +02:00
const applyModelInstanceChange = ( ) = > {
if ( selectedModelInstance === null ) {
setStatusMessage ( "Select a model instance before editing it." ) ;
return ;
}
try {
const nextModelInstance = createModelInstance ( {
id : selectedModelInstance.id ,
assetId : selectedModelInstance.assetId ,
name : selectedModelInstance.name ,
position : readVec3Draft ( modelPositionDraft , "Model instance position" ) ,
rotationDegrees : readVec3Draft ( modelRotationDraft , "Model instance rotation" ) ,
scale : readPositiveVec3Draft ( modelScaleDraft , "Model instance scale" )
} ) ;
commitModelInstanceChange ( selectedModelInstance , nextModelInstance , "Updated model instance." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 05:55:51 +02:00
const applyPlayerStartChange = ( ) = > {
if ( selectedPlayerStart === null ) {
setStatusMessage ( "Select a Player Start before editing it." ) ;
return ;
}
try {
const snappedPosition = snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Player Start position" ) , DEFAULT_GRID_SIZE ) ;
const yawDegrees = readYawDegreesDraft ( playerStartYawDraft ) ;
const nextEntity = createPlayerStartEntity ( {
id : selectedPlayerStart.id ,
position : snappedPosition ,
yawDegrees
} ) ;
commitEntityChange ( selectedPlayerStart , nextEntity , "Updated Player Start." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 20:13:57 +02:00
const applyPointLightChange = ( ) = > {
if ( selectedPointLight === null ) {
setStatusMessage ( "Select a Point Light before editing it." ) ;
return ;
}
try {
const nextEntity = createPointLightEntity ( {
id : selectedPointLight.id ,
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Point Light position" ) , DEFAULT_GRID_SIZE ) ,
colorHex : pointLightColorDraft ,
intensity : readNonNegativeNumberDraft ( pointLightIntensityDraft , "Point Light intensity" ) ,
distance : readPositiveNumberDraft ( pointLightDistanceDraft , "Point Light distance" )
} ) ;
commitEntityChange ( selectedPointLight , nextEntity , "Updated Point Light." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applySpotLightChange = ( ) = > {
if ( selectedSpotLight === null ) {
setStatusMessage ( "Select a Spot Light before editing it." ) ;
return ;
}
try {
const nextEntity = createSpotLightEntity ( {
id : selectedSpotLight.id ,
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Spot Light position" ) , DEFAULT_GRID_SIZE ) ,
direction : readVec3Draft ( spotLightDirectionDraft , "Spot Light direction" ) ,
colorHex : spotLightColorDraft ,
intensity : readNonNegativeNumberDraft ( spotLightIntensityDraft , "Spot Light intensity" ) ,
distance : readPositiveNumberDraft ( spotLightDistanceDraft , "Spot Light distance" ) ,
angleDegrees : readPositiveNumberDraft ( spotLightAngleDraft , "Spot Light angle" )
} ) ;
commitEntityChange ( selectedSpotLight , nextEntity , "Updated Spot Light." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 20:14:12 +02:00
const applySelectedEntityDraftChange = ( ) = > {
if ( selectedEntity === null ) {
return ;
}
switch ( selectedEntity . kind ) {
case "pointLight" :
applyPointLightChange ( ) ;
break ;
case "spotLight" :
applySpotLightChange ( ) ;
break ;
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} ;
2026-03-31 05:55:51 +02:00
const applySoundEmitterChange = ( ) = > {
if ( selectedSoundEmitter === null ) {
setStatusMessage ( "Select a Sound Emitter before editing it." ) ;
return ;
}
try {
const nextEntity = createSoundEmitterEntity ( {
id : selectedSoundEmitter.id ,
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Sound Emitter position" ) , DEFAULT_GRID_SIZE ) ,
radius : readPositiveNumberDraft ( soundEmitterRadiusDraft , "Sound Emitter radius" ) ,
gain : readNonNegativeNumberDraft ( soundEmitterGainDraft , "Sound Emitter gain" ) ,
autoplay : soundEmitterAutoplayDraft ,
loop : soundEmitterLoopDraft
} ) ;
commitEntityChange ( selectedSoundEmitter , nextEntity , "Updated Sound Emitter." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyTriggerVolumeChange = ( ) = > {
if ( selectedTriggerVolume === null ) {
setStatusMessage ( "Select a Trigger Volume before editing it." ) ;
return ;
}
try {
const nextEntity = createTriggerVolumeEntity ( {
id : selectedTriggerVolume.id ,
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Trigger Volume position" ) , DEFAULT_GRID_SIZE ) ,
size : snapPositiveSizeToGrid ( readVec3Draft ( triggerVolumeSizeDraft , "Trigger Volume size" ) , DEFAULT_GRID_SIZE ) ,
triggerOnEnter : triggerOnEnterDraft ,
triggerOnExit : triggerOnExitDraft
} ) ;
commitEntityChange ( selectedTriggerVolume , nextEntity , "Updated Trigger Volume." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyTeleportTargetChange = ( ) = > {
if ( selectedTeleportTarget === null ) {
setStatusMessage ( "Select a Teleport Target before editing it." ) ;
return ;
}
try {
const nextEntity = createTeleportTargetEntity ( {
id : selectedTeleportTarget.id ,
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Teleport Target position" ) , DEFAULT_GRID_SIZE ) ,
yawDegrees : readYawDegreesDraft ( teleportTargetYawDraft )
} ) ;
commitEntityChange ( selectedTeleportTarget , nextEntity , "Updated Teleport Target." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyInteractableChange = ( ) = > {
if ( selectedInteractable === null ) {
setStatusMessage ( "Select an Interactable before editing it." ) ;
return ;
}
try {
const nextEntity = createInteractableEntity ( {
id : selectedInteractable.id ,
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Interactable position" ) , DEFAULT_GRID_SIZE ) ,
radius : readPositiveNumberDraft ( interactableRadiusDraft , "Interactable radius" ) ,
prompt : readInteractablePromptDraft ( interactablePromptDraft ) ,
enabled : interactableEnabledDraft
} ) ;
commitEntityChange ( selectedInteractable , nextEntity , "Updated Interactable." ) ;
2026-03-31 03:09:04 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 06:18:16 +02:00
const commitInteractionLinkChange = ( currentLink : InteractionLink , nextLink : InteractionLink , successMessage : string , label = "Update interaction link" ) = > {
if ( areInteractionLinksEqual ( currentLink , nextLink ) ) {
return ;
}
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link : nextLink ,
label
} )
) ;
setStatusMessage ( successMessage ) ;
} ;
2026-03-31 06:47:47 +02:00
const getInteractionSourceEntityForLink = ( link : InteractionLink ) : InteractionSourceEntity | null = > {
const sourceEntity = editorState . document . entities [ link . sourceEntityId ] ;
return sourceEntity ? . kind === "triggerVolume" || sourceEntity ? . kind === "interactable" ? sourceEntity : null ;
} ;
2026-03-31 06:18:16 +02:00
const handleAddTeleportInteractionLink = ( ) = > {
2026-03-31 06:47:47 +02:00
if ( selectedInteractionSource === null ) {
setStatusMessage ( "Select a Trigger Volume or Interactable before adding links." ) ;
2026-03-31 06:18:16 +02:00
return ;
}
const defaultTarget = teleportTargetOptions [ 0 ] ? . entity ;
if ( defaultTarget === undefined || defaultTarget . kind !== "teleportTarget" ) {
setStatusMessage ( "Author a Teleport Target before adding a teleport link." ) ;
return ;
}
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link : createTeleportPlayerInteractionLink ( {
2026-03-31 06:47:47 +02:00
sourceEntityId : selectedInteractionSource.id ,
trigger : getDefaultInteractionLinkTrigger ( selectedInteractionSource ) ,
2026-03-31 06:18:16 +02:00
targetEntityId : defaultTarget.id
} ) ,
label : "Add teleport interaction link"
} )
) ;
2026-03-31 06:47:47 +02:00
setStatusMessage ( ` Added a teleport link to the selected ${ selectedInteractionSource . kind === "triggerVolume" ? "Trigger Volume" : "Interactable" } . ` ) ;
2026-03-31 06:18:16 +02:00
} ;
const handleAddVisibilityInteractionLink = ( ) = > {
2026-03-31 06:47:47 +02:00
if ( selectedInteractionSource === null ) {
setStatusMessage ( "Select a Trigger Volume or Interactable before adding links." ) ;
2026-03-31 06:18:16 +02:00
return ;
}
const defaultTarget = visibilityBrushOptions [ 0 ] ? . brush ;
if ( defaultTarget === undefined ) {
setStatusMessage ( "Author at least one brush before adding a visibility link." ) ;
return ;
}
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link : createToggleVisibilityInteractionLink ( {
2026-03-31 06:47:47 +02:00
sourceEntityId : selectedInteractionSource.id ,
trigger : getDefaultInteractionLinkTrigger ( selectedInteractionSource ) ,
2026-03-31 06:18:16 +02:00
targetBrushId : defaultTarget.id
} ) ,
label : "Add visibility interaction link"
} )
) ;
2026-03-31 06:47:47 +02:00
setStatusMessage ( ` Added a visibility link to the selected ${ selectedInteractionSource . kind === "triggerVolume" ? "Trigger Volume" : "Interactable" } . ` ) ;
2026-03-31 06:18:16 +02:00
} ;
const handleDeleteInteractionLink = ( linkId : string ) = > {
try {
store . executeCommand ( createDeleteInteractionLinkCommand ( linkId ) ) ;
setStatusMessage ( "Deleted interaction link." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const updateInteractionLinkTrigger = ( link : InteractionLink , trigger : InteractionTriggerKind ) = > {
2026-03-31 06:47:47 +02:00
const sourceEntity = getInteractionSourceEntityForLink ( link ) ;
if ( sourceEntity ? . kind === "interactable" && trigger !== "click" ) {
setStatusMessage ( "Interactable links always use the click trigger." ) ;
return ;
}
if ( sourceEntity ? . kind === "triggerVolume" && trigger === "click" ) {
setStatusMessage ( "Trigger Volume links may only use enter or exit triggers." ) ;
return ;
}
2026-03-31 06:18:16 +02:00
const nextLink =
link . action . type === "teleportPlayer"
? createTeleportPlayerInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetEntityId : link.action.targetEntityId
} )
: createToggleVisibilityInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetBrushId : link.action.targetBrushId ,
visible : link.action.visible
} ) ;
commitInteractionLinkChange ( link , nextLink , ` Updated ${ getInteractionTriggerLabel ( trigger ) . toLowerCase ( ) } trigger link. ` ) ;
} ;
const updateInteractionLinkActionType = ( link : InteractionLink , actionType : InteractionLink [ "action" ] [ "type" ] ) = > {
2026-03-31 06:47:47 +02:00
const sourceEntity = getInteractionSourceEntityForLink ( link ) ;
if ( sourceEntity === null || link . action . type === actionType ) {
2026-03-31 06:18:16 +02:00
return ;
}
if ( actionType === "teleportPlayer" ) {
const defaultTarget = teleportTargetOptions [ 0 ] ? . entity ;
if ( defaultTarget === undefined || defaultTarget . kind !== "teleportTarget" ) {
setStatusMessage ( "Author a Teleport Target before switching this link to teleport." ) ;
return ;
}
commitInteractionLinkChange (
link ,
createTeleportPlayerInteractionLink ( {
id : link.id ,
2026-03-31 06:47:47 +02:00
sourceEntityId : sourceEntity.id ,
2026-03-31 06:18:16 +02:00
trigger : link.trigger ,
targetEntityId : defaultTarget.id
} ) ,
"Switched link action to teleport player."
) ;
return ;
}
const defaultBrush = visibilityBrushOptions [ 0 ] ? . brush ;
if ( defaultBrush === undefined ) {
setStatusMessage ( "Author at least one brush before switching this link to visibility." ) ;
return ;
}
commitInteractionLinkChange (
link ,
createToggleVisibilityInteractionLink ( {
id : link.id ,
2026-03-31 06:47:47 +02:00
sourceEntityId : sourceEntity.id ,
2026-03-31 06:18:16 +02:00
trigger : link.trigger ,
targetBrushId : defaultBrush.id
} ) ,
"Switched link action to toggle visibility."
) ;
} ;
const updateTeleportInteractionLinkTarget = ( link : InteractionLink , targetEntityId : string ) = > {
if ( link . action . type !== "teleportPlayer" ) {
return ;
}
commitInteractionLinkChange (
link ,
createTeleportPlayerInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetEntityId
} ) ,
"Updated teleport link target."
) ;
} ;
const updateVisibilityInteractionLinkTarget = ( link : InteractionLink , targetBrushId : string ) = > {
if ( link . action . type !== "toggleVisibility" ) {
return ;
}
commitInteractionLinkChange (
link ,
createToggleVisibilityInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetBrushId ,
visible : link.action.visible
} ) ,
"Updated visibility link target."
) ;
} ;
const updateVisibilityInteractionMode = ( link : InteractionLink , mode : "toggle" | "show" | "hide" ) = > {
if ( link . action . type !== "toggleVisibility" ) {
return ;
}
commitInteractionLinkChange (
link ,
createToggleVisibilityInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetBrushId : link.action.targetBrushId ,
visible : readVisibilityModeSelectValue ( mode )
} ) ,
"Updated visibility link mode."
) ;
} ;
2026-03-31 06:48:15 +02:00
const renderInteractionLinksSection = (
sourceEntity : InteractionSourceEntity ,
links : InteractionLink [ ] ,
addTeleportTestId : string ,
addVisibilityTestId : string
) = > (
< div className = "form-section" >
< div className = "label" > Links < / div >
{ links . length === 0 ? (
< div className = "outliner-empty" >
{ sourceEntity . kind === "triggerVolume" ? "No trigger links authored yet." : "No click links authored yet." }
< / div >
) : (
< div className = "outliner-list" >
{ links . map ( ( link , index ) = > (
< div key = { link . id } className = "outliner-item" >
< div className = "outliner-item__select" >
< span className = "outliner-item__title" > { ` Link ${ index + 1 } ` } < / span >
< span className = "outliner-item__meta" > { getInteractionActionLabel ( link ) } < / span >
< / div >
< div className = "form-section" >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Trigger < / span >
{ sourceEntity . kind === "triggerVolume" ? (
< select
data - testid = { ` interaction-link-trigger- ${ link . id } ` }
className = "text-input"
value = { link . trigger }
onChange = { ( event ) = > updateInteractionLinkTrigger ( link , event . currentTarget . value as InteractionTriggerKind ) }
>
< option value = "enter" > On Enter < / option >
< option value = "exit" > On Exit < / option >
< / select >
) : (
< input
data - testid = { ` interaction-link-trigger- ${ link . id } ` }
className = "text-input"
type = "text"
value = { getInteractionTriggerLabel ( link . trigger ) }
readOnly
/ >
) }
< / label >
< label className = "form-field" >
< span className = "label" > Action < / span >
< select
data - testid = { ` interaction-link-action- ${ link . id } ` }
className = "text-input"
value = { link . action . type }
onChange = { ( event ) = > updateInteractionLinkActionType ( link , event . currentTarget . value as InteractionLink [ "action" ] [ "type" ] ) }
>
< option value = "teleportPlayer" > Teleport Player < / option >
< option value = "toggleVisibility" > Toggle Visibility < / option >
< / select >
< / label >
< / div >
< / div >
{ link . action . type === "teleportPlayer" ? (
< div className = "form-section" >
< label className = "form-field" >
< span className = "label" > Target < / span >
< select
data - testid = { ` interaction-link-teleport-target- ${ link . id } ` }
className = "text-input"
value = { link . action . targetEntityId }
onChange = { ( event ) = > updateTeleportInteractionLinkTarget ( link , event . currentTarget . value ) }
>
{ teleportTargetOptions . map ( ( { entity , label } ) = > (
< option key = { entity . id } value = { entity . id } >
{ label }
< / option >
) ) }
< / select >
< / label >
< / div >
) : (
< div className = "form-section" >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Brush < / span >
< select
data - testid = { ` interaction-link-visibility-target- ${ link . id } ` }
className = "text-input"
value = { link . action . targetBrushId }
onChange = { ( event ) = > updateVisibilityInteractionLinkTarget ( link , event . currentTarget . value ) }
>
{ visibilityBrushOptions . map ( ( { brush , label } ) = > (
< option key = { brush . id } value = { brush . id } >
{ label }
< / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< span className = "label" > Mode < / span >
< select
data - testid = { ` interaction-link-visibility-mode- ${ link . id } ` }
className = "text-input"
value = { getVisibilityModeSelectValue ( link . action . visible ) }
onChange = { ( event ) = >
updateVisibilityInteractionMode ( link , event . currentTarget . value as ReturnType < typeof getVisibilityModeSelectValue > )
}
>
< option value = "toggle" > Toggle < / option >
< option value = "show" > Show < / option >
< option value = "hide" > Hide < / option >
< / select >
< / label >
< / div >
< / div >
) }
< div className = "inline-actions" >
< button
className = "toolbar__button"
type = "button"
data - testid = { ` delete-interaction-link- ${ link . id } ` }
onClick = { ( ) = > handleDeleteInteractionLink ( link . id ) }
>
Delete Link
< / button >
< / div >
< / div >
) ) }
< / div >
) }
< div className = "inline-actions" >
< button
className = "toolbar__button"
type = "button"
data - testid = { addTeleportTestId }
disabled = { teleportTargetOptions . length === 0 }
onClick = { handleAddTeleportInteractionLink }
>
Add Teleport Link
< / button >
< button
className = "toolbar__button"
type = "button"
data - testid = { addVisibilityTestId }
disabled = { visibilityBrushOptions . length === 0 }
onClick = { handleAddVisibilityInteractionLink }
>
Add Visibility Link
< / button >
< / div >
< / div >
) ;
2026-03-31 05:12:23 +02:00
const applyWorldSettings = ( nextWorld : WorldSettings , label : string , successMessage : string ) = > {
if ( areWorldSettingsEqual ( editorState . document . world , nextWorld ) ) {
return ;
}
try {
store . executeCommand (
createSetWorldSettingsCommand ( {
label ,
world : nextWorld
} )
) ;
setStatusMessage ( successMessage ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 20:13:50 +02:00
const applyWorldBackgroundMode = ( mode : WorldBackgroundMode , imageAssetId? : string ) = > {
if ( mode === "image" ) {
const nextImageAssetId =
imageAssetId ? ?
( editorState . document . world . background . mode === "image"
? editorState . document . world . background . assetId
: imageAssetList [ 0 ] ? . id ) ;
if ( nextImageAssetId === undefined ) {
setStatusMessage ( "Import an image asset before using an image background." ) ;
return ;
}
applyWorldSettings (
{
. . . editorState . document . world ,
background : changeWorldBackgroundMode ( editorState . document . world . background , "image" , nextImageAssetId )
} ,
"Set world background image" ,
` World background set to ${ editorState . document . assets [ nextImageAssetId ] ? . sourceName ? ? nextImageAssetId } . `
) ;
return ;
}
2026-03-31 05:12:23 +02:00
applyWorldSettings (
{
. . . editorState . document . world ,
background : changeWorldBackgroundMode ( editorState . document . world . background , mode )
} ,
"Set world background mode" ,
mode === "solid" ? "World background set to a solid color." : "World background set to a vertical gradient."
) ;
} ;
const applyWorldBackgroundColor = ( colorHex : string ) = > {
if ( editorState . document . world . background . mode !== "solid" ) {
return ;
}
applyWorldSettings (
{
. . . editorState . document . world ,
background : {
mode : "solid" ,
colorHex
}
} ,
"Set world background color" ,
"Updated the world background color."
) ;
} ;
const applyWorldGradientColor = ( edge : "top" | "bottom" , colorHex : string ) = > {
if ( editorState . document . world . background . mode !== "verticalGradient" ) {
return ;
}
applyWorldSettings (
{
. . . editorState . document . world ,
background :
edge === "top"
? {
. . . editorState . document . world . background ,
topColorHex : colorHex
}
: {
. . . editorState . document . world . background ,
bottomColorHex : colorHex
}
} ,
edge === "top" ? "Set world gradient top color" : "Set world gradient bottom color" ,
edge === "top" ? "Updated the world gradient top color." : "Updated the world gradient bottom color."
) ;
} ;
const applyAmbientLightColor = ( colorHex : string ) = > {
applyWorldSettings (
{
. . . editorState . document . world ,
ambientLight : {
. . . editorState . document . world . ambientLight ,
colorHex
}
} ,
"Set world ambient light color" ,
"Updated the world ambient light color."
) ;
} ;
const applyAmbientLightIntensity = ( ) = > {
try {
applyWorldSettings (
{
. . . editorState . document . world ,
ambientLight : {
. . . editorState . document . world . ambientLight ,
intensity : readNonNegativeNumberDraft ( ambientLightIntensityDraft , "Ambient light intensity" )
}
} ,
"Set world ambient light intensity" ,
"Updated the world ambient light intensity."
) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applySunLightColor = ( colorHex : string ) = > {
applyWorldSettings (
{
. . . editorState . document . world ,
sunLight : {
. . . editorState . document . world . sunLight ,
colorHex
}
} ,
"Set world sun color" ,
"Updated the world sun color."
) ;
} ;
const applySunLightIntensity = ( ) = > {
try {
applyWorldSettings (
{
. . . editorState . document . world ,
sunLight : {
. . . editorState . document . world . sunLight ,
intensity : readNonNegativeNumberDraft ( sunLightIntensityDraft , "Sun intensity" )
}
} ,
"Set world sun intensity" ,
"Updated the world sun intensity."
) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applySunLightDirection = ( ) = > {
try {
const direction = readVec3Draft ( sunDirectionDraft , "Sun direction" ) ;
if ( direction . x === 0 && direction . y === 0 && direction . z === 0 ) {
throw new Error ( "Sun direction must not be the zero vector." ) ;
}
applyWorldSettings (
{
. . . editorState . document . world ,
sunLight : {
. . . editorState . document . world . sunLight ,
direction
}
} ,
"Set world sun direction" ,
"Updated the world sun direction."
) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 04:25:19 +02:00
const applyBrushNameChange = ( ) = > {
if ( selectedBrush === null ) {
setStatusMessage ( "Select a box brush before renaming it." ) ;
return ;
}
const nextName = normalizeBrushName ( brushNameDraft ) ;
if ( selectedBrush . name === nextName ) {
return ;
}
try {
store . executeCommand (
createSetBoxBrushNameCommand ( {
brushId : selectedBrush.id ,
name : brushNameDraft
} )
) ;
setStatusMessage ( nextName === undefined ? "Cleared the authored brush name." : ` Renamed brush to ${ nextName } . ` ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-03-31 04:29:05 +02:00
const handleDraftVectorKeyDown = ( event : ReactKeyboardEvent < HTMLInputElement > , applyChange : ( ) = > void ) = > {
2026-03-31 02:37:22 +02:00
if ( event . key === "Enter" ) {
applyChange ( ) ;
}
} ;
2026-03-31 04:37:53 +02:00
const scheduleDraftCommit = ( applyChange : ( ) = > void ) = > {
window . setTimeout ( ( ) = > {
applyChange ( ) ;
} , 0 ) ;
} ;
const handleNumberInputPointerUp = ( _event : ReactPointerEvent < HTMLInputElement > , applyChange : ( ) = > void ) = > {
scheduleDraftCommit ( applyChange ) ;
} ;
const handleNumberInputKeyUp = ( event : ReactKeyboardEvent < HTMLInputElement > , applyChange : ( ) = > void ) = > {
if ( ! isCommitIncrementKey ( event . key ) ) {
return ;
}
scheduleDraftCommit ( applyChange ) ;
} ;
2026-03-31 02:37:22 +02:00
const handleSaveDraft = ( ) = > {
const result = store . saveDraft ( ) ;
setStatusMessage ( result . message ) ;
} ;
const handleLoadDraft = ( ) = > {
const result = store . loadDraft ( ) ;
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 ) ;
setStatusMessage ( "Scene document exported as JSON." ) ;
} catch ( error ) {
const message = getErrorMessage ( error ) ;
setStatusMessage ( message ) ;
}
2026-03-31 02:37:22 +02:00
} ;
2026-03-31 17:42:43 +02:00
const handleImportJsonButtonClick = ( ) = > {
2026-03-31 02:37:22 +02:00
importInputRef . current ? . click ( ) ;
} ;
2026-03-31 17:42:43 +02:00
const handleImportJsonChange = async ( event : ChangeEvent < HTMLInputElement > ) = > {
2026-03-31 17:53:29 +02:00
const input = event . currentTarget ;
const file = input . files ? . [ 0 ] ;
2026-03-31 02:37:22 +02:00
if ( file === undefined ) {
return ;
}
try {
const source = await file . text ( ) ;
store . importDocumentJson ( source ) ;
setStatusMessage ( ` Imported ${ file . name } . ` ) ;
} catch ( error ) {
2026-03-31 04:50:21 +02:00
setStatusMessage ( getErrorMessage ( error ) ) ;
2026-03-31 02:37:22 +02:00
} finally {
2026-03-31 17:53:29 +02:00
input . value = "" ;
2026-03-31 02:37:22 +02:00
}
} ;
2026-03-31 17:42:43 +02:00
const handleImportModelButtonClick = ( ) = > {
importModelInputRef . current ? . click ( ) ;
} ;
2026-03-31 20:14:05 +02:00
const handleImportBackgroundImageButtonClick = ( ) = > {
importBackgroundImageInputRef . current ? . click ( ) ;
} ;
2026-03-31 17:42:43 +02:00
const handleImportModelChange = async ( event : ChangeEvent < HTMLInputElement > ) = > {
2026-03-31 17:53:29 +02:00
const input = event . currentTarget ;
2026-03-31 18:45:19 +02:00
const files = Array . from ( input . files ? ? [ ] ) ;
2026-03-31 17:42:43 +02:00
2026-03-31 18:45:19 +02:00
if ( files . length === 0 ) {
2026-03-31 17:42:43 +02:00
return ;
}
if ( projectAssetStorage === null ) {
setAssetStatusMessage ( "Imported model assets require project asset storage. IndexedDB is unavailable in this browser." ) ;
2026-03-31 17:54:24 +02:00
input . value = "" ;
2026-03-31 17:42:43 +02:00
return ;
}
2026-03-31 17:56:40 +02:00
let importedModelForCleanup : ImportedModelAssetResult | null = null ;
2026-03-31 17:56:29 +02:00
2026-03-31 17:42:43 +02:00
try {
2026-03-31 18:45:19 +02:00
const importedModel = files . length === 1
? await importModelAssetFromFile ( files [ 0 ] , projectAssetStorage )
: await importModelAssetFromFiles ( files , projectAssetStorage ) ;
2026-03-31 17:56:40 +02:00
importedModelForCleanup = importedModel ;
2026-03-31 17:42:43 +02:00
store . executeCommand (
createImportModelAssetCommand ( {
asset : importedModel.asset ,
modelInstance : importedModel.modelInstance ,
label : ` Import ${ importedModel . asset . sourceName } `
} )
) ;
loadedModelAssetsRef . current = {
. . . loadedModelAssetsRef . current ,
[ importedModel . asset . id ] : importedModel . loadedAsset
} ;
setLoadedModelAssets ( ( currentLoadedAssets ) = > ( {
. . . currentLoadedAssets ,
[ importedModel . asset . id ] : importedModel . loadedAsset
} ) ) ;
setAssetStatusMessage ( null ) ;
setStatusMessage ( ` Imported ${ importedModel . asset . sourceName } and placed a model instance. ` ) ;
} catch ( error ) {
2026-03-31 17:56:40 +02:00
if ( importedModelForCleanup !== null ) {
await projectAssetStorage . deleteAsset ( importedModelForCleanup . asset . storageKey ) . catch ( ( ) = > undefined ) ;
disposeModelTemplate ( importedModelForCleanup . loadedAsset . template ) ;
2026-03-31 17:56:29 +02:00
}
2026-03-31 17:42:43 +02:00
const message = getErrorMessage ( error ) ;
setStatusMessage ( message ) ;
setAssetStatusMessage ( message ) ;
} finally {
2026-03-31 17:53:29 +02:00
input . value = "" ;
2026-03-31 17:42:43 +02:00
}
} ;
2026-03-31 20:14:05 +02:00
const handleImportBackgroundImageChange = async ( event : ChangeEvent < HTMLInputElement > ) = > {
const input = event . currentTarget ;
const file = input . files ? . [ 0 ] ;
if ( file === undefined ) {
return ;
}
if ( projectAssetStorage === null ) {
setAssetStatusMessage ( "Imported background images require project asset storage. IndexedDB is unavailable in this browser." ) ;
input . value = "" ;
return ;
}
let importedImageForCleanup : ImportedImageAssetResult | null = null ;
try {
const importedImage = await importBackgroundImageAssetFromFile ( file , projectAssetStorage ) ;
importedImageForCleanup = importedImage ;
store . executeCommand (
createImportBackgroundImageAssetCommand ( {
asset : importedImage.asset ,
world : {
. . . editorState . document . world ,
background : changeWorldBackgroundMode ( editorState . document . world . background , "image" , importedImage . asset . id )
} ,
label : ` Import ${ importedImage . asset . sourceName } as background `
} )
) ;
loadedImageAssetsRef . current = {
. . . loadedImageAssetsRef . current ,
[ importedImage . asset . id ] : importedImage . loadedAsset
} ;
setLoadedImageAssets ( ( currentLoadedAssets ) = > ( {
. . . currentLoadedAssets ,
[ importedImage . asset . id ] : importedImage . loadedAsset
} ) ) ;
setAssetStatusMessage ( null ) ;
setStatusMessage ( ` Imported ${ importedImage . asset . sourceName } and set it as the world background. ` ) ;
} catch ( error ) {
if ( importedImageForCleanup !== null ) {
await projectAssetStorage . deleteAsset ( importedImageForCleanup . asset . storageKey ) . catch ( ( ) = > undefined ) ;
disposeLoadedImageAsset ( importedImageForCleanup . loadedAsset ) ;
}
const message = getErrorMessage ( error ) ;
setStatusMessage ( message ) ;
setAssetStatusMessage ( message ) ;
} finally {
input . value = "" ;
}
} ;
2026-03-31 02:37:22 +02:00
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 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 ) ;
2026-03-31 06:48:24 +02:00
setRuntimeInteractionPrompt ( null ) ;
2026-03-31 03:09:04 +02:00
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 ) ;
2026-03-31 06:48:24 +02:00
setRuntimeInteractionPrompt ( null ) ;
2026-03-31 03:09:04 +02:00
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" >
2026-03-31 06:48:24 +02:00
< div className = "toolbar__brand" >
< div className = "toolbar__title" > WebEditor3D < / div >
2026-03-31 17:44:55 +02:00
< div className = "toolbar__subtitle" > Slice 3.1 GLB / GLTF import and model placement < / div >
2026-03-31 06:48:24 +02:00
< / div >
2026-03-31 03:09:04 +02:00
< 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 }
2026-03-31 17:44:32 +02:00
projectAssets = { editorState . document . assets }
loadedModelAssets = { loadedModelAssets }
2026-03-31 03:09:04 +02:00
navigationMode = { activeNavigationMode }
onRuntimeMessageChange = { setRuntimeMessage }
onFirstPersonTelemetryChange = { setFirstPersonTelemetry }
2026-03-31 06:48:37 +02:00
onInteractionPromptChange = { setRuntimeInteractionPrompt }
2026-03-31 03:09:04 +02:00
/ >
< / 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 >
2026-03-31 06:48:37 +02:00
< div className = "stat-card" >
< div className = "label" > Interaction < / div >
< div className = "value" data-testid = "runner-interaction-state" >
{ activeNavigationMode === "firstPerson" ? ( runtimeInteractionPrompt === null ? "No target" : "Ready" ) : "Not available" }
< / div >
< div className = "material-summary" data-testid = "runner-interaction-summary" >
{ activeNavigationMode === "firstPerson"
? runtimeInteractionPrompt === null
? "Aim at an authored Interactable and click when a prompt appears."
: ` Click " ${ runtimeInteractionPrompt . prompt } " within ${ runtimeInteractionPrompt . range . toFixed ( 1 ) } m. `
: "Switch to First Person to use click interactions." }
< / div >
< / div >
2026-03-31 04:26:39 +02:00
{ runtimeMessage === null ? null : < div className = "info-banner" > { runtimeMessage } < / div > }
2026-03-31 06:48:37 +02:00
{ activeNavigationMode === "firstPerson" ? (
< div className = "info-banner" data-testid = "runner-interaction-help" >
Mouse click activates the current prompt target . Keyboard / controller fallback is not active yet .
< / div >
) : null }
2026-03-31 03:09:04 +02:00
< / 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" >
2026-03-31 04:50:21 +02:00
< label className = "toolbar__scene-name" >
< span className = "visually-hidden" > Scene Name < / span >
< input
data - testid = "toolbar-scene-name"
className = "text-input toolbar__scene-name-input"
type = "text"
value = { sceneNameDraft }
onChange = { ( event ) = > setSceneNameDraft ( event . currentTarget . value ) }
onBlur = { applySceneName }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applySceneName ( ) ;
}
} }
/ >
< / label >
2026-03-31 02:37:22 +02:00
< div className = "toolbar__actions" >
2026-03-31 04:50:21 +02:00
< div className = "toolbar__group" >
< 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 >
2026-03-31 17:44:20 +02:00
< button className = "toolbar__button" type = "button" onClick = { handleImportJsonButtonClick } >
2026-03-31 04:50:21 +02:00
Import JSON
< / button >
2026-03-31 17:44:20 +02:00
< button
className = "toolbar__button"
type = "button"
onClick = { handleImportModelButtonClick }
disabled = { ! projectAssetStorageReady || projectAssetStorage === null }
>
Import Model
< / button >
2026-03-31 04:50:21 +02:00
< / div >
2026-03-31 02:37:22 +02:00
< 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" >
< 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 >
2026-03-31 04:58:05 +02:00
< div className = "toolbar__group" >
< 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 >
2026-03-31 02:37:22 +02:00
< 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" >
< 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 >
2026-03-31 17:44:10 +02:00
< span className = "material-item__meta" > { material . tags . join ( " | " ) } < / span >
2026-03-31 02:37:22 +02:00
< / span >
< / button >
) ) }
< / div >
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" disabled = { selectedFace === null } onClick = { clearFaceMaterial } >
Clear Face Material
< / button >
< / div >
< / Panel >
2026-03-31 17:43:20 +02:00
< Panel title = "Assets" >
< div className = "inline-actions" >
< button
className = "toolbar__button toolbar__button--accent"
type = "button"
data - testid = "import-model-asset"
onClick = { handleImportModelButtonClick }
disabled = { ! projectAssetStorageReady || projectAssetStorage === null }
>
Import GLB / GLTF
< / button >
< / div >
{ assetStatusMessage === null ? null : (
< div className = "info-banner" data-testid = "asset-status-message" >
{ assetStatusMessage }
< / div >
) }
{ projectAssetStorageReady && projectAssetStorage === null ? (
< div className = "outliner-empty" > Project asset storage is unavailable . Imported assets cannot be persisted . < / div >
) : null }
2026-03-31 17:46:31 +02:00
{ projectAssetList . length === 0 ? (
2026-03-31 17:43:20 +02:00
< div className = "outliner-empty" >
No imported model assets yet . Import a GLB or GLTF to register the first project asset .
< / div >
) : (
< div className = "outliner-list" data-testid = "asset-list" >
2026-03-31 17:46:31 +02:00
{ projectAssetList . map ( ( asset ) = > (
2026-03-31 17:43:20 +02:00
< div key = { asset . id } className = "outliner-item asset-item" >
< div className = "outliner-item__select" >
< span className = "outliner-item__title" > { asset . sourceName } < / span >
2026-03-31 17:46:31 +02:00
< span className = "outliner-item__meta" > { getProjectAssetKindLabel ( asset . kind ) } < / span >
2026-03-31 17:43:20 +02:00
< / div >
2026-03-31 17:46:31 +02:00
< div className = "asset-item__summary" >
{ formatByteLength ( asset . byteLength ) } | { asset . mimeType }
2026-03-31 17:43:20 +02:00
< / div >
2026-03-31 17:46:31 +02:00
< div className = "asset-item__summary" > Storage key : { asset . storageKey } < / div >
{ asset . kind !== "model" ? (
< div className = "asset-item__summary" > This asset kind is registered for future slices but is not placeable yet . < / div >
) : (
< >
< div className = "asset-item__summary" > { formatModelAssetSummary ( asset ) } < / div >
< div className = "asset-item__summary" > { formatModelBoundingBoxLabel ( asset ) } < / div >
{ asset . metadata . materialNames . length === 0 ? null : (
< div className = "asset-item__summary" > Materials : { asset . metadata . materialNames . join ( ", " ) } < / div >
) }
{ asset . metadata . textureNames . length === 0 ? null : (
< div className = "asset-item__summary" > Textures : { asset . metadata . textureNames . join ( ", " ) } < / div >
) }
{ asset . metadata . animationNames . length === 0 ? null : (
< div className = "asset-item__summary" > Animations : { asset . metadata . animationNames . join ( ", " ) } < / div >
) }
{ asset . metadata . warnings . length === 0 ? null : (
< div className = "asset-item__warnings" > { asset . metadata . warnings . join ( " | " ) } < / div >
) }
< div className = "inline-actions" >
< button
className = "toolbar__button"
type = "button"
data - testid = { ` place-model-instance- ${ asset . id } ` }
onClick = { ( ) = > handlePlaceModelInstance ( asset . id ) }
>
Place Instance
< / button >
< / div >
< / >
) }
2026-03-31 17:43:20 +02:00
< / div >
) ) }
< / div >
) }
< / Panel >
2026-03-31 02:37:22 +02:00
< Panel title = "Outliner" >
2026-03-31 03:09:04 +02:00
< div className = "outliner-section" >
< div className = "label" > Brushes < / div >
{ brushList . length === 0 ? (
2026-03-31 04:25:56 +02:00
< div className = "outliner-empty" > Switch to Box Create and click in the viewport to place the first brush . < / div >
2026-03-31 03:09:04 +02:00
) : (
< div className = "outliner-list" data-testid = "outliner-brush-list" >
{ brushList . map ( ( brush , brushIndex ) = > (
2026-03-31 04:25:56 +02:00
< div
2026-03-31 03:09:04 +02:00
key = { brush . id }
className = { ` outliner-item ${ isBrushSelected ( editorState . selection , brush . id ) ? "outliner-item--selected" : "" } ` }
>
2026-03-31 04:25:56 +02:00
< button
className = "outliner-item__select"
type = "button"
data - testid = { ` outliner-brush- ${ brush . id } ` }
onClick = { ( ) = >
applySelection (
{
kind : "brushes" ,
ids : [ brush . id ]
} ,
"outliner" ,
{
focusViewport : true
}
)
}
>
< span className = "outliner-item__title" > { getBrushLabel ( brush , brushIndex ) } < / span >
< span className = "outliner-item__meta" > Brush < / span >
< / button >
{ selectedBrush ? . id !== brush . id ? null : (
< label className = "form-field outliner-item__editor" >
< span className = "label" > Name < / span >
< input
className = "text-input text-input--dense"
data - testid = "selected-brush-name"
type = "text"
value = { brushNameDraft }
placeholder = { ` Box Brush ${ brushIndex + 1 } ` }
onChange = { ( event ) = > setBrushNameDraft ( event . currentTarget . value ) }
onBlur = { applyBrushNameChange }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applyBrushNameChange ( ) ;
}
} }
/ >
< / label >
) }
< / div >
2026-03-31 03:09:04 +02:00
) ) }
< / div >
) }
< / div >
2026-03-31 17:43:20 +02:00
< div className = "outliner-section" >
< div className = "label" > Model Instances < / div >
{ modelInstanceDisplayList . length === 0 ? (
< div className = "outliner-empty" > No model instances placed yet . < / div >
) : (
< div className = "outliner-list" data-testid = "outliner-model-instance-list" >
{ modelInstanceDisplayList . map ( ( { modelInstance , label } ) = > (
< button
key = { modelInstance . id }
data - testid = { ` outliner-model-instance- ${ modelInstance . id } ` }
className = { ` outliner-item ${
editorState . selection . kind === "modelInstances" && editorState . selection . ids . includes ( modelInstance . id )
? "outliner-item--selected"
: ""
} ` }
type = "button"
onClick = { ( ) = >
applySelection (
{
kind : "modelInstances" ,
ids : [ modelInstance . id ]
} ,
"outliner" ,
{
focusViewport : true
}
)
}
>
< span className = "outliner-item__title" > { label } < / span >
2026-03-31 17:43:27 +02:00
< span className = "outliner-item__meta" > { editorState . document . assets [ modelInstance . assetId ] ? . sourceName ? ? modelInstance . assetId } < / span >
2026-03-31 17:43:20 +02:00
< / button >
) ) }
< / div >
) }
< / div >
2026-03-31 03:09:04 +02:00
< div className = "outliner-section" >
< div className = "label" > Entities < / div >
2026-03-31 05:56:22 +02:00
{ entityDisplayList . length === 0 ? < div className = "outliner-empty" > No entities authored yet . < / div > : null }
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" data-testid = "place-player-start" onClick = { ( ) = > handlePlaceEntity ( "playerStart" ) } >
Add Player Start
< / button >
< button
className = "toolbar__button"
type = "button"
data - testid = "add-entity-soundEmitter"
onClick = { ( ) = > handlePlaceEntity ( "soundEmitter" ) }
>
Add Sound Emitter
< / button >
< / div >
< div className = "inline-actions" >
< button
className = "toolbar__button"
type = "button"
data - testid = "add-entity-triggerVolume"
onClick = { ( ) = > handlePlaceEntity ( "triggerVolume" ) }
>
Add Trigger Volume
< / button >
< button
className = "toolbar__button"
type = "button"
data - testid = "add-entity-teleportTarget"
onClick = { ( ) = > handlePlaceEntity ( "teleportTarget" ) }
>
Add Teleport Target
< / button >
< / div >
< div className = "inline-actions" >
< button
className = "toolbar__button"
type = "button"
data - testid = "add-entity-interactable"
onClick = { ( ) = > handlePlaceEntity ( "interactable" ) }
>
Add Interactable
< / button >
< / div >
{ entityDisplayList . length === 0 ? null : (
2026-03-31 03:09:04 +02:00
< div className = "outliner-list" >
2026-03-31 05:56:22 +02:00
{ entityDisplayList . map ( ( { entity , label } ) = > (
2026-03-31 03:09:04 +02:00
< button
2026-03-31 05:56:22 +02:00
key = { entity . id }
data - testid = { ` outliner-entity- ${ entity . id } ` }
2026-03-31 03:09:04 +02:00
className = { ` outliner-item ${
2026-03-31 05:56:22 +02:00
editorState . selection . kind === "entities" && editorState . selection . ids . includes ( entity . id )
2026-03-31 03:09:04 +02:00
? "outliner-item--selected"
: ""
} ` }
type = "button"
onClick = { ( ) = >
applySelection (
{
kind : "entities" ,
2026-03-31 05:56:22 +02:00
ids : [ entity . id ]
2026-03-31 03:09:04 +02:00
} ,
2026-03-31 04:25:56 +02:00
"outliner" ,
{
focusViewport : true
}
2026-03-31 03:09:04 +02:00
)
}
>
2026-03-31 05:56:22 +02:00
< span className = "outliner-item__title" > { label } < / span >
< span className = "outliner-item__meta" > { getEntityKindLabel ( entity . kind ) } < / span >
2026-03-31 03:09:04 +02:00
< / 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 }
2026-03-31 17:44:32 +02:00
projectAssets = { editorState . document . assets }
loadedModelAssets = { loadedModelAssets }
2026-03-31 02:37:22 +02:00
selection = { editorState . selection }
2026-03-31 03:44:17 +02:00
toolMode = { editorState . toolMode }
2026-03-31 04:26:05 +02:00
focusRequestId = { focusRequest . id }
focusSelection = { focusRequest . selection }
2026-03-31 03:44:17 +02:00
onSelectionChange = { ( selection ) = > applySelection ( selection , "viewport" ) }
onCreateBoxBrush = { handleCreateBoxBrush }
2026-03-31 02:37:22 +02:00
/ >
< / main >
< aside className = "side-column" >
2026-03-31 05:13:07 +02:00
< Panel title = "World" >
< div className = "stat-card" >
< div className = "label" > Background < / div >
< div className = "value" data-testid = "world-background-mode-value" >
{ formatWorldBackgroundLabel ( editorState . document . world ) }
< / div >
< div
className = "world-background-preview"
data - testid = "world-background-preview"
style = { createWorldBackgroundStyle ( editorState . document . world . background ) }
/ >
< div className = "material-summary" >
{ editorState . document . world . background . mode === "solid"
? editorState . document . world . background . colorHex
: ` ${ editorState . document . world . background . topColorHex } -> ${ editorState . document . world . background . bottomColorHex } ` }
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Background Mode < / div >
< div className = "inline-actions" >
< button
className = { ` toolbar__button ${ editorState . document . world . background . mode === "solid" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "world-background-mode-solid"
onClick = { ( ) = > applyWorldBackgroundMode ( "solid" ) }
>
Solid
< / button >
< button
className = { ` toolbar__button ${
editorState . document . world . background . mode === "verticalGradient" ? "toolbar__button--active" : ""
} ` }
type = "button"
data - testid = "world-background-mode-gradient"
onClick = { ( ) = > applyWorldBackgroundMode ( "verticalGradient" ) }
>
Gradient
< / button >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Background Colors < / div >
{ editorState . document . world . background . mode === "solid" ? (
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "world-background-solid-color"
className = "color-input"
type = "color"
value = { editorState . document . world . background . colorHex }
onChange = { ( event ) = > applyWorldBackgroundColor ( event . currentTarget . value ) }
/ >
< / label >
) : (
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Top < / span >
< input
data - testid = "world-background-top-color"
className = "color-input"
type = "color"
value = { editorState . document . world . background . topColorHex }
onChange = { ( event ) = > applyWorldGradientColor ( "top" , event . currentTarget . value ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Bottom < / span >
< input
data - testid = "world-background-bottom-color"
className = "color-input"
type = "color"
value = { editorState . document . world . background . bottomColorHex }
onChange = { ( event ) = > applyWorldGradientColor ( "bottom" , event . currentTarget . value ) }
/ >
< / label >
< / div >
) }
< / div >
< div className = "form-section" >
< div className = "label" > Ambient Light < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "world-ambient-color"
className = "color-input"
type = "color"
value = { editorState . document . world . ambientLight . colorHex }
onChange = { ( event ) = > applyAmbientLightColor ( event . currentTarget . value ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
data - testid = "world-ambient-intensity"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { ambientLightIntensityDraft }
onChange = { ( event ) = > setAmbientLightIntensityDraft ( event . currentTarget . value ) }
onBlur = { applyAmbientLightIntensity }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAmbientLightIntensity ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAmbientLightIntensity ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAmbientLightIntensity ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Sun Light < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "world-sun-color"
className = "color-input"
type = "color"
value = { editorState . document . world . sunLight . colorHex }
onChange = { ( event ) = > applySunLightColor ( event . currentTarget . value ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
data - testid = "world-sun-intensity"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { sunLightIntensityDraft }
onChange = { ( event ) = > setSunLightIntensityDraft ( event . currentTarget . value ) }
onBlur = { applySunLightIntensity }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySunLightIntensity ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySunLightIntensity ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySunLightIntensity ) }
/ >
< / label >
< / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > Dir X < / span >
< input
data - testid = "world-sun-direction-x"
className = "text-input"
type = "number"
step = "0.1"
value = { sunDirectionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSunDirectionDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onBlur = { applySunLightDirection }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySunLightDirection ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySunLightDirection ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySunLightDirection ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Dir Y < / span >
< input
data - testid = "world-sun-direction-y"
className = "text-input"
type = "number"
step = "0.1"
value = { sunDirectionDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSunDirectionDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onBlur = { applySunLightDirection }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySunLightDirection ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySunLightDirection ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySunLightDirection ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Dir Z < / span >
< input
data - testid = "world-sun-direction-z"
className = "text-input"
type = "number"
step = "0.1"
value = { sunDirectionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSunDirectionDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onBlur = { applySunLightDirection }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySunLightDirection ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySunLightDirection ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySunLightDirection ) }
/ >
< / label >
< / div >
< / div >
< / Panel >
2026-03-31 02:37:22 +02:00
< Panel title = "Inspector" >
< div className = "stat-card" >
< div className = "label" > Selection < / div >
2026-03-31 17:43:54 +02:00
< div className = "value" >
{ describeSelection ( editorState . selection , brushList , editorState . document . modelInstances , editorState . document . assets , editorState . document . entities ) }
< / div >
2026-03-31 02:37:22 +02:00
< / div >
2026-03-31 17:43:54 +02:00
{ selectedModelInstance !== null ? (
< >
< div className = "stat-card" >
< div className = "label" > Model Asset < / div >
< div className = "value" > { selectedModelAsset ? . sourceName ? ? "Missing Asset" } < / div >
< div className = "material-summary" >
2026-03-31 17:49:07 +02:00
{ selectedModelAssetRecord === null
? "This model instance references an asset that is missing from the registry."
: formatModelAssetSummary ( selectedModelAssetRecord ) }
2026-03-31 17:43:54 +02:00
< / div >
2026-03-31 17:49:07 +02:00
{ selectedModelAssetRecord === null ? null : (
< div className = "material-summary" > { formatModelBoundingBoxLabel ( selectedModelAssetRecord ) } < / div >
) }
2026-03-31 17:43:54 +02:00
< / 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 = "model-instance-position-x"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { modelPositionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelPositionDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "model-instance-position-y"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { modelPositionDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelPositionDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "model-instance-position-z"
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
value = { modelPositionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelPositionDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Rotation < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
data - testid = "model-instance-rotation-x"
className = "text-input"
type = "number"
step = "1"
value = { modelRotationDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelRotationDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "model-instance-rotation-y"
className = "text-input"
type = "number"
step = "1"
value = { modelRotationDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelRotationDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "model-instance-rotation-z"
className = "text-input"
type = "number"
step = "1"
value = { modelRotationDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelRotationDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Scale < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
data - testid = "model-instance-scale-x"
className = "text-input"
type = "number"
min = "0.001"
step = "0.1"
value = { modelScaleDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelScaleDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "model-instance-scale-y"
className = "text-input"
type = "number"
min = "0.001"
step = "0.1"
value = { modelScaleDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelScaleDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "model-instance-scale-z"
className = "text-input"
type = "number"
min = "0.001"
step = "0.1"
value = { modelScaleDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setModelScaleDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onBlur = { applyModelInstanceChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyModelInstanceChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyModelInstanceChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyModelInstanceChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" data-testid = "apply-model-instance" onClick = { applyModelInstanceChange } >
Apply Transform
< / button >
< / div >
< / >
) : selectedEntity !== null ? (
2026-03-31 03:09:04 +02:00
< >
< div className = "stat-card" >
< div className = "label" > Entity Kind < / div >
2026-03-31 05:57:54 +02:00
< div className = "value" > { getEntityKindLabel ( selectedEntity . kind ) } < / div >
2026-03-31 03:09:04 +02:00
< / div >
< div className = "form-section" >
< div className = "label" > Position < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
2026-03-31 05:57:54 +02:00
data - testid = { selectedEntity . kind === "playerStart" ? "player-start-position-x" : ` ${ selectedEntity . kind } -position-x ` }
2026-03-31 03:09:04 +02:00
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
2026-03-31 05:57:54 +02:00
value = { entityPositionDraft . x }
2026-03-31 03:09:04 +02:00
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
2026-03-31 05:57:54 +02:00
setEntityPositionDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
2026-03-31 03:09:04 +02:00
} }
2026-03-31 05:57:54 +02:00
onBlur = { ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
2026-03-31 03:09:04 +02:00
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
2026-03-31 05:57:54 +02:00
data - testid = { selectedEntity . kind === "playerStart" ? "player-start-position-y" : ` ${ selectedEntity . kind } -position-y ` }
2026-03-31 03:09:04 +02:00
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
2026-03-31 05:57:54 +02:00
value = { entityPositionDraft . y }
2026-03-31 03:09:04 +02:00
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
2026-03-31 05:57:54 +02:00
setEntityPositionDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
2026-03-31 03:09:04 +02:00
} }
2026-03-31 05:57:54 +02:00
onBlur = { ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
2026-03-31 03:09:04 +02:00
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
2026-03-31 05:57:54 +02:00
data - testid = { selectedEntity . kind === "playerStart" ? "player-start-position-z" : ` ${ selectedEntity . kind } -position-z ` }
2026-03-31 03:09:04 +02:00
className = "text-input"
type = "number"
step = { DEFAULT_GRID_SIZE }
2026-03-31 05:57:54 +02:00
value = { entityPositionDraft . z }
2026-03-31 03:09:04 +02:00
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
2026-03-31 05:57:54 +02:00
setEntityPositionDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
2026-03-31 03:09:04 +02:00
} }
2026-03-31 05:57:54 +02:00
onBlur = { ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp ( event , ( ) = > {
switch ( selectedEntity . kind ) {
case "playerStart" :
applyPlayerStartChange ( ) ;
break ;
case "soundEmitter" :
applySoundEmitterChange ( ) ;
break ;
case "triggerVolume" :
applyTriggerVolumeChange ( ) ;
break ;
case "teleportTarget" :
applyTeleportTargetChange ( ) ;
break ;
case "interactable" :
applyInteractableChange ( ) ;
break ;
}
} )
}
/ >
< / label >
< / div >
< / div >
{ selectedPlayerStart !== null ? (
< 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 ) }
2026-03-31 04:26:19 +02:00
onBlur = { applyPlayerStartChange }
2026-03-31 03:09:04 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
2026-03-31 03:09:04 +02:00
/ >
< / label >
< / div >
2026-03-31 05:57:54 +02:00
) : null }
{ selectedSoundEmitter !== null ? (
< >
< div className = "form-section" >
< div className = "label" > Audio Shape < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Radius < / span >
< input
data - testid = "sound-emitter-radius"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { soundEmitterRadiusDraft }
onChange = { ( event ) = > setSoundEmitterRadiusDraft ( event . currentTarget . value ) }
onBlur = { applySoundEmitterChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySoundEmitterChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySoundEmitterChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySoundEmitterChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Gain < / span >
< input
data - testid = "sound-emitter-gain"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { soundEmitterGainDraft }
onChange = { ( event ) = > setSoundEmitterGainDraft ( event . currentTarget . value ) }
onBlur = { applySoundEmitterChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySoundEmitterChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySoundEmitterChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySoundEmitterChange ) }
/ >
< / label >
< / div >
< / div >
2026-03-31 03:09:04 +02:00
2026-03-31 05:57:54 +02:00
< div className = "form-section" >
< div className = "label" > Playback < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Autoplay < / span >
< input
data - testid = "sound-emitter-autoplay"
type = "checkbox"
checked = { soundEmitterAutoplayDraft }
onChange = { ( event ) = > {
setSoundEmitterAutoplayDraft ( event . currentTarget . checked ) ;
scheduleDraftCommit ( applySoundEmitterChange ) ;
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Loop < / span >
< input
data - testid = "sound-emitter-loop"
type = "checkbox"
checked = { soundEmitterLoopDraft }
onChange = { ( event ) = > {
setSoundEmitterLoopDraft ( event . currentTarget . checked ) ;
scheduleDraftCommit ( applySoundEmitterChange ) ;
} }
/ >
< / label >
< / div >
< / div >
< / >
) : null }
{ selectedTriggerVolume !== null ? (
< >
< 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 = "trigger-volume-size-x"
className = "text-input"
type = "number"
min = { DEFAULT_GRID_SIZE }
step = { DEFAULT_GRID_SIZE }
value = { triggerVolumeSizeDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setTriggerVolumeSizeDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
onBlur = { applyTriggerVolumeChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyTriggerVolumeChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyTriggerVolumeChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyTriggerVolumeChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "trigger-volume-size-y"
className = "text-input"
type = "number"
min = { DEFAULT_GRID_SIZE }
step = { DEFAULT_GRID_SIZE }
value = { triggerVolumeSizeDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setTriggerVolumeSizeDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
onBlur = { applyTriggerVolumeChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyTriggerVolumeChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyTriggerVolumeChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyTriggerVolumeChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "trigger-volume-size-z"
className = "text-input"
type = "number"
min = { DEFAULT_GRID_SIZE }
step = { DEFAULT_GRID_SIZE }
value = { triggerVolumeSizeDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setTriggerVolumeSizeDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
onBlur = { applyTriggerVolumeChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyTriggerVolumeChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyTriggerVolumeChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyTriggerVolumeChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Trigger Sources < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > On Enter < / span >
< input
data - testid = "trigger-volume-enter"
type = "checkbox"
checked = { triggerOnEnterDraft }
onChange = { ( event ) = > {
setTriggerOnEnterDraft ( event . currentTarget . checked ) ;
scheduleDraftCommit ( applyTriggerVolumeChange ) ;
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > On Exit < / span >
< input
data - testid = "trigger-volume-exit"
type = "checkbox"
checked = { triggerOnExitDraft }
onChange = { ( event ) = > {
setTriggerOnExitDraft ( event . currentTarget . checked ) ;
scheduleDraftCommit ( applyTriggerVolumeChange ) ;
} }
/ >
< / label >
< / div >
< / div >
2026-03-31 06:18:46 +02:00
2026-03-31 06:49:10 +02:00
{ renderInteractionLinksSection (
selectedTriggerVolume ,
selectedTriggerVolumeLinks ,
"add-trigger-teleport-link" ,
"add-trigger-visibility-link"
) }
2026-03-31 05:57:54 +02:00
< / >
) : null }
{ selectedTeleportTarget !== null ? (
< div className = "form-section" >
< div className = "label" > Yaw < / div >
< label className = "form-field" >
< span className = "label" > Degrees < / span >
< input
data - testid = "teleport-target-yaw"
className = "text-input"
type = "number"
step = "1"
value = { teleportTargetYawDraft }
onChange = { ( event ) = > setTeleportTargetYawDraft ( event . currentTarget . value ) }
onBlur = { applyTeleportTargetChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyTeleportTargetChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyTeleportTargetChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyTeleportTargetChange ) }
/ >
< / label >
< / div >
) : null }
{ selectedInteractable !== null ? (
< >
< div className = "form-section" >
< div className = "label" > Interaction < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
2026-03-31 06:49:21 +02:00
< span className = "label" > Range < / span >
2026-03-31 05:57:54 +02:00
< input
data - testid = "interactable-radius"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { interactableRadiusDraft }
onChange = { ( event ) = > setInteractableRadiusDraft ( event . currentTarget . value ) }
onBlur = { applyInteractableChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyInteractableChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyInteractableChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyInteractableChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Enabled < / span >
< input
data - testid = "interactable-enabled"
type = "checkbox"
checked = { interactableEnabledDraft }
onChange = { ( event ) = > {
setInteractableEnabledDraft ( event . currentTarget . checked ) ;
scheduleDraftCommit ( applyInteractableChange ) ;
} }
/ >
< / label >
< / div >
2026-03-31 06:49:21 +02:00
< div className = "material-summary" > Range defines how close the player must be before the click prompt can activate . < / div >
2026-03-31 05:57:54 +02:00
< / div >
< div className = "form-section" >
< div className = "label" > Prompt < / div >
< label className = "form-field" >
< span className = "label" > Text < / span >
< input
data - testid = "interactable-prompt"
className = "text-input"
type = "text"
value = { interactablePromptDraft }
onChange = { ( event ) = > setInteractablePromptDraft ( event . currentTarget . value ) }
onBlur = { applyInteractableChange }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applyInteractableChange ( ) ;
}
} }
/ >
< / label >
< / div >
2026-03-31 06:49:21 +02:00
{ renderInteractionLinksSection (
selectedInteractable ,
selectedInteractableLinks ,
"add-interactable-teleport-link" ,
"add-interactable-visibility-link"
) }
2026-03-31 05:57:54 +02:00
< / >
) : null }
2026-03-31 03:09:04 +02:00
< / >
) : selectedBrush === null ? (
2026-03-31 05:57:54 +02:00
< div className = "outliner-empty" > Select a brush or entity to edit authored properties . < / div >
2026-03-31 02:37:22 +02:00
) : (
< >
< 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 } ) ) ;
} }
2026-03-31 04:26:19 +02:00
onBlur = { applyPositionChange }
2026-03-31 02:37:22 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPositionChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPositionChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPositionChange ) }
2026-03-31 02:37:22 +02:00
/ >
< / 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 } ) ) ;
} }
2026-03-31 04:26:19 +02:00
onBlur = { applyPositionChange }
2026-03-31 02:37:22 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPositionChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPositionChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPositionChange ) }
2026-03-31 02:37:22 +02:00
/ >
< / 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 } ) ) ;
} }
2026-03-31 04:26:19 +02:00
onBlur = { applyPositionChange }
2026-03-31 02:37:22 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPositionChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPositionChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPositionChange ) }
2026-03-31 02:37:22 +02:00
/ >
< / label >
< / div >
< / 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 } ) ) ;
} }
2026-03-31 04:26:19 +02:00
onBlur = { applySizeChange }
2026-03-31 02:37:22 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySizeChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySizeChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySizeChange ) }
2026-03-31 02:37:22 +02:00
/ >
< / 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 } ) ) ;
} }
2026-03-31 04:26:19 +02:00
onBlur = { applySizeChange }
2026-03-31 02:37:22 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySizeChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySizeChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySizeChange ) }
2026-03-31 02:37:22 +02:00
/ >
< / 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 } ) ) ;
} }
2026-03-31 04:26:19 +02:00
onBlur = { applySizeChange }
2026-03-31 02:37:22 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySizeChange ) }
2026-03-31 04:38:04 +02:00
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySizeChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySizeChange ) }
2026-03-31 02:37:22 +02:00
/ >
< / label >
< / div >
< / 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 ? (
2026-03-31 04:26:19 +02:00
< div className = "outliner-empty" > Select a face to edit its material and UV transform . < / div >
2026-03-31 02:37:22 +02:00
) : (
< >
< 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 >
2026-03-31 04:26:19 +02:00
< div className = "stat-card" >
< div className = "label" > UV Flags < / div >
< div className = "value" > Rotation { selectedFace . uv . rotationQuarterTurns * 90 } ° < / div >
< div className = "material-summary" >
U { selectedFace . uv . flipU ? "flipped" : "normal" } · V { selectedFace . uv . flipV ? "flipped" : "normal" }
< / div >
< / div >
2026-03-31 02:37:22 +02:00
< / >
) }
< / >
) }
< / Panel >
< / aside >
< / div >
< footer className = "status-bar" >
2026-03-31 04:53:26 +02:00
< div className = "status-bar__item" data-testid = "status-message" >
2026-03-31 02:37:22 +02:00
< span className = "status-bar__strong" > Status : < / span > { statusMessage }
< / div >
2026-03-31 04:53:26 +02:00
< div className = "status-bar__item" data-testid = "status-document" >
< span className = "status-bar__strong" > Document : < / span > { documentStatusLabel }
< / div >
< div className = "status-bar__item" data-testid = "status-run-preflight" >
< span className = "status-bar__strong" > Run : < / span > { runReadyLabel }
< / div >
< div className = "status-bar__item" data-testid = "status-warnings" >
< span className = "status-bar__strong" > Warnings : < / span > { warningDiagnostics . length }
< / div >
< div className = "status-bar__item" data-testid = "status-last-command" >
< span className = "status-bar__strong" > Last : < / span > { lastCommandLabel }
2026-03-31 02:37:22 +02:00
< / div >
< / footer >
< input
ref = { importInputRef }
className = "visually-hidden"
type = "file"
accept = ".json,application/json"
2026-03-31 17:44:25 +02:00
onChange = { handleImportJsonChange }
/ >
< input
ref = { importModelInputRef }
className = "visually-hidden"
type = "file"
2026-03-31 18:45:19 +02:00
multiple
2026-03-31 17:44:25 +02:00
accept = ".glb,.gltf,model/gltf-binary,model/gltf+json,application/octet-stream"
onChange = { handleImportModelChange }
2026-03-31 02:37:22 +02:00
/ >
< / div >
) ;
}