2026-03-31 04:37:44 +02:00
import {
useEffect ,
useRef ,
useState ,
type CSSProperties ,
type ChangeEvent ,
2026-04-02 23:23:18 +02:00
type MouseEvent as ReactMouseEvent ,
2026-03-31 04:37:44 +02:00
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-04-03 00:55:13 +02:00
import { createDeleteBoxBrushCommand } from "../commands/delete-box-brush-command" ;
import { createDeleteEntityCommand } from "../commands/delete-entity-command" ;
2026-04-02 19:40:11 +02:00
import { createImportAudioAssetCommand } from "../commands/import-audio-asset-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-04-03 00:55:13 +02:00
import { createDeleteModelInstanceCommand } from "../commands/delete-model-instance-command" ;
2026-04-03 02:15:11 +02:00
import { createCommitTransformSessionCommand } from "../commands/commit-transform-session-command" ;
2026-03-31 02:37:22 +02:00
import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command" ;
2026-04-04 19:29:52 +02:00
import { createRotateBoxBrushCommand } from "../commands/rotate-box-brush-command" ;
2026-03-31 02:37:22 +02:00
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-04-03 01:03:35 +02:00
import { createSetEntityNameCommand } from "../commands/set-entity-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-04-03 01:03:35 +02:00
import { createSetModelInstanceNameCommand } from "../commands/set-model-instance-name-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" ;
2026-04-03 02:15:11 +02:00
import {
createTransformSession ,
doesTransformSessionChangeTarget ,
getTransformOperationLabel ,
getTransformTargetLabel ,
resolveTransformTarget ,
supportsTransformAxisConstraint ,
supportsTransformOperation ,
type ActiveTransformSession ,
type TransformAxis ,
type TransformOperation ,
2026-04-03 02:16:54 +02:00
type TransformSessionSource
2026-04-03 02:15:11 +02:00
} from "../core/transform-session" ;
2026-03-31 02:37:22 +02:00
import type { Vec2 , Vec3 } from "../core/vector" ;
2026-03-31 17:41:12 +02:00
import {
2026-04-04 07:55:42 +02:00
MODEL_INSTANCE_COLLISION_MODES ,
2026-03-31 17:41:12 +02:00
areModelInstancesEqual ,
createModelInstance ,
2026-04-02 23:54:39 +02:00
createModelInstancePlacementPosition ,
2026-03-31 17:41:12 +02:00
DEFAULT_MODEL_INSTANCE_POSITION ,
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES ,
DEFAULT_MODEL_INSTANCE_SCALE ,
2026-04-03 01:03:35 +02:00
normalizeModelInstanceName ,
2026-04-04 07:55:42 +02:00
type ModelInstance ,
type ModelInstanceCollisionMode
2026-03-31 17:41:12 +02:00
} from "../assets/model-instances" ;
import {
getModelInstanceDisplayLabelById ,
getSortedModelInstanceDisplayLabels
} from "../assets/model-instance-labels" ;
2026-04-02 19:40:11 +02:00
import {
importAudioAssetFromFile ,
loadAudioAssetFromStorage ,
type LoadedAudioAsset
} from "../assets/audio-assets" ;
2026-03-31 17:41:12 +02:00
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" ;
2026-04-02 19:40:11 +02:00
import type { AudioAssetRecord , 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 ,
2026-04-04 19:29:52 +02:00
DEFAULT_BOX_BRUSH_ROTATION_DEGREES ,
2026-03-31 02:40:32 +02:00
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-04-02 20:52:43 +02:00
import {
ADVANCED_RENDERING_SHADOW_MAP_SIZES ,
ADVANCED_RENDERING_SHADOW_TYPES ,
ADVANCED_RENDERING_TONE_MAPPING_MODES ,
2026-04-02 20:58:26 +02:00
areWorldSettingsEqual ,
changeWorldBackgroundMode ,
2026-04-02 20:52:43 +02:00
cloneWorldSettings ,
2026-04-02 20:58:26 +02:00
type WorldBackgroundMode ,
2026-04-02 20:52:43 +02:00
type AdvancedRenderingSettings ,
type AdvancedRenderingShadowMapSize ,
type AdvancedRenderingShadowType ,
2026-04-02 20:58:26 +02:00
type AdvancedRenderingToneMappingMode ,
type WorldSettings
2026-04-02 20:52:43 +02:00
} from "../document/world-settings" ;
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-04-04 19:29:52 +02:00
import { DEFAULT_GRID_SIZE } from "../geometry/grid-snapping" ;
2026-03-31 02:37:22 +02:00
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 ,
2026-04-04 15:55:31 +02:00
DEFAULT_PLAYER_START_BOX_SIZE ,
DEFAULT_PLAYER_START_CAPSULE_HEIGHT ,
DEFAULT_PLAYER_START_CAPSULE_RADIUS ,
DEFAULT_PLAYER_START_EYE_HEIGHT ,
2026-04-02 19:40:11 +02:00
DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ,
DEFAULT_SOUND_EMITTER_VOLUME ,
DEFAULT_SOUND_EMITTER_REF_DISTANCE ,
DEFAULT_SOUND_EMITTER_MAX_DISTANCE ,
2026-03-31 05:54:29 +02:00
DEFAULT_TELEPORT_TARGET_YAW_DEGREES ,
2026-04-04 15:55:31 +02:00
PLAYER_START_COLLIDER_MODES ,
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 ,
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 ,
2026-04-03 01:03:35 +02:00
normalizeEntityName ,
2026-03-31 03:09:04 +02:00
normalizeYawDegrees ,
2026-03-31 05:54:29 +02:00
normalizeInteractablePrompt ,
2026-04-04 15:55:31 +02:00
type PlayerStartColliderMode ,
2026-03-31 05:54:29 +02:00
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 ,
2026-04-01 00:16:35 +02:00
createPlayAnimationInteractionLink ,
2026-04-02 19:42:29 +02:00
createPlaySoundInteractionLink ,
2026-04-01 00:16:35 +02:00
createStopAnimationInteractionLink ,
2026-04-02 19:42:29 +02:00
createStopSoundInteractionLink ,
2026-03-31 06:17:43 +02:00
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-04-02 23:23:18 +02:00
import { HierarchicalMenu , type HierarchicalMenuItem , type HierarchicalMenuPosition } from "../shared-ui/HierarchicalMenu" ;
2026-03-31 05:11:32 +02:00
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style" ;
2026-04-02 22:32:49 +02:00
import { ViewportPanel } from "../viewport-three/ViewportPanel" ;
2026-04-03 00:22:38 +02:00
import type { CreationViewportToolPreview } from "../viewport-three/viewport-transient-state" ;
2026-04-02 22:33:00 +02:00
import { getViewportViewModeLabel , type ViewportViewMode } from "../viewport-three/viewport-view-modes" ;
2026-04-02 22:12:30 +02:00
import {
2026-04-02 22:34:07 +02:00
VIEWPORT_LAYOUT_MODES ,
2026-04-02 22:33:00 +02:00
VIEWPORT_PANEL_IDS ,
2026-04-02 22:32:49 +02:00
getViewportDisplayModeLabel ,
getViewportLayoutModeLabel ,
2026-04-03 02:15:11 +02:00
getViewportPanelLabel ,
2026-04-02 22:32:49 +02:00
type ViewportDisplayMode ,
type ViewportLayoutMode ,
2026-04-03 01:26:21 +02:00
type ViewportPanelId ,
type ViewportQuadSplit
2026-04-02 22:32:49 +02:00
} from "../viewport-three/viewport-layout" ;
2026-03-31 02:37:22 +02:00
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"
} ;
2026-04-04 07:55:42 +02:00
function getModelInstanceCollisionModeDescription ( mode : ModelInstanceCollisionMode ) : string {
switch ( mode ) {
case "none" :
return "No generated collider is built for this model instance." ;
case "terrain" :
return "Builds a Rapier heightfield from a regular-grid terrain mesh. Unsupported terrain sources fail with build diagnostics." ;
case "static" :
return "Builds a fixed Rapier triangle-mesh collider from the imported model geometry." ;
case "dynamic" :
return "Builds convex compound pieces for Rapier queries. In this slice they participate as fixed world collision, not fully simulated rigid bodies." ;
case "simple" :
return "Builds one cheap oriented box from the imported model bounds." ;
}
}
2026-04-04 15:56:01 +02:00
function getPlayerStartColliderModeDescription ( mode : PlayerStartColliderMode ) : string {
switch ( mode ) {
case "capsule" :
return "Uses a capsule player collider for standard grounded first-person traversal." ;
case "box" :
return "Uses an axis-aligned box player collider for sharper footprint bounds." ;
case "none" :
return "Disables player collision detection. First-person traversal continues without world clipping." ;
}
}
2026-03-31 02:37:22 +02:00
const STARTER_MATERIAL_ORDER = new Map ( STARTER_MATERIAL_LIBRARY . map ( ( material , index ) = > [ material . id , index ] ) ) ;
2026-04-03 01:26:21 +02:00
const MIN_VIEWPORT_QUAD_SPLIT = 0.2 ;
const MAX_VIEWPORT_QUAD_SPLIT = 0.8 ;
type ViewportQuadResizeMode = "vertical" | "horizontal" | "center" ;
2026-04-04 19:30:03 +02:00
type NumberInputStep = number | "any" ;
2026-03-31 03:44:17 +02:00
function formatVec3 ( vector : Vec3 ) : string {
return ` ${ vector . x } , ${ vector . y } , ${ vector . z } ` ;
}
2026-04-04 19:30:03 +02:00
function resolveOptionalPositiveNumber ( value : string , fallback : number ) : number {
const parsedValue = Number ( value ) ;
return Number . isFinite ( parsedValue ) && parsedValue > 0 ? parsedValue : fallback ;
}
function getWhiteboxInputStep ( enabled : boolean , step : number ) : NumberInputStep {
return enabled ? step : "any" ;
}
2026-03-31 03:44:17 +02:00
function formatDiagnosticCount ( count : number , label : string ) : string {
return ` ${ count } ${ label } ${ count === 1 ? "" : "s" } ` ;
}
2026-04-03 01:26:21 +02:00
function clampViewportQuadSplitValue ( value : number ) : number {
return Math . min ( MAX_VIEWPORT_QUAD_SPLIT , Math . max ( MIN_VIEWPORT_QUAD_SPLIT , value ) ) ;
}
function createViewportQuadPanelsStyle ( viewportQuadSplit : ViewportQuadSplit ) : CSSProperties {
return {
"--viewport-quad-split-x" : String ( viewportQuadSplit . x ) ,
"--viewport-quad-split-y" : String ( viewportQuadSplit . y )
} as CSSProperties ;
}
function getViewportQuadResizeCursor ( resizeMode : ViewportQuadResizeMode ) : string {
switch ( resizeMode ) {
case "vertical" :
return "col-resize" ;
case "horizontal" :
return "row-resize" ;
case "center" :
return "move" ;
}
}
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-04-02 20:52:50 +02:00
function readFiniteNumberDraft ( source : string , label : string ) : number {
const value = Number ( source ) ;
if ( ! Number . isFinite ( value ) ) {
throw new Error ( ` ${ label } must be a finite number. ` ) ;
}
return value ;
}
function readPositiveIntegerDraft ( source : string , label : string ) : number {
const value = Number ( source ) ;
if ( ! Number . isFinite ( value ) || value <= 0 || ! Number . isInteger ( value ) ) {
throw new Error ( ` ${ label } must be a positive integer. ` ) ;
}
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 ;
}
2026-04-04 19:30:07 +02:00
function maybeSnapVec3 ( vector : Vec3 , enabled : boolean , step : number ) : Vec3 {
if ( ! enabled ) {
return vector ;
}
return {
x : Math.round ( vector . x / step ) * step ,
y : Math.round ( vector . y / step ) * step ,
z : Math.round ( vector . z / step ) * step
} ;
}
function maybeSnapPositiveSize ( size : Vec3 , enabled : boolean , step : number ) : Vec3 {
const clampComponent = ( value : number ) = > Math . max ( 0.01 , Math . abs ( value ) ) ;
if ( ! enabled ) {
return {
x : clampComponent ( size . x ) ,
y : clampComponent ( size . y ) ,
z : clampComponent ( size . z )
} ;
}
return {
x : Math.max ( 0.01 , Math . round ( Math . abs ( size . x ) / step ) * step ) ,
y : Math.max ( 0.01 , Math . round ( Math . abs ( size . y ) / step ) * step ) ,
z : Math.max ( 0.01 , Math . round ( Math . abs ( size . z ) / step ) * step )
} ;
}
2026-03-31 02:37:22 +02:00
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-04-02 19:40:11 +02:00
function isAudioAsset ( asset : ProjectAssetRecord ) : asset is AudioAssetRecord {
return asset . kind === "audio" ;
}
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-04-02 19:40:11 +02:00
function formatAudioAssetSummary ( asset : AudioAssetRecord ) : string {
const details = [
asset . metadata . durationSeconds === null ? "duration unavailable" : ` ${ asset . metadata . durationSeconds . toFixed ( 2 ) } s ` ,
asset . metadata . channelCount === null ? "channels unavailable" : ` ${ asset . metadata . channelCount } channel ${ asset . metadata . channelCount === 1 ? "" : "s" } ` ,
asset . metadata . sampleRateHz === null ? "sample rate unavailable" : ` ${ asset . metadata . sampleRateHz } Hz ` ,
formatByteLength ( asset . byteLength )
] ;
return details . join ( " | " ) ;
}
2026-04-02 23:23:21 +02:00
function formatAssetHoverStatus ( asset : ProjectAssetRecord ) : string {
const details = [
` ${ getProjectAssetKindLabel ( asset . kind ) } asset ` ,
asset . mimeType ,
asset . kind === "model"
? formatModelAssetSummary ( asset )
: asset . kind === "image"
? formatImageAssetSummary ( asset )
: formatAudioAssetSummary ( asset ) ,
` Storage key: ${ asset . storageKey } `
] ;
if ( asset . kind === "model" ) {
details . push ( formatModelBoundingBoxLabel ( asset ) ) ;
}
if ( asset . metadata . warnings . length > 0 ) {
details . push ( ` Warnings: ${ asset . metadata . warnings . join ( " | " ) } ` ) ;
}
return ` ${ asset . sourceName } | ${ details . join ( " | " ) } ` ;
}
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-04-02 19:40:23 +02:00
return ` ${ selection . ids . length } entity selected ( ${ getEntityDisplayLabelById ( selection . ids [ 0 ] , entities , assets ) } ) ` ;
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" ;
2026-04-01 00:16:40 +02:00
case "playAnimation" :
return "Play Animation" ;
case "stopAnimation" :
return "Stop Animation" ;
2026-04-02 19:40:11 +02:00
case "playSound" :
return "Play Sound" ;
case "stopSound" :
return "Stop Sound" ;
2026-03-31 06:17:51 +02:00
}
}
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" ) ;
}
2026-04-02 19:48:04 +02:00
function isSoundEmitterEntity ( entity : EntityInstance | null ) : entity is Extract < EntityInstance , { kind : "soundEmitter" } > {
return entity !== null && entity . kind === "soundEmitter" ;
}
2026-03-31 06:47:21 +02:00
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-04-03 02:15:11 +02:00
function getTransformOperationPastTense ( operation : TransformOperation ) : string {
switch ( operation ) {
case "translate" :
return "Moved" ;
case "rotate" :
return "Rotated" ;
case "scale" :
return "Scaled" ;
}
}
function getTransformOperationShortcut ( operation : TransformOperation ) : string {
switch ( operation ) {
case "translate" :
return "G" ;
case "rotate" :
return "R" ;
case "scale" :
return "S" ;
}
}
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-04-02 20:53:37 +02:00
function formatAdvancedRenderingShadowTypeLabel ( type : AdvancedRenderingShadowType ) : string {
switch ( type ) {
case "basic" :
return "Basic" ;
case "pcf" :
return "PCF" ;
case "pcfSoft" :
return "PCF Soft" ;
}
}
function formatAdvancedRenderingToneMappingLabel ( mode : AdvancedRenderingToneMappingMode ) : string {
switch ( mode ) {
case "none" :
return "None" ;
case "linear" :
return "Linear" ;
case "reinhard" :
return "Reinhard" ;
case "cineon" :
return "Cineon" ;
case "acesFilmic" :
return "ACES Filmic" ;
}
}
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-04-02 22:33:18 +02:00
const layoutMode = editorState . viewportLayoutMode ;
const activePanelId = editorState . activeViewportPanelId ;
2026-04-02 23:01:41 +02:00
const viewportToolPreview = editorState . viewportTransientState . toolPreview ;
2026-04-03 02:15:11 +02:00
const transformSession = editorState . viewportTransientState . transformSession ;
2026-03-31 05:54:57 +02:00
const entityList = getEntityInstances ( editorState . document . entities ) ;
2026-04-02 19:40:23 +02:00
const entityDisplayList = getSortedEntityDisplayLabels ( editorState . document . entities , editorState . document . assets ) ;
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 ;
2026-04-02 19:48:04 +02:00
const selectedSoundEmitter = isSoundEmitterEntity ( selectedEntity ) ? selectedEntity : null ;
2026-04-02 19:40:43 +02:00
const selectedSoundEmitterAsset =
2026-04-02 19:45:43 +02:00
selectedSoundEmitter === null
? null
: selectedSoundEmitter . audioAssetId === null
? null
: editorState . document . assets [ selectedSoundEmitter . audioAssetId ] ? ? null ;
2026-04-02 19:40:43 +02:00
const selectedSoundEmitterAudioAssetRecord =
selectedSoundEmitterAsset !== null && selectedSoundEmitterAsset . kind === "audio" ? selectedSoundEmitterAsset : null ;
2026-03-31 05:54:57 +02:00
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 ) ;
2026-04-02 19:40:32 +02:00
const audioAssetList = projectAssetList . filter ( isAudioAsset ) ;
2026-03-31 20:09:36 +02:00
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" ) ;
2026-04-02 19:48:15 +02:00
const soundEmitterOptions = entityDisplayList . filter ( ( { entity } ) = > entity . kind === "soundEmitter" ) as Array < {
entity : Extract < EntityInstance , { kind : "soundEmitter" } > ;
label : string ;
} > ;
2026-04-02 19:42:09 +02:00
const playableSoundEmitterOptions = soundEmitterOptions . filter ( ( { entity } ) = > {
if ( entity . audioAssetId === null ) {
return false ;
}
return editorState . document . assets [ entity . audioAssetId ] ? . kind === "audio" ;
} ) ;
2026-03-31 06:17:55 +02:00
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-04-03 01:03:39 +02:00
const [ entityNameDraft , setEntityNameDraft ] = useState ( "" ) ;
const [ modelInstanceNameDraft , setModelInstanceNameDraft ] = 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-04-04 15:56:13 +02:00
const [ playerStartColliderModeDraft , setPlayerStartColliderModeDraft ] = useState < PlayerStartColliderMode > ( "capsule" ) ;
const [ playerStartEyeHeightDraft , setPlayerStartEyeHeightDraft ] = useState ( String ( DEFAULT_PLAYER_START_EYE_HEIGHT ) ) ;
const [ playerStartCapsuleRadiusDraft , setPlayerStartCapsuleRadiusDraft ] = useState ( String ( DEFAULT_PLAYER_START_CAPSULE_RADIUS ) ) ;
const [ playerStartCapsuleHeightDraft , setPlayerStartCapsuleHeightDraft ] = useState ( String ( DEFAULT_PLAYER_START_CAPSULE_HEIGHT ) ) ;
const [ playerStartBoxSizeDraft , setPlayerStartBoxSizeDraft ] = useState ( createVec3Draft ( DEFAULT_PLAYER_START_BOX_SIZE ) ) ;
2026-04-02 19:40:23 +02:00
const [ soundEmitterAudioAssetIdDraft , setSoundEmitterAudioAssetIdDraft ] = useState ( DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ? ? "" ) ;
const [ soundEmitterVolumeDraft , setSoundEmitterVolumeDraft ] = useState ( String ( DEFAULT_SOUND_EMITTER_VOLUME ) ) ;
const [ soundEmitterRefDistanceDraft , setSoundEmitterRefDistanceDraft ] = useState ( String ( DEFAULT_SOUND_EMITTER_REF_DISTANCE ) ) ;
const [ soundEmitterMaxDistanceDraft , setSoundEmitterMaxDistanceDraft ] = useState ( String ( DEFAULT_SOUND_EMITTER_MAX_DISTANCE ) ) ;
2026-03-31 05:54:57 +02:00
const [ soundEmitterAutoplayDraft , setSoundEmitterAutoplayDraft ] = useState ( false ) ;
const [ soundEmitterLoopDraft , setSoundEmitterLoopDraft ] = useState ( false ) ;
const [ triggerVolumeSizeDraft , setTriggerVolumeSizeDraft ] = useState ( createVec3Draft ( DEFAULT_TRIGGER_VOLUME_SIZE ) ) ;
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 23:15:09 +02:00
const [ backgroundEnvironmentIntensityDraft , setBackgroundEnvironmentIntensityDraft ] = useState (
editorState . document . world . background . mode === "image" ? String ( editorState . document . world . background . environmentIntensity ) : "0.5"
) ;
2026-04-02 20:53:26 +02:00
const [ advancedRenderingShadowBiasDraft , setAdvancedRenderingShadowBiasDraft ] = useState (
String ( editorState . document . world . advancedRendering . shadows . bias )
) ;
const [ advancedRenderingAmbientOcclusionIntensityDraft , setAdvancedRenderingAmbientOcclusionIntensityDraft ] = useState (
String ( editorState . document . world . advancedRendering . ambientOcclusion . intensity )
) ;
const [ advancedRenderingAmbientOcclusionRadiusDraft , setAdvancedRenderingAmbientOcclusionRadiusDraft ] = useState (
String ( editorState . document . world . advancedRendering . ambientOcclusion . radius )
) ;
const [ advancedRenderingAmbientOcclusionSamplesDraft , setAdvancedRenderingAmbientOcclusionSamplesDraft ] = useState (
String ( editorState . document . world . advancedRendering . ambientOcclusion . samples )
) ;
const [ advancedRenderingBloomIntensityDraft , setAdvancedRenderingBloomIntensityDraft ] = useState (
String ( editorState . document . world . advancedRendering . bloom . intensity )
) ;
const [ advancedRenderingBloomThresholdDraft , setAdvancedRenderingBloomThresholdDraft ] = useState (
String ( editorState . document . world . advancedRendering . bloom . threshold )
) ;
const [ advancedRenderingBloomRadiusDraft , setAdvancedRenderingBloomRadiusDraft ] = useState (
String ( editorState . document . world . advancedRendering . bloom . radius )
) ;
const [ advancedRenderingToneMappingExposureDraft , setAdvancedRenderingToneMappingExposureDraft ] = useState (
String ( editorState . document . world . advancedRendering . toneMapping . exposure )
) ;
const [ advancedRenderingDepthOfFieldFocusDistanceDraft , setAdvancedRenderingDepthOfFieldFocusDistanceDraft ] = useState (
String ( editorState . document . world . advancedRendering . depthOfField . focusDistance )
) ;
const [ advancedRenderingDepthOfFieldFocalLengthDraft , setAdvancedRenderingDepthOfFieldFocalLengthDraft ] = useState (
String ( editorState . document . world . advancedRendering . depthOfField . focalLength )
) ;
const [ advancedRenderingDepthOfFieldBokehScaleDraft , setAdvancedRenderingDepthOfFieldBokehScaleDraft ] = useState (
String ( editorState . document . world . advancedRendering . depthOfField . bokehScale )
) ;
const [ statusMessage , setStatusMessage ] = useState ( initialStatusMessage ? ? "Slice 3.5 advanced rendering ready." ) ;
2026-03-31 17:41:42 +02:00
const [ assetStatusMessage , setAssetStatusMessage ] = useState < string | null > ( null ) ;
2026-04-02 23:23:50 +02:00
const [ hoveredAssetId , setHoveredAssetId ] = useState < string | null > ( null ) ;
2026-04-03 02:35:30 +02:00
const [ hoveredViewportPanelId , setHoveredViewportPanelId ] = useState < ViewportPanelId | null > ( null ) ;
2026-04-02 23:23:50 +02:00
const [ addMenuPosition , setAddMenuPosition ] = useState < HierarchicalMenuPosition | 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-04-02 19:42:39 +02:00
const [ loadedAudioAssets , setLoadedAudioAssets ] = useState < Record < string , LoadedAudioAsset > > ( { } ) ;
2026-04-02 22:33:18 +02:00
const [ focusRequest , setFocusRequest ] = useState < { id : number ; selection : EditorSelection ; panelId : ViewportPanelId } > ( {
2026-03-31 04:25:02 +02:00
id : 0 ,
2026-04-02 22:33:18 +02:00
panelId : "topLeft" ,
2026-03-31 04:25:02 +02:00
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-04-02 19:43:13 +02:00
const importAudioInputRef = useRef < HTMLInputElement | null > ( null ) ;
2026-04-03 01:26:21 +02:00
const viewportPanelsRef = useRef < HTMLDivElement | 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-04-02 19:42:39 +02:00
const loadedAudioAssetsRef = useRef < Record < string , LoadedAudioAsset > > ( { } ) ;
2026-04-03 01:26:21 +02:00
const viewportQuadSplitRef = useRef ( editorState . viewportQuadSplit ) ;
2026-04-02 23:23:50 +02:00
const lastPointerPositionRef = useRef < HierarchicalMenuPosition > ( {
x : Math.round ( window . innerWidth * 0.5 ) ,
y : Math.round ( window . innerHeight * 0.5 )
} ) ;
2026-04-03 01:26:21 +02:00
const [ viewportQuadResizeMode , setViewportQuadResizeMode ] = useState < ViewportQuadResizeMode | null > ( null ) ;
2026-03-31 03:44:17 +02:00
const documentValidation = validateSceneDocument ( editorState . document ) ;
2026-04-04 07:55:42 +02:00
const runValidation = validateRuntimeSceneBuild ( editorState . document , {
navigationMode : preferredNavigationMode ,
loadedModelAssets
} ) ;
2026-03-31 03:44:17 +02:00
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-04-02 20:53:26 +02:00
const advancedRendering = editorState . document . world . advancedRendering ;
2026-04-02 23:23:50 +02:00
const hoveredAsset = hoveredAssetId === null ? null : editorState . document . assets [ hoveredAssetId ] ? ? null ;
const hoveredAssetStatusMessage = hoveredAsset === null ? null : formatAssetHoverStatus ( hoveredAsset ) ;
2026-04-03 02:17:41 +02:00
const selectedTransformTarget = resolveTransformTarget ( editorState . document , editorState . selection ) . target ;
2026-04-03 02:15:38 +02:00
const canTranslateSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation ( selectedTransformTarget , "translate" ) ;
const canRotateSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation ( selectedTransformTarget , "rotate" ) ;
const canScaleSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation ( selectedTransformTarget , "scale" ) ;
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-04-03 01:03:39 +02:00
useEffect ( ( ) = > {
setEntityNameDraft ( selectedEntity ? . name ? ? "" ) ;
} , [ selectedEntity ] ) ;
useEffect ( ( ) = > {
setModelInstanceNameDraft ( selectedModelInstance ? . name ? ? "" ) ;
} , [ selectedModelInstance ] ) ;
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-04-04 15:56:20 +02:00
setPlayerStartColliderModeDraft ( "capsule" ) ;
setPlayerStartEyeHeightDraft ( String ( DEFAULT_PLAYER_START_EYE_HEIGHT ) ) ;
setPlayerStartCapsuleRadiusDraft ( String ( DEFAULT_PLAYER_START_CAPSULE_RADIUS ) ) ;
setPlayerStartCapsuleHeightDraft ( String ( DEFAULT_PLAYER_START_CAPSULE_HEIGHT ) ) ;
setPlayerStartBoxSizeDraft ( createVec3Draft ( DEFAULT_PLAYER_START_BOX_SIZE ) ) ;
2026-04-02 19:40:32 +02:00
setSoundEmitterAudioAssetIdDraft ( DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ? ? "" ) ;
setSoundEmitterVolumeDraft ( String ( DEFAULT_SOUND_EMITTER_VOLUME ) ) ;
setSoundEmitterRefDistanceDraft ( String ( DEFAULT_SOUND_EMITTER_REF_DISTANCE ) ) ;
setSoundEmitterMaxDistanceDraft ( String ( DEFAULT_SOUND_EMITTER_MAX_DISTANCE ) ) ;
2026-03-31 05:55:08 +02:00
setSoundEmitterAutoplayDraft ( false ) ;
setSoundEmitterLoopDraft ( false ) ;
setTriggerVolumeSizeDraft ( createVec3Draft ( DEFAULT_TRIGGER_VOLUME_SIZE ) ) ;
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 ) ) ;
2026-04-04 15:56:20 +02:00
setPlayerStartColliderModeDraft ( selectedEntity . collider . mode ) ;
setPlayerStartEyeHeightDraft ( String ( selectedEntity . collider . eyeHeight ) ) ;
setPlayerStartCapsuleRadiusDraft ( String ( selectedEntity . collider . capsuleRadius ) ) ;
setPlayerStartCapsuleHeightDraft ( String ( selectedEntity . collider . capsuleHeight ) ) ;
setPlayerStartBoxSizeDraft ( createVec3Draft ( selectedEntity . collider . boxSize ) ) ;
2026-03-31 05:55:08 +02:00
break ;
case "soundEmitter" :
2026-04-02 19:40:32 +02:00
setSoundEmitterAudioAssetIdDraft ( selectedEntity . audioAssetId ? ? "" ) ;
setSoundEmitterVolumeDraft ( String ( selectedEntity . volume ) ) ;
setSoundEmitterRefDistanceDraft ( String ( selectedEntity . refDistance ) ) ;
setSoundEmitterMaxDistanceDraft ( String ( selectedEntity . maxDistance ) ) ;
2026-03-31 05:55:08 +02:00
setSoundEmitterAutoplayDraft ( selectedEntity . autoplay ) ;
setSoundEmitterLoopDraft ( selectedEntity . loop ) ;
break ;
case "triggerVolume" :
setTriggerVolumeSizeDraft ( createVec3Draft ( selectedEntity . size ) ) ;
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 ] ) ;
2026-03-31 23:15:17 +02:00
useEffect ( ( ) = > {
if ( editorState . document . world . background . mode === "image" ) {
setBackgroundEnvironmentIntensityDraft ( String ( editorState . document . world . background . environmentIntensity ) ) ;
}
} , [ editorState . document . world . background ] ) ;
2026-03-31 05:11:52 +02:00
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-04-02 20:53:26 +02:00
useEffect ( ( ) = > {
const advancedRendering = editorState . document . world . advancedRendering ;
setAdvancedRenderingShadowBiasDraft ( String ( advancedRendering . shadows . bias ) ) ;
setAdvancedRenderingAmbientOcclusionIntensityDraft ( String ( advancedRendering . ambientOcclusion . intensity ) ) ;
setAdvancedRenderingAmbientOcclusionRadiusDraft ( String ( advancedRendering . ambientOcclusion . radius ) ) ;
setAdvancedRenderingAmbientOcclusionSamplesDraft ( String ( advancedRendering . ambientOcclusion . samples ) ) ;
setAdvancedRenderingBloomIntensityDraft ( String ( advancedRendering . bloom . intensity ) ) ;
setAdvancedRenderingBloomThresholdDraft ( String ( advancedRendering . bloom . threshold ) ) ;
setAdvancedRenderingBloomRadiusDraft ( String ( advancedRendering . bloom . radius ) ) ;
setAdvancedRenderingToneMappingExposureDraft ( String ( advancedRendering . toneMapping . exposure ) ) ;
setAdvancedRenderingDepthOfFieldFocusDistanceDraft ( String ( advancedRendering . depthOfField . focusDistance ) ) ;
setAdvancedRenderingDepthOfFieldFocalLengthDraft ( String ( advancedRendering . depthOfField . focalLength ) ) ;
setAdvancedRenderingDepthOfFieldBokehScaleDraft ( String ( advancedRendering . depthOfField . bokehScale ) ) ;
} , [ editorState . document . world . advancedRendering ] ) ;
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 ] ) ;
2026-04-02 19:42:47 +02:00
useEffect ( ( ) = > {
loadedAudioAssetsRef . current = loadedAudioAssets ;
} , [ loadedAudioAssets ] ) ;
2026-04-03 01:26:21 +02:00
useEffect ( ( ) = > {
viewportQuadSplitRef . current = editorState . viewportQuadSplit ;
} , [ editorState . viewportQuadSplit ] ) ;
2026-03-31 17:41:56 +02:00
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 ;
2026-04-02 19:42:56 +02:00
const previousLoadedAudioAssets = loadedAudioAssetsRef . current ;
2026-03-31 20:13:17 +02:00
const previousLoadedModelAssetIds = new Set ( Object . keys ( previousLoadedModelAssets ) ) ;
const previousLoadedImageAssetIds = new Set ( Object . keys ( previousLoadedImageAssets ) ) ;
2026-04-02 19:42:56 +02:00
const previousLoadedAudioAssetIds = new Set ( Object . keys ( previousLoadedAudioAssets ) ) ;
2026-03-31 20:13:17 +02:00
const nextLoadedModelAssets : Record < string , LoadedModelAsset > = { } ;
const nextLoadedImageAssets : Record < string , LoadedImageAsset > = { } ;
2026-04-02 19:42:56 +02:00
const nextLoadedAudioAssets : Record < string , LoadedAudioAsset > = { } ;
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-04-02 19:42:56 +02:00
loadedAudioAssetsRef . current = { } ;
2026-03-31 17:41:56 +02:00
setLoadedModelAssets ( { } ) ;
2026-03-31 20:13:17 +02:00
setLoadedImageAssets ( { } ) ;
2026-04-02 19:42:56 +02:00
setLoadedAudioAssets ( { } ) ;
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-04-02 19:42:56 +02:00
continue ;
}
if ( isAudioAsset ( asset ) ) {
previousLoadedAudioAssetIds . delete ( asset . id ) ;
const cachedLoadedAsset = previousLoadedAudioAssets [ asset . id ] ;
if ( cachedLoadedAsset !== undefined && cachedLoadedAsset . storageKey === asset . storageKey ) {
nextLoadedAudioAssets [ asset . id ] = cachedLoadedAsset ;
continue ;
}
try {
nextLoadedAudioAssets [ asset . id ] = await loadAudioAssetFromStorage ( projectAssetStorage , asset ) ;
} catch ( error ) {
syncErrorMessages . push ( ` Audio 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 ;
2026-04-02 19:42:56 +02:00
loadedAudioAssetsRef . current = nextLoadedAudioAssets ;
2026-03-31 20:13:17 +02:00
setLoadedModelAssets ( nextLoadedModelAssets ) ;
setLoadedImageAssets ( nextLoadedImageAssets ) ;
2026-04-02 19:42:56 +02:00
setLoadedAudioAssets ( nextLoadedAudioAssets ) ;
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-04-02 23:23:50 +02:00
const handleWindowPointerMove = ( event : globalThis.PointerEvent ) = > {
lastPointerPositionRef . current = {
x : event.clientX ,
y : event.clientY
} ;
2026-04-03 02:37:22 +02:00
const hoveredViewportPanelElement =
event . target instanceof Element ? event . target . closest < HTMLElement > ( "[data-viewport-panel-id]" ) : null ;
const hoveredPanelId = hoveredViewportPanelElement ? . dataset . viewportPanelId ;
setHoveredViewportPanelId (
hoveredPanelId === "topLeft" || hoveredPanelId === "topRight" || hoveredPanelId === "bottomLeft" || hoveredPanelId === "bottomRight"
? hoveredPanelId
: null
) ;
2026-04-02 23:23:50 +02:00
} ;
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-04-03 01:13:22 +02:00
const hasPrimaryModifier = ( event . metaKey || event . ctrlKey ) && ! event . altKey ;
2026-04-03 01:15:06 +02:00
if ( hasPrimaryModifier && event . code === "KeyR" && ! event . shiftKey ) {
event . preventDefault ( ) ;
handleEnterPlayMode ( ) ;
return ;
}
2026-04-03 01:13:22 +02:00
if ( hasPrimaryModifier && event . code === "KeyS" && ! event . shiftKey ) {
event . preventDefault ( ) ;
handleSaveDraft ( ) ;
return ;
}
if ( hasPrimaryModifier && event . code === "KeyZ" ) {
event . preventDefault ( ) ;
if ( event . shiftKey ) {
if ( store . redo ( ) ) {
setStatusMessage ( "Redid the last action." ) ;
} else {
setStatusMessage ( "Nothing to redo." ) ;
}
} else if ( store . undo ( ) ) {
setStatusMessage ( "Undid the last action." ) ;
} else {
setStatusMessage ( "Nothing to undo." ) ;
}
return ;
}
if ( hasPrimaryModifier && event . code === "KeyY" ) {
event . preventDefault ( ) ;
if ( store . redo ( ) ) {
setStatusMessage ( "Redid the last action." ) ;
} else {
setStatusMessage ( "Nothing to redo." ) ;
}
return ;
}
2026-04-02 23:23:50 +02:00
if ( event . key === "Escape" && addMenuPosition !== null ) {
event . preventDefault ( ) ;
setAddMenuPosition ( null ) ;
return ;
}
2026-04-03 02:15:11 +02:00
if ( transformSession . kind === "active" ) {
if ( event . key === "Escape" ) {
event . preventDefault ( ) ;
cancelTransformSession ( ) ;
return ;
}
if ( event . key === "Enter" ) {
event . preventDefault ( ) ;
commitTransformSession ( transformSession ) ;
return ;
}
if ( ! event . altKey && ! event . ctrlKey && ! event . metaKey ) {
if ( event . code === "KeyX" ) {
event . preventDefault ( ) ;
applyTransformAxisConstraint ( "x" ) ;
return ;
}
if ( event . code === "KeyY" ) {
event . preventDefault ( ) ;
applyTransformAxisConstraint ( "y" ) ;
return ;
}
if ( event . code === "KeyZ" ) {
event . preventDefault ( ) ;
applyTransformAxisConstraint ( "z" ) ;
return ;
}
}
}
2026-04-03 00:22:56 +02:00
if ( event . key === "Escape" && editorState . toolMode === "create" ) {
2026-04-02 23:55:15 +02:00
event . preventDefault ( ) ;
store . setToolMode ( "select" ) ;
2026-04-03 00:22:56 +02:00
setStatusMessage ( "Cancelled the current creation preview." ) ;
2026-04-02 23:55:15 +02:00
return ;
}
2026-04-02 23:23:50 +02:00
if ( event . shiftKey && event . code === "KeyA" ) {
event . preventDefault ( ) ;
setAddMenuPosition ( {
x : lastPointerPositionRef.current.x ,
y : lastPointerPositionRef.current.y
} ) ;
return ;
}
2026-04-03 02:15:11 +02:00
if ( ! event . altKey && ! event . ctrlKey && ! event . metaKey ) {
let transformOperation : TransformOperation | null = null ;
if ( event . code === "KeyG" ) {
transformOperation = "translate" ;
} else if ( event . code === "KeyR" ) {
transformOperation = "rotate" ;
} else if ( event . code === "KeyS" ) {
transformOperation = "scale" ;
}
if ( transformOperation !== null ) {
event . preventDefault ( ) ;
beginTransformOperation ( transformOperation , "keyboard" ) ;
return ;
}
}
2026-04-03 00:55:13 +02:00
const isDeletionKey = event . key === "Delete" || event . key === "Backspace" ;
const isDeleteShortcut = ! event . altKey && ! event . ctrlKey && ! event . metaKey && ( event . code === "KeyX" || isDeletionKey ) ;
if ( addMenuPosition !== null ) {
if ( isDeletionKey ) {
event . preventDefault ( ) ;
}
return ;
}
2026-04-03 00:55:40 +02:00
if ( isDeleteShortcut ) {
if ( editorState . toolMode !== "create" ) {
const deleted = handleDeleteSelectedSceneItem ( ) ;
2026-04-03 00:55:13 +02:00
2026-04-03 00:55:40 +02:00
if ( deleted || isDeletionKey ) {
event . preventDefault ( ) ;
}
} else if ( isDeletionKey ) {
2026-04-03 00:55:13 +02:00
event . preventDefault ( ) ;
}
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 ,
2026-04-02 22:33:42 +02:00
panelId : activePanelId ,
2026-03-31 04:25:02 +02:00
selection : editorState.selection
} ) ) ;
setStatusMessage ( editorState . selection . kind === "none" ? "Framed the authored scene in the viewport." : "Framed the current selection." ) ;
} ;
2026-04-03 02:37:34 +02:00
document . addEventListener ( "pointermove" , handleWindowPointerMove ) ;
2026-04-02 23:23:50 +02:00
window . addEventListener ( "pointermove" , handleWindowPointerMove ) ;
2026-03-31 04:25:02 +02:00
window . addEventListener ( "keydown" , handleWindowKeyDown ) ;
return ( ) = > {
2026-04-03 02:37:34 +02:00
document . removeEventListener ( "pointermove" , handleWindowPointerMove ) ;
2026-04-02 23:23:50 +02:00
window . removeEventListener ( "pointermove" , handleWindowPointerMove ) ;
2026-03-31 04:25:02 +02:00
window . removeEventListener ( "keydown" , handleWindowKeyDown ) ;
} ;
2026-04-03 02:38:00 +02:00
} , [ activePanelId , addMenuPosition , brushList . length , editorState . selection , editorState . toolMode , entityList . length , hoveredViewportPanelId , layoutMode , transformSession ] ) ;
2026-03-31 04:25:02 +02:00
2026-04-03 01:26:21 +02:00
useEffect ( ( ) = > {
if ( layoutMode === "quad" || viewportQuadResizeMode === null ) {
return ;
}
setViewportQuadResizeMode ( null ) ;
} , [ layoutMode , viewportQuadResizeMode ] ) ;
useEffect ( ( ) = > {
if ( layoutMode !== "quad" || viewportQuadResizeMode === null ) {
return ;
}
const previousCursor = document . body . style . cursor ;
const previousUserSelect = document . body . style . userSelect ;
document . body . style . cursor = getViewportQuadResizeCursor ( viewportQuadResizeMode ) ;
document . body . style . userSelect = "none" ;
const handlePointerMove = ( event : globalThis.PointerEvent ) = > {
const viewportPanels = viewportPanelsRef . current ;
if ( viewportPanels === null ) {
return ;
}
const rect = viewportPanels . getBoundingClientRect ( ) ;
if ( rect . width <= 0 || rect . height <= 0 ) {
return ;
}
const nextViewportQuadSplit = {
. . . viewportQuadSplitRef . current
} ;
if ( viewportQuadResizeMode !== "horizontal" ) {
nextViewportQuadSplit . x = clampViewportQuadSplitValue ( ( event . clientX - rect . left ) / rect . width ) ;
}
if ( viewportQuadResizeMode !== "vertical" ) {
nextViewportQuadSplit . y = clampViewportQuadSplitValue ( ( event . clientY - rect . top ) / rect . height ) ;
}
store . setViewportQuadSplit ( nextViewportQuadSplit ) ;
} ;
const stopViewportResize = ( ) = > {
setViewportQuadResizeMode ( null ) ;
} ;
window . addEventListener ( "pointermove" , handlePointerMove ) ;
window . addEventListener ( "pointerup" , stopViewportResize ) ;
window . addEventListener ( "pointercancel" , stopViewportResize ) ;
return ( ) = > {
document . body . style . cursor = previousCursor ;
document . body . style . userSelect = previousUserSelect ;
window . removeEventListener ( "pointermove" , handlePointerMove ) ;
window . removeEventListener ( "pointerup" , stopViewportResize ) ;
window . removeEventListener ( "pointercancel" , stopViewportResize ) ;
} ;
} , [ layoutMode , store , viewportQuadResizeMode ] ) ;
2026-04-03 01:15:06 +02:00
useEffect ( ( ) = > {
if ( editorState . toolMode !== "play" ) {
return ;
}
const handleWindowKeyDown = ( event : globalThis.KeyboardEvent ) = > {
if ( isTextEntryTarget ( event . target ) ) {
return ;
}
if ( event . key !== "Escape" ) {
return ;
}
const pointerCaptured = activeNavigationMode === "firstPerson" && firstPersonTelemetry ? . pointerLocked === true ;
if ( pointerCaptured ) {
return ;
}
event . preventDefault ( ) ;
handleExitPlayMode ( ) ;
} ;
window . addEventListener ( "keydown" , handleWindowKeyDown ) ;
return ( ) = > {
window . removeEventListener ( "keydown" , handleWindowKeyDown ) ;
} ;
} , [ activeNavigationMode , editorState . toolMode , firstPersonTelemetry ] ) ;
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 ,
2026-04-02 22:33:28 +02:00
panelId : activePanelId ,
2026-03-31 04:25:13 +02:00
selection
} ) ) ;
if ( status !== undefined ) {
setStatusMessage ( status ) ;
}
} ;
2026-04-02 23:23:50 +02:00
const openAddMenuAt = ( position : HierarchicalMenuPosition ) = > {
2026-04-03 00:42:54 +02:00
setHoveredAssetId ( null ) ;
2026-04-02 23:23:50 +02:00
setAddMenuPosition ( position ) ;
} ;
const closeAddMenu = ( ) = > {
2026-04-03 00:42:54 +02:00
setHoveredAssetId ( null ) ;
2026-04-02 23:23:50 +02:00
setAddMenuPosition ( null ) ;
} ;
const handleOpenAddMenuFromButton = ( event : ReactMouseEvent < HTMLButtonElement > ) = > {
const rect = event . currentTarget . getBoundingClientRect ( ) ;
openAddMenuAt ( {
x : rect.left ,
y : rect.bottom + 8
} ) ;
} ;
2026-04-02 22:33:28 +02:00
const handleSetViewportLayoutMode = ( nextLayoutMode : ViewportLayoutMode ) = > {
if ( editorState . viewportLayoutMode === nextLayoutMode ) {
2026-04-02 22:16:59 +02:00
return ;
}
blurActiveTextEntry ( ) ;
2026-04-02 22:33:28 +02:00
store . setViewportLayoutMode ( nextLayoutMode ) ;
setStatusMessage ( ` Switched the viewport to ${ getViewportLayoutModeLabel ( nextLayoutMode ) } . ` ) ;
} ;
const handleActivateViewportPanel = ( panelId : ViewportPanelId ) = > {
if ( editorState . activeViewportPanelId === panelId ) {
return ;
}
blurActiveTextEntry ( ) ;
store . setActiveViewportPanel ( panelId ) ;
2026-04-02 23:57:42 +02:00
setStatusMessage ( "Activated the viewport panel." ) ;
2026-04-02 22:33:28 +02:00
} ;
const handleSetViewportPanelViewMode = ( panelId : ViewportPanelId , nextViewMode : ViewportViewMode ) = > {
if ( editorState . viewportPanels [ panelId ] . viewMode === nextViewMode ) {
return ;
}
2026-04-02 23:05:18 +02:00
blurActiveTextEntry ( ) ;
store . setViewportPanelViewMode ( panelId , nextViewMode ) ;
2026-04-02 23:57:42 +02:00
setStatusMessage ( ` Set the viewport panel to ${ getViewportViewModeLabel ( nextViewMode ) } view. ` ) ;
2026-04-02 22:33:28 +02:00
} ;
const handleSetViewportPanelDisplayMode = ( panelId : ViewportPanelId , nextDisplayMode : ViewportDisplayMode ) = > {
if ( editorState . viewportPanels [ panelId ] . displayMode === nextDisplayMode ) {
return ;
}
blurActiveTextEntry ( ) ;
store . setViewportPanelDisplayMode ( panelId , nextDisplayMode ) ;
2026-04-02 23:57:42 +02:00
setStatusMessage ( ` Set the viewport panel to ${ getViewportDisplayModeLabel ( nextDisplayMode ) } display. ` ) ;
2026-04-02 22:16:59 +02:00
} ;
2026-04-03 02:15:11 +02:00
const beginTransformOperation = ( operation : TransformOperation , source : TransformSessionSource ) = > {
if ( editorState . toolMode !== "select" ) {
return ;
}
2026-04-03 02:35:53 +02:00
const transformSourcePanelId = layoutMode === "quad" ? hoveredViewportPanelId ? ? activePanelId : activePanelId ;
2026-04-03 02:35:30 +02:00
2026-04-03 02:15:11 +02:00
const transformTargetResult = resolveTransformTarget ( editorState . document , editorState . selection ) ;
const transformTarget = transformTargetResult . target ;
if ( transformTarget === null ) {
setStatusMessage ( transformTargetResult . message ? ? "Select a single brush, entity, or model instance before transforming it." ) ;
return ;
}
if ( ! supportsTransformOperation ( transformTarget , operation ) ) {
setStatusMessage ( ` ${ getTransformOperationLabel ( operation ) } is not supported for ${ getTransformTargetLabel ( transformTarget ) } . ` ) ;
return ;
}
blurActiveTextEntry ( ) ;
closeAddMenu ( ) ;
2026-04-03 02:35:30 +02:00
if ( editorState . activeViewportPanelId !== transformSourcePanelId ) {
store . setActiveViewportPanel ( transformSourcePanelId ) ;
}
2026-04-03 02:15:11 +02:00
store . setTransformSession (
createTransformSession ( {
source ,
2026-04-03 02:35:30 +02:00
sourcePanelId : transformSourcePanelId ,
2026-04-03 02:15:11 +02:00
operation ,
target : transformTarget
} )
) ;
setStatusMessage (
` ${ getTransformOperationLabel ( operation ) } ${ getTransformTargetLabel ( transformTarget ) . toLowerCase ( ) } in ${ getViewportPanelLabel (
2026-04-03 02:35:30 +02:00
transformSourcePanelId
2026-04-03 02:15:11 +02:00
) } . Move the pointer , press X / Y / Z to constrain , click or press Enter to commit , Escape cancels . `
) ;
} ;
const cancelTransformSession = ( status = "Cancelled the current transform." ) = > {
if ( transformSession . kind === "none" ) {
return ;
}
store . clearTransformSession ( ) ;
setStatusMessage ( status ) ;
} ;
const commitTransformSession = ( activeTransformSession : ActiveTransformSession ) = > {
if ( ! doesTransformSessionChangeTarget ( activeTransformSession ) ) {
store . clearTransformSession ( ) ;
setStatusMessage ( "No transform change was committed." ) ;
return ;
}
try {
store . clearTransformSession ( ) ;
store . executeCommand ( createCommitTransformSessionCommand ( editorState . document , activeTransformSession ) ) ;
setStatusMessage (
` ${ getTransformOperationPastTense ( activeTransformSession . operation ) } ${ getTransformTargetLabel ( activeTransformSession . target ) . toLowerCase ( ) } . `
) ;
} catch ( error ) {
store . clearTransformSession ( ) ;
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyTransformAxisConstraint = ( axis : TransformAxis ) = > {
if ( transformSession . kind !== "active" ) {
return ;
}
if ( ! supportsTransformAxisConstraint ( transformSession , axis ) ) {
const supportedAxes = ( [ "x" , "y" , "z" ] as const )
. filter ( ( candidateAxis ) = > supportsTransformAxisConstraint ( transformSession , candidateAxis ) )
. map ( ( candidateAxis ) = > candidateAxis . toUpperCase ( ) )
. join ( "/" ) ;
setStatusMessage (
supportedAxes . length === 0
? ` ${ getTransformOperationLabel ( transformSession . operation ) } does not support axis constraints for ${ getTransformTargetLabel ( transformSession . target ) } . `
: ` ${ getTransformOperationLabel ( transformSession . operation ) } on ${ getTransformTargetLabel ( transformSession . target ) } only supports ${ supportedAxes } . `
) ;
return ;
}
store . setTransformAxisConstraint ( axis ) ;
setStatusMessage ( ` Constrained ${ getTransformOperationLabel ( transformSession . operation ) . toLowerCase ( ) } to ${ axis . toUpperCase ( ) } . ` ) ;
} ;
2026-04-03 01:26:21 +02:00
const handleViewportQuadResizeStart =
( resizeMode : ViewportQuadResizeMode ) = > ( event : ReactPointerEvent < HTMLDivElement > ) = > {
if ( layoutMode !== "quad" ) {
return ;
}
const viewportPanels = viewportPanelsRef . current ;
if ( viewportPanels === null ) {
return ;
}
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
blurActiveTextEntry ( ) ;
const rect = viewportPanels . getBoundingClientRect ( ) ;
if ( rect . width > 0 && rect . height > 0 ) {
const nextViewportQuadSplit = {
. . . viewportQuadSplitRef . current
} ;
if ( resizeMode !== "horizontal" ) {
nextViewportQuadSplit . x = clampViewportQuadSplitValue ( ( event . clientX - rect . left ) / rect . width ) ;
}
if ( resizeMode !== "vertical" ) {
nextViewportQuadSplit . y = clampViewportQuadSplitValue ( ( event . clientY - rect . top ) / rect . height ) ;
}
store . setViewportQuadSplit ( nextViewportQuadSplit ) ;
}
setViewportQuadResizeMode ( resizeMode ) ;
} ;
2026-04-03 00:22:56 +02:00
const beginCreation = ( toolPreview : CreationViewportToolPreview , status : string ) = > {
blurActiveTextEntry ( ) ;
closeAddMenu ( ) ;
store . setToolMode ( "create" ) ;
2026-04-03 00:26:20 +02:00
store . setViewportToolPreview ( toolPreview ) ;
2026-04-03 00:22:56 +02:00
setStatusMessage ( status ) ;
} ;
2026-04-03 00:30:20 +02:00
const completeCreation = ( status : string ) = > {
store . setToolMode ( "select" ) ;
2026-04-03 00:34:23 +02:00
store . clearViewportToolPreview ( ) ;
2026-04-03 00:30:20 +02:00
setStatusMessage ( status ) ;
} ;
2026-04-03 00:22:56 +02:00
const beginBoxCreation = ( ) = > {
beginCreation (
{
kind : "create" ,
sourcePanelId : activePanelId ,
target : {
kind : "box-brush"
} ,
center : null
} ,
"Previewing a box brush. Click in the viewport to create it."
) ;
2026-03-31 02:37:22 +02:00
} ;
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-04-02 19:40:23 +02:00
setStatusMessage ( ` Selected ${ getEntityDisplayLabelById ( selection . ids [ 0 ] , editorState . document . entities , editorState . document . assets ) } 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 ) ;
} ;
2026-04-03 00:22:56 +02:00
const beginEntityCreation = ( kind : EntityKind , options : { audioAssetId? : string | null } = { } ) = > {
beginCreation (
2026-04-02 23:54:17 +02:00
{
2026-04-03 00:22:56 +02:00
kind : "create" ,
2026-04-02 23:54:17 +02:00
sourcePanelId : activePanelId ,
target : {
kind : "entity" ,
entityKind : kind ,
audioAssetId : options.audioAssetId ? ? null
} ,
center : null
} ,
` Previewing ${ getEntityKindLabel ( kind ) } . Click in the viewport to place it. `
) ;
} ;
2026-04-03 00:22:56 +02:00
const beginModelInstanceCreation = ( assetId : string ) = > {
2026-04-02 23:54:17 +02:00
const asset = editorState . document . assets [ assetId ] ;
2026-03-31 05:55:51 +02:00
2026-04-02 23:54:17 +02:00
if ( asset === undefined || asset . kind !== "model" ) {
setStatusMessage ( "Select a model asset before placing a model instance." ) ;
return ;
2026-03-31 05:55:51 +02:00
}
2026-04-03 00:22:56 +02:00
beginCreation (
2026-04-02 23:54:17 +02:00
{
2026-04-03 00:22:56 +02:00
kind : "create" ,
2026-04-02 23:54:17 +02:00
sourcePanelId : activePanelId ,
target : {
kind : "model-instance" ,
assetId : asset.id
} ,
center : null
} ,
` Previewing ${ asset . sourceName } . Click in the viewport to place it. `
) ;
2026-03-31 05:55:51 +02:00
} ;
2026-04-03 00:32:17 +02:00
const handleCommitCreation = ( creationPreview : CreationViewportToolPreview ) : boolean = > {
2026-03-31 05:55:51 +02:00
try {
2026-04-03 00:22:56 +02:00
if ( creationPreview . target . kind === "box-brush" ) {
const center = creationPreview . center === null ? undefined : creationPreview . center ;
store . executeCommand (
createCreateBoxBrushCommand ( center === undefined ? { } : { center } )
) ;
2026-04-03 00:30:20 +02:00
completeCreation (
2026-04-03 00:22:56 +02:00
center === undefined
? ` Created a box brush snapped to the ${ DEFAULT_GRID_SIZE } m grid. `
: ` Created a box brush at snapped center ${ formatVec3 ( center ) } . `
) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-04-03 00:22:56 +02:00
}
if ( creationPreview . target . kind === "model-instance" ) {
const asset = editorState . document . assets [ creationPreview . target . assetId ] ;
2026-04-02 23:54:17 +02:00
if ( asset === undefined || asset . kind !== "model" ) {
setStatusMessage ( "Select a model asset before placing a model instance." ) ;
2026-04-03 00:32:17 +02:00
return false ;
2026-04-02 23:54:17 +02:00
}
2026-03-31 05:58:29 +02:00
2026-04-02 23:54:17 +02:00
const nextModelInstance = createModelInstance ( {
assetId : asset.id ,
position :
2026-04-03 00:22:56 +02:00
creationPreview . center === null ? createModelInstancePlacementPosition ( asset , null ) : creationPreview . center ,
2026-04-02 23:54:17 +02:00
rotationDegrees : DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES ,
scale : DEFAULT_MODEL_INSTANCE_SCALE
} ) ;
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : nextModelInstance ,
label : ` Place ${ asset . sourceName } `
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( ` Placed ${ asset . sourceName } . ` ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-04-02 23:54:17 +02:00
}
2026-04-03 00:22:56 +02:00
const position = creationPreview . center ? ? DEFAULT_ENTITY_POSITION ;
2026-04-02 23:54:17 +02:00
2026-04-03 00:22:56 +02:00
switch ( creationPreview . target . entityKind ) {
2026-03-31 20:13:54 +02:00
case "pointLight" :
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createPointLightEntity ( {
position
} ) ,
label : "Place point light"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( "Placed Point Light." ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-03-31 20:13:54 +02:00
case "spotLight" :
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createSpotLightEntity ( {
position
} ) ,
label : "Place spot light"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( "Placed Spot Light." ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-03-31 05:58:29 +02:00
case "playerStart" :
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createPlayerStartEntity ( {
position
} ) ,
label : "Place player start"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( "Placed Player Start." ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-04-02 23:54:17 +02:00
case "soundEmitter" : {
2026-04-03 00:22:56 +02:00
const placedAudioAssetId = creationPreview . target . audioAssetId ? ? audioAssetList [ 0 ] ? . id ? ? null ;
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createSoundEmitterEntity ( {
position ,
audioAssetId : placedAudioAssetId
} ) ,
label : "Place sound emitter"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation (
2026-04-02 23:54:17 +02:00
placedAudioAssetId === null
? "Placed Sound Emitter."
: ` Placed Sound Emitter using ${ editorState . document . assets [ placedAudioAssetId ] ? . sourceName ? ? "the authored audio asset" } . `
) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-04-02 23:54:17 +02:00
}
2026-03-31 05:58:29 +02:00
case "triggerVolume" :
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createTriggerVolumeEntity ( {
position
} ) ,
label : "Place trigger volume"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( "Placed Trigger Volume." ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-03-31 05:58:29 +02:00
case "teleportTarget" :
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createTeleportTargetEntity ( {
position
} ) ,
label : "Place teleport target"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( "Placed Teleport Target." ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-03-31 05:58:29 +02:00
case "interactable" :
2026-04-02 23:54:17 +02:00
store . executeCommand (
createUpsertEntityCommand ( {
entity : createInteractableEntity ( {
position
} ) ,
label : "Place interactable"
} )
) ;
2026-04-03 00:30:20 +02:00
completeCreation ( "Placed Interactable." ) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-04-02 23:24:08 +02:00
}
2026-03-31 05:55:51 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
2026-04-03 00:32:17 +02:00
return false ;
2026-03-31 05:55:51 +02:00
} ;
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 ) ;
} ;
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 ,
2026-04-04 07:55:42 +02:00
collision : selectedModelInstance.collision ,
2026-03-31 17:42:52 +02:00
position : readVec3Draft ( modelPositionDraft , "Model instance position" ) ,
rotationDegrees : readVec3Draft ( modelRotationDraft , "Model instance rotation" ) ,
2026-04-04 07:55:42 +02:00
scale : readPositiveVec3Draft ( modelScaleDraft , "Model instance scale" ) ,
animationClipName : selectedModelInstance.animationClipName ,
animationAutoplay : selectedModelInstance.animationAutoplay
2026-03-31 17:42:52 +02:00
} ) ;
commitModelInstanceChange ( selectedModelInstance , nextModelInstance , "Updated model instance." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-04 15:56:30 +02:00
const applyPlayerStartChange = ( overrides : { colliderMode? : PlayerStartColliderMode } = { } ) = > {
2026-03-31 05:55:51 +02:00
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 ) ;
2026-04-04 15:56:30 +02:00
const colliderMode = overrides . colliderMode ? ? playerStartColliderModeDraft ;
2026-03-31 05:55:51 +02:00
const nextEntity = createPlayerStartEntity ( {
id : selectedPlayerStart.id ,
2026-04-03 01:03:58 +02:00
name : selectedPlayerStart.name ,
2026-03-31 05:55:51 +02:00
position : snappedPosition ,
2026-04-04 15:56:30 +02:00
yawDegrees ,
collider : {
mode : colliderMode ,
eyeHeight : readPositiveNumberDraft ( playerStartEyeHeightDraft , "Player Start eye height" ) ,
capsuleRadius : readPositiveNumberDraft ( playerStartCapsuleRadiusDraft , "Player Start capsule radius" ) ,
capsuleHeight : readPositiveNumberDraft ( playerStartCapsuleHeightDraft , "Player Start capsule height" ) ,
boxSize : readPositiveVec3Draft ( playerStartBoxSizeDraft , "Player Start box size" )
}
2026-03-31 05:55:51 +02:00
} ) ;
commitEntityChange ( selectedPlayerStart , nextEntity , "Updated Player Start." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-02 20:19:41 +02:00
const applyPointLightChange = ( overrides : { colorHex? : string } = { } ) = > {
2026-03-31 20:13:57 +02:00
if ( selectedPointLight === null ) {
setStatusMessage ( "Select a Point Light before editing it." ) ;
return ;
}
try {
const nextEntity = createPointLightEntity ( {
id : selectedPointLight.id ,
2026-04-03 01:03:58 +02:00
name : selectedPointLight.name ,
2026-03-31 20:13:57 +02:00
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Point Light position" ) , DEFAULT_GRID_SIZE ) ,
2026-04-02 20:19:41 +02:00
colorHex : overrides.colorHex ? ? pointLightColorDraft ,
2026-03-31 20:13:57 +02:00
intensity : readNonNegativeNumberDraft ( pointLightIntensityDraft , "Point Light intensity" ) ,
distance : readPositiveNumberDraft ( pointLightDistanceDraft , "Point Light distance" )
} ) ;
commitEntityChange ( selectedPointLight , nextEntity , "Updated Point Light." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-02 20:19:41 +02:00
const applySpotLightChange = ( overrides : { colorHex? : string } = { } ) = > {
2026-03-31 20:13:57 +02:00
if ( selectedSpotLight === null ) {
setStatusMessage ( "Select a Spot Light before editing it." ) ;
return ;
}
try {
const nextEntity = createSpotLightEntity ( {
id : selectedSpotLight.id ,
2026-04-03 01:03:58 +02:00
name : selectedSpotLight.name ,
2026-03-31 20:13:57 +02:00
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Spot Light position" ) , DEFAULT_GRID_SIZE ) ,
direction : readVec3Draft ( spotLightDirectionDraft , "Spot Light direction" ) ,
2026-04-02 20:19:41 +02:00
colorHex : overrides.colorHex ? ? spotLightColorDraft ,
2026-03-31 20:13:57 +02:00
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-04-02 20:19:41 +02:00
const applySoundEmitterChange = (
overrides : { audioAssetId? : string | null ; autoplay? : boolean ; loop? : boolean } = { }
) = > {
2026-03-31 05:55:51 +02:00
if ( selectedSoundEmitter === null ) {
setStatusMessage ( "Select a Sound Emitter before editing it." ) ;
return ;
}
try {
2026-04-02 19:40:39 +02:00
const trimmedAudioAssetId = soundEmitterAudioAssetIdDraft . trim ( ) ;
2026-04-02 20:19:41 +02:00
const nextAudioAssetId =
overrides . audioAssetId !== undefined
? overrides . audioAssetId
: trimmedAudioAssetId . length === 0
? null
: trimmedAudioAssetId ;
2026-03-31 05:55:51 +02:00
const nextEntity = createSoundEmitterEntity ( {
id : selectedSoundEmitter.id ,
2026-04-03 01:03:58 +02:00
name : selectedSoundEmitter.name ,
2026-03-31 05:55:51 +02:00
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Sound Emitter position" ) , DEFAULT_GRID_SIZE ) ,
2026-04-02 20:19:41 +02:00
audioAssetId : nextAudioAssetId ,
2026-04-02 19:40:39 +02:00
volume : readNonNegativeNumberDraft ( soundEmitterVolumeDraft , "Sound Emitter volume" ) ,
refDistance : readPositiveNumberDraft ( soundEmitterRefDistanceDraft , "Sound Emitter ref distance" ) ,
maxDistance : readPositiveNumberDraft ( soundEmitterMaxDistanceDraft , "Sound Emitter max distance" ) ,
2026-04-02 20:19:41 +02:00
autoplay : overrides.autoplay ? ? soundEmitterAutoplayDraft ,
loop : overrides.loop ? ? soundEmitterLoopDraft
2026-03-31 05:55:51 +02:00
} ) ;
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 {
2026-04-01 04:19:11 +02:00
// Derive triggerOnEnter/triggerOnExit from the actual links so the flags
// stay in sync automatically — no manual checkbox needed.
const links = getInteractionLinksForSource ( editorState . document . interactionLinks , selectedTriggerVolume . id ) ;
const triggerOnEnter = links . some ( ( l ) = > l . trigger === "enter" ) ;
const triggerOnExit = links . some ( ( l ) = > l . trigger === "exit" ) ;
2026-03-31 05:55:51 +02:00
const nextEntity = createTriggerVolumeEntity ( {
id : selectedTriggerVolume.id ,
2026-04-03 01:03:58 +02:00
name : selectedTriggerVolume.name ,
2026-03-31 05:55:51 +02:00
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Trigger Volume position" ) , DEFAULT_GRID_SIZE ) ,
size : snapPositiveSizeToGrid ( readVec3Draft ( triggerVolumeSizeDraft , "Trigger Volume size" ) , DEFAULT_GRID_SIZE ) ,
2026-04-01 04:19:11 +02:00
triggerOnEnter ,
triggerOnExit
2026-03-31 05:55:51 +02:00
} ) ;
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 ,
2026-04-03 01:03:58 +02:00
name : selectedTeleportTarget.name ,
2026-03-31 05:55:51 +02:00
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Teleport Target position" ) , DEFAULT_GRID_SIZE ) ,
yawDegrees : readYawDegreesDraft ( teleportTargetYawDraft )
} ) ;
commitEntityChange ( selectedTeleportTarget , nextEntity , "Updated Teleport Target." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-02 20:19:41 +02:00
const applyInteractableChange = ( overrides : { enabled? : boolean } = { } ) = > {
2026-03-31 05:55:51 +02:00
if ( selectedInteractable === null ) {
setStatusMessage ( "Select an Interactable before editing it." ) ;
return ;
}
try {
const nextEntity = createInteractableEntity ( {
id : selectedInteractable.id ,
2026-04-03 01:03:58 +02:00
name : selectedInteractable.name ,
2026-03-31 05:55:51 +02:00
position : snapVec3ToGrid ( readVec3Draft ( entityPositionDraft , "Interactable position" ) , DEFAULT_GRID_SIZE ) ,
radius : readPositiveNumberDraft ( interactableRadiusDraft , "Interactable radius" ) ,
prompt : readInteractablePromptDraft ( interactablePromptDraft ) ,
2026-04-02 20:19:41 +02:00
enabled : overrides.enabled ? ? interactableEnabledDraft
2026-03-31 05:55:51 +02:00
} ) ;
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
} ;
2026-04-02 19:41:26 +02:00
const handleAddSoundInteractionLink = ( actionType : "playSound" | "stopSound" ) = > {
if ( selectedInteractionSource === null ) {
setStatusMessage ( "Select a Trigger Volume or Interactable before adding links." ) ;
return ;
}
2026-04-02 19:42:12 +02:00
const defaultTarget = playableSoundEmitterOptions [ 0 ] ? . entity ;
2026-04-02 19:41:26 +02:00
if ( defaultTarget === undefined ) {
setStatusMessage ( "Author a Sound Emitter with an audio asset before adding sound links." ) ;
return ;
}
const link =
actionType === "playSound"
? createPlaySoundInteractionLink ( {
sourceEntityId : selectedInteractionSource.id ,
trigger : getDefaultInteractionLinkTrigger ( selectedInteractionSource ) ,
targetSoundEmitterId : defaultTarget.id
} )
: createStopSoundInteractionLink ( {
sourceEntityId : selectedInteractionSource.id ,
trigger : getDefaultInteractionLinkTrigger ( selectedInteractionSource ) ,
targetSoundEmitterId : defaultTarget.id
} ) ;
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link ,
label : actionType === "playSound" ? "Add play sound link" : "Add stop sound link"
} )
) ;
setStatusMessage (
` Added a ${ actionType === "playSound" ? "play sound" : "stop sound" } 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 ) ) ;
}
} ;
2026-04-03 00:55:13 +02:00
const confirmDeleteSceneItem = ( label : string ) = >
globalThis . window . confirm ( ` Delete ${ label } ? \ n \ nThis can be undone with Undo. ` ) ;
const handleDeleteBrush = ( brushId : string ) = > {
const label = getBrushLabelById ( brushId , brushList ) ;
if ( ! confirmDeleteSceneItem ( label ) ) {
return false ;
}
try {
store . executeCommand ( createDeleteBoxBrushCommand ( brushId ) ) ;
setStatusMessage ( ` Deleted ${ label } . ` ) ;
return true ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
return false ;
}
} ;
const handleDeleteEntity = ( entityId : string ) = > {
const label = getEntityDisplayLabelById ( entityId , editorState . document . entities , editorState . document . assets ) ;
if ( ! confirmDeleteSceneItem ( label ) ) {
return false ;
}
try {
store . executeCommand ( createDeleteEntityCommand ( entityId ) ) ;
setStatusMessage ( ` Deleted ${ label } . ` ) ;
return true ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
return false ;
}
} ;
const handleDeleteModelInstance = ( modelInstanceId : string ) = > {
const label = getModelInstanceDisplayLabelById (
modelInstanceId ,
editorState . document . modelInstances ,
editorState . document . assets
) ;
if ( ! confirmDeleteSceneItem ( label ) ) {
return false ;
}
try {
store . executeCommand ( createDeleteModelInstanceCommand ( modelInstanceId ) ) ;
setStatusMessage ( ` Deleted ${ label } . ` ) ;
return true ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
return false ;
}
} ;
const handleDeleteSelectedSceneItem = ( ) = > {
const selectedBrushId = getSingleSelectedBrushId ( editorState . selection ) ;
if ( selectedBrushId !== null ) {
return handleDeleteBrush ( selectedBrushId ) ;
}
const selectedEntityId = getSingleSelectedEntityId ( editorState . selection ) ;
if ( selectedEntityId !== null ) {
return handleDeleteEntity ( selectedEntityId ) ;
}
const selectedModelInstanceId = getSingleSelectedModelInstanceId ( editorState . selection ) ;
if ( selectedModelInstanceId !== null ) {
return handleDeleteModelInstance ( selectedModelInstanceId ) ;
}
return false ;
} ;
2026-03-31 06:18:16 +02:00
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-04-02 19:19:54 +02:00
let nextLink : InteractionLink ;
switch ( link . action . type ) {
case "teleportPlayer" :
nextLink = createTeleportPlayerInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetEntityId : link.action.targetEntityId
} ) ;
break ;
case "toggleVisibility" :
nextLink = createToggleVisibilityInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetBrushId : link.action.targetBrushId ,
visible : link.action.visible
} ) ;
break ;
case "playAnimation" :
nextLink = createPlayAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetModelInstanceId : link.action.targetModelInstanceId ,
clipName : link.action.clipName ,
loop : link.action.loop
} ) ;
break ;
case "stopAnimation" :
nextLink = createStopAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetModelInstanceId : link.action.targetModelInstanceId
} ) ;
break ;
2026-04-02 19:41:37 +02:00
case "playSound" :
nextLink = createPlaySoundInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetSoundEmitterId : link.action.targetSoundEmitterId
} ) ;
break ;
case "stopSound" :
nextLink = createStopSoundInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger ,
targetSoundEmitterId : link.action.targetSoundEmitterId
} ) ;
break ;
2026-04-02 19:19:54 +02:00
}
2026-03-31 06:18:16 +02:00
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 ;
}
2026-04-01 02:53:12 +02:00
if ( actionType === "playAnimation" ) {
2026-04-02 19:19:54 +02:00
const targetModelInstance =
( link . action . type === "playAnimation" || link . action . type === "stopAnimation"
? editorState . document . modelInstances [ link . action . targetModelInstanceId ]
: undefined ) ? ? modelInstanceDisplayList [ 0 ] ? . modelInstance ;
2026-04-01 02:53:12 +02:00
2026-04-02 19:19:54 +02:00
if ( targetModelInstance === undefined ) {
2026-04-01 02:53:12 +02:00
setStatusMessage ( "Place a model instance before switching this link to play animation." ) ;
return ;
}
2026-04-02 19:19:54 +02:00
const asset = editorState . document . assets [ targetModelInstance . assetId ] ;
2026-04-01 02:53:12 +02:00
const firstClip = asset ? . kind === "model" ? ( asset . metadata . animationNames [ 0 ] ? ? "" ) : "" ;
if ( firstClip === "" ) {
setStatusMessage ( "The model instance has no animation clips." ) ;
return ;
}
commitInteractionLinkChange (
link ,
createPlayAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : sourceEntity.id ,
trigger : link.trigger ,
2026-04-02 19:19:54 +02:00
targetModelInstanceId : targetModelInstance.id ,
2026-04-01 02:53:12 +02:00
clipName : firstClip
} ) ,
"Switched link action to play animation."
) ;
return ;
}
if ( actionType === "stopAnimation" ) {
2026-04-02 19:19:54 +02:00
const targetModelInstance =
( link . action . type === "playAnimation" || link . action . type === "stopAnimation"
? editorState . document . modelInstances [ link . action . targetModelInstanceId ]
: undefined ) ? ? modelInstanceDisplayList [ 0 ] ? . modelInstance ;
2026-04-01 02:53:12 +02:00
2026-04-02 19:19:54 +02:00
if ( targetModelInstance === undefined ) {
2026-04-01 02:53:12 +02:00
setStatusMessage ( "Place a model instance before switching this link to stop animation." ) ;
return ;
}
commitInteractionLinkChange (
link ,
createStopAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : sourceEntity.id ,
trigger : link.trigger ,
2026-04-02 19:19:54 +02:00
targetModelInstanceId : targetModelInstance.id
2026-04-01 02:53:12 +02:00
} ) ,
"Switched link action to stop animation."
) ;
return ;
}
2026-04-02 19:41:37 +02:00
if ( actionType === "playSound" || actionType === "stopSound" ) {
const targetSoundEmitter =
( link . action . type === "playSound" || link . action . type === "stopSound"
? editorState . document . entities [ link . action . targetSoundEmitterId ]
2026-04-02 19:42:22 +02:00
: undefined ) ? ? playableSoundEmitterOptions [ 0 ] ? . entity ;
2026-04-02 19:41:37 +02:00
if ( targetSoundEmitter === undefined || targetSoundEmitter . kind !== "soundEmitter" ) {
setStatusMessage ( "Author a Sound Emitter with an audio asset before switching this link to sound playback." ) ;
return ;
}
if ( actionType === "playSound" ) {
commitInteractionLinkChange (
link ,
createPlaySoundInteractionLink ( {
id : link.id ,
sourceEntityId : sourceEntity.id ,
trigger : link.trigger ,
targetSoundEmitterId : targetSoundEmitter.id
} ) ,
"Switched link action to play sound."
) ;
} else {
commitInteractionLinkChange (
link ,
createStopSoundInteractionLink ( {
id : link.id ,
sourceEntityId : sourceEntity.id ,
trigger : link.trigger ,
targetSoundEmitterId : targetSoundEmitter.id
} ) ,
"Switched link action to stop sound."
) ;
}
return ;
}
2026-03-31 06:18:16 +02:00
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-04-02 19:41:43 +02:00
const updateSoundInteractionLinkTarget = ( link : InteractionLink , targetSoundEmitterId : string ) = > {
if ( link . action . type !== "playSound" && link . action . type !== "stopSound" ) {
return ;
}
if ( link . action . type === "playSound" ) {
commitInteractionLinkChange (
link ,
createPlaySoundInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetSoundEmitterId
} ) ,
"Updated play sound link target."
) ;
} else {
commitInteractionLinkChange (
link ,
createStopSoundInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetSoundEmitterId
} ) ,
"Updated stop sound link target."
) ;
}
} ;
2026-04-01 02:53:03 +02:00
const updateAnimationInteractionLinkTarget = ( link : InteractionLink , targetModelInstanceId : string ) = > {
if ( link . action . type !== "playAnimation" && link . action . type !== "stopAnimation" ) {
return ;
}
if ( link . action . type === "playAnimation" ) {
commitInteractionLinkChange (
link ,
createPlayAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetModelInstanceId ,
2026-04-01 04:06:12 +02:00
clipName : link.action.clipName ,
loop : link.action.loop
2026-04-01 02:53:03 +02:00
} ) ,
"Updated play animation link target."
) ;
} else {
commitInteractionLinkChange (
link ,
createStopAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetModelInstanceId
} ) ,
"Updated stop animation link target."
) ;
}
} ;
const updatePlayAnimationLinkClip = ( link : InteractionLink , clipName : string ) = > {
if ( link . action . type !== "playAnimation" ) {
return ;
}
commitInteractionLinkChange (
link ,
createPlayAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetModelInstanceId : link.action.targetModelInstanceId ,
2026-04-01 04:06:12 +02:00
clipName ,
loop : link.action.loop
2026-04-01 02:53:03 +02:00
} ) ,
"Updated play animation clip."
) ;
} ;
2026-04-01 04:06:12 +02:00
const updatePlayAnimationLinkLoop = ( link : InteractionLink , loop : boolean ) = > {
if ( link . action . type !== "playAnimation" ) {
return ;
}
commitInteractionLinkChange (
link ,
createPlayAnimationInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetModelInstanceId : link.action.targetModelInstanceId ,
clipName : link.action.clipName ,
loop
} ) ,
"Updated play animation loop setting."
) ;
} ;
2026-04-01 02:53:03 +02:00
const handleAddPlayAnimationLink = ( sourceEntity : InteractionSourceEntity ) = > {
const firstInstance = modelInstanceDisplayList [ 0 ] ;
if ( firstInstance === undefined ) {
setStatusMessage ( "Place a model instance before adding an animation link." ) ;
return ;
}
const asset = editorState . document . assets [ firstInstance . modelInstance . assetId ] ;
const firstClip = asset ? . kind === "model" ? ( asset . metadata . animationNames [ 0 ] ? ? "" ) : "" ;
if ( firstClip === "" ) {
setStatusMessage ( "The model instance has no animation clips." ) ;
return ;
}
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link : createPlayAnimationInteractionLink ( {
sourceEntityId : sourceEntity.id ,
trigger : getDefaultInteractionLinkTrigger ( sourceEntity ) ,
targetModelInstanceId : firstInstance.modelInstance.id ,
clipName : firstClip
} ) ,
label : "Add play animation link"
} )
) ;
setStatusMessage ( "Added a play animation link." ) ;
} ;
const handleAddStopAnimationLink = ( sourceEntity : InteractionSourceEntity ) = > {
const firstInstance = modelInstanceDisplayList [ 0 ] ;
if ( firstInstance === undefined ) {
setStatusMessage ( "Place a model instance before adding an animation link." ) ;
return ;
}
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link : createStopAnimationInteractionLink ( {
sourceEntityId : sourceEntity.id ,
trigger : getDefaultInteractionLinkTrigger ( sourceEntity ) ,
targetModelInstanceId : firstInstance.modelInstance.id
} ) ,
label : "Add stop animation link"
} )
) ;
setStatusMessage ( "Added a stop animation link." ) ;
} ;
2026-03-31 06:48:15 +02:00
const renderInteractionLinksSection = (
sourceEntity : InteractionSourceEntity ,
links : InteractionLink [ ] ,
addTeleportTestId : string ,
2026-04-02 19:41:48 +02:00
addVisibilityTestId : string ,
addPlaySoundTestId : string ,
addStopSoundTestId : string
2026-03-31 06:48:15 +02:00
) = > (
< 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 >
2026-04-01 02:53:34 +02:00
< option value = "playAnimation" > Play Animation < / option >
< option value = "stopAnimation" > Stop Animation < / option >
2026-04-02 19:41:48 +02:00
< option value = "playSound" > Play Sound < / option >
< option value = "stopSound" > Stop Sound < / option >
2026-03-31 06:48:15 +02:00
< / 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 >
2026-04-01 02:53:51 +02:00
) : link . action . type === "toggleVisibility" ? (
2026-03-31 06:48:15 +02:00
< 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 >
2026-04-01 02:53:51 +02:00
) : link . action . type === "playAnimation" ? (
< div className = "form-section" >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Instance < / span >
< select
data - testid = { ` interaction-link-play-anim-instance- ${ link . id } ` }
className = "text-input"
value = { link . action . targetModelInstanceId }
onChange = { ( event ) = > updateAnimationInteractionLinkTarget ( link , event . currentTarget . value ) }
>
{ modelInstanceDisplayList . map ( ( { modelInstance , label } ) = > (
< option key = { modelInstance . id } value = { modelInstance . id } >
{ label }
< / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< span className = "label" > Clip < / span >
< select
data - testid = { ` interaction-link-play-anim-clip- ${ link . id } ` }
className = "text-input"
value = { link . action . clipName }
onChange = { ( event ) = > updatePlayAnimationLinkClip ( link , event . currentTarget . value ) }
>
{ ( editorState . document . assets [
editorState . document . modelInstances [ link . action . targetModelInstanceId ] ? . assetId ? ? ""
] as { kind : "model" ; metadata : { animationNames : string [ ] } } | undefined ) ? . metadata . animationNames . map ( ( name ) = > (
< option key = { name } value = { name } > { name } < / option >
) ) ? ? < option value = { link . action . clipName } > { link . action . clipName } < / option > }
< / select >
< / label >
< / div >
2026-04-01 04:06:24 +02:00
< label className = "form-field" >
< input
type = "checkbox"
data - testid = { ` interaction-link-play-anim-loop- ${ link . id } ` }
checked = { link . action . loop !== false }
onChange = { ( event ) = > updatePlayAnimationLinkLoop ( link , event . currentTarget . checked ) }
/ >
< span className = "label" > Loop < / span >
< / label >
2026-04-01 02:53:51 +02:00
< / div >
2026-04-02 19:41:57 +02:00
) : link . action . type === "playSound" || link . action . type === "stopSound" ? (
< div className = "form-section" >
< label className = "form-field" >
< span className = "label" > Emitter < / span >
< select
data - testid = { ` interaction-link-sound-target- ${ link . id } ` }
className = "text-input"
value = { link . action . targetSoundEmitterId }
onChange = { ( event ) = > updateSoundInteractionLinkTarget ( link , event . currentTarget . value ) }
>
{ soundEmitterOptions . map ( ( { entity , label } ) = > (
< option key = { entity . id } value = { entity . id } >
{ label }
< / option >
) ) }
< / select >
< / label >
< / div >
2026-04-01 02:53:51 +02:00
) : (
< div className = "form-section" >
< label className = "form-field" >
< span className = "label" > Instance < / span >
< select
data - testid = { ` interaction-link-stop-anim-instance- ${ link . id } ` }
className = "text-input"
value = { link . action . targetModelInstanceId }
onChange = { ( event ) = > updateAnimationInteractionLinkTarget ( link , event . currentTarget . value ) }
>
{ modelInstanceDisplayList . map ( ( { modelInstance , label } ) = > (
< option key = { modelInstance . id } value = { modelInstance . id } >
{ label }
< / option >
) ) }
< / select >
< / label >
< / div >
2026-03-31 06:48:15 +02:00
) }
< 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 >
2026-04-02 19:41:57 +02:00
2026-03-31 06:48:15 +02:00
< / 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 >
2026-04-01 02:54:01 +02:00
< button
className = "toolbar__button"
type = "button"
disabled = { modelInstanceDisplayList . length === 0 }
onClick = { ( ) = > handleAddPlayAnimationLink ( sourceEntity ) }
>
Add Play Anim Link
< / button >
< button
className = "toolbar__button"
type = "button"
disabled = { modelInstanceDisplayList . length === 0 }
onClick = { ( ) = > handleAddStopAnimationLink ( sourceEntity ) }
>
Add Stop Anim Link
< / button >
2026-04-02 19:42:22 +02:00
< button
className = "toolbar__button"
type = "button"
data - testid = { addPlaySoundTestId }
disabled = { playableSoundEmitterOptions . length === 0 }
onClick = { ( ) = > handleAddSoundInteractionLink ( "playSound" ) }
>
Add Play Sound Link
< / button >
< button
className = "toolbar__button"
type = "button"
data - testid = { addStopSoundTestId }
disabled = { playableSoundEmitterOptions . length === 0 }
onClick = { ( ) = > handleAddSoundInteractionLink ( "stopSound" ) }
>
Add Stop Sound Link
< / button >
2026-03-31 06:48:15 +02:00
< / 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-04-02 20:52:54 +02:00
const applyAdvancedRenderingSettings = (
label : string ,
successMessage : string ,
mutate : ( advancedRendering : AdvancedRenderingSettings ) = > void
) = > {
const nextWorld = cloneWorldSettings ( editorState . document . world ) ;
mutate ( nextWorld . advancedRendering ) ;
applyWorldSettings ( nextWorld , label , successMessage ) ;
} ;
2026-03-31 20:13:50 +02:00
const applyWorldBackgroundMode = ( mode : WorldBackgroundMode , imageAssetId? : string ) = > {
if ( mode === "image" ) {
2026-03-31 20:19:04 +02:00
const currentBackgroundAssetId =
editorState . document . world . background . mode === "image" ? editorState.document.world.background.assetId : null ;
2026-03-31 20:13:50 +02:00
const nextImageAssetId =
imageAssetId ? ?
2026-03-31 20:19:04 +02:00
( currentBackgroundAssetId !== null && editorState . document . assets [ currentBackgroundAssetId ] ? . kind === "image"
? currentBackgroundAssetId
2026-03-31 20:13:50 +02:00
: 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."
) ;
} ;
2026-03-31 23:15:34 +02:00
const applyBackgroundEnvironmentIntensity = ( ) = > {
if ( editorState . document . world . background . mode !== "image" ) {
return ;
}
const intensity = readNonNegativeNumberDraft ( backgroundEnvironmentIntensityDraft , "Environment intensity" ) ;
applyWorldSettings (
{
. . . editorState . document . world ,
background : {
. . . editorState . document . world . background ,
environmentIntensity : intensity
}
} ,
"Set background environment intensity" ,
"Updated the background environment intensity."
) ;
} ;
2026-03-31 05:12:23 +02:00
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-04-02 20:53:09 +02:00
const applyAdvancedRenderingEnabled = ( enabled : boolean ) = > {
applyAdvancedRenderingSettings (
"Set advanced rendering" ,
enabled ? "Advanced rendering enabled." : "Advanced rendering disabled." ,
( advancedRendering ) = > {
advancedRendering . enabled = enabled ;
}
) ;
} ;
const applyAdvancedRenderingShadowsEnabled = ( enabled : boolean ) = > {
applyAdvancedRenderingSettings (
"Set advanced rendering shadows" ,
enabled ? "Advanced rendering shadows enabled." : "Advanced rendering shadows disabled." ,
( advancedRendering ) = > {
advancedRendering . shadows . enabled = enabled ;
}
) ;
} ;
const applyAdvancedRenderingShadowMapSize = ( shadowMapSize : AdvancedRenderingShadowMapSize ) = > {
applyAdvancedRenderingSettings ( "Set advanced rendering shadow map size" , "Updated the shadow map size." , ( advancedRendering ) = > {
advancedRendering . shadows . mapSize = shadowMapSize ;
} ) ;
} ;
const applyAdvancedRenderingShadowType = ( shadowType : AdvancedRenderingShadowType ) = > {
applyAdvancedRenderingSettings ( "Set advanced rendering shadow type" , "Updated the shadow map type." , ( advancedRendering ) = > {
advancedRendering . shadows . type = shadowType ;
} ) ;
} ;
const applyAdvancedRenderingShadowBias = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set advanced rendering shadow bias" , "Updated the shadow bias." , ( advancedRendering ) = > {
advancedRendering . shadows . bias = readFiniteNumberDraft ( advancedRenderingShadowBiasDraft , "Shadow bias" ) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingAmbientOcclusionEnabled = ( enabled : boolean ) = > {
applyAdvancedRenderingSettings (
"Set ambient occlusion" ,
enabled ? "Ambient occlusion enabled." : "Ambient occlusion disabled." ,
( advancedRendering ) = > {
advancedRendering . ambientOcclusion . enabled = enabled ;
}
) ;
} ;
const applyAdvancedRenderingAmbientOcclusionIntensity = ( ) = > {
try {
applyAdvancedRenderingSettings (
"Set ambient occlusion intensity" ,
"Updated the ambient occlusion intensity." ,
( advancedRendering ) = > {
advancedRendering . ambientOcclusion . intensity = readNonNegativeNumberDraft (
advancedRenderingAmbientOcclusionIntensityDraft ,
"Ambient occlusion intensity"
) ;
}
) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingAmbientOcclusionRadius = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set ambient occlusion radius" , "Updated the ambient occlusion radius." , ( advancedRendering ) = > {
advancedRendering . ambientOcclusion . radius = readNonNegativeNumberDraft (
advancedRenderingAmbientOcclusionRadiusDraft ,
"Ambient occlusion radius"
) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingAmbientOcclusionSamples = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set ambient occlusion samples" , "Updated the ambient occlusion samples." , ( advancedRendering ) = > {
advancedRendering . ambientOcclusion . samples = readPositiveIntegerDraft (
advancedRenderingAmbientOcclusionSamplesDraft ,
"Ambient occlusion samples"
) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingBloomEnabled = ( enabled : boolean ) = > {
applyAdvancedRenderingSettings (
"Set bloom" ,
enabled ? "Bloom enabled." : "Bloom disabled." ,
( advancedRendering ) = > {
advancedRendering . bloom . enabled = enabled ;
}
) ;
} ;
const applyAdvancedRenderingBloomIntensity = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set bloom intensity" , "Updated the bloom intensity." , ( advancedRendering ) = > {
advancedRendering . bloom . intensity = readNonNegativeNumberDraft ( advancedRenderingBloomIntensityDraft , "Bloom intensity" ) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingBloomThreshold = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set bloom threshold" , "Updated the bloom threshold." , ( advancedRendering ) = > {
advancedRendering . bloom . threshold = readNonNegativeNumberDraft ( advancedRenderingBloomThresholdDraft , "Bloom threshold" ) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingBloomRadius = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set bloom radius" , "Updated the bloom radius." , ( advancedRendering ) = > {
advancedRendering . bloom . radius = readNonNegativeNumberDraft ( advancedRenderingBloomRadiusDraft , "Bloom radius" ) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingToneMappingMode = ( mode : AdvancedRenderingToneMappingMode ) = > {
applyAdvancedRenderingSettings ( "Set tone mapping mode" , "Updated the tone mapping mode." , ( advancedRendering ) = > {
advancedRendering . toneMapping . mode = mode ;
} ) ;
} ;
const applyAdvancedRenderingToneMappingExposure = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set tone mapping exposure" , "Updated the tone mapping exposure." , ( advancedRendering ) = > {
advancedRendering . toneMapping . exposure = readPositiveNumberDraft (
advancedRenderingToneMappingExposureDraft ,
"Tone mapping exposure"
) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingDepthOfFieldEnabled = ( enabled : boolean ) = > {
applyAdvancedRenderingSettings (
"Set depth of field" ,
enabled ? "Depth of field enabled." : "Depth of field disabled." ,
( advancedRendering ) = > {
advancedRendering . depthOfField . enabled = enabled ;
}
) ;
} ;
const applyAdvancedRenderingDepthOfFieldFocusDistance = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set focus distance" , "Updated the focus distance." , ( advancedRendering ) = > {
advancedRendering . depthOfField . focusDistance = readNonNegativeNumberDraft (
advancedRenderingDepthOfFieldFocusDistanceDraft ,
"Focus distance"
) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingDepthOfFieldFocalLength = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set focal length" , "Updated the focal length." , ( advancedRendering ) = > {
advancedRendering . depthOfField . focalLength = readPositiveNumberDraft (
advancedRenderingDepthOfFieldFocalLengthDraft ,
"Focal length"
) ;
} ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingDepthOfFieldBokehScale = ( ) = > {
try {
applyAdvancedRenderingSettings ( "Set bokeh scale" , "Updated the bokeh scale." , ( advancedRendering ) = > {
advancedRendering . depthOfField . bokehScale = readPositiveNumberDraft (
advancedRenderingDepthOfFieldBokehScaleDraft ,
"Bokeh scale"
) ;
} ) ;
} 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-04-03 01:03:58 +02:00
const applyEntityNameChange = ( ) = > {
if ( selectedEntity === null ) {
setStatusMessage ( "Select an entity before renaming it." ) ;
return ;
}
const nextName = normalizeEntityName ( entityNameDraft ) ;
if ( selectedEntity . name === nextName ) {
return ;
}
try {
store . executeCommand (
createSetEntityNameCommand ( {
entityId : selectedEntity.id ,
name : entityNameDraft
} )
) ;
setStatusMessage ( nextName === undefined ? "Cleared the authored entity name." : ` Renamed entity to ${ nextName } . ` ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyModelInstanceNameChange = ( ) = > {
if ( selectedModelInstance === null ) {
setStatusMessage ( "Select a model instance before renaming it." ) ;
return ;
}
const nextName = normalizeModelInstanceName ( modelInstanceNameDraft ) ;
if ( selectedModelInstance . name === nextName ) {
return ;
}
try {
store . executeCommand (
createSetModelInstanceNameCommand ( {
modelInstanceId : selectedModelInstance.id ,
name : modelInstanceNameDraft
} )
) ;
setStatusMessage ( nextName === undefined ? "Cleared the authored model instance name." : ` Renamed model instance to ${ nextName } . ` ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const handleInlineNameInputKeyDown = (
event : ReactKeyboardEvent < HTMLInputElement > ,
resetDraft : ( ) = > void
) = > {
if ( event . key === "Enter" ) {
event . preventDefault ( ) ;
event . currentTarget . blur ( ) ;
return ;
}
if ( event . key === "Escape" ) {
event . preventDefault ( ) ;
resetDraft ( ) ;
event . currentTarget . blur ( ) ;
}
} ;
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-04-02 19:43:13 +02:00
const handleImportAudioButtonClick = ( ) = > {
importAudioInputRef . 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-04-02 19:43:13 +02:00
const handleImportAudioChange = async ( event : ChangeEvent < HTMLInputElement > ) = > {
const input = event . currentTarget ;
const file = input . files ? . [ 0 ] ;
if ( file === undefined ) {
return ;
}
if ( projectAssetStorage === null ) {
setAssetStatusMessage ( "Imported audio assets require project asset storage. IndexedDB is unavailable in this browser." ) ;
input . value = "" ;
return ;
}
let importedAudioForCleanup : { asset : AudioAssetRecord ; loadedAsset : LoadedAudioAsset } | null = null ;
try {
const importedAudio = await importAudioAssetFromFile ( file , projectAssetStorage ) ;
importedAudioForCleanup = importedAudio ;
store . executeCommand (
createImportAudioAssetCommand ( {
asset : importedAudio.asset ,
label : ` Import ${ importedAudio . asset . sourceName } `
} )
) ;
loadedAudioAssetsRef . current = {
. . . loadedAudioAssetsRef . current ,
[ importedAudio . asset . id ] : importedAudio . loadedAsset
} ;
setLoadedAudioAssets ( ( currentLoadedAssets ) = > ( {
. . . currentLoadedAssets ,
[ importedAudio . asset . id ] : importedAudio . loadedAsset
} ) ) ;
setAssetStatusMessage ( null ) ;
setStatusMessage ( ` Imported ${ importedAudio . asset . sourceName } and registered it as an audio asset. ` ) ;
} catch ( error ) {
if ( importedAudioForCleanup !== null ) {
await projectAssetStorage . deleteAsset ( importedAudioForCleanup . asset . storageKey ) . catch ( ( ) = > undefined ) ;
}
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 , {
2026-04-04 07:55:42 +02:00
navigationMode : preferredNavigationMode ,
loadedModelAssets
2026-03-31 03:44:17 +02:00
} ) ;
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." ) ;
}
} ;
2026-04-03 00:43:25 +02:00
const createAssetMenuHoverHandler = ( assetId : string ) = > ( hovered : boolean ) = > {
setHoveredAssetId ( ( current ) = > ( hovered ? assetId : current === assetId ? null : current ) ) ;
} ;
const createDisabledMenuAction = ( label : string , testId : string ) : HierarchicalMenuItem = > ( {
kind : "action" ,
label ,
testId ,
disabled : true ,
onSelect : ( ) = > undefined
} ) ;
2026-04-02 23:24:01 +02:00
const addMenuItems : HierarchicalMenuItem [ ] = [
2026-04-03 00:43:25 +02:00
{
kind : "action" ,
label : "Box" ,
testId : "add-menu-box" ,
onSelect : beginBoxCreation
} ,
{
kind : "group" ,
label : "Entities" ,
testId : "add-menu-entities" ,
children : [
{
kind : "action" ,
label : "Player Start" ,
testId : "add-menu-player-start" ,
onSelect : ( ) = > beginEntityCreation ( "playerStart" )
} ,
{
kind : "action" ,
label : "Sound Emitter" ,
testId : "add-menu-sound-emitter" ,
onSelect : ( ) = > beginEntityCreation ( "soundEmitter" , { audioAssetId : audioAssetList [ 0 ] ? . id ? ? null } )
} ,
{
kind : "action" ,
label : "Trigger Volume" ,
testId : "add-menu-trigger-volume" ,
onSelect : ( ) = > beginEntityCreation ( "triggerVolume" )
} ,
{
kind : "action" ,
label : "Teleport Target" ,
testId : "add-menu-teleport-target" ,
onSelect : ( ) = > beginEntityCreation ( "teleportTarget" )
} ,
{
kind : "action" ,
label : "Interactable" ,
testId : "add-menu-interactable" ,
onSelect : ( ) = > beginEntityCreation ( "interactable" )
}
]
} ,
{
kind : "group" ,
label : "Lights" ,
testId : "add-menu-lights" ,
children : [
{
kind : "action" ,
label : "Point Light" ,
testId : "add-menu-point-light" ,
onSelect : ( ) = > beginEntityCreation ( "pointLight" )
} ,
{
kind : "action" ,
label : "Spot Light" ,
testId : "add-menu-spot-light" ,
onSelect : ( ) = > beginEntityCreation ( "spotLight" )
}
]
} ,
{
kind : "group" ,
label : "Assets" ,
testId : "add-menu-assets" ,
children : [
{
kind : "group" ,
label : "3D Models" ,
testId : "add-menu-assets-models" ,
children :
modelAssetList . length === 0
? [ createDisabledMenuAction ( "No imported 3D models" , "add-menu-assets-models-empty" ) ]
: modelAssetList . map ( ( asset ) = > ( {
kind : "action" as const ,
label : asset.sourceName ,
testId : ` add-menu-model-asset- ${ asset . id } ` ,
onSelect : ( ) = > beginModelInstanceCreation ( asset . id ) ,
onHoverChange : createAssetMenuHoverHandler ( asset . id )
} ) )
} ,
{
kind : "group" ,
label : "Environments" ,
testId : "add-menu-assets-environments" ,
children :
imageAssetList . length === 0
? [ createDisabledMenuAction ( "No imported environments" , "add-menu-assets-environments-empty" ) ]
: imageAssetList . map ( ( asset ) = > ( {
kind : "action" as const ,
label : asset.sourceName ,
testId : ` add-menu-image-asset- ${ asset . id } ` ,
onSelect : ( ) = > applyWorldBackgroundMode ( "image" , asset . id ) ,
onHoverChange : createAssetMenuHoverHandler ( asset . id )
} ) )
} ,
{
kind : "group" ,
label : "Audio" ,
testId : "add-menu-assets-audio" ,
children :
audioAssetList . length === 0
? [ createDisabledMenuAction ( "No imported audio" , "add-menu-assets-audio-empty" ) ]
: audioAssetList . map ( ( asset ) = > ( {
kind : "action" as const ,
label : asset.sourceName ,
testId : ` add-menu-audio-asset- ${ asset . id } ` ,
onSelect : ( ) = > beginEntityCreation ( "soundEmitter" , { audioAssetId : asset.id } ) ,
onHoverChange : createAssetMenuHoverHandler ( asset . id )
} ) )
}
]
} ,
2026-04-02 23:54:53 +02:00
{
kind : "group" ,
label : "Import" ,
testId : "add-menu-import" ,
children : [
{
kind : "action" ,
label : "3D Model (GLB/GLTF)" ,
testId : "import-menu-model" ,
2026-04-02 23:55:37 +02:00
disabled : ! projectAssetStorageReady || projectAssetStorage === null ,
2026-04-02 23:54:53 +02:00
onSelect : handleImportModelButtonClick
} ,
{
kind : "action" ,
label : "Environment" ,
testId : "import-menu-environment" ,
2026-04-02 23:55:37 +02:00
disabled : ! projectAssetStorageReady || projectAssetStorage === null ,
2026-04-02 23:54:53 +02:00
onSelect : handleImportBackgroundImageButtonClick
} ,
{
kind : "action" ,
label : "Audio" ,
testId : "import-menu-audio" ,
2026-04-02 23:55:37 +02:00
disabled : ! projectAssetStorageReady || projectAssetStorage === null ,
2026-04-02 23:54:53 +02:00
onSelect : handleImportAudioButtonClick
}
]
2026-04-03 00:43:25 +02:00
}
] ;
2026-04-02 23:24:01 +02:00
2026-04-03 01:26:21 +02:00
const viewportPanelsStyle = layoutMode === "quad" ? createViewportQuadPanelsStyle ( editorState . viewportQuadSplit ) : undefined ;
2026-03-31 03:09:04 +02:00
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-04-03 00:26:42 +02:00
< div className = "toolbar__subtitle" > Slice 3.1 GLB / GLTF import and unified creation < / 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 20:16:11 +02:00
loadedImageAssets = { loadedImageAssets }
2026-04-02 19:43:29 +02:00
loadedAudioAssets = { loadedAudioAssets }
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" >
2026-04-03 00:55:13 +02:00
< button
className = "toolbar__button toolbar__button--accent"
type = "button"
data - testid = "outliner-add-button"
aria - haspopup = "menu"
aria - expanded = { addMenuPosition !== null }
onClick = { handleOpenAddMenuFromButton }
>
Add
< / button >
2026-03-31 04:50:21 +02:00
< 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 >
< / div >
2026-04-02 23:24:37 +02:00
< div className = "toolbar__group" role = "group" aria-label = "Viewport layout mode" >
{ VIEWPORT_LAYOUT_MODES . map ( ( mode ) = > (
< button
key = { mode }
className = { ` toolbar__button toolbar__button--compact ${ editorState . viewportLayoutMode === mode ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = { ` viewport-layout- ${ mode } ` }
aria - pressed = { editorState . viewportLayoutMode === mode }
onClick = { ( ) = > handleSetViewportLayoutMode ( mode ) }
>
{ getViewportLayoutModeLabel ( mode ) }
< / button >
) ) }
< / div >
2026-04-03 02:15:38 +02:00
< div className = "toolbar__group" role = "group" aria-label = "Transform operations" >
< button
className = { ` toolbar__button ${ transformSession . kind === "active" && transformSession . operation === "translate" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "transform-translate-button"
aria - pressed = { transformSession . kind === "active" && transformSession . operation === "translate" }
disabled = { editorState . toolMode !== "select" || ! canTranslateSelectedTarget }
onClick = { ( ) = > beginTransformOperation ( "translate" , "toolbar" ) }
>
Move ( { getTransformOperationShortcut ( "translate" ) } )
< / button >
< button
className = { ` toolbar__button ${ transformSession . kind === "active" && transformSession . operation === "rotate" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "transform-rotate-button"
aria - pressed = { transformSession . kind === "active" && transformSession . operation === "rotate" }
disabled = { editorState . toolMode !== "select" || ! canRotateSelectedTarget }
onClick = { ( ) = > beginTransformOperation ( "rotate" , "toolbar" ) }
>
Rotate ( { getTransformOperationShortcut ( "rotate" ) } )
< / button >
< button
className = { ` toolbar__button ${ transformSession . kind === "active" && transformSession . operation === "scale" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "transform-scale-button"
aria - pressed = { transformSession . kind === "active" && transformSession . operation === "scale" }
disabled = { editorState . toolMode !== "select" || ! canScaleSelectedTarget }
onClick = { ( ) = > beginTransformOperation ( "scale" , "toolbar" ) }
>
Scale ( { getTransformOperationShortcut ( "scale" ) } )
< / button >
< / div >
2026-03-31 03:44:17 +02:00
< 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" >
2026-04-03 00:43:51 +02:00
< Panel title = "Outliner" >
2026-03-31 17:43:20 +02:00
{ 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 03:09:04 +02:00
< div className = "outliner-section" >
< div className = "label" > Brushes < / div >
{ brushList . length === 0 ? (
2026-04-03 00:43:51 +02:00
< div className = "outliner-empty" > Use Add & gt ; Box and click in the viewport to create the first brush . < / div >
2026-03-31 03:09:04 +02:00
) : (
< div className = "outliner-list" data-testid = "outliner-brush-list" >
2026-04-03 00:55:13 +02:00
{ brushList . map ( ( brush , brushIndex ) = > {
const label = getBrushLabel ( brush , brushIndex ) ;
2026-04-03 01:04:33 +02:00
const isSelected = selectedBrush ? . id === brush . id ;
2026-04-03 00:55:13 +02:00
return (
< div
key = { brush . id }
className = { ` outliner-item outliner-item--compact ${ isBrushSelected ( editorState . selection , brush . id ) ? "outliner-item--selected" : "" } ` }
2026-03-31 04:25:56 +02:00
>
2026-04-03 00:55:13 +02:00
< div className = "outliner-item__row" >
2026-04-03 01:04:33 +02:00
{ isSelected ? (
< input
className = "outliner-item__rename"
data - testid = "selected-brush-name"
type = "text"
value = { brushNameDraft }
placeholder = { ` Box Brush ${ brushIndex + 1 } ` }
onChange = { ( event ) = > setBrushNameDraft ( event . currentTarget . value ) }
onBlur = { applyBrushNameChange }
onFocus = { ( event ) = > event . currentTarget . select ( ) }
onKeyDown = { ( event ) = >
handleInlineNameInputKeyDown ( event , ( ) = > {
setBrushNameDraft ( selectedBrush ? . name ? ? "" ) ;
} )
}
/ >
) : (
< 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" > { label } < / span >
< / button >
) }
2026-04-03 00:55:13 +02:00
< button
className = "outliner-item__delete"
type = "button"
data - testid = { ` outliner-delete-brush- ${ brush . id } ` }
aria - label = { ` Delete ${ label } ` }
onClick = { ( ) = > handleDeleteBrush ( brush . id ) }
>
x
< / button >
< / div >
< / 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" >
2026-04-03 01:04:33 +02:00
{ modelInstanceDisplayList . map ( ( { modelInstance , label } ) = > {
const isSelected =
editorState . selection . kind === "modelInstances" && editorState . selection . ids . includes ( modelInstance . id ) ;
return (
< div
key = { modelInstance . id }
className = { ` outliner-item ${ isSelected ? "outliner-item--selected" : "" } outliner-item--compact ` }
>
< div className = "outliner-item__row" >
{ isSelected ? (
< input
className = "outliner-item__rename"
data - testid = "selected-model-instance-name"
type = "text"
value = { modelInstanceNameDraft }
placeholder = { editorState . document . assets [ modelInstance . assetId ] ? . sourceName ? ? "Model Instance" }
onChange = { ( event ) = > setModelInstanceNameDraft ( event . currentTarget . value ) }
onBlur = { applyModelInstanceNameChange }
onFocus = { ( event ) = > event . currentTarget . select ( ) }
onKeyDown = { ( event ) = >
handleInlineNameInputKeyDown ( event , ( ) = > {
setModelInstanceNameDraft ( selectedModelInstance ? . name ? ? "" ) ;
} )
2026-04-03 00:55:13 +02:00
}
2026-04-03 01:04:33 +02:00
/ >
) : (
< button
data - testid = { ` outliner-model-instance- ${ modelInstance . id } ` }
className = "outliner-item__select"
type = "button"
onClick = { ( ) = >
applySelection (
{
kind : "modelInstances" ,
ids : [ modelInstance . id ]
} ,
"outliner" ,
{
focusViewport : true
}
)
}
>
< span className = "outliner-item__title" > { label } < / span >
< / button >
) }
< button
className = "outliner-item__delete"
type = "button"
data - testid = { ` outliner-delete-model-instance- ${ modelInstance . id } ` }
aria - label = { ` Delete ${ label } ` }
onClick = { ( ) = > handleDeleteModelInstance ( modelInstance . id ) }
>
x
< / button >
< / div >
2026-04-03 00:55:13 +02:00
< / div >
2026-04-03 01:04:33 +02:00
) ;
} ) }
2026-03-31 17:43:20 +02:00
< / 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
2026-04-02 23:24:37 +02:00
{ entityDisplayList . length === 0 ? < div className = "outliner-empty" > No entities authored yet . < / div > : null }
2026-03-31 05:56:22 +02:00
{ entityDisplayList . length === 0 ? null : (
2026-03-31 03:09:04 +02:00
< div className = "outliner-list" >
2026-04-03 01:04:33 +02:00
{ entityDisplayList . map ( ( { entity , label } ) = > {
const isSelected = editorState . selection . kind === "entities" && editorState . selection . ids . includes ( entity . id ) ;
return (
< div
key = { entity . id }
className = { ` outliner-item ${ isSelected ? "outliner-item--selected" : "" } outliner-item--compact ` }
>
< div className = "outliner-item__row" >
{ isSelected ? (
< input
className = "outliner-item__rename"
data - testid = "selected-entity-name"
type = "text"
value = { entityNameDraft }
placeholder = { getEntityKindLabel ( entity . kind ) }
onChange = { ( event ) = > setEntityNameDraft ( event . currentTarget . value ) }
onBlur = { applyEntityNameChange }
onFocus = { ( event ) = > event . currentTarget . select ( ) }
onKeyDown = { ( event ) = >
handleInlineNameInputKeyDown ( event , ( ) = > {
setEntityNameDraft ( selectedEntity ? . name ? ? "" ) ;
} )
}
/ >
) : (
< button
data - testid = { ` outliner-entity- ${ entity . id } ` }
className = "outliner-item__select"
type = "button"
onClick = { ( ) = >
applySelection (
{
kind : "entities" ,
ids : [ entity . id ]
} ,
"outliner" ,
{
focusViewport : true
}
)
2026-04-03 00:55:13 +02:00
}
2026-04-03 01:04:33 +02:00
>
< span className = "outliner-item__title" > { label } < / span >
< / button >
) }
< button
className = "outliner-item__delete"
type = "button"
data - testid = { ` outliner-delete-entity- ${ entity . id } ` }
aria - label = { ` Delete ${ label } ` }
onClick = { ( ) = > handleDeleteEntity ( entity . id ) }
>
x
< / button >
< / div >
2026-04-03 00:55:13 +02:00
< / div >
2026-04-03 01:04:33 +02:00
) ;
} ) }
2026-03-31 03:09:04 +02:00
< / div >
) }
< / div >
2026-03-31 02:37:22 +02:00
< / Panel >
< / aside >
2026-04-02 22:35:31 +02:00
< main className = { ` viewport-region viewport-region-- ${ layoutMode } ` } data-testid = "viewport-shell" >
2026-04-03 01:26:48 +02:00
< div
ref = { viewportPanelsRef }
className = { ` viewport-region__panels viewport-region__panels-- ${ layoutMode } ${
viewportQuadResizeMode === null ? "" : "viewport-region__panels--resizing"
} ` .trim()}
style = { viewportPanelsStyle }
>
2026-04-02 22:33:53 +02:00
{ VIEWPORT_PANEL_IDS . map ( ( panelId ) = > (
2026-04-03 01:26:48 +02:00
< ViewportPanel
key = { panelId }
panelId = { panelId }
className = { ` viewport-panel-- ${ panelId } ` }
2026-04-02 22:33:53 +02:00
panelState = { editorState . viewportPanels [ panelId ] }
layoutMode = { layoutMode }
isActive = { activePanelId === panelId }
world = { editorState . document . world }
sceneDocument = { editorState . document }
projectAssets = { editorState . document . assets }
loadedModelAssets = { loadedModelAssets }
loadedImageAssets = { loadedImageAssets }
selection = { editorState . selection }
toolMode = { editorState . toolMode }
2026-04-02 23:01:41 +02:00
toolPreview = { viewportToolPreview }
2026-04-03 02:15:38 +02:00
transformSession = { transformSession }
2026-04-03 01:37:16 +02:00
cameraState = { editorState . viewportPanels [ panelId ] . cameraState }
2026-04-02 22:33:53 +02:00
focusRequestId = { focusRequest . panelId === panelId ? focusRequest.id : 0 }
focusSelection = { focusRequest . selection }
onActivatePanel = { handleActivateViewportPanel }
onSetPanelViewMode = { handleSetViewportPanelViewMode }
onSetPanelDisplayMode = { handleSetViewportPanelDisplayMode }
2026-04-03 00:23:14 +02:00
onCommitCreation = { handleCommitCreation }
2026-04-03 01:37:16 +02:00
onCameraStateChange = { ( cameraState ) = > {
store . setViewportPanelCameraState ( panelId , cameraState ) ;
} }
2026-04-02 23:02:50 +02:00
onToolPreviewChange = { ( toolPreview ) = > {
store . setViewportToolPreview ( toolPreview ) ;
} }
2026-04-03 02:15:38 +02:00
onTransformSessionChange = { ( nextTransformSession ) = > {
store . setTransformSession ( nextTransformSession ) ;
} }
onTransformCommit = { commitTransformSession }
onTransformCancel = { ( ) = > cancelTransformSession ( ) }
2026-04-02 22:33:53 +02:00
onSelectionChange = { ( selection ) = > applySelection ( selection , "viewport" ) }
/ >
) ) }
2026-04-03 01:26:48 +02:00
{ layoutMode !== "quad" ? null : (
< >
< div
className = "viewport-region__splitter viewport-region__splitter--vertical"
data - testid = "viewport-quad-splitter-vertical"
onPointerDown = { handleViewportQuadResizeStart ( "vertical" ) }
/ >
< div
className = "viewport-region__splitter viewport-region__splitter--horizontal"
data - testid = "viewport-quad-splitter-horizontal"
onPointerDown = { handleViewportQuadResizeStart ( "horizontal" ) }
/ >
< div
className = "viewport-region__splitter viewport-region__splitter--center"
data - testid = "viewport-quad-splitter-center"
onPointerDown = { handleViewportQuadResizeStart ( "center" ) }
/ >
< / >
) }
2026-04-02 22:33:53 +02:00
< / div >
2026-03-31 02:37:22 +02:00
< / main >
< aside className = "side-column" >
2026-04-01 04:58:25 +02:00
{ editorState . selection . kind === "none" ? (
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"
2026-03-31 20:16:06 +02:00
style = { createWorldBackgroundStyle (
editorState . document . world . background ,
editorState . document . world . background . mode === "image"
? loadedImageAssets [ editorState . document . world . background . assetId ] ? . sourceUrl ? ? null
: null
) }
2026-03-31 05:13:07 +02:00
/ >
< div className = "material-summary" >
{ editorState . document . world . background . mode === "solid"
? editorState . document . world . background . colorHex
2026-03-31 20:16:06 +02:00
: editorState . document . world . background . mode === "verticalGradient"
? ` ${ editorState . document . world . background . topColorHex } -> ${ editorState . document . world . background . bottomColorHex } `
: editorState . document . assets [ editorState . document . world . background . assetId ] ? . sourceName ? ?
editorState . document . world . background . assetId }
2026-03-31 05:13:07 +02:00
< / div >
2026-03-31 20:16:06 +02:00
{ editorState . document . world . background . mode !== "image" ? null : (
< div className = "material-summary" data-testid = "world-background-asset-value" >
Background Asset : { " " }
{ editorState . document . assets [ editorState . document . world . background . assetId ] ? . sourceName ? ?
editorState . document . world . background . assetId }
< / div >
) }
2026-03-31 05:13:07 +02:00
< / 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 >
2026-03-31 20:16:06 +02:00
< button
className = { ` toolbar__button ${ editorState . document . world . background . mode === "image" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "world-background-mode-image"
onClick = { ( ) = > applyWorldBackgroundMode ( "image" ) }
>
Image
< / button >
2026-03-31 05:13:07 +02:00
< / div >
< / div >
2026-03-31 23:15:56 +02:00
{ editorState . document . world . background . mode === "image" && (
< div className = "form-section" >
< div className = "label" > Environment Intensity < / div >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
data - testid = "world-background-environment-intensity"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { backgroundEnvironmentIntensityDraft }
onChange = { ( event ) = > setBackgroundEnvironmentIntensityDraft ( event . currentTarget . value ) }
onBlur = { applyBackgroundEnvironmentIntensity }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyBackgroundEnvironmentIntensity ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyBackgroundEnvironmentIntensity ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyBackgroundEnvironmentIntensity ) }
/ >
< / label >
< / div >
) }
2026-03-31 22:59:39 +02:00
{ editorState . document . world . background . mode !== "image" && (
< div className = "form-section" >
< div className = "label" > Background Colors < / div >
{ editorState . document . world . background . mode === "solid" ? (
2026-03-31 05:13:07 +02:00
< label className = "form-field" >
2026-03-31 22:59:39 +02:00
< span className = "label" > Color < / span >
2026-03-31 05:13:07 +02:00
< input
2026-03-31 22:59:39 +02:00
data - testid = "world-background-solid-color"
2026-03-31 05:13:07 +02:00
className = "color-input"
type = "color"
2026-03-31 22:59:39 +02:00
value = { editorState . document . world . background . colorHex }
onChange = { ( event ) = > applyWorldBackgroundColor ( event . currentTarget . value ) }
2026-03-31 05:13:07 +02:00
/ >
< / label >
2026-03-31 22:59:39 +02:00
) : (
< 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 >
) }
2026-03-31 05:13:07 +02:00
< 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 >
2026-04-02 20:54:20 +02:00
< div className = "form-section" >
< div className = "label" > Advanced Rendering < / div >
< label className = "form-field form-field--toggle" >
< span className = "label" > Advanced Rendering < / span >
< input
type = "checkbox"
checked = { advancedRendering . enabled }
onChange = { ( event ) = > applyAdvancedRenderingEnabled ( event . currentTarget . checked ) }
/ >
< / label >
{ ! advancedRendering . enabled ? null : (
< >
< div className = "form-section" >
< div className = "label" > Shadows < / div >
< label className = "form-field form-field--toggle" >
< span className = "label" > Enabled < / span >
< input
type = "checkbox"
checked = { advancedRendering . shadows . enabled }
onChange = { ( event ) = > applyAdvancedRenderingShadowsEnabled ( event . currentTarget . checked ) }
/ >
< / label >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Shadow Map Size < / span >
< select
className = "select-input"
value = { advancedRendering . shadows . mapSize }
onChange = { ( event ) = >
applyAdvancedRenderingShadowMapSize ( Number ( event . currentTarget . value ) as AdvancedRenderingShadowMapSize )
}
>
{ ADVANCED_RENDERING_SHADOW_MAP_SIZES . map ( ( size ) = > (
< option key = { size } value = { size } >
{ size }
< / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< span className = "label" > Shadow Type < / span >
< select
className = "select-input"
value = { advancedRendering . shadows . type }
onChange = { ( event ) = > applyAdvancedRenderingShadowType ( event . currentTarget . value as AdvancedRenderingShadowType ) }
>
{ ADVANCED_RENDERING_SHADOW_TYPES . map ( ( shadowType ) = > (
< option key = { shadowType } value = { shadowType } >
{ formatAdvancedRenderingShadowTypeLabel ( shadowType ) }
< / option >
) ) }
< / select >
< / label >
< / div >
< label className = "form-field" >
< span className = "label" > Bias < / span >
< input
className = "text-input"
type = "number"
step = "0.0001"
value = { advancedRenderingShadowBiasDraft }
onChange = { ( event ) = > setAdvancedRenderingShadowBiasDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingShadowBias }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingShadowBias ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingShadowBias ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingShadowBias ) }
/ >
< / label >
< / div >
< div className = "form-section" >
< div className = "label" > Ambient Occlusion < / div >
< label className = "form-field form-field--toggle" >
< span className = "label" > Enabled < / span >
< input
type = "checkbox"
checked = { advancedRendering . ambientOcclusion . enabled }
onChange = { ( event ) = > applyAdvancedRenderingAmbientOcclusionEnabled ( event . currentTarget . checked ) }
/ >
< / label >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { advancedRenderingAmbientOcclusionIntensityDraft }
onChange = { ( event ) = > setAdvancedRenderingAmbientOcclusionIntensityDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingAmbientOcclusionIntensity }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingAmbientOcclusionIntensity ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingAmbientOcclusionIntensity ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingAmbientOcclusionIntensity ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Radius < / span >
< input
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { advancedRenderingAmbientOcclusionRadiusDraft }
onChange = { ( event ) = > setAdvancedRenderingAmbientOcclusionRadiusDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingAmbientOcclusionRadius }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingAmbientOcclusionRadius ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingAmbientOcclusionRadius ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingAmbientOcclusionRadius ) }
/ >
< / label >
< / div >
< label className = "form-field" >
< span className = "label" > Samples < / span >
< input
className = "text-input"
type = "number"
min = "1"
step = "1"
value = { advancedRenderingAmbientOcclusionSamplesDraft }
onChange = { ( event ) = > setAdvancedRenderingAmbientOcclusionSamplesDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingAmbientOcclusionSamples }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingAmbientOcclusionSamples ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingAmbientOcclusionSamples ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingAmbientOcclusionSamples ) }
/ >
< / label >
< / div >
< div className = "form-section" >
< div className = "label" > Bloom < / div >
< label className = "form-field form-field--toggle" >
< span className = "label" > Enabled < / span >
< input
type = "checkbox"
checked = { advancedRendering . bloom . enabled }
onChange = { ( event ) = > applyAdvancedRenderingBloomEnabled ( event . currentTarget . checked ) }
/ >
< / label >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { advancedRenderingBloomIntensityDraft }
onChange = { ( event ) = > setAdvancedRenderingBloomIntensityDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingBloomIntensity }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingBloomIntensity ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingBloomIntensity ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingBloomIntensity ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Threshold < / span >
< input
className = "text-input"
type = "number"
min = "0"
step = "0.05"
value = { advancedRenderingBloomThresholdDraft }
onChange = { ( event ) = > setAdvancedRenderingBloomThresholdDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingBloomThreshold }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingBloomThreshold ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingBloomThreshold ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingBloomThreshold ) }
/ >
< / label >
< / div >
< label className = "form-field" >
< span className = "label" > Radius < / span >
< input
className = "text-input"
type = "number"
min = "0"
step = "0.05"
value = { advancedRenderingBloomRadiusDraft }
onChange = { ( event ) = > setAdvancedRenderingBloomRadiusDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingBloomRadius }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingBloomRadius ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingBloomRadius ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingBloomRadius ) }
/ >
< / label >
< / div >
< div className = "form-section" >
< div className = "label" > Tone Mapping < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Mode < / span >
< select
className = "select-input"
value = { advancedRendering . toneMapping . mode }
onChange = { ( event ) = >
applyAdvancedRenderingToneMappingMode ( event . currentTarget . value as AdvancedRenderingToneMappingMode )
}
>
{ ADVANCED_RENDERING_TONE_MAPPING_MODES . map ( ( mode ) = > (
< option key = { mode } value = { mode } >
{ formatAdvancedRenderingToneMappingLabel ( mode ) }
< / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< span className = "label" > Exposure < / span >
< input
className = "text-input"
type = "number"
min = "0.001"
step = "0.1"
value = { advancedRenderingToneMappingExposureDraft }
onChange = { ( event ) = > setAdvancedRenderingToneMappingExposureDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingToneMappingExposure }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingToneMappingExposure ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingToneMappingExposure ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingToneMappingExposure ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Depth of Field < / div >
< label className = "form-field form-field--toggle" >
< span className = "label" > Enabled < / span >
< input
type = "checkbox"
checked = { advancedRendering . depthOfField . enabled }
onChange = { ( event ) = > applyAdvancedRenderingDepthOfFieldEnabled ( event . currentTarget . checked ) }
/ >
< / label >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Focus Distance < / span >
< input
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { advancedRenderingDepthOfFieldFocusDistanceDraft }
onChange = { ( event ) = > setAdvancedRenderingDepthOfFieldFocusDistanceDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingDepthOfFieldFocusDistance }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingDepthOfFieldFocusDistance ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingDepthOfFieldFocusDistance ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingDepthOfFieldFocusDistance ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Focal Length < / span >
< input
className = "text-input"
type = "number"
min = "0.001"
step = "0.001"
value = { advancedRenderingDepthOfFieldFocalLengthDraft }
onChange = { ( event ) = > setAdvancedRenderingDepthOfFieldFocalLengthDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingDepthOfFieldFocalLength }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingDepthOfFieldFocalLength ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingDepthOfFieldFocalLength ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingDepthOfFieldFocalLength ) }
/ >
< / label >
< / div >
< label className = "form-field" >
< span className = "label" > Bokeh Scale < / span >
< input
className = "text-input"
type = "number"
min = "0.001"
step = "0.1"
value = { advancedRenderingDepthOfFieldBokehScaleDraft }
onChange = { ( event ) = > setAdvancedRenderingDepthOfFieldBokehScaleDraft ( event . currentTarget . value ) }
onBlur = { applyAdvancedRenderingDepthOfFieldBokehScale }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyAdvancedRenderingDepthOfFieldBokehScale ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyAdvancedRenderingDepthOfFieldBokehScale ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyAdvancedRenderingDepthOfFieldBokehScale ) }
/ >
< / label >
< / div >
< / >
) }
< / div >
2026-03-31 05:13:07 +02:00
< / Panel >
2026-04-01 04:58:36 +02:00
) : (
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 >
2026-04-04 07:55:42 +02:00
< div className = "form-section" >
< div className = "label" > Collision < / div >
< label className = "form-field" >
< span className = "label" > Mode < / span >
< select
data - testid = "model-instance-collision-mode"
className = "select-input"
value = { selectedModelInstance . collision . mode }
onChange = { ( event ) = > {
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : {
. . . selectedModelInstance ,
collision : {
. . . selectedModelInstance . collision ,
mode : event.target.value as ModelInstanceCollisionMode
}
} ,
label : "Set model collision mode"
} )
) ;
} }
>
{ MODEL_INSTANCE_COLLISION_MODES . map ( ( mode ) = > (
< option key = { mode } value = { mode } >
{ mode }
< / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< input
data - testid = "model-instance-collision-visible"
type = "checkbox"
checked = { selectedModelInstance . collision . visible }
onChange = { ( event ) = > {
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : {
. . . selectedModelInstance ,
collision : {
. . . selectedModelInstance . collision ,
visible : event.target.checked
}
} ,
label : event.target.checked ? "Show model collision debug" : "Hide model collision debug"
} )
) ;
} }
/ >
< span className = "label" > Show generated collision debug < / span >
< / label >
< div className = "material-summary" > { getModelInstanceCollisionModeDescription ( selectedModelInstance . collision . mode ) } < / div >
< / div >
2026-04-01 00:14:20 +02:00
{ selectedModelAssetRecord !== null && selectedModelAssetRecord . metadata . animationNames . length > 0 && (
< div className = "form-section" >
< div className = "label" > Animation < / div >
< label className = "form-field" >
< span className = "label" > Clip < / span >
< select
className = "select-input"
value = { selectedModelInstance . animationClipName ? ? "" }
onChange = { ( e ) = > {
const clipName = e . target . value || undefined ;
2026-04-01 00:14:38 +02:00
store . executeCommand (
2026-04-01 00:14:20 +02:00
createUpsertModelInstanceCommand ( {
modelInstance : { . . . selectedModelInstance , animationClipName : clipName } ,
label : "Set animation clip"
} )
) ;
} }
>
< option value = "" > — none — < / option >
{ selectedModelAssetRecord . metadata . animationNames . map ( ( name ) = > (
< option key = { name } value = { name } > { name } < / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< input
type = "checkbox"
checked = { selectedModelInstance . animationAutoplay ? ? false }
onChange = { ( e ) = > {
2026-04-01 00:14:42 +02:00
store . executeCommand (
2026-04-01 00:14:20 +02:00
createUpsertModelInstanceCommand ( {
modelInstance : { . . . selectedModelInstance , animationAutoplay : e.target.checked } ,
label : "Set animation autoplay"
} )
) ;
} }
/ >
< span className = "label" > Autoplay on scene load < / span >
< / label >
< / div >
) }
2026-03-31 17:43:54 +02:00
< 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 20:14:33 +02:00
onBlur = { applySelectedEntityDraftChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySelectedEntityDraftChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySelectedEntityDraftChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySelectedEntityDraftChange ) }
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 20:14:39 +02:00
onBlur = { applySelectedEntityDraftChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySelectedEntityDraftChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySelectedEntityDraftChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySelectedEntityDraftChange ) }
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 20:14:45 +02:00
onBlur = { applySelectedEntityDraftChange }
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySelectedEntityDraftChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySelectedEntityDraftChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySelectedEntityDraftChange ) }
2026-03-31 05:57:54 +02:00
/ >
< / label >
< / div >
< / div >
2026-03-31 20:15:10 +02:00
{ selectedPointLight !== null ? (
< >
< div className = "form-section" >
< div className = "label" > Light < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "point-light-color"
className = "color-input"
type = "color"
value = { pointLightColorDraft }
onChange = { ( event ) = > {
2026-04-02 20:19:41 +02:00
const nextColorHex = event . currentTarget . value ;
setPointLightColorDraft ( nextColorHex ) ;
2026-04-02 20:29:50 +02:00
scheduleDraftCommit ( ( ) = > applyPointLightChange ( { colorHex : nextColorHex } ) ) ;
2026-03-31 20:15:10 +02:00
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
data - testid = "point-light-intensity"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { pointLightIntensityDraft }
onChange = { ( event ) = > setPointLightIntensityDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applyPointLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPointLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPointLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPointLightChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Range < / div >
< label className = "form-field" >
< span className = "label" > Distance < / span >
< input
data - testid = "point-light-distance"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { pointLightDistanceDraft }
onChange = { ( event ) = > setPointLightDistanceDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applyPointLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPointLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPointLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPointLightChange ) }
/ >
< / label >
< / div >
< / >
) : null }
{ selectedSpotLight !== null ? (
< >
< div className = "form-section" >
< div className = "label" > Light < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "spot-light-color"
className = "color-input"
type = "color"
value = { spotLightColorDraft }
onChange = { ( event ) = > {
2026-04-02 20:19:41 +02:00
const nextColorHex = event . currentTarget . value ;
setSpotLightColorDraft ( nextColorHex ) ;
2026-04-02 20:29:50 +02:00
scheduleDraftCommit ( ( ) = > applySpotLightChange ( { colorHex : nextColorHex } ) ) ;
2026-03-31 20:15:10 +02:00
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
data - testid = "spot-light-intensity"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { spotLightIntensityDraft }
onChange = { ( event ) = > setSpotLightIntensityDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySpotLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySpotLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySpotLightChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Range < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Distance < / span >
< input
data - testid = "spot-light-distance"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { spotLightDistanceDraft }
onChange = { ( event ) = > setSpotLightDistanceDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySpotLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySpotLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySpotLightChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Angle < / span >
< input
data - testid = "spot-light-angle"
className = "text-input"
type = "number"
min = "1"
max = "179"
step = "1"
value = { spotLightAngleDraft }
onChange = { ( event ) = > setSpotLightAngleDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySpotLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySpotLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySpotLightChange ) }
/ >
< / label >
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Direction < / div >
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > X < / span >
< input
data - testid = "spot-light-direction-x"
className = "text-input"
type = "number"
step = "0.1"
value = { spotLightDirectionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSpotLightDirectionDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySpotLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySpotLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySpotLightChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "spot-light-direction-y"
className = "text-input"
type = "number"
step = "0.1"
value = { spotLightDirectionDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSpotLightDirectionDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySpotLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySpotLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySpotLightChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "spot-light-direction-z"
className = "text-input"
type = "number"
step = "0.1"
value = { spotLightDirectionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSpotLightDirectionDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
2026-03-31 20:15:10 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySpotLightChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySpotLightChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySpotLightChange ) }
/ >
< / label >
< / div >
< / div >
< / >
) : null }
2026-03-31 05:57:54 +02:00
{ selectedPlayerStart !== null ? (
2026-04-04 15:56:54 +02:00
< >
< 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-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
< / div >
< div className = "form-section" >
< div className = "label" > Player Collider < / div >
< label className = "form-field" >
< span className = "label" > Mode < / span >
< select
data - testid = "player-start-collider-mode"
className = "select-input"
value = { playerStartColliderModeDraft }
onChange = { ( event ) = > {
const nextMode = event . currentTarget . value as PlayerStartColliderMode ;
setPlayerStartColliderModeDraft ( nextMode ) ;
scheduleDraftCommit ( ( ) = > applyPlayerStartChange ( { colliderMode : nextMode } ) ) ;
} }
>
{ PLAYER_START_COLLIDER_MODES . map ( ( mode ) = > (
< option key = { mode } value = { mode } >
{ mode }
< / option >
) ) }
< / select >
< / label >
< label className = "form-field" >
< span className = "label" > Eye Height < / span >
< input
data - testid = "player-start-eye-height"
className = "text-input"
type = "number"
min = "0.01"
step = "0.1"
value = { playerStartEyeHeightDraft }
onChange = { ( event ) = > setPlayerStartEyeHeightDraft ( event . currentTarget . value ) }
2026-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
{ playerStartColliderModeDraft === "capsule" ? (
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > Radius < / span >
< input
data - testid = "player-start-capsule-radius"
className = "text-input"
type = "number"
min = "0.01"
step = "0.1"
value = { playerStartCapsuleRadiusDraft }
onChange = { ( event ) = > setPlayerStartCapsuleRadiusDraft ( event . currentTarget . value ) }
2026-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Height < / span >
< input
data - testid = "player-start-capsule-height"
className = "text-input"
type = "number"
min = "0.01"
step = "0.1"
value = { playerStartCapsuleHeightDraft }
onChange = { ( event ) = > setPlayerStartCapsuleHeightDraft ( event . currentTarget . value ) }
2026-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
< / div >
) : null }
{ playerStartColliderModeDraft === "box" ? (
< div className = "vector-inputs" >
< label className = "form-field" >
< span className = "label" > Size X < / span >
< input
data - testid = "player-start-box-size-x"
className = "text-input"
type = "number"
min = "0.01"
step = "0.1"
value = { playerStartBoxSizeDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPlayerStartBoxSizeDraft ( ( draft ) = > ( { . . . draft , x : nextValue } ) ) ;
} }
2026-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Size Y < / span >
< input
data - testid = "player-start-box-size-y"
className = "text-input"
type = "number"
min = "0.01"
step = "0.1"
value = { playerStartBoxSizeDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPlayerStartBoxSizeDraft ( ( draft ) = > ( { . . . draft , y : nextValue } ) ) ;
} }
2026-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Size Z < / span >
< input
data - testid = "player-start-box-size-z"
className = "text-input"
type = "number"
min = "0.01"
step = "0.1"
value = { playerStartBoxSizeDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPlayerStartBoxSizeDraft ( ( draft ) = > ( { . . . draft , z : nextValue } ) ) ;
} }
2026-04-04 16:01:18 +02:00
onBlur = { ( ) = > applyPlayerStartChange ( ) }
2026-04-04 15:56:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applyPlayerStartChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applyPlayerStartChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applyPlayerStartChange ) }
/ >
< / label >
< / div >
) : null }
< div className = "material-summary" > { getPlayerStartColliderModeDescription ( playerStartColliderModeDraft ) } < / div >
< / div >
< / >
2026-03-31 05:57:54 +02:00
) : null }
{ selectedSoundEmitter !== null ? (
< >
< div className = "form-section" >
2026-04-02 19:41:01 +02:00
< div className = "label" > Audio Asset < / div >
< div className = "stat-card" >
< div className = "value" >
{ selectedSoundEmitter . audioAssetId === null
? "Unassigned"
: selectedSoundEmitterAudioAssetRecord ? . sourceName ? ? "Missing Audio Asset" }
< / div >
< div className = "material-summary" >
{ selectedSoundEmitter . audioAssetId === null
? "Choose an audio asset to make this emitter playable."
: selectedSoundEmitterAudioAssetRecord === null
? ` This sound emitter references ${ selectedSoundEmitter . audioAssetId } , but the asset is missing or not audio. `
: formatAudioAssetSummary ( selectedSoundEmitterAudioAssetRecord ) }
< / div >
< / div >
< label className = "form-field" >
< span className = "label" > Audio < / span >
< select
data - testid = "sound-emitter-audio-asset"
className = "text-input"
value = { soundEmitterAudioAssetIdDraft }
onChange = { ( event ) = > {
2026-04-02 20:19:41 +02:00
const nextAudioAssetId = event . currentTarget . value . trim ( ) ;
setSoundEmitterAudioAssetIdDraft ( nextAudioAssetId ) ;
2026-04-02 20:29:50 +02:00
scheduleDraftCommit ( ( ) = >
applySoundEmitterChange ( {
audioAssetId : nextAudioAssetId.length === 0 ? null : nextAudioAssetId
} )
) ;
2026-04-02 19:41:01 +02:00
} }
>
< option value = "" > — none — < / option >
{ audioAssetList . map ( ( asset ) = > (
< option key = { asset . id } value = { asset . id } >
{ asset . sourceName }
< / option >
) ) }
< / select >
< / label >
< / div >
< div className = "form-section" >
< div className = "label" > Volume < / div >
< label className = "form-field" >
< span className = "label" > Amount < / span >
< input
data - testid = "sound-emitter-volume"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { soundEmitterVolumeDraft }
onChange = { ( event ) = > setSoundEmitterVolumeDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySoundEmitterChange ( ) }
2026-04-02 19:41:01 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySoundEmitterChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySoundEmitterChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySoundEmitterChange ) }
/ >
< / label >
< / div >
< div className = "form-section" >
< div className = "label" > Distance < / div >
2026-03-31 05:57:54 +02:00
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
2026-04-02 19:41:01 +02:00
< span className = "label" > Ref Distance < / span >
2026-03-31 05:57:54 +02:00
< input
2026-04-02 19:41:01 +02:00
data - testid = "sound-emitter-ref-distance"
2026-03-31 05:57:54 +02:00
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
2026-04-02 19:41:01 +02:00
value = { soundEmitterRefDistanceDraft }
onChange = { ( event ) = > setSoundEmitterRefDistanceDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySoundEmitterChange ( ) }
2026-03-31 05:57:54 +02:00
onKeyDown = { ( event ) = > handleDraftVectorKeyDown ( event , applySoundEmitterChange ) }
onKeyUp = { ( event ) = > handleNumberInputKeyUp ( event , applySoundEmitterChange ) }
onPointerUp = { ( event ) = > handleNumberInputPointerUp ( event , applySoundEmitterChange ) }
/ >
< / label >
< label className = "form-field" >
2026-04-02 19:41:01 +02:00
< span className = "label" > Max Distance < / span >
2026-03-31 05:57:54 +02:00
< input
2026-04-02 19:41:01 +02:00
data - testid = "sound-emitter-max-distance"
2026-03-31 05:57:54 +02:00
className = "text-input"
type = "number"
2026-04-02 19:41:01 +02:00
min = "0.1"
2026-03-31 05:57:54 +02:00
step = "0.1"
2026-04-02 19:41:01 +02:00
value = { soundEmitterMaxDistanceDraft }
onChange = { ( event ) = > setSoundEmitterMaxDistanceDraft ( event . currentTarget . value ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applySoundEmitterChange ( ) }
2026-03-31 05:57:54 +02:00
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 ) = > {
2026-04-02 20:19:41 +02:00
const nextAutoplay = event . currentTarget . checked ;
setSoundEmitterAutoplayDraft ( nextAutoplay ) ;
2026-04-02 20:29:50 +02:00
scheduleDraftCommit ( ( ) = > applySoundEmitterChange ( { autoplay : nextAutoplay } ) ) ;
2026-03-31 05:57:54 +02:00
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Loop < / span >
< input
data - testid = "sound-emitter-loop"
type = "checkbox"
checked = { soundEmitterLoopDraft }
onChange = { ( event ) = > {
2026-04-02 20:19:41 +02:00
const nextLoop = event . currentTarget . checked ;
setSoundEmitterLoopDraft ( nextLoop ) ;
2026-04-02 20:29:50 +02:00
scheduleDraftCommit ( ( ) = > applySoundEmitterChange ( { loop : nextLoop } ) ) ;
2026-03-31 05:57:54 +02:00
} }
/ >
< / 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 >
2026-03-31 06:18:46 +02:00
2026-03-31 06:49:10 +02:00
{ renderInteractionLinksSection (
selectedTriggerVolume ,
selectedTriggerVolumeLinks ,
"add-trigger-teleport-link" ,
2026-04-02 19:42:28 +02:00
"add-trigger-visibility-link" ,
"add-trigger-play-sound-link" ,
"add-trigger-stop-sound-link"
2026-03-31 06:49:10 +02:00
) }
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 ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applyInteractableChange ( ) }
2026-03-31 05:57:54 +02:00
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 ) = > {
2026-04-02 20:19:41 +02:00
const nextEnabled = event . currentTarget . checked ;
setInteractableEnabledDraft ( nextEnabled ) ;
2026-04-02 20:29:50 +02:00
scheduleDraftCommit ( ( ) = > applyInteractableChange ( { enabled : nextEnabled } ) ) ;
2026-03-31 05:57:54 +02:00
} }
/ >
< / 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 ) }
2026-04-02 20:20:18 +02:00
onBlur = { ( ) = > applyInteractableChange ( ) }
2026-03-31 05:57:54 +02:00
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applyInteractableChange ( ) ;
}
} }
/ >
< / label >
< / div >
2026-03-31 06:49:21 +02:00
{ renderInteractionLinksSection (
selectedInteractable ,
selectedInteractableLinks ,
"add-interactable-teleport-link" ,
2026-04-02 19:42:28 +02:00
"add-interactable-visibility-link" ,
"add-interactable-play-sound-link" ,
"add-interactable-stop-sound-link"
2026-03-31 06:49:21 +02:00
) }
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 >
2026-04-01 05:18:13 +02:00
< div className = "form-section" >
< div className = "label" > Material < / div >
< 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" : "" } ` }
onClick = { ( ) = > applyFaceMaterial ( material . id ) }
>
< span className = "material-item__preview" style = { getMaterialPreviewStyle ( material ) } aria-hidden = "true" / >
< span className = "material-item__text" >
< span className = "material-item__title" > { material . name } < / span >
< span className = "material-item__meta" > { material . tags . join ( " | " ) } < / span >
< / span >
< / button >
) ) }
< / div >
< div className = "inline-actions" >
< button className = "toolbar__button" type = "button" onClick = { clearFaceMaterial } >
Clear Material
< / button >
< / div >
< / div >
2026-03-31 02:37:22 +02:00
< 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 >
2026-04-01 04:59:09 +02:00
) }
2026-03-31 02:37:22 +02:00
< / aside >
< / div >
2026-04-02 23:25:05 +02:00
{ addMenuPosition === null ? null : (
< HierarchicalMenu title = "Add" position = { addMenuPosition } items = { addMenuItems } onClose = { closeAddMenu } / >
) }
2026-03-31 02:37:22 +02:00
< 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 >
2026-04-02 23:25:01 +02:00
{ hoveredAssetStatusMessage === null ? null : (
< div className = "status-bar__item status-bar__item--asset" data-testid = "status-asset-hover" >
< span className = "status-bar__strong" > Asset : < / span > { hoveredAssetStatusMessage }
< / div >
) }
2026-03-31 04:53:26 +02:00
< 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
/ >
2026-03-31 20:16:13 +02:00
< input
ref = { importBackgroundImageInputRef }
className = "visually-hidden"
type = "file"
2026-03-31 23:04:57 +02:00
accept = ".avif,.exr,.gif,.hdr,.jpg,.jpeg,.png,.svg,.webp,image/*"
2026-03-31 20:16:13 +02:00
onChange = { handleImportBackgroundImageChange }
/ >
2026-04-02 19:43:26 +02:00
< input
ref = { importAudioInputRef }
className = "visually-hidden"
type = "file"
accept = ".aac,.flac,.m4a,.mp3,.oga,.ogg,.wav,.webm,audio/*"
onChange = { handleImportAudioChange }
/ >
2026-03-31 02:37:22 +02:00
< / div >
) ;
}