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-11 03:49:13 +02:00
import { createCreateSceneCommand } from "../commands/create-scene-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-05 04:23:19 +02:00
import { createDuplicateSelectionCommand } from "../commands/duplicate-selection-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-06 08:28:13 +02:00
import { createSetBoxBrushVolumeSettingsCommand } from "../commands/set-box-brush-volume-settings-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-04-11 03:49:13 +02:00
import { createSetActiveSceneCommand } from "../commands/set-active-scene-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-04-11 04:15:46 +02:00
import { createSetSceneLoadingScreenCommand } from "../commands/set-scene-loading-screen-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 {
2026-04-04 20:12:24 +02:00
getSelectedBrushEdgeId ,
2026-03-31 02:37:22 +02:00
getSelectedBrushFaceId ,
2026-04-04 20:12:24 +02:00
getSelectedBrushVertexId ,
2026-03-31 02:37:22 +02:00
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 ,
2026-04-11 02:40:21 +02:00
getTransformAxisLabel ,
getTransformAxisSpaceLabel ,
2026-04-03 02:15:11 +02:00
getTransformOperationLabel ,
getTransformTargetLabel ,
resolveTransformTarget ,
2026-04-11 02:40:21 +02:00
supportsLocalTransformAxisConstraint ,
2026-04-03 02:15:11 +02:00
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-11 02:44:48 +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 {
2026-04-04 20:12:24 +02:00
getWhiteboxSelectionModeLabel ,
WHITEBOX_SELECTION_MODES ,
type WhiteboxSelectionMode
} from "../core/whitebox-selection-mode" ;
import {
2026-04-06 08:28:13 +02:00
BOX_BRUSH_VOLUME_MODES ,
2026-04-04 20:12:24 +02:00
BOX_EDGE_LABELS ,
2026-03-31 02:37:22 +02:00
BOX_FACE_IDS ,
2026-04-04 20:12:24 +02:00
BOX_FACE_LABELS ,
BOX_VERTEX_LABELS ,
2026-04-07 06:45:41 +02:00
DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT ,
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-04-07 06:45:41 +02:00
MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT ,
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 ,
2026-04-06 08:28:13 +02:00
type BoxBrushVolumeMode ,
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 {
2026-04-07 06:45:41 +02:00
ADVANCED_RENDERING_WATER_REFLECTION_MODES ,
2026-04-06 08:28:13 +02:00
BOX_VOLUME_RENDER_PATHS ,
2026-04-02 20:52:43 +02:00
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 ,
2026-04-06 08:28:13 +02:00
type BoxVolumeRenderPath ,
2026-04-07 06:45:41 +02:00
type AdvancedRenderingWaterReflectionMode ,
2026-04-02 20:52:43 +02:00
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-04-11 04:15:46 +02:00
import {
areSceneLoadingScreenSettingsEqual ,
cloneSceneLoadingScreenSettings ,
2026-04-11 04:35:15 +02:00
createSceneDocumentFromProject ,
2026-04-11 04:15:46 +02:00
type SceneLoadingScreenSettings
} from "../document/scene-document" ;
2026-04-11 02:44:48 +02:00
import {
formatSceneDiagnosticSummary ,
2026-04-11 04:35:15 +02:00
validateProjectDocument ,
2026-04-11 02:44:48 +02:00
validateSceneDocument
} from "../document/scene-document-validation" ;
import {
getBrowserProjectAssetStorageAccess ,
type ProjectAssetStorage
} from "../assets/project-asset-storage" ;
import {
DEFAULT_GRID_SIZE ,
snapPositiveSizeToGrid ,
snapVec3ToGrid
} 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 ,
2026-04-11 04:35:15 +02:00
DEFAULT_SCENE_ENTRY_YAW_DEGREES ,
DEFAULT_SCENE_EXIT_PROMPT ,
DEFAULT_SCENE_EXIT_RADIUS ,
2026-04-02 19:40:11 +02:00
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 ,
2026-04-11 04:35:15 +02:00
createSceneEntryEntity ,
createSceneExitEntity ,
2026-03-31 05:54:29 +02:00
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-04-11 02:44:48 +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-04-11 02:44:48 +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-04-11 04:35:15 +02:00
import { createDefaultRuntimeGlobalState , type RuntimeGlobalState } from "../runtime-three/runtime-global-state" ;
import type { RuntimeSceneExitTransitionRequest } from "../runtime-three/runtime-host" ;
2026-04-11 02:44:48 +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-04-10 12:23:11 +02:00
import { EditorAutosaveController } from "../serialization/editor-autosave" ;
2026-03-31 02:37:22 +02:00
import { Panel } from "../shared-ui/Panel" ;
2026-04-10 12:23:11 +02:00
import {
loadProjectPackage ,
PROJECT_PACKAGE_FILE_EXTENSION ,
saveProjectPackage
} from "../serialization/project-package" ;
2026-04-11 02:44:48 +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-11 02:44:48 +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-04-11 02:44:48 +02:00
type InteractionSourceEntity = Extract <
EntityInstance ,
{ kind : "triggerVolume" | "interactable" }
> ;
2026-03-31 06:47:21 +02:00
2026-04-11 02:44:48 +02:00
function getModelInstanceCollisionModeDescription (
mode : ModelInstanceCollisionMode
) : string {
2026-04-04 07:55:42 +02:00
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-11 02:44:48 +02:00
function getPlayerStartColliderModeDescription (
mode : PlayerStartColliderMode
) : string {
2026-04-04 15:56:01 +02:00
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-04-11 02:44:48 +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-11 02:44:48 +02:00
function resolveOptionalPositiveNumber (
value : string ,
fallback : number
) : number {
2026-04-04 19:30:03 +02:00
const parsedValue = Number ( value ) ;
2026-04-11 02:44:48 +02:00
return Number . isFinite ( parsedValue ) && parsedValue > 0
? parsedValue
: fallback ;
2026-04-04 19:30:03 +02:00
}
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 {
2026-04-11 02:44:48 +02:00
return Math . min (
MAX_VIEWPORT_QUAD_SPLIT ,
Math . max ( MIN_VIEWPORT_QUAD_SPLIT , value )
) ;
2026-04-03 01:26:21 +02:00
}
2026-04-11 02:44:48 +02:00
function createViewportQuadPanelsStyle (
viewportQuadSplit : ViewportQuadSplit
) : CSSProperties {
2026-04-03 01:26:21 +02:00
return {
"--viewport-quad-split-x" : String ( viewportQuadSplit . x ) ,
"--viewport-quad-split-y" : String ( viewportQuadSplit . y )
} as CSSProperties ;
}
2026-04-11 02:44:48 +02:00
function getViewportQuadResizeCursor (
resizeMode : ViewportQuadResizeMode
) : string {
2026-04-03 01:26:21 +02:00
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 )
} ;
2026-04-11 02:44:48 +02:00
if (
! Number . isFinite ( vector . x ) ||
! Number . isFinite ( vector . y ) ||
! Number . isFinite ( vector . z )
) {
2026-03-31 02:37:22 +02:00
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 ) {
2026-04-11 02:44:48 +02:00
throw new Error (
` ${ label } must be a finite number greater than or equal to zero. `
) ;
2026-03-31 05:12:01 +02:00
}
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-04-07 06:45:48 +02:00
function readWaterFoamContactLimitDraft ( source : string ) : number {
const value = readPositiveIntegerDraft ( source , "Water foam contact limit" ) ;
if ( value > MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT ) {
2026-04-11 02:44:48 +02:00
throw new Error (
` Water foam contact limit must be ${ MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT } or less. `
) ;
2026-04-07 06:45:48 +02:00
}
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
} ;
}
2026-04-11 02:44:48 +02:00
function maybeSnapPositiveSize (
size : Vec3 ,
enabled : boolean ,
step : number
) : Vec3 {
2026-04-04 19:30:07 +02:00
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
) ;
}
2026-04-11 02:44:48 +02:00
function getSelectedBoxBrush (
selection : EditorSelection ,
brushes : BoxBrush [ ]
) : BoxBrush | null {
2026-03-31 02:37:22 +02:00
const selectedBrushId = getSingleSelectedBrushId ( selection ) ;
if ( selectedBrushId === null ) {
return null ;
}
return brushes . find ( ( brush ) = > brush . id === selectedBrushId ) ? ? null ;
}
2026-04-11 02:44:48 +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-04-11 02:44:48 +02:00
function getSelectedModelInstance (
selection : EditorSelection ,
modelInstances : ModelInstance [ ]
) : ModelInstance | null {
2026-03-31 17:41:18 +02:00
const selectedModelInstanceId = getSingleSelectedModelInstanceId ( selection ) ;
if ( selectedModelInstanceId === null ) {
return null ;
}
2026-04-11 02:44:48 +02:00
return (
modelInstances . find (
( modelInstance ) = > modelInstance . id === selectedModelInstanceId
) ? ? null
) ;
2026-03-31 17:41:18 +02:00
}
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 ) {
2026-04-11 02:44:48 +02:00
details . push (
` ${ asset . metadata . animationNames . length } animation ${ asset . metadata . animationNames . length === 1 ? "" : "s" } `
) ;
2026-03-31 17:41:18 +02:00
}
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 = [
2026-04-11 02:44:48 +02:00
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 ` ,
2026-04-02 19:40:11 +02:00
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 {
2026-04-04 19:30:13 +02:00
return brush . name ? ? ` Whitebox Box ${ 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-04-11 02:44:48 +02:00
return brushIndex === - 1
? "Whitebox Box"
: getBrushLabel ( brushes [ brushIndex ] , brushIndex ) ;
2026-03-31 02:37:22 +02:00
}
2026-04-11 02:44:48 +02:00
function getSelectedBrushLabel (
selection : EditorSelection ,
brushes : BoxBrush [ ]
) : string {
2026-03-31 02:37:22 +02:00
const selectedBrushId = getSingleSelectedBrushId ( selection ) ;
if ( selectedBrushId === null ) {
2026-04-04 19:31:06 +02:00
return "No solid selected" ;
2026-03-31 02:37:22 +02:00
}
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" :
2026-04-04 19:30:13 +02:00
return ` ${ selection . ids . length } solid ${ selection . ids . length === 1 ? "" : "s" } selected ( ${ getSelectedBrushLabel ( selection , brushes ) } ) ` ;
2026-03-31 02:37:22 +02:00
case "brushFace" :
2026-04-04 20:12:33 +02:00
return ` 1 face selected ( ${ BOX_FACE_LABELS [ selection . faceId ] } on ${ getBrushLabelById ( selection . brushId , brushes ) } ) ` ;
2026-04-04 20:12:45 +02:00
case "brushEdge" :
return ` 1 edge selected ( ${ BOX_EDGE_LABELS [ selection . edgeId ] } on ${ getBrushLabelById ( selection . brushId , brushes ) } ) ` ;
case "brushVertex" :
return ` 1 vertex selected ( ${ BOX_VERTEX_LABELS [ selection . vertexId ] } on ${ getBrushLabelById ( selection . brushId , brushes ) } ) ` ;
2026-03-31 02:37:22 +02:00
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-04-04 20:12:45 +02:00
function getWhiteboxSelectionModeStatus ( mode : WhiteboxSelectionMode ) : string {
switch ( mode ) {
case "object" :
return "Whitebox selection mode set to Object. Whole-solid transforms are available." ;
case "face" :
return "Whitebox selection mode set to Face. Click a face to edit materials and UVs." ;
case "edge" :
return "Whitebox selection mode set to Edge. Edge transforms land in the next slice." ;
case "vertex" :
return "Whitebox selection mode set to Vertex. Vertex transforms land in the next slice." ;
}
}
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
}
}
2026-04-11 02:44:48 +02:00
function getVisibilityModeSelectValue (
visible : boolean | undefined
) : "toggle" | "show" | "hide" {
2026-03-31 06:17:51 +02:00
if ( visible === true ) {
return "show" ;
}
if ( visible === false ) {
return "hide" ;
}
return "toggle" ;
}
2026-04-11 02:44:48 +02:00
function readVisibilityModeSelectValue (
value : "toggle" | "show" | "hide"
) : boolean | undefined {
2026-03-31 06:17:51 +02:00
switch ( value ) {
case "toggle" :
return undefined ;
case "show" :
return true ;
case "hide" :
return false ;
}
}
2026-04-11 02:44:48 +02:00
function getDefaultTriggerVolumeLinkTrigger (
triggerOnEnter : boolean ,
triggerOnExit : boolean
) : InteractionTriggerKind {
2026-03-31 06:19:09 +02:00
if ( triggerOnEnter ) {
return "enter" ;
}
if ( triggerOnExit ) {
return "exit" ;
}
return "enter" ;
}
2026-04-11 02:44:48 +02:00
function isInteractionSourceEntity (
entity : EntityInstance | null
) : entity is InteractionSourceEntity {
return (
entity !== null &&
( entity . kind === "triggerVolume" || entity . kind === "interactable" )
) ;
2026-03-31 06:47:21 +02:00
}
2026-04-11 02:44:48 +02:00
function isSoundEmitterEntity (
entity : EntityInstance | null
) : entity is Extract < EntityInstance , { kind : "soundEmitter" } > {
2026-04-02 19:48:04 +02:00
return entity !== null && entity . kind === "soundEmitter" ;
}
2026-04-11 02:44:48 +02:00
function getDefaultInteractionLinkTrigger (
sourceEntity : InteractionSourceEntity
) : InteractionTriggerKind {
2026-03-31 06:47:21 +02:00
return sourceEntity . kind === "triggerVolume"
2026-04-11 02:44:48 +02:00
? getDefaultTriggerVolumeLinkTrigger (
sourceEntity . triggerOnEnter ,
sourceEntity . triggerOnExit
)
2026-03-31 06:47:21 +02:00
: "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-04-05 04:23:19 +02:00
function selectionCanBeDuplicated ( selection : EditorSelection ) : boolean {
switch ( selection . kind ) {
case "brushes" :
case "entities" :
case "modelInstances" :
return selection . ids . length > 0 ;
case "brushFace" :
case "brushEdge" :
case "brushVertex" :
return true ;
case "none" :
return false ;
}
}
2026-03-31 04:37:44 +02:00
function isCommitIncrementKey ( key : string ) : boolean {
2026-04-11 02:44:48 +02:00
return (
key === "ArrowUp" ||
key === "ArrowDown" ||
key === "PageUp" ||
key === "PageDown"
) ;
2026-03-31 04:37:44 +02:00
}
2026-03-31 04:41:03 +02:00
function blurActiveTextEntry() {
const activeElement = document . activeElement ;
2026-04-11 02:44:48 +02:00
if (
! ( activeElement instanceof HTMLElement ) ||
! isTextEntryTarget ( activeElement )
) {
2026-03-31 04:41:03 +02:00
return ;
}
activeElement . blur ( ) ;
}
2026-04-11 02:44:48 +02:00
function sortDocumentMaterials (
materials : Record < string , MaterialDef >
) : MaterialDef [ ] {
2026-03-31 02:37:22 +02:00
return Object . values ( materials ) . sort ( ( left , right ) = > {
2026-04-11 02:44:48 +02:00
const leftStarterIndex =
STARTER_MATERIAL_ORDER . get ( left . id ) ? ? Number . MAX_SAFE_INTEGER ;
const rightStarterIndex =
STARTER_MATERIAL_ORDER . get ( right . id ) ? ? Number . MAX_SAFE_INTEGER ;
2026-03-31 02:37:22 +02:00
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"
} ;
}
}
2026-04-11 02:44:48 +02:00
function rotateQuarterTurns (
rotationQuarterTurns : FaceUvRotationQuarterTurns
) : FaceUvRotationQuarterTurns {
2026-03-31 02:37:22 +02:00
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-11 02:44:48 +02:00
function formatAdvancedRenderingShadowTypeLabel (
type : AdvancedRenderingShadowType
) : string {
2026-04-02 20:53:37 +02:00
switch ( type ) {
case "basic" :
return "Basic" ;
case "pcf" :
return "PCF" ;
case "pcfSoft" :
return "PCF Soft" ;
}
}
2026-04-11 02:44:48 +02:00
function formatAdvancedRenderingToneMappingLabel (
mode : AdvancedRenderingToneMappingMode
) : string {
2026-04-02 20:53:37 +02:00
switch ( mode ) {
case "none" :
return "None" ;
case "linear" :
return "Linear" ;
case "reinhard" :
return "Reinhard" ;
case "cineon" :
return "Cineon" ;
case "acesFilmic" :
return "ACES Filmic" ;
}
}
2026-04-11 02:44:48 +02:00
function formatAdvancedRenderingWaterReflectionModeLabel (
mode : AdvancedRenderingWaterReflectionMode
) : string {
2026-04-07 06:45:48 +02:00
switch ( mode ) {
case "none" :
return "Nothing" ;
case "world" :
return "World" ;
case "all" :
return "All" ;
}
}
2026-04-06 08:28:19 +02:00
function formatBoxVolumeModeLabel ( mode : BoxBrushVolumeMode ) : string {
switch ( mode ) {
case "none" :
return "None" ;
case "water" :
return "Water" ;
case "fog" :
return "Fog" ;
}
}
function formatBoxVolumeRenderPathLabel ( path : BoxVolumeRenderPath ) : string {
switch ( path ) {
case "performance" :
return "Performance" ;
case "quality" :
return "Quality" ;
}
}
2026-04-10 12:23:11 +02:00
function createProjectDownloadName ( documentName : string ) : string {
const slug = documentName
. trim ( )
. toLowerCase ( )
. replace ( / [ ^ a - z 0 - 9 ] + / g u , " - " )
. replace ( / ^ - + | - + $ / g u , " " ) ;
return ` ${ slug . length > 0 ? slug : "scene" } ${ PROJECT_PACKAGE_FILE_EXTENSION } ` ;
}
2026-03-31 02:37:22 +02:00
export function App ( { store , initialStatusMessage } : AppProps ) {
const editorState = useEditorStoreState ( store ) ;
2026-04-11 03:49:16 +02:00
const sceneList = Object . values ( editorState . projectDocument . scenes ) ;
2026-04-11 04:15:49 +02:00
const activeProjectScene =
editorState . projectDocument . scenes [ editorState . activeSceneId ] ;
2026-04-11 04:15:52 +02:00
if ( activeProjectScene === undefined ) {
throw new Error (
` Active scene ${ editorState . activeSceneId } does not exist in the project document. `
) ;
}
2026-03-31 02:37:22 +02:00
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-11 02:44:48 +02:00
const entityDisplayList = getSortedEntityDisplayLabels (
editorState . document . entities ,
editorState . document . assets
) ;
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-04-11 02:44:48 +02:00
const selectedModelInstance = getSelectedModelInstance (
editorState . selection ,
Object . values ( editorState . document . modelInstances )
) ;
2026-04-04 20:12:56 +02:00
const whiteboxSelectionMode = editorState . whiteboxSelectionMode ;
2026-03-31 02:37:22 +02:00
const selectedFaceId = getSelectedBrushFaceId ( editorState . selection ) ;
2026-04-04 20:12:56 +02:00
const selectedEdgeId = getSelectedBrushEdgeId ( editorState . selection ) ;
const selectedVertexId = getSelectedBrushVertexId ( editorState . selection ) ;
2026-04-11 02:44:48 +02:00
const selectedFace =
selectedBrush !== null && selectedFaceId !== null
? selectedBrush . faces [ selectedFaceId ]
: null ;
2026-03-31 02:37:22 +02:00
const selectedFaceMaterial =
2026-04-11 02:44:48 +02:00
selectedFace !== null && selectedFace . materialId !== null
? ( editorState . document . materials [ selectedFace . materialId ] ? ? null )
: null ;
2026-03-31 17:41:42 +02:00
const selectedModelAsset =
2026-04-11 02:44:48 +02:00
selectedModelInstance !== null
? ( editorState . document . assets [ selectedModelInstance . assetId ] ? ? null )
: null ;
const selectedModelAssetRecord =
selectedModelAsset !== null && selectedModelAsset . kind === "model"
? selectedModelAsset
: null ;
const selectedPlayerStart =
selectedEntity ? . kind === "playerStart" ? selectedEntity : null ;
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
2026-04-11 02:44:48 +02:00
: ( editorState . document . assets [ selectedSoundEmitter . audioAssetId ] ? ?
null ) ;
2026-04-02 19:40:43 +02:00
const selectedSoundEmitterAudioAssetRecord =
2026-04-11 02:44:48 +02:00
selectedSoundEmitterAsset !== null &&
selectedSoundEmitterAsset . kind === "audio"
? selectedSoundEmitterAsset
: null ;
const selectedTriggerVolume =
selectedEntity ? . kind === "triggerVolume" ? selectedEntity : null ;
2026-04-11 04:35:32 +02:00
const selectedSceneEntry =
selectedEntity ? . kind === "sceneEntry" ? selectedEntity : null ;
2026-04-11 02:44:48 +02:00
const selectedTeleportTarget =
selectedEntity ? . kind === "teleportTarget" ? selectedEntity : null ;
const selectedInteractable =
selectedEntity ? . kind === "interactable" ? selectedEntity : null ;
2026-04-11 04:35:32 +02:00
const selectedSceneExit =
selectedEntity ? . kind === "sceneExit" ? 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-04-11 02:44:48 +02:00
const selectedPointLight =
selectedEntity ? . kind === "pointLight" ? selectedEntity : null ;
const selectedSpotLight =
selectedEntity ? . kind === "spotLight" ? selectedEntity : null ;
const modelInstanceDisplayList = getSortedModelInstanceDisplayLabels (
editorState . document . modelInstances ,
editorState . document . assets
) ;
const selectedInteractionSource = isInteractionSourceEntity ( selectedEntity )
? selectedEntity
: null ;
2026-03-31 06:17:55 +02:00
const selectedTriggerVolumeLinks =
selectedTriggerVolume === null
? [ ]
2026-04-11 02:44:48 +02:00
: getInteractionLinksForSource (
editorState . document . interactionLinks ,
selectedTriggerVolume . id
) ;
2026-03-31 06:47:28 +02:00
const selectedInteractableLinks =
2026-04-11 02:44:48 +02:00
selectedInteractable === null
? [ ]
: getInteractionLinksForSource (
editorState . document . interactionLinks ,
selectedInteractable . id
) ;
2026-04-11 04:35:32 +02:00
const sceneTargetOptions = sceneList . map ( ( scene ) = > ( {
id : scene.id ,
name : scene.name
} ) ) ;
const sceneEntryOptionsBySceneId = Object . fromEntries (
sceneTargetOptions . map ( ( { id } ) = > {
const scene = editorState . projectDocument . scenes [ id ] ;
const sceneEntries = getSortedEntityDisplayLabels (
scene ? . entities ? ? { } ,
editorState . projectDocument . assets
) . filter ( ( { entity } ) = > entity . kind === "sceneEntry" ) ;
return [
id ,
sceneEntries as Array < {
entity : Extract < EntityInstance , { kind : "sceneEntry" } > ;
label : string ;
} >
] ;
} )
) as Record <
string ,
Array < {
entity : Extract < EntityInstance , { kind : "sceneEntry" } > ;
label : string ;
} >
> ;
2026-04-11 02:44:48 +02:00
const teleportTargetOptions = entityDisplayList . filter (
( { entity } ) = > entity . kind === "teleportTarget"
) ;
const soundEmitterOptions = entityDisplayList . filter (
( { entity } ) = > entity . kind === "soundEmitter"
) as Array < {
2026-04-02 19:48:15 +02:00
entity : Extract < EntityInstance , { kind : "soundEmitter" } > ;
label : string ;
} > ;
2026-04-11 02:44:48 +02:00
const playableSoundEmitterOptions = soundEmitterOptions . filter (
( { entity } ) = > {
if ( entity . audioAssetId === null ) {
return false ;
}
2026-04-02 19:42:09 +02:00
2026-04-11 02:44:48 +02:00
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
2026-04-11 02:44:48 +02:00
const [ sceneNameDraft , setSceneNameDraft ] = useState (
editorState . document . name
) ;
2026-04-11 04:16:04 +02:00
const [ sceneLoadingHeadlineDraft , setSceneLoadingHeadlineDraft ] = useState (
activeProjectScene . loadingScreen . headline ? ? ""
) ;
2026-04-11 04:20:45 +02:00
const [ sceneLoadingDescriptionDraft , setSceneLoadingDescriptionDraft ] =
useState ( activeProjectScene . loadingScreen . description ? ? "" ) ;
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-04-11 02:44:48 +02:00
const [ positionDraft , setPositionDraft ] = useState (
createVec3Draft ( DEFAULT_BOX_BRUSH_CENTER )
) ;
const [ rotationDraft , setRotationDraft ] = useState (
createVec3Draft ( DEFAULT_BOX_BRUSH_ROTATION_DEGREES )
2026-04-07 06:46:01 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [ sizeDraft , setSizeDraft ] = useState (
createVec3Draft ( DEFAULT_BOX_BRUSH_SIZE )
) ;
const [ boxVolumeModeDraft , setBoxVolumeModeDraft ] =
useState < BoxBrushVolumeMode > ( "none" ) ;
const [ boxVolumeWaterColorDraft , setBoxVolumeWaterColorDraft ] =
useState ( "#4da6d9" ) ;
const [
boxVolumeWaterSurfaceOpacityDraft ,
setBoxVolumeWaterSurfaceOpacityDraft
] = useState ( "0.55" ) ;
const [ boxVolumeWaterWaveStrengthDraft , setBoxVolumeWaterWaveStrengthDraft ] =
useState ( "0.35" ) ;
const [
boxVolumeWaterFoamContactLimitDraft ,
setBoxVolumeWaterFoamContactLimitDraft
] = useState ( String ( DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT ) ) ;
const [
boxVolumeWaterSurfaceDisplacementEnabledDraft ,
setBoxVolumeWaterSurfaceDisplacementEnabledDraft
] = useState ( false ) ;
const [ boxVolumeFogColorDraft , setBoxVolumeFogColorDraft ] =
useState ( "#9cb7c7" ) ;
const [ boxVolumeFogDensityDraft , setBoxVolumeFogDensityDraft ] =
useState ( "0.08" ) ;
const [ boxVolumeFogPaddingDraft , setBoxVolumeFogPaddingDraft ] =
useState ( "0.2" ) ;
2026-04-04 19:30:17 +02:00
const [ whiteboxSnapEnabled , setWhiteboxSnapEnabled ] = useState ( true ) ;
2026-04-11 02:44:48 +02:00
const [ whiteboxSnapStepDraft , setWhiteboxSnapStepDraft ] = useState (
String ( DEFAULT_GRID_SIZE )
) ;
2026-04-11 02:40:51 +02:00
const [ viewportGridVisible , setViewportGridVisible ] = useState ( true ) ;
2026-04-11 03:22:03 +02:00
const [ viewportCameraSyncRequestId , setViewportCameraSyncRequestId ] =
useState ( 0 ) ;
2026-04-11 02:44:48 +02:00
const [ uvOffsetDraft , setUvOffsetDraft ] = useState (
createVec2Draft ( createDefaultFaceUvState ( ) . offset )
) ;
const [ uvScaleDraft , setUvScaleDraft ] = useState (
createVec2Draft ( createDefaultFaceUvState ( ) . scale )
) ;
const [ entityPositionDraft , setEntityPositionDraft ] = useState (
createVec3Draft ( DEFAULT_ENTITY_POSITION )
) ;
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-11 02:44:48 +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 )
) ;
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 ) ) ;
const [ soundEmitterAutoplayDraft , setSoundEmitterAutoplayDraft ] =
useState ( false ) ;
2026-03-31 05:54:57 +02:00
const [ soundEmitterLoopDraft , setSoundEmitterLoopDraft ] = useState ( false ) ;
2026-04-11 02:44:48 +02:00
const [ triggerVolumeSizeDraft , setTriggerVolumeSizeDraft ] = useState (
createVec3Draft ( DEFAULT_TRIGGER_VOLUME_SIZE )
) ;
2026-04-11 04:35:42 +02:00
const [ sceneEntryYawDraft , setSceneEntryYawDraft ] = useState (
String ( DEFAULT_SCENE_ENTRY_YAW_DEGREES )
) ;
2026-04-11 02:44:48 +02:00
const [ teleportTargetYawDraft , setTeleportTargetYawDraft ] = useState (
String ( DEFAULT_TELEPORT_TARGET_YAW_DEGREES )
) ;
const [ interactableRadiusDraft , setInteractableRadiusDraft ] = useState (
String ( DEFAULT_INTERACTABLE_RADIUS )
2026-03-31 23:15:09 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [ interactablePromptDraft , setInteractablePromptDraft ] = useState (
DEFAULT_INTERACTABLE_PROMPT
) ;
const [ interactableEnabledDraft , setInteractableEnabledDraft ] =
useState ( true ) ;
2026-04-11 04:35:42 +02:00
const [ sceneExitRadiusDraft , setSceneExitRadiusDraft ] = useState (
String ( DEFAULT_SCENE_EXIT_RADIUS )
) ;
const [ sceneExitPromptDraft , setSceneExitPromptDraft ] = useState (
DEFAULT_SCENE_EXIT_PROMPT
) ;
const [ sceneExitEnabledDraft , setSceneExitEnabledDraft ] = useState ( true ) ;
const [ sceneExitTargetSceneIdDraft , setSceneExitTargetSceneIdDraft ] =
useState ( "" ) ;
const [ sceneExitTargetEntryIdDraft , setSceneExitTargetEntryIdDraft ] =
useState ( "" ) ;
2026-04-11 04:35:50 +02:00
const selectedSceneExitTargetEntryOptions = [
. . . ( sceneEntryOptionsBySceneId [ sceneExitTargetSceneIdDraft ] ? ? [ ] )
] ;
if (
sceneExitTargetEntryIdDraft . trim ( ) . length > 0 &&
! selectedSceneExitTargetEntryOptions . some (
( { entity } ) = > entity . id === sceneExitTargetEntryIdDraft
)
) {
selectedSceneExitTargetEntryOptions . push ( {
entity : createSceneEntryEntity ( {
id : sceneExitTargetEntryIdDraft ,
name : "Missing Scene Entry"
} ) ,
label : ` Missing Scene Entry ( ${ sceneExitTargetEntryIdDraft } ) `
} ) ;
}
2026-04-11 02:44:48 +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 )
) ;
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 )
) ;
const [
backgroundEnvironmentIntensityDraft ,
setBackgroundEnvironmentIntensityDraft
] = useState (
editorState . document . world . background . mode === "image"
? String ( editorState . document . world . background . environmentIntensity )
: "0.5"
) ;
const [
advancedRenderingShadowBiasDraft ,
setAdvancedRenderingShadowBiasDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . shadows . bias )
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingAmbientOcclusionIntensityDraft ,
setAdvancedRenderingAmbientOcclusionIntensityDraft
] = useState (
String (
editorState . document . world . advancedRendering . ambientOcclusion . intensity
)
2026-04-02 20:53:26 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingAmbientOcclusionRadiusDraft ,
setAdvancedRenderingAmbientOcclusionRadiusDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . ambientOcclusion . radius )
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingAmbientOcclusionSamplesDraft ,
setAdvancedRenderingAmbientOcclusionSamplesDraft
] = useState (
String (
editorState . document . world . advancedRendering . ambientOcclusion . samples
)
2026-04-02 20:53:26 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingBloomIntensityDraft ,
setAdvancedRenderingBloomIntensityDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . bloom . intensity )
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingBloomThresholdDraft ,
setAdvancedRenderingBloomThresholdDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . bloom . threshold )
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingBloomRadiusDraft ,
setAdvancedRenderingBloomRadiusDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . bloom . radius )
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingToneMappingExposureDraft ,
setAdvancedRenderingToneMappingExposureDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . toneMapping . exposure )
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingDepthOfFieldFocusDistanceDraft ,
setAdvancedRenderingDepthOfFieldFocusDistanceDraft
] = useState (
String (
editorState . document . world . advancedRendering . depthOfField . focusDistance
)
2026-04-02 20:53:26 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingDepthOfFieldFocalLengthDraft ,
setAdvancedRenderingDepthOfFieldFocalLengthDraft
] = useState (
String (
editorState . document . world . advancedRendering . depthOfField . focalLength
)
2026-04-02 20:53:26 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [
advancedRenderingDepthOfFieldBokehScaleDraft ,
setAdvancedRenderingDepthOfFieldBokehScaleDraft
] = useState (
2026-04-02 20:53:26 +02:00
String ( editorState . document . world . advancedRendering . depthOfField . bokehScale )
) ;
2026-04-11 02:44:48 +02:00
const [ statusMessage , setStatusMessage ] = useState (
initialStatusMessage ? ? "Slice 3.5 advanced rendering ready."
2026-03-31 03:09:04 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [ assetStatusMessage , setAssetStatusMessage ] = useState < string | null > (
null
2026-03-31 03:09:04 +02:00
) ;
2026-04-11 02:44:48 +02:00
const [ hoveredAssetId , setHoveredAssetId ] = useState < string | null > ( null ) ;
const [ hoveredViewportPanelId , setHoveredViewportPanelId ] =
useState < ViewportPanelId | null > ( null ) ;
const [ addMenuPosition , setAddMenuPosition ] =
useState < HierarchicalMenuPosition | null > ( null ) ;
const [ preferredNavigationMode , setPreferredNavigationMode ] =
useState < RuntimeNavigationMode > (
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
) ;
const [ activeNavigationMode , setActiveNavigationMode ] =
useState < RuntimeNavigationMode > (
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
) ;
const [ projectAssetStorage , setProjectAssetStorage ] =
useState < ProjectAssetStorage | null > ( null ) ;
const [ projectAssetStorageReady , setProjectAssetStorageReady ] =
useState ( false ) ;
const [ runtimeScene , setRuntimeScene ] =
useState < RuntimeSceneDefinition | null > ( null ) ;
2026-04-11 04:35:32 +02:00
const [ runtimeSceneId , setRuntimeSceneId ] = useState < string | null > ( null ) ;
const [ runtimeSceneName , setRuntimeSceneName ] = useState < string | null > ( null ) ;
const [ runtimeSceneLoadingScreen , setRuntimeSceneLoadingScreen ] =
useState < SceneLoadingScreenSettings | null > ( null ) ;
const [ runtimeGlobalState , setRuntimeGlobalState ] =
useState < RuntimeGlobalState > ( createDefaultRuntimeGlobalState ( ) ) ;
2026-03-31 03:09:04 +02:00
const [ runtimeMessage , setRuntimeMessage ] = useState < string | null > ( null ) ;
2026-04-11 02:44:48 +02:00
const [ firstPersonTelemetry , setFirstPersonTelemetry ] =
useState < FirstPersonTelemetry | null > ( null ) ;
const [ runtimeInteractionPrompt , setRuntimeInteractionPrompt ] =
useState < RuntimeInteractionPrompt | null > ( null ) ;
const [ loadedModelAssets , setLoadedModelAssets ] = useState <
Record < string , LoadedModelAsset >
> ( { } ) ;
const [ loadedImageAssets , setLoadedImageAssets ] = useState <
Record < string , LoadedImageAsset >
> ( { } ) ;
const [ loadedAudioAssets , setLoadedAudioAssets ] = useState <
Record < string , LoadedAudioAsset >
> ( { } ) ;
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-04-10 12:23:11 +02:00
const importProjectInputRef = 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-10 12:23:11 +02:00
const autosaveControllerRef = useRef < EditorAutosaveController | null > ( null ) ;
const lastAutosaveErrorRef = useRef < string | null > ( null ) ;
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-11 02:44:48 +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-04-11 02:44:48 +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 =
2026-04-11 02:44:48 +02:00
documentValidation . errors . length === 0
? "Valid"
: formatDiagnosticCount ( documentValidation . errors . length , "error" ) ;
2026-03-31 04:53:26 +02:00
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-11 02:44:48 +02:00
const hoveredAsset =
hoveredAssetId === null
? null
: ( editorState . document . assets [ hoveredAssetId ] ? ? null ) ;
const hoveredAssetStatusMessage =
hoveredAsset === null ? null : formatAssetHoverStatus ( hoveredAsset ) ;
const selectedTransformTarget = resolveTransformTarget (
editorState . document ,
editorState . selection ,
whiteboxSelectionMode
) . target ;
const canTranslateSelectedTarget =
selectedTransformTarget !== null &&
supportsTransformOperation ( selectedTransformTarget , "translate" ) ;
const canRotateSelectedTarget =
selectedTransformTarget !== null &&
supportsTransformOperation ( selectedTransformTarget , "rotate" ) ;
const canScaleSelectedTarget =
selectedTransformTarget !== null &&
supportsTransformOperation ( selectedTransformTarget , "scale" ) ;
const whiteboxSnapStep = resolveOptionalPositiveNumber (
whiteboxSnapStepDraft ,
DEFAULT_GRID_SIZE
) ;
const whiteboxVectorInputStep = getWhiteboxInputStep (
whiteboxSnapEnabled ,
whiteboxSnapStep
) ;
2026-03-31 02:37:22 +02:00
useEffect ( ( ) = > {
setSceneNameDraft ( editorState . document . name ) ;
} , [ editorState . document . name ] ) ;
2026-04-11 04:16:04 +02:00
useEffect ( ( ) = > {
setSceneLoadingHeadlineDraft (
activeProjectScene . loadingScreen . headline ? ? ""
) ;
setSceneLoadingDescriptionDraft (
activeProjectScene . loadingScreen . description ? ? ""
) ;
} , [
activeProjectScene . id ,
activeProjectScene . loadingScreen . headline ,
activeProjectScene . loadingScreen . description
] ) ;
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 ) ) ;
2026-04-04 19:30:35 +02:00
setRotationDraft ( createVec3Draft ( DEFAULT_BOX_BRUSH_ROTATION_DEGREES ) ) ;
2026-03-31 02:37:22 +02:00
setSizeDraft ( createVec3Draft ( DEFAULT_BOX_BRUSH_SIZE ) ) ;
2026-04-06 08:28:33 +02:00
setBoxVolumeModeDraft ( "none" ) ;
2026-04-11 02:44:48 +02:00
setBoxVolumeWaterFoamContactLimitDraft (
String ( DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT )
) ;
2026-04-07 07:11:15 +02:00
setBoxVolumeWaterSurfaceDisplacementEnabledDraft ( false ) ;
2026-03-31 02:37:22 +02:00
return ;
}
setPositionDraft ( createVec3Draft ( selectedBrush . center ) ) ;
2026-04-04 19:30:35 +02:00
setRotationDraft ( createVec3Draft ( selectedBrush . rotationDegrees ) ) ;
2026-03-31 02:37:22 +02:00
setSizeDraft ( createVec3Draft ( selectedBrush . size ) ) ;
2026-04-06 08:28:33 +02:00
setBoxVolumeModeDraft ( selectedBrush . volume . mode ) ;
if ( selectedBrush . volume . mode === "water" ) {
setBoxVolumeWaterColorDraft ( selectedBrush . volume . water . colorHex ) ;
2026-04-11 02:44:48 +02:00
setBoxVolumeWaterSurfaceOpacityDraft (
String ( selectedBrush . volume . water . surfaceOpacity )
) ;
setBoxVolumeWaterWaveStrengthDraft (
String ( selectedBrush . volume . water . waveStrength )
) ;
setBoxVolumeWaterFoamContactLimitDraft (
String ( selectedBrush . volume . water . foamContactLimit )
) ;
setBoxVolumeWaterSurfaceDisplacementEnabledDraft (
selectedBrush . volume . water . surfaceDisplacementEnabled
) ;
2026-04-06 08:28:33 +02:00
}
if ( selectedBrush . volume . mode === "fog" ) {
setBoxVolumeFogColorDraft ( selectedBrush . volume . fog . colorHex ) ;
setBoxVolumeFogDensityDraft ( String ( selectedBrush . volume . fog . density ) ) ;
setBoxVolumeFogPaddingDraft ( String ( selectedBrush . volume . fog . padding ) ) ;
}
2026-03-31 02:37:22 +02:00
} , [ 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 ) ) ;
2026-04-11 02:44:48 +02:00
setPlayerStartCapsuleRadiusDraft (
String ( DEFAULT_PLAYER_START_CAPSULE_RADIUS )
) ;
setPlayerStartCapsuleHeightDraft (
String ( DEFAULT_PLAYER_START_CAPSULE_HEIGHT )
) ;
setPlayerStartBoxSizeDraft (
createVec3Draft ( DEFAULT_PLAYER_START_BOX_SIZE )
) ;
setSoundEmitterAudioAssetIdDraft (
DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ? ? ""
) ;
2026-04-02 19:40:32 +02:00
setSoundEmitterVolumeDraft ( String ( DEFAULT_SOUND_EMITTER_VOLUME ) ) ;
2026-04-11 02:44:48 +02:00
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 ) ) ;
2026-04-11 04:35:58 +02:00
setSceneEntryYawDraft ( String ( DEFAULT_SCENE_ENTRY_YAW_DEGREES ) ) ;
2026-03-31 05:55:08 +02:00
setTeleportTargetYawDraft ( String ( DEFAULT_TELEPORT_TARGET_YAW_DEGREES ) ) ;
setInteractableRadiusDraft ( String ( DEFAULT_INTERACTABLE_RADIUS ) ) ;
setInteractablePromptDraft ( DEFAULT_INTERACTABLE_PROMPT ) ;
setInteractableEnabledDraft ( true ) ;
2026-04-11 04:35:58 +02:00
setSceneExitRadiusDraft ( String ( DEFAULT_SCENE_EXIT_RADIUS ) ) ;
setSceneExitPromptDraft ( DEFAULT_SCENE_EXIT_PROMPT ) ;
setSceneExitEnabledDraft ( true ) ;
setSceneExitTargetSceneIdDraft ( sceneTargetOptions [ 0 ] ? . id ? ? "" ) ;
setSceneExitTargetEntryIdDraft (
( sceneEntryOptionsBySceneId [ sceneTargetOptions [ 0 ] ? . id ? ? "" ] ? . [ 0 ]
? . entity . id ? ? "" )
) ;
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 ) ) ;
2026-04-11 02:44:48 +02:00
setPlayerStartCapsuleRadiusDraft (
String ( selectedEntity . collider . capsuleRadius )
) ;
setPlayerStartCapsuleHeightDraft (
String ( selectedEntity . collider . capsuleHeight )
) ;
setPlayerStartBoxSizeDraft (
createVec3Draft ( selectedEntity . collider . boxSize )
) ;
2026-03-31 05:55:08 +02:00
break ;
2026-04-11 04:35:58 +02:00
case "sceneEntry" :
setSceneEntryYawDraft ( String ( selectedEntity . yawDegrees ) ) ;
break ;
2026-03-31 05:55:08 +02:00
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 ;
2026-04-11 04:35:58 +02:00
case "sceneExit" :
setSceneExitRadiusDraft ( String ( selectedEntity . radius ) ) ;
setSceneExitPromptDraft ( selectedEntity . prompt ) ;
setSceneExitEnabledDraft ( selectedEntity . enabled ) ;
setSceneExitTargetSceneIdDraft ( selectedEntity . targetSceneId ) ;
setSceneExitTargetEntryIdDraft ( selectedEntity . targetEntryEntityId ) ;
break ;
2026-03-31 05:55:08 +02:00
}
2026-04-11 04:36:07 +02:00
} , [ editorState . projectDocument , 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 ) ) ;
2026-04-11 02:44:48 +02:00
setModelRotationDraft (
createVec3Draft ( DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES )
) ;
2026-03-31 17:42:23 +02:00
setModelScaleDraft ( createVec3Draft ( DEFAULT_MODEL_INSTANCE_SCALE ) ) ;
return ;
}
setModelPositionDraft ( createVec3Draft ( selectedModelInstance . position ) ) ;
2026-04-11 02:44:48 +02:00
setModelRotationDraft (
createVec3Draft ( selectedModelInstance . rotationDegrees )
) ;
2026-03-31 17:42:23 +02:00
setModelScaleDraft ( createVec3Draft ( selectedModelInstance . scale ) ) ;
} , [ selectedModelInstance ] ) ;
2026-03-31 05:11:52 +02:00
useEffect ( ( ) = > {
2026-04-11 02:44:48 +02:00
setAmbientLightIntensityDraft (
String ( editorState . document . world . ambientLight . intensity )
) ;
2026-03-31 05:11:52 +02:00
} , [ editorState . document . world . ambientLight . intensity ] ) ;
2026-03-31 23:15:17 +02:00
useEffect ( ( ) = > {
if ( editorState . document . world . background . mode === "image" ) {
2026-04-11 02:44:48 +02:00
setBackgroundEnvironmentIntensityDraft (
String ( editorState . document . world . background . environmentIntensity )
) ;
2026-03-31 23:15:17 +02:00
}
} , [ editorState . document . world . background ] ) ;
2026-03-31 05:11:52 +02:00
useEffect ( ( ) = > {
2026-04-11 02:44:48 +02:00
setSunLightIntensityDraft (
String ( editorState . document . world . sunLight . intensity )
) ;
2026-03-31 05:11:52 +02:00
} , [ editorState . document . world . sunLight . intensity ] ) ;
useEffect ( ( ) = > {
2026-04-11 02:44:48 +02:00
setSunDirectionDraft (
createVec3Draft ( editorState . document . world . sunLight . direction )
) ;
2026-03-31 05:11:52 +02:00
} , [ 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 ) ) ;
2026-04-11 02:44:48 +02:00
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 )
) ;
2026-04-02 20:53:26 +02:00
} , [ 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 ;
} ;
} , [ ] ) ;
2026-04-10 12:23:11 +02:00
useEffect ( ( ) = > {
autosaveControllerRef . current = new EditorAutosaveController ( {
saveDraft : ( ) = > store . saveDraft ( ) ,
onComplete : ( result ) = > {
if ( result . status === "error" ) {
if ( lastAutosaveErrorRef . current !== result . message ) {
lastAutosaveErrorRef . current = result . message ;
setStatusMessage ( result . message ) ;
}
return ;
}
lastAutosaveErrorRef . current = null ;
}
} ) ;
return ( ) = > {
autosaveControllerRef . current ? . dispose ( ) ;
autosaveControllerRef . current = null ;
} ;
} , [ store ] ) ;
useEffect ( ( ) = > {
if ( ! editorState . storageAvailable ) {
return ;
}
autosaveControllerRef . current ? . schedule ( ) ;
} , [
editorState . activeViewportPanelId ,
editorState . document ,
editorState . storageAvailable ,
editorState . viewportLayoutMode ,
editorState . viewportPanels ,
editorState . viewportQuadSplit
] ) ;
useEffect ( ( ) = > {
if ( ! editorState . storageAvailable ) {
return ;
}
const flushAutosave = ( ) = > {
autosaveControllerRef . current ? . flush ( ) ;
} ;
const handleVisibilityChange = ( ) = > {
if ( document . visibilityState === "hidden" ) {
flushAutosave ( ) ;
}
} ;
window . addEventListener ( "beforeunload" , flushAutosave ) ;
window . addEventListener ( "pagehide" , flushAutosave ) ;
document . addEventListener ( "visibilitychange" , handleVisibilityChange ) ;
return ( ) = > {
window . removeEventListener ( "beforeunload" , flushAutosave ) ;
window . removeEventListener ( "pagehide" , flushAutosave ) ;
document . removeEventListener ( "visibilitychange" , handleVisibilityChange ) ;
} ;
} , [ editorState . storageAvailable ] ) ;
2026-03-31 17:41:56 +02:00
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-04-11 02:44:48 +02:00
const previousLoadedModelAssetIds = new Set (
Object . keys ( previousLoadedModelAssets )
) ;
const previousLoadedImageAssetIds = new Set (
Object . keys ( previousLoadedImageAssets )
) ;
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-04-11 02:44:48 +02:00
if (
cachedLoadedAsset !== undefined &&
cachedLoadedAsset . storageKey === asset . storageKey
) {
2026-03-31 20:13:17 +02:00
nextLoadedModelAssets [ asset . id ] = cachedLoadedAsset ;
continue ;
}
2026-03-31 17:41:56 +02:00
2026-03-31 20:13:17 +02:00
try {
2026-04-11 02:44:48 +02:00
nextLoadedModelAssets [ asset . id ] = await loadModelAssetFromStorage (
projectAssetStorage ,
asset
) ;
2026-03-31 20:13:17 +02:00
} catch ( error ) {
2026-04-11 02:44:48 +02:00
syncErrorMessages . push (
` Model asset ${ asset . sourceName } could not be restored: ${ getErrorMessage ( error ) } `
) ;
2026-03-31 20:13:17 +02:00
}
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 ] ;
2026-04-11 02:44:48 +02:00
if (
cachedLoadedAsset !== undefined &&
cachedLoadedAsset . storageKey === asset . storageKey
) {
2026-03-31 20:13:17 +02:00
nextLoadedImageAssets [ asset . id ] = cachedLoadedAsset ;
continue ;
}
try {
2026-04-11 02:44:48 +02:00
nextLoadedImageAssets [ asset . id ] = await loadImageAssetFromStorage (
projectAssetStorage ,
asset
) ;
2026-03-31 20:13:17 +02:00
} catch ( error ) {
2026-04-11 02:44:48 +02:00
syncErrorMessages . push (
` Image asset ${ asset . sourceName } could not be restored: ${ getErrorMessage ( error ) } `
) ;
2026-03-31 20:13:17 +02:00
}
2026-04-02 19:42:56 +02:00
continue ;
}
if ( isAudioAsset ( asset ) ) {
previousLoadedAudioAssetIds . delete ( asset . id ) ;
const cachedLoadedAsset = previousLoadedAudioAssets [ asset . id ] ;
2026-04-11 02:44:48 +02:00
if (
cachedLoadedAsset !== undefined &&
cachedLoadedAsset . storageKey === asset . storageKey
) {
2026-04-02 19:42:56 +02:00
nextLoadedAudioAssets [ asset . id ] = cachedLoadedAsset ;
continue ;
}
try {
2026-04-11 02:44:48 +02:00
nextLoadedAudioAssets [ asset . id ] = await loadAudioAssetFromStorage (
projectAssetStorage ,
asset
) ;
2026-04-02 19:42:56 +02:00
} catch ( error ) {
2026-04-11 02:44:48 +02:00
syncErrorMessages . push (
` Audio asset ${ asset . sourceName } could not be restored: ${ getErrorMessage ( error ) } `
) ;
2026-04-02 19:42:56 +02:00
}
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-04-11 02:44:48 +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 ;
} ;
2026-04-11 02:44:48 +02:00
} , [
editorState . document . assets ,
projectAssetStorage ,
projectAssetStorageReady
] ) ;
2026-03-31 17:41:56 +02:00
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 =
2026-04-11 02:44:48 +02:00
event . target instanceof Element
? event . target . closest < HTMLElement > ( "[data-viewport-panel-id]" )
: null ;
const hoveredPanelId =
hoveredViewportPanelElement ? . dataset . viewportPanelId ;
2026-04-03 02:37:22 +02:00
setHoveredViewportPanelId (
2026-04-11 02:44:48 +02:00
hoveredPanelId === "topLeft" ||
hoveredPanelId === "topRight" ||
hoveredPanelId === "bottomLeft" ||
hoveredPanelId === "bottomRight"
2026-04-03 02:37:22 +02:00
? 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-11 02:44:48 +02:00
const hasPrimaryModifier =
( event . metaKey || event . ctrlKey ) && ! event . altKey ;
2026-04-03 01:13:22 +02:00
2026-04-03 01:15:06 +02:00
if ( hasPrimaryModifier && event . code === "KeyR" && ! event . shiftKey ) {
event . preventDefault ( ) ;
handleEnterPlayMode ( ) ;
return ;
}
2026-04-10 12:23:27 +02:00
if ( hasPrimaryModifier && event . code === "KeyS" ) {
2026-04-03 01:13:22 +02:00
event . preventDefault ( ) ;
2026-04-10 12:23:27 +02:00
void handleSaveProject ( ) ;
2026-04-03 01:13:22 +02:00
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" ;
2026-04-11 02:44:48 +02:00
const isDeleteShortcut =
! event . altKey &&
! event . ctrlKey &&
! event . metaKey &&
( event . code === "KeyX" || isDeletionKey ) ;
const isDuplicateShortcut =
event . shiftKey &&
! event . altKey &&
! event . ctrlKey &&
! event . metaKey &&
event . code === "KeyD" ;
2026-04-03 00:55:13 +02:00
if ( addMenuPosition !== null ) {
if ( isDeletionKey ) {
event . preventDefault ( ) ;
}
return ;
}
2026-04-05 04:23:19 +02:00
if ( isDuplicateShortcut ) {
const duplicated = handleDuplicateSelection ( ) ;
if ( duplicated ) {
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" &&
2026-04-11 02:44:48 +02:00
! (
event . key === "," &&
event . location === globalThis . KeyboardEvent . DOM_KEY_LOCATION_NUMPAD
)
2026-03-31 04:26:39 +02:00
) {
2026-03-31 04:25:02 +02:00
return ;
}
event . preventDefault ( ) ;
2026-04-11 02:44:48 +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
} ) ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
editorState . selection . kind === "none"
? "Framed the authored scene in the viewport."
: "Framed the current selection."
) ;
2026-03-31 04:25:02 +02:00
} ;
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-10 12:23:48 +02:00
} , [
activePanelId ,
addMenuPosition ,
brushList . length ,
editorState . document ,
editorState . selection ,
editorState . toolMode ,
entityList . length ,
hoveredViewportPanelId ,
layoutMode ,
projectAssetStorage ,
projectAssetStorageReady ,
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 ;
2026-04-11 02:44:48 +02:00
document . body . style . cursor = getViewportQuadResizeCursor (
viewportQuadResizeMode
) ;
2026-04-03 01:26:21 +02:00
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" ) {
2026-04-11 02:44:48 +02:00
nextViewportQuadSplit . x = clampViewportQuadSplitValue (
( event . clientX - rect . left ) / rect . width
) ;
2026-04-03 01:26:21 +02:00
}
if ( viewportQuadResizeMode !== "vertical" ) {
2026-04-11 02:44:48 +02:00
nextViewportQuadSplit . y = clampViewportQuadSplitValue (
( event . clientY - rect . top ) / rect . height
) ;
2026-04-03 01:26:21 +02:00
}
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 ;
}
2026-04-11 02:44:48 +02:00
const pointerCaptured =
activeNavigationMode === "firstPerson" &&
firstPersonTelemetry ? . pointerLocked === true ;
2026-04-03 01:15:06 +02:00
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-04-11 03:49:23 +02:00
const handleCreateScene = ( ) = > {
store . executeCommand ( createCreateSceneCommand ( ) ) ;
setStatusMessage ( "Created a new scene." ) ;
} ;
2026-04-11 04:20:45 +02:00
const handleActiveSceneChange = ( event : ChangeEvent < HTMLSelectElement > ) = > {
2026-04-11 03:49:23 +02:00
const nextSceneId = event . currentTarget . value ;
if ( nextSceneId === editorState . activeSceneId ) {
return ;
}
const nextScene = editorState . projectDocument . scenes [ nextSceneId ] ;
if ( nextScene === undefined ) {
return ;
}
store . executeCommand ( createSetActiveSceneCommand ( nextSceneId ) ) ;
setStatusMessage ( ` Switched to scene ${ nextScene . name } . ` ) ;
} ;
2026-04-11 04:16:12 +02:00
const applySceneLoadingScreen = (
loadingScreen : SceneLoadingScreenSettings ,
label : string ,
successMessage : string
) = > {
if (
areSceneLoadingScreenSettingsEqual (
activeProjectScene . loadingScreen ,
loadingScreen
)
) {
return ;
}
store . executeCommand (
createSetSceneLoadingScreenCommand ( {
sceneId : activeProjectScene.id ,
label ,
loadingScreen
} )
) ;
setStatusMessage ( successMessage ) ;
} ;
const applySceneLoadingColor = ( colorHex : string ) = > {
applySceneLoadingScreen (
{
. . . cloneSceneLoadingScreenSettings ( activeProjectScene . loadingScreen ) ,
colorHex
} ,
"Update scene loading overlay color" ,
"Updated the runner loading overlay color."
) ;
} ;
const applySceneLoadingHeadline = ( ) = > {
const normalizedHeadline = sceneLoadingHeadlineDraft . trim ( ) ;
applySceneLoadingScreen (
{
. . . cloneSceneLoadingScreenSettings ( activeProjectScene . loadingScreen ) ,
headline : normalizedHeadline.length === 0 ? null : normalizedHeadline
} ,
"Update scene loading overlay headline" ,
normalizedHeadline . length === 0
? "Cleared the runner loading overlay headline."
: "Updated the runner loading overlay headline."
) ;
} ;
const applySceneLoadingDescription = ( ) = > {
const normalizedDescription = sceneLoadingDescriptionDraft . trim ( ) ;
applySceneLoadingScreen (
{
. . . cloneSceneLoadingScreenSettings ( activeProjectScene . loadingScreen ) ,
description :
normalizedDescription . length === 0 ? null : normalizedDescription
} ,
"Update scene loading overlay description" ,
normalizedDescription . length === 0
? "Cleared the runner loading overlay description."
: "Updated the runner loading overlay description."
) ;
} ;
2026-04-11 02:44:48 +02:00
const requestViewportFocus = (
selection : EditorSelection ,
status? : string
) = > {
2026-03-31 04:25:13 +02:00
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-11 03:22:25 +02:00
const requestViewportCameraSync = ( ) = > {
setViewportCameraSyncRequestId ( ( current ) = > current + 1 ) ;
} ;
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 ) ;
} ;
2026-04-11 02:44:48 +02:00
const handleOpenAddMenuFromButton = (
event : ReactMouseEvent < HTMLButtonElement >
) = > {
2026-04-02 23:23:50 +02:00
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 ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Switched the viewport to ${ getViewportLayoutModeLabel ( nextLayoutMode ) } . `
) ;
2026-04-02 22:33:28 +02:00
} ;
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
} ;
2026-04-11 02:44:48 +02:00
const handleSetViewportPanelViewMode = (
panelId : ViewportPanelId ,
nextViewMode : ViewportViewMode
) = > {
2026-04-02 22:33:28 +02:00
if ( editorState . viewportPanels [ panelId ] . viewMode === nextViewMode ) {
return ;
}
2026-04-02 23:05:18 +02:00
blurActiveTextEntry ( ) ;
store . setViewportPanelViewMode ( panelId , nextViewMode ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Set the viewport panel to ${ getViewportViewModeLabel ( nextViewMode ) } view. `
) ;
2026-04-02 22:33:28 +02:00
} ;
2026-04-11 02:44:48 +02:00
const handleSetViewportPanelDisplayMode = (
panelId : ViewportPanelId ,
nextDisplayMode : ViewportDisplayMode
) = > {
2026-04-02 22:33:28 +02:00
if ( editorState . viewportPanels [ panelId ] . displayMode === nextDisplayMode ) {
return ;
}
blurActiveTextEntry ( ) ;
store . setViewportPanelDisplayMode ( panelId , nextDisplayMode ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Set the viewport panel to ${ getViewportDisplayModeLabel ( nextDisplayMode ) } display. `
) ;
2026-04-02 22:16:59 +02:00
} ;
2026-04-11 02:44:48 +02:00
const beginTransformOperation = (
operation : TransformOperation ,
source : TransformSessionSource
) = > {
2026-04-03 02:15:11 +02:00
if ( editorState . toolMode !== "select" ) {
return ;
}
2026-04-11 02:44:48 +02:00
const transformSourcePanelId =
layoutMode === "quad"
? ( hoveredViewportPanelId ? ? activePanelId )
: activePanelId ;
2026-04-03 02:35:30 +02:00
2026-04-11 02:44:48 +02:00
const transformTargetResult = resolveTransformTarget (
editorState . document ,
editorState . selection ,
whiteboxSelectionMode
) ;
2026-04-03 02:15:11 +02:00
const transformTarget = transformTargetResult . target ;
if ( transformTarget === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
transformTargetResult . message ? ?
"Select a single brush, entity, or model instance before transforming it."
) ;
2026-04-03 02:15:11 +02:00
return ;
}
if ( ! supportsTransformOperation ( transformTarget , operation ) ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
` ${ getTransformOperationLabel ( operation ) } is not supported for ${ getTransformTargetLabel ( transformTarget ) } . `
) ;
2026-04-03 02:15:11 +02:00
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-11 02:43:26 +02:00
) } . Move the pointer , press X / Y / Z to constrain , press the same axis again for local when supported , click or press Enter to commit , Escape cancels . `
2026-04-03 02:15:11 +02:00
) ;
} ;
2026-04-11 02:44:48 +02:00
const cancelTransformSession = (
status = "Cancelled the current transform."
) = > {
2026-04-03 02:15:11 +02:00
if ( transformSession . kind === "none" ) {
return ;
}
store . clearTransformSession ( ) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 02:15:11 +02:00
setStatusMessage ( status ) ;
} ;
2026-04-11 02:44:48 +02:00
const commitTransformSession = (
activeTransformSession : ActiveTransformSession
) = > {
2026-04-03 02:15:11 +02:00
if ( ! doesTransformSessionChangeTarget ( activeTransformSession ) ) {
store . clearTransformSession ( ) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 02:15:11 +02:00
setStatusMessage ( "No transform change was committed." ) ;
return ;
}
try {
store . clearTransformSession ( ) ;
2026-04-11 02:44:48 +02:00
store . executeCommand (
createCommitTransformSessionCommand (
editorState . document ,
activeTransformSession
)
) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 02:15:11 +02:00
setStatusMessage (
` ${ getTransformOperationPastTense ( activeTransformSession . operation ) } ${ getTransformTargetLabel ( activeTransformSession . target ) . toLowerCase ( ) } . `
) ;
} catch ( error ) {
store . clearTransformSession ( ) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 02:15:11 +02:00
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyTransformAxisConstraint = ( axis : TransformAxis ) = > {
if ( transformSession . kind !== "active" ) {
return ;
}
if ( ! supportsTransformAxisConstraint ( transformSession , axis ) ) {
const supportedAxes = ( [ "x" , "y" , "z" ] as const )
2026-04-11 02:44:48 +02:00
. filter ( ( candidateAxis ) = >
supportsTransformAxisConstraint ( transformSession , candidateAxis )
)
2026-04-03 02:15:11 +02:00
. 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 ;
}
2026-04-11 02:40:51 +02:00
const nextAxisConstraintSpace =
2026-04-11 02:44:48 +02:00
transformSession . axisConstraint === axis
? transformSession . axisConstraintSpace === "world"
? "local"
: "world"
: "world" ;
if (
nextAxisConstraintSpace === "local" &&
! supportsLocalTransformAxisConstraint ( transformSession , axis )
) {
2026-04-11 02:40:51 +02:00
setStatusMessage (
` Local ${ getTransformAxisLabel ( axis ) } is not supported for ${ getTransformOperationLabel ( transformSession . operation ) . toLowerCase ( ) } on ${ getTransformTargetLabel (
transformSession . target
) } . `
) ;
return ;
}
store . setTransformAxisConstraint ( axis , nextAxisConstraintSpace ) ;
setStatusMessage (
` Constrained ${ getTransformOperationLabel ( transformSession . operation ) . toLowerCase ( ) } to ${ getTransformAxisSpaceLabel (
nextAxisConstraintSpace
) . toLowerCase ( ) } $ { getTransformAxisLabel ( axis ) } . `
) ;
2026-04-03 02:15:11 +02:00
} ;
2026-04-03 01:26:21 +02:00
const handleViewportQuadResizeStart =
2026-04-11 02:44:48 +02:00
( resizeMode : ViewportQuadResizeMode ) = >
( event : ReactPointerEvent < HTMLDivElement > ) = > {
2026-04-03 01:26:21 +02:00
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" ) {
2026-04-11 02:44:48 +02:00
nextViewportQuadSplit . x = clampViewportQuadSplitValue (
( event . clientX - rect . left ) / rect . width
) ;
2026-04-03 01:26:21 +02:00
}
if ( resizeMode !== "vertical" ) {
2026-04-11 02:44:48 +02:00
nextViewportQuadSplit . y = clampViewportQuadSplitValue (
( event . clientY - rect . top ) / rect . height
) ;
2026-04-03 01:26:21 +02:00
}
store . setViewportQuadSplit ( nextViewportQuadSplit ) ;
}
setViewportQuadResizeMode ( resizeMode ) ;
} ;
2026-04-11 02:44:48 +02:00
const beginCreation = (
toolPreview : CreationViewportToolPreview ,
status : string
) = > {
2026-04-03 00:22:56 +02:00
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
} ,
2026-04-04 19:30:47 +02:00
` Previewing a whitebox box. Click in the viewport to create it ${ whiteboxSnapEnabled ? ` on the ${ whiteboxSnapStep } m grid ` : "" } . `
2026-04-03 00:22:56 +02:00
) ;
2026-03-31 02:37:22 +02:00
} ;
2026-04-04 19:30:47 +02:00
const handleWhiteboxSnapToggle = ( ) = > {
const nextEnabled = ! whiteboxSnapEnabled ;
setWhiteboxSnapEnabled ( nextEnabled ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
nextEnabled
? ` Grid snap enabled at ${ whiteboxSnapStep } m. `
: "Grid snap disabled for whitebox transforms."
) ;
2026-04-04 19:30:47 +02:00
} ;
2026-04-11 02:40:51 +02:00
const handleViewportGridToggle = ( ) = > {
const nextVisible = ! viewportGridVisible ;
setViewportGridVisible ( nextVisible ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
nextVisible ? "Viewport grid enabled." : "Viewport grid hidden."
) ;
2026-04-11 02:40:51 +02:00
} ;
2026-04-04 19:30:47 +02:00
const handleWhiteboxSnapStepBlur = ( ) = > {
2026-04-11 02:44:48 +02:00
const normalizedStep = resolveOptionalPositiveNumber (
whiteboxSnapStepDraft ,
DEFAULT_GRID_SIZE
) ;
2026-04-04 19:30:47 +02:00
setWhiteboxSnapStepDraft ( String ( normalizedStep ) ) ;
} ;
2026-04-04 20:13:29 +02:00
const handleWhiteboxSelectionModeChange = ( mode : WhiteboxSelectionMode ) = > {
if ( whiteboxSelectionMode === mode ) {
return ;
}
blurActiveTextEntry ( ) ;
store . setWhiteboxSelectionMode ( mode ) ;
setStatusMessage ( getWhiteboxSelectionModeStatus ( mode ) ) ;
} ;
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-04-11 02:44:48 +02:00
const suffix =
source === "outliner" && options . focusViewport
? " and framed it in the viewport"
: "" ;
2026-03-31 04:25:13 +02:00
2026-03-31 02:37:22 +02:00
switch ( selection . kind ) {
case "none" :
2026-04-11 02:44:48 +02:00
setStatusMessage (
` ${ source === "viewport" ? "Viewport" : "Editor" } selection cleared ${ suffix } . `
) ;
2026-03-31 02:37:22 +02:00
break ;
case "brushes" :
2026-04-11 02:44:48 +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 (
2026-04-04 20:12:33 +02:00
` Selected ${ BOX_FACE_LABELS [ selection . faceId ] } on ${ getBrushLabelById ( selection . brushId , brushList ) } from the ${ source } ${ suffix } . `
2026-03-31 04:25:13 +02:00
) ;
2026-03-31 02:37:22 +02:00
break ;
2026-04-04 20:13:29 +02:00
case "brushEdge" :
setStatusMessage (
` Selected ${ BOX_EDGE_LABELS [ selection . edgeId ] } on ${ getBrushLabelById ( selection . brushId , brushList ) } from the ${ source } ${ suffix } . `
) ;
break ;
case "brushVertex" :
setStatusMessage (
` Selected ${ BOX_VERTEX_LABELS [ selection . vertexId ] } on ${ getBrushLabelById ( selection . brushId , brushList ) } from the ${ source } ${ suffix } . `
) ;
break ;
2026-03-31 03:09:04 +02:00
case "entities" :
2026-04-11 02:44:48 +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 = ( ) = > {
2026-04-11 02:44:48 +02:00
if (
selectedBrush === null ||
editorState . selection . kind !== "brushes" ||
whiteboxSelectionMode !== "object"
) {
setStatusMessage (
"Switch to Object mode and select a whitebox box before moving it."
) ;
2026-03-31 02:37:22 +02:00
return ;
}
try {
2026-04-11 02:44:48 +02:00
const nextCenter = maybeSnapVec3 (
readVec3Draft ( positionDraft , "Whitebox box position" ) ,
whiteboxSnapEnabled ,
whiteboxSnapStep
) ;
2026-03-31 02:37:22 +02:00
2026-04-04 19:30:47 +02:00
if ( areVec3Equal ( nextCenter , selectedBrush . center ) ) {
2026-03-31 02:37:22 +02:00
return ;
}
store . executeCommand (
createMoveBoxBrushCommand ( {
brushId : selectedBrush.id ,
2026-04-04 19:30:47 +02:00
center : nextCenter ,
snapToGrid : false
} )
) ;
setStatusMessage ( "Moved selected whitebox box." ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyRotationChange = ( ) = > {
2026-04-11 02:44:48 +02:00
if (
selectedBrush === null ||
editorState . selection . kind !== "brushes" ||
whiteboxSelectionMode !== "object"
) {
setStatusMessage (
"Switch to Object mode and select a whitebox box before rotating it."
) ;
2026-04-04 19:30:47 +02:00
return ;
}
try {
2026-04-11 02:44:48 +02:00
const nextRotationDegrees = readVec3Draft (
rotationDraft ,
"Whitebox box rotation"
) ;
2026-04-04 19:30:47 +02:00
if ( areVec3Equal ( nextRotationDegrees , selectedBrush . rotationDegrees ) ) {
return ;
}
store . executeCommand (
createRotateBoxBrushCommand ( {
brushId : selectedBrush.id ,
rotationDegrees : nextRotationDegrees
2026-03-31 02:37:22 +02:00
} )
) ;
2026-04-04 19:30:47 +02:00
setStatusMessage ( "Rotated selected whitebox box." ) ;
2026-03-31 02:37:22 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applySizeChange = ( ) = > {
2026-04-11 02:44:48 +02:00
if (
selectedBrush === null ||
editorState . selection . kind !== "brushes" ||
whiteboxSelectionMode !== "object"
) {
setStatusMessage (
"Switch to Object mode and select a whitebox box before scaling it."
) ;
2026-03-31 02:37:22 +02:00
return ;
}
try {
2026-04-11 02:44:48 +02:00
const nextSize = maybeSnapPositiveSize (
readVec3Draft ( sizeDraft , "Whitebox box size" ) ,
whiteboxSnapEnabled ,
whiteboxSnapStep
) ;
2026-03-31 02:37:22 +02:00
2026-04-04 19:30:47 +02:00
if ( areVec3Equal ( nextSize , selectedBrush . size ) ) {
2026-03-31 02:37:22 +02:00
return ;
}
store . executeCommand (
createResizeBoxBrushCommand ( {
brushId : selectedBrush.id ,
2026-04-04 19:30:47 +02:00
size : nextSize ,
snapToGrid : false
2026-03-31 02:37:22 +02:00
} )
) ;
2026-04-04 19:30:47 +02:00
setStatusMessage ( "Scaled selected whitebox box." ) ;
2026-03-31 02:37:22 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-06 08:28:50 +02:00
const applyBoxVolumeSettings = (
mutate : ( next : BoxBrush [ "volume" ] ) = > BoxBrush [ "volume" ] ,
label : string ,
successMessage : string
) = > {
if ( selectedBrush === null ) {
setStatusMessage ( "Select a whitebox box before editing volume settings." ) ;
return ;
}
try {
const nextVolume = mutate ( selectedBrush . volume ) ;
store . executeCommand (
createSetBoxBrushVolumeSettingsCommand ( {
brushId : selectedBrush.id ,
volume : nextVolume ,
label
} )
) ;
setStatusMessage ( successMessage ) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyBoxVolumeModeChange = ( mode : BoxBrushVolumeMode ) = > {
if ( selectedBrush === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Select a whitebox box before changing the volume mode."
) ;
2026-04-06 08:28:50 +02:00
return ;
}
if ( selectedBrush . volume . mode === mode ) {
return ;
}
applyBoxVolumeSettings (
( currentVolume ) = > {
if ( mode === "none" ) {
return {
mode : "none"
} ;
}
if ( mode === "water" ) {
return currentVolume . mode === "water"
? currentVolume
: {
mode : "water" ,
water : {
colorHex : boxVolumeWaterColorDraft ,
2026-04-11 02:44:48 +02:00
surfaceOpacity : readNonNegativeNumberDraft (
boxVolumeWaterSurfaceOpacityDraft ,
"Water surface opacity"
) ,
waveStrength : readNonNegativeNumberDraft (
boxVolumeWaterWaveStrengthDraft ,
"Water wave strength"
) ,
foamContactLimit : readWaterFoamContactLimitDraft (
boxVolumeWaterFoamContactLimitDraft
) ,
surfaceDisplacementEnabled :
boxVolumeWaterSurfaceDisplacementEnabledDraft
2026-04-06 08:28:50 +02:00
}
} ;
}
return currentVolume . mode === "fog"
? currentVolume
: {
mode : "fog" ,
fog : {
colorHex : boxVolumeFogColorDraft ,
2026-04-11 02:44:48 +02:00
density : readNonNegativeNumberDraft (
boxVolumeFogDensityDraft ,
"Fog density"
) ,
padding : readNonNegativeNumberDraft (
boxVolumeFogPaddingDraft ,
"Fog padding"
)
2026-04-06 08:28:50 +02:00
}
} ;
} ,
` Set box volume mode to ${ mode } ` ,
` Set selected whitebox box volume mode to ${ formatBoxVolumeModeLabel ( mode ) } . `
) ;
} ;
2026-04-07 07:32:25 +02:00
const resolveDraftBoxWaterSettings = (
overrides : {
colorHex? : string ;
surfaceOpacity? : number ;
waveStrength? : number ;
foamContactLimit? : number ;
surfaceDisplacementEnabled? : boolean ;
} = { }
) = > ( {
colorHex : overrides.colorHex ? ? boxVolumeWaterColorDraft ,
surfaceOpacity :
2026-04-11 02:44:48 +02:00
overrides . surfaceOpacity ? ?
readNonNegativeNumberDraft (
boxVolumeWaterSurfaceOpacityDraft ,
"Water surface opacity"
) ,
waveStrength :
overrides . waveStrength ? ?
readNonNegativeNumberDraft (
boxVolumeWaterWaveStrengthDraft ,
"Water wave strength"
) ,
foamContactLimit :
overrides . foamContactLimit ? ?
readWaterFoamContactLimitDraft ( boxVolumeWaterFoamContactLimitDraft ) ,
2026-04-07 07:32:25 +02:00
surfaceDisplacementEnabled :
2026-04-11 02:44:48 +02:00
overrides . surfaceDisplacementEnabled ? ?
boxVolumeWaterSurfaceDisplacementEnabledDraft
2026-04-07 07:32:25 +02:00
} ) ;
const applyBoxWaterSettings = (
overrides : {
colorHex? : string ;
surfaceOpacity? : number ;
waveStrength? : number ;
foamContactLimit? : number ;
surfaceDisplacementEnabled? : boolean ;
} = { }
) = > {
2026-04-06 08:28:50 +02:00
if ( selectedBrush === null || selectedBrush . volume . mode !== "water" ) {
return ;
}
applyBoxVolumeSettings (
( ) = > ( {
mode : "water" ,
2026-04-07 07:32:25 +02:00
water : resolveDraftBoxWaterSettings ( overrides )
2026-04-06 08:28:50 +02:00
} ) ,
"Set box water settings" ,
"Updated selected whitebox water settings."
) ;
} ;
2026-04-06 17:25:42 +02:00
const applyBoxWaterColorDraft = ( colorHex : string ) = > {
if ( selectedBrush === null || selectedBrush . volume . mode !== "water" ) {
return ;
}
applyBoxVolumeSettings (
( ) = > ( {
mode : "water" ,
2026-04-07 07:32:25 +02:00
water : resolveDraftBoxWaterSettings ( { colorHex } )
2026-04-06 17:25:42 +02:00
} ) ,
"Set box water color" ,
"Updated selected whitebox water color."
) ;
} ;
2026-04-06 08:28:50 +02:00
const applyBoxFogSettings = ( ) = > {
if ( selectedBrush === null || selectedBrush . volume . mode !== "fog" ) {
return ;
}
applyBoxVolumeSettings (
( ) = > ( {
mode : "fog" ,
fog : {
colorHex : boxVolumeFogColorDraft ,
2026-04-11 02:44:48 +02:00
density : readNonNegativeNumberDraft (
boxVolumeFogDensityDraft ,
"Fog density"
) ,
padding : readNonNegativeNumberDraft (
boxVolumeFogPaddingDraft ,
"Fog padding"
)
2026-04-06 08:28:50 +02:00
}
} ) ,
"Set box fog settings" ,
"Updated selected whitebox fog settings."
) ;
} ;
2026-04-06 17:25:42 +02:00
const applyBoxFogColorDraft = ( colorHex : string ) = > {
if ( selectedBrush === null || selectedBrush . volume . mode !== "fog" ) {
return ;
}
applyBoxVolumeSettings (
( ) = > ( {
mode : "fog" ,
fog : {
colorHex ,
2026-04-11 02:44:48 +02:00
density : readNonNegativeNumberDraft (
boxVolumeFogDensityDraft ,
"Fog density"
) ,
padding : readNonNegativeNumberDraft (
boxVolumeFogPaddingDraft ,
"Fog padding"
)
2026-04-06 17:25:42 +02:00
}
} ) ,
"Set box fog color" ,
"Updated selected whitebox fog color."
) ;
} ;
2026-04-11 02:44:48 +02:00
const commitEntityChange = (
currentEntity : EntityInstance ,
nextEntity : EntityInstance ,
successMessage : string
) = > {
2026-03-31 05:55:51 +02:00
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-11 02:44:48 +02:00
const beginEntityCreation = (
kind : EntityKind ,
options : { audioAssetId? : string | null } = { }
) = > {
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 : "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-11 02:44:48 +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" ) {
2026-04-11 02:44:48 +02:00
const center =
creationPreview . center === null ? undefined : creationPreview . center ;
2026-04-03 00:22:56 +02:00
store . executeCommand (
2026-04-04 19:31:06 +02:00
createCreateBoxBrushCommand (
center === undefined
? {
snapToGrid : whiteboxSnapEnabled ,
gridSize : whiteboxSnapStep
}
: {
center ,
snapToGrid : whiteboxSnapEnabled ,
gridSize : whiteboxSnapStep
}
)
2026-04-03 00:22:56 +02:00
) ;
2026-04-03 00:30:20 +02:00
completeCreation (
2026-04-03 00:22:56 +02:00
center === undefined
2026-04-04 19:31:06 +02:00
? whiteboxSnapEnabled
? ` Created a whitebox box on the ${ whiteboxSnapStep } m grid. `
: "Created a whitebox box."
: whiteboxSnapEnabled
? ` Created a whitebox box at snapped center ${ formatVec3 ( center ) } . `
: ` Created a whitebox box at ${ formatVec3 ( center ) } . `
2026-04-03 00:22:56 +02:00
) ;
2026-04-03 00:32:17 +02:00
return true ;
2026-04-03 00:22:56 +02:00
}
if ( creationPreview . target . kind === "model-instance" ) {
2026-04-11 02:44:48 +02:00
const asset =
editorState . document . assets [ creationPreview . target . assetId ] ;
2026-04-02 23:54:17 +02:00
if ( asset === undefined || asset . kind !== "model" ) {
2026-04-11 02:44:48 +02:00
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-11 02:44:48 +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-11 02:44:48 +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-04-11 02:44:48 +02:00
const commitModelInstanceChange = (
currentModelInstance : ModelInstance ,
nextModelInstance : ModelInstance ,
successMessage : string
) = > {
2026-03-31 17:42:35 +02:00
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" ) ,
2026-04-11 02:44:48 +02:00
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
} ) ;
2026-04-11 02:44:48 +02:00
commitModelInstanceChange (
selectedModelInstance ,
nextModelInstance ,
"Updated model instance."
) ;
2026-03-31 17:42:52 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-11 02:44:48 +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 {
2026-04-11 02:44:48 +02:00
const snappedPosition = snapVec3ToGrid (
readVec3Draft ( entityPositionDraft , "Player Start position" ) ,
DEFAULT_GRID_SIZE
) ;
2026-03-31 05:55:51 +02:00
const yawDegrees = readYawDegreesDraft ( playerStartYawDraft ) ;
2026-04-11 02:44:48 +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 ,
2026-04-11 02:44:48 +02:00
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-04-04 15:56:30 +02:00
}
2026-03-31 05:55:51 +02:00
} ) ;
2026-04-11 02:44:48 +02:00
commitEntityChange (
selectedPlayerStart ,
nextEntity ,
"Updated Player Start."
) ;
2026-03-31 05:55:51 +02:00
} 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-04-11 02:44:48 +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-04-11 02:44:48 +02:00
intensity : readNonNegativeNumberDraft (
pointLightIntensityDraft ,
"Point Light intensity"
) ,
distance : readPositiveNumberDraft (
pointLightDistanceDraft ,
"Point Light distance"
)
2026-03-31 20:13:57 +02:00
} ) ;
2026-04-11 02:44:48 +02:00
commitEntityChange (
selectedPointLight ,
nextEntity ,
"Updated Point Light."
) ;
2026-03-31 20:13:57 +02:00
} 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-04-11 02:44:48 +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-04-11 02:44:48 +02:00
intensity : readNonNegativeNumberDraft (
spotLightIntensityDraft ,
"Spot Light intensity"
) ,
distance : readPositiveNumberDraft (
spotLightDistanceDraft ,
"Spot Light distance"
) ,
angleDegrees : readPositiveNumberDraft (
spotLightAngleDraft ,
"Spot Light angle"
)
2026-03-31 20:13:57 +02:00
} ) ;
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 = (
2026-04-11 02:44:48 +02:00
overrides : {
audioAssetId? : string | null ;
autoplay? : boolean ;
loop? : boolean ;
} = { }
2026-04-02 20:19:41 +02:00
) = > {
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-04-11 02:44:48 +02:00
position : snapVec3ToGrid (
readVec3Draft ( entityPositionDraft , "Sound Emitter position" ) ,
DEFAULT_GRID_SIZE
) ,
2026-04-02 20:19:41 +02:00
audioAssetId : nextAudioAssetId ,
2026-04-11 02:44:48 +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
} ) ;
2026-04-11 02:44:48 +02:00
commitEntityChange (
selectedSoundEmitter ,
nextEntity ,
"Updated Sound Emitter."
) ;
2026-03-31 05:55:51 +02:00
} 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.
2026-04-11 02:44:48 +02:00
const links = getInteractionLinksForSource (
editorState . document . interactionLinks ,
selectedTriggerVolume . id
) ;
2026-04-01 04:19:11 +02:00
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-04-11 02:44:48 +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
} ) ;
2026-04-11 02:44:48 +02:00
commitEntityChange (
selectedTriggerVolume ,
nextEntity ,
"Updated Trigger Volume."
) ;
2026-03-31 05:55:51 +02:00
} 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-04-11 02:44:48 +02:00
position : snapVec3ToGrid (
readVec3Draft ( entityPositionDraft , "Teleport Target position" ) ,
DEFAULT_GRID_SIZE
) ,
2026-03-31 05:55:51 +02:00
yawDegrees : readYawDegreesDraft ( teleportTargetYawDraft )
} ) ;
2026-04-11 02:44:48 +02:00
commitEntityChange (
selectedTeleportTarget ,
nextEntity ,
"Updated Teleport Target."
) ;
2026-03-31 05:55:51 +02:00
} 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-04-11 02:44:48 +02:00
position : snapVec3ToGrid (
readVec3Draft ( entityPositionDraft , "Interactable position" ) ,
DEFAULT_GRID_SIZE
) ,
radius : readPositiveNumberDraft (
interactableRadiusDraft ,
"Interactable radius"
) ,
2026-03-31 05:55:51 +02:00
prompt : readInteractablePromptDraft ( interactablePromptDraft ) ,
2026-04-02 20:19:41 +02:00
enabled : overrides.enabled ? ? interactableEnabledDraft
2026-03-31 05:55:51 +02:00
} ) ;
2026-04-11 02:44:48 +02:00
commitEntityChange (
selectedInteractable ,
nextEntity ,
"Updated Interactable."
) ;
2026-03-31 03:09:04 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-11 02:44:48 +02:00
const commitInteractionLinkChange = (
currentLink : InteractionLink ,
nextLink : InteractionLink ,
successMessage : string ,
label = "Update interaction link"
) = > {
2026-03-31 06:18:16 +02:00
if ( areInteractionLinksEqual ( currentLink , nextLink ) ) {
return ;
}
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link : nextLink ,
label
} )
) ;
setStatusMessage ( successMessage ) ;
} ;
2026-04-11 02:44:48 +02:00
const getInteractionSourceEntityForLink = (
link : InteractionLink
) : InteractionSourceEntity | null = > {
2026-03-31 06:47:47 +02:00
const sourceEntity = editorState . document . entities [ link . sourceEntityId ] ;
2026-04-11 02:44:48 +02:00
return sourceEntity ? . kind === "triggerVolume" ||
sourceEntity ? . kind === "interactable"
? sourceEntity
: null ;
2026-03-31 06:47:47 +02:00
} ;
2026-03-31 06:18:16 +02:00
const handleAddTeleportInteractionLink = ( ) = > {
2026-03-31 06:47:47 +02:00
if ( selectedInteractionSource === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Select a Trigger Volume or Interactable before adding links."
) ;
2026-03-31 06:18:16 +02:00
return ;
}
const defaultTarget = teleportTargetOptions [ 0 ] ? . entity ;
2026-04-11 02:44:48 +02:00
if (
defaultTarget === undefined ||
defaultTarget . kind !== "teleportTarget"
) {
setStatusMessage (
"Author a Teleport Target before adding a teleport link."
) ;
2026-03-31 06:18:16 +02:00
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-04-11 02:44:48 +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 ) {
2026-04-11 02:44:48 +02:00
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 ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Author at least one whitebox solid before adding a visibility link."
) ;
2026-03-31 06:18:16 +02:00
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-04-11 02:44:48 +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-11 02:44:48 +02:00
const handleAddSoundInteractionLink = (
actionType : "playSound" | "stopSound"
) = > {
2026-04-02 19:41:26 +02:00
if ( selectedInteractionSource === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Select a Trigger Volume or Interactable before adding links."
) ;
2026-04-02 19:41:26 +02:00
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 ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Author a Sound Emitter with an audio asset before adding sound links."
) ;
2026-04-02 19:41:26 +02:00
return ;
}
const link =
actionType === "playSound"
? createPlaySoundInteractionLink ( {
sourceEntityId : selectedInteractionSource.id ,
2026-04-11 02:44:48 +02:00
trigger : getDefaultInteractionLinkTrigger (
selectedInteractionSource
) ,
2026-04-02 19:41:26 +02:00
targetSoundEmitterId : defaultTarget.id
} )
: createStopSoundInteractionLink ( {
sourceEntityId : selectedInteractionSource.id ,
2026-04-11 02:44:48 +02:00
trigger : getDefaultInteractionLinkTrigger (
selectedInteractionSource
) ,
2026-04-02 19:41:26 +02:00
targetSoundEmitterId : defaultTarget.id
} ) ;
store . executeCommand (
createUpsertInteractionLinkCommand ( {
link ,
2026-04-11 02:44:48 +02:00
label :
actionType === "playSound"
? "Add play sound link"
: "Add stop sound link"
2026-04-02 19:41:26 +02:00
} )
) ;
setStatusMessage (
` Added a ${ actionType === "playSound" ? "play sound" : "stop sound" } link to the selected ${ selectedInteractionSource . kind === "triggerVolume" ? "Trigger Volume" : "Interactable" } . `
) ;
2026-04-07 07:36:48 +02:00
} ;
2026-04-02 19:41:26 +02:00
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 ) = >
2026-04-11 02:44:48 +02:00
globalThis . window . confirm (
` Delete ${ label } ? \ n \ nThis can be undone with Undo. `
) ;
2026-04-03 00:55:13 +02:00
const handleDeleteBrush = ( brushId : string ) = > {
const label = getBrushLabelById ( brushId , brushList ) ;
if ( ! confirmDeleteSceneItem ( label ) ) {
return false ;
}
try {
store . executeCommand ( createDeleteBoxBrushCommand ( brushId ) ) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 00:55:13 +02:00
setStatusMessage ( ` Deleted ${ label } . ` ) ;
return true ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
return false ;
}
} ;
const handleDeleteEntity = ( entityId : string ) = > {
2026-04-11 02:44:48 +02:00
const label = getEntityDisplayLabelById (
entityId ,
editorState . document . entities ,
editorState . document . assets
) ;
2026-04-03 00:55:13 +02:00
if ( ! confirmDeleteSceneItem ( label ) ) {
return false ;
}
try {
store . executeCommand ( createDeleteEntityCommand ( entityId ) ) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 00:55:13 +02:00
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 ) ) ;
2026-04-11 03:22:25 +02:00
requestViewportCameraSync ( ) ;
2026-04-03 00:55:13 +02:00
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 ) ;
}
2026-04-11 02:44:48 +02:00
const selectedModelInstanceId = getSingleSelectedModelInstanceId (
editorState . selection
) ;
2026-04-03 00:55:13 +02:00
if ( selectedModelInstanceId !== null ) {
return handleDeleteModelInstance ( selectedModelInstanceId ) ;
}
return false ;
} ;
2026-04-05 04:23:19 +02:00
const handleDuplicateSelection = ( ) = > {
if ( ! selectionCanBeDuplicated ( editorState . selection ) ) {
return false ;
}
try {
store . executeCommand ( createDuplicateSelectionCommand ( ) ) ;
2026-04-05 04:33:18 +02:00
2026-04-05 04:36:26 +02:00
const duplicatedState = store . getState ( ) ;
const duplicatedSelection = duplicatedState . selection ;
2026-04-05 04:33:18 +02:00
const canGrabDuplicatedSelection =
2026-04-11 02:44:48 +02:00
( duplicatedSelection . kind === "brushes" ||
duplicatedSelection . kind === "entities" ||
duplicatedSelection . kind === "modelInstances" ) &&
2026-04-05 04:33:18 +02:00
duplicatedSelection . ids . length === 1 ;
if ( canGrabDuplicatedSelection ) {
2026-04-11 02:44:48 +02:00
const transformSourcePanelId =
layoutMode === "quad"
? ( hoveredViewportPanelId ? ? activePanelId )
: activePanelId ;
const transformTargetResult = resolveTransformTarget (
duplicatedState . document ,
duplicatedSelection ,
whiteboxSelectionMode
) ;
2026-04-05 04:36:26 +02:00
const transformTarget = transformTargetResult . target ;
if ( transformTarget === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
transformTargetResult . message ? ?
"Duplicated selection, but could not start move transform."
) ;
2026-04-05 04:36:26 +02:00
return true ;
}
if ( duplicatedState . activeViewportPanelId !== transformSourcePanelId ) {
store . setActiveViewportPanel ( transformSourcePanelId ) ;
}
store . setTransformSession (
createTransformSession ( {
source : "keyboard" ,
sourcePanelId : transformSourcePanelId ,
operation : "translate" ,
target : transformTarget
} )
) ;
setStatusMessage (
` Move ${ getTransformTargetLabel ( transformTarget ) . toLowerCase ( ) } in ${ getViewportPanelLabel (
transformSourcePanelId
2026-04-11 02:43:26 +02:00
) } . Move the pointer , press X / Y / Z to constrain , press the same axis again for local when supported , click or press Enter to commit , Escape cancels . `
2026-04-05 04:36:26 +02:00
) ;
2026-04-05 04:33:18 +02:00
} else {
setStatusMessage ( "Duplicated selection." ) ;
}
2026-04-05 04:23:19 +02:00
return true ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
return false ;
}
} ;
2026-04-11 02:44:48 +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" ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Trigger Volume links may only use enter or exit triggers."
) ;
2026-03-31 06:47:47 +02:00
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
2026-04-11 02:44:48 +02:00
commitInteractionLinkChange (
link ,
nextLink ,
` Updated ${ getInteractionTriggerLabel ( trigger ) . toLowerCase ( ) } trigger link. `
) ;
2026-03-31 06:18:16 +02:00
} ;
2026-04-11 02:44:48 +02:00
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 ;
2026-04-11 02:44:48 +02:00
if (
defaultTarget === undefined ||
defaultTarget . kind !== "teleportTarget"
) {
setStatusMessage (
"Author a Teleport Target before switching this link to teleport."
) ;
2026-03-31 06:18:16 +02:00
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
} ) ,
2026-04-07 07:36:23 +02:00
"Switched link action to teleport player."
2026-03-31 06:18:16 +02:00
) ;
return ;
}
2026-04-01 02:53:12 +02:00
if ( actionType === "playAnimation" ) {
2026-04-02 19:19:54 +02:00
const targetModelInstance =
2026-04-11 02:44:48 +02:00
( link . action . type === "playAnimation" ||
link . action . type === "stopAnimation"
? editorState . document . modelInstances [
link . action . targetModelInstanceId
]
2026-04-02 19:19:54 +02:00
: 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-11 02:44:48 +02:00
setStatusMessage (
"Place a model instance before switching this link to play animation."
) ;
2026-04-01 02:53:12 +02:00
return ;
}
2026-04-02 19:19:54 +02:00
const asset = editorState . document . assets [ targetModelInstance . assetId ] ;
2026-04-11 02:44:48 +02:00
const firstClip =
asset ? . kind === "model" ? ( asset . metadata . animationNames [ 0 ] ? ? "" ) : "" ;
2026-04-01 02:53:12 +02:00
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
2026-04-07 07:36:23 +02:00
} ) ,
2026-04-01 02:53:12 +02:00
"Switched link action to play animation."
) ;
return ;
}
if ( actionType === "stopAnimation" ) {
2026-04-02 19:19:54 +02:00
const targetModelInstance =
2026-04-11 02:44:48 +02:00
( link . action . type === "playAnimation" ||
link . action . type === "stopAnimation"
? editorState . document . modelInstances [
link . action . targetModelInstanceId
]
2026-04-02 19:19:54 +02:00
: 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-11 02:44:48 +02:00
setStatusMessage (
"Place a model instance before switching this link to stop animation."
) ;
2026-04-01 02:53:12 +02:00
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
2026-04-11 02:44:48 +02:00
if (
targetSoundEmitter === undefined ||
targetSoundEmitter . kind !== "soundEmitter"
) {
setStatusMessage (
"Author a Sound Emitter with an audio asset before switching this link to sound playback."
) ;
2026-04-02 19:41:37 +02:00
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 ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Author at least one whitebox solid before switching this link to visibility."
) ;
2026-03-31 06:18:16 +02:00
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."
) ;
} ;
2026-04-11 02:44:48 +02:00
const updateTeleportInteractionLinkTarget = (
link : InteractionLink ,
targetEntityId : string
) = > {
2026-03-31 06:18:16 +02:00
if ( link . action . type !== "teleportPlayer" ) {
return ;
}
commitInteractionLinkChange (
link ,
createTeleportPlayerInteractionLink ( {
id : link.id ,
sourceEntityId : link.sourceEntityId ,
trigger : link.trigger ,
targetEntityId
} ) ,
"Updated teleport link target."
) ;
} ;
2026-04-11 02:44:48 +02:00
const updateVisibilityInteractionLinkTarget = (
link : InteractionLink ,
targetBrushId : string
) = > {
2026-03-31 06:18:16 +02:00
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."
) ;
} ;
2026-04-11 02:44:48 +02:00
const updateVisibilityInteractionMode = (
link : InteractionLink ,
mode : "toggle" | "show" | "hide"
) = > {
2026-03-31 06:18:16 +02:00
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-11 02:44:48 +02:00
const updateSoundInteractionLinkTarget = (
link : InteractionLink ,
targetSoundEmitterId : string
) = > {
2026-04-02 19:41:43 +02:00
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-11 02:44:48 +02:00
const updateAnimationInteractionLinkTarget = (
link : InteractionLink ,
targetModelInstanceId : string
) = > {
if (
link . action . type !== "playAnimation" &&
link . action . type !== "stopAnimation"
) {
2026-04-01 02:53:03 +02:00
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."
) ;
}
} ;
2026-04-11 02:44:48 +02:00
const updatePlayAnimationLinkClip = (
link : InteractionLink ,
clipName : string
) = > {
2026-04-01 02:53:03 +02:00
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-11 02:44:48 +02:00
const updatePlayAnimationLinkLoop = (
link : InteractionLink ,
loop : boolean
) = > {
2026-04-01 04:06:12 +02:00
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-11 02:44:48 +02:00
const handleAddPlayAnimationLink = (
sourceEntity : InteractionSourceEntity
) = > {
2026-04-01 02:53:03 +02:00
const firstInstance = modelInstanceDisplayList [ 0 ] ;
if ( firstInstance === undefined ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Place a model instance before adding an animation link."
) ;
2026-04-01 02:53:03 +02:00
return ;
}
2026-04-11 02:44:48 +02:00
const asset =
editorState . document . assets [ firstInstance . modelInstance . assetId ] ;
const firstClip =
asset ? . kind === "model" ? ( asset . metadata . animationNames [ 0 ] ? ? "" ) : "" ;
2026-04-01 02:53:03 +02:00
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." ) ;
} ;
2026-04-11 02:44:48 +02:00
const handleAddStopAnimationLink = (
sourceEntity : InteractionSourceEntity
) = > {
2026-04-01 02:53:03 +02:00
const firstInstance = modelInstanceDisplayList [ 0 ] ;
if ( firstInstance === undefined ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Place a model instance before adding an animation link."
) ;
2026-04-01 02:53:03 +02:00
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" >
2026-04-11 02:44:48 +02:00
{ sourceEntity . kind === "triggerVolume"
? "No trigger links authored yet."
: "No click links authored yet." }
2026-03-31 06:48:15 +02:00
< / 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 >
2026-04-11 02:44:48 +02:00
< span className = "outliner-item__meta" >
{ getInteractionActionLabel ( link ) }
< / span >
2026-03-31 06:48:15 +02:00
< / 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateInteractionLinkTrigger (
link ,
event . currentTarget . value as InteractionTriggerKind
)
}
>
2026-03-31 06:48:15 +02:00
< 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateInteractionLinkActionType (
link ,
event . currentTarget
. value as InteractionLink [ "action" ] [ "type" ]
)
}
2026-03-31 06:48:15 +02:00
>
< option value = "teleportPlayer" > Teleport Player < / option >
2026-04-11 02:44:48 +02:00
< 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateTeleportInteractionLinkTarget (
link ,
event . currentTarget . value
)
}
2026-03-31 06:48:15 +02:00
>
{ 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" >
2026-04-04 19:32:46 +02:00
< span className = "label" > Solid < / span >
2026-03-31 06:48:15 +02:00
< select
data - testid = { ` interaction-link-visibility-target- ${ link . id } ` }
className = "text-input"
value = { link . action . targetBrushId }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateVisibilityInteractionLinkTarget (
link ,
event . currentTarget . value
)
}
2026-03-31 06:48:15 +02:00
>
{ 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"
2026-04-11 02:44:48 +02:00
value = { getVisibilityModeSelectValue (
link . action . visible
) }
2026-03-31 06:48:15 +02:00
onChange = { ( event ) = >
2026-04-11 02:44:48 +02:00
updateVisibilityInteractionMode (
link ,
event . currentTarget . value as ReturnType <
typeof getVisibilityModeSelectValue
>
)
2026-03-31 06:48:15 +02:00
}
>
< 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateAnimationInteractionLinkTarget (
link ,
event . currentTarget . value
)
}
2026-04-01 02:53:51 +02:00
>
2026-04-11 02:44:48 +02:00
{ modelInstanceDisplayList . map (
( { modelInstance , label } ) = > (
< option
key = { modelInstance . id }
value = { modelInstance . id }
>
{ label }
< / option >
)
) }
2026-04-01 02:53:51 +02:00
< / 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updatePlayAnimationLinkClip (
link ,
event . currentTarget . value
)
}
2026-04-01 02:53:51 +02:00
>
2026-04-11 02:44:48 +02:00
{ (
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 >
) }
2026-04-01 02:53:51 +02:00
< / 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updatePlayAnimationLinkLoop (
link ,
event . currentTarget . checked
)
}
2026-04-01 04:06:24 +02:00
/ >
< span className = "label" > Loop < / span >
< / label >
2026-04-01 02:53:51 +02:00
< / div >
2026-04-11 02:44:48 +02:00
) : link . action . type === "playSound" ||
link . action . type === "stopSound" ? (
2026-04-02 19:41:57 +02:00
< 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateSoundInteractionLinkTarget (
link ,
event . currentTarget . value
)
}
2026-04-02 19:41:57 +02:00
>
{ 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
updateAnimationInteractionLinkTarget (
link ,
event . currentTarget . value
)
}
2026-04-01 02:53:51 +02:00
>
2026-04-11 02:44:48 +02:00
{ modelInstanceDisplayList . map (
( { modelInstance , label } ) = > (
< option
key = { modelInstance . id }
value = { modelInstance . id }
>
{ label }
< / option >
)
) }
2026-04-01 02:53:51 +02:00
< / 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 >
< / 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-04-11 02:44:48 +02:00
const applyWorldSettings = (
nextWorld : WorldSettings ,
label : string ,
successMessage : string
) = > {
2026-03-31 05:12:23 +02:00
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-04-11 02:44:48 +02:00
const applyWorldBackgroundMode = (
mode : WorldBackgroundMode ,
imageAssetId? : string
) = > {
2026-03-31 20:13:50 +02:00
if ( mode === "image" ) {
2026-03-31 20:19:04 +02:00
const currentBackgroundAssetId =
2026-04-11 02:44:48 +02:00
editorState . document . world . background . mode === "image"
? editorState . document . world . background . assetId
: null ;
2026-03-31 20:13:50 +02:00
const nextImageAssetId =
imageAssetId ? ?
2026-04-11 02:44:48 +02:00
( currentBackgroundAssetId !== null &&
editorState . document . assets [ currentBackgroundAssetId ] ? . kind === "image"
2026-03-31 20:19:04 +02:00
? currentBackgroundAssetId
2026-03-31 20:13:50 +02:00
: imageAssetList [ 0 ] ? . id ) ;
if ( nextImageAssetId === undefined ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"Import an image asset before using an image background."
) ;
2026-03-31 20:13:50 +02:00
return ;
}
applyWorldSettings (
{
. . . editorState . document . world ,
2026-04-11 02:44:48 +02:00
background : changeWorldBackgroundMode (
editorState . document . world . background ,
"image" ,
nextImageAssetId
)
2026-03-31 20:13:50 +02:00
} ,
"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 ,
2026-04-11 02:44:48 +02:00
background : changeWorldBackgroundMode (
editorState . document . world . background ,
mode
)
2026-03-31 05:12:23 +02:00
} ,
"Set world background mode" ,
2026-04-11 02:44:48 +02:00
mode === "solid"
? "World background set to a solid color."
: "World background set to a vertical gradient."
2026-03-31 05:12:23 +02:00
) ;
} ;
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."
) ;
} ;
2026-04-11 02:44:48 +02:00
const applyWorldGradientColor = (
edge : "top" | "bottom" ,
colorHex : string
) = > {
2026-03-31 05:12:23 +02:00
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
}
} ,
2026-04-11 02:44:48 +02:00
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 05:12:23 +02:00
) ;
} ;
2026-03-31 23:15:34 +02:00
const applyBackgroundEnvironmentIntensity = ( ) = > {
if ( editorState . document . world . background . mode !== "image" ) {
return ;
}
2026-04-11 02:44:48 +02:00
const intensity = readNonNegativeNumberDraft (
backgroundEnvironmentIntensityDraft ,
"Environment intensity"
) ;
2026-03-31 23:15:34 +02:00
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 ,
2026-04-11 02:44:48 +02:00
intensity : readNonNegativeNumberDraft (
ambientLightIntensityDraft ,
"Ambient light intensity"
)
2026-03-31 05:12:23 +02:00
}
} ,
"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 ,
2026-04-11 02:44:48 +02:00
intensity : readNonNegativeNumberDraft (
sunLightIntensityDraft ,
"Sun intensity"
)
2026-03-31 05:12:23 +02:00
}
} ,
"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" ,
2026-04-11 02:44:48 +02:00
enabled
? "Advanced rendering shadows enabled."
: "Advanced rendering shadows disabled." ,
2026-04-02 20:53:09 +02:00
( advancedRendering ) = > {
advancedRendering . shadows . enabled = enabled ;
}
) ;
} ;
2026-04-11 02:44:48 +02:00
const applyAdvancedRenderingShadowMapSize = (
shadowMapSize : AdvancedRenderingShadowMapSize
) = > {
applyAdvancedRenderingSettings (
"Set advanced rendering shadow map size" ,
"Updated the shadow map size." ,
( advancedRendering ) = > {
advancedRendering . shadows . mapSize = shadowMapSize ;
}
) ;
2026-04-02 20:53:09 +02:00
} ;
2026-04-11 02:44:48 +02:00
const applyAdvancedRenderingShadowType = (
shadowType : AdvancedRenderingShadowType
) = > {
applyAdvancedRenderingSettings (
"Set advanced rendering shadow type" ,
"Updated the shadow map type." ,
( advancedRendering ) = > {
advancedRendering . shadows . type = shadowType ;
}
) ;
2026-04-02 20:53:09 +02:00
} ;
const applyAdvancedRenderingShadowBias = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set advanced rendering shadow bias" ,
"Updated the shadow bias." ,
( advancedRendering ) = > {
advancedRendering . shadows . bias = readFiniteNumberDraft (
advancedRenderingShadowBiasDraft ,
"Shadow bias"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} 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 ) = > {
2026-04-11 02:44:48 +02:00
advancedRendering . ambientOcclusion . intensity =
readNonNegativeNumberDraft (
advancedRenderingAmbientOcclusionIntensityDraft ,
"Ambient occlusion intensity"
) ;
2026-04-02 20:53:09 +02:00
}
) ;
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingAmbientOcclusionRadius = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set ambient occlusion radius" ,
"Updated the ambient occlusion radius." ,
( advancedRendering ) = > {
advancedRendering . ambientOcclusion . radius =
readNonNegativeNumberDraft (
advancedRenderingAmbientOcclusionRadiusDraft ,
"Ambient occlusion radius"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingAmbientOcclusionSamples = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set ambient occlusion samples" ,
"Updated the ambient occlusion samples." ,
( advancedRendering ) = > {
advancedRendering . ambientOcclusion . samples = readPositiveIntegerDraft (
advancedRenderingAmbientOcclusionSamplesDraft ,
"Ambient occlusion samples"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} 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 {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set bloom intensity" ,
"Updated the bloom intensity." ,
( advancedRendering ) = > {
advancedRendering . bloom . intensity = readNonNegativeNumberDraft (
advancedRenderingBloomIntensityDraft ,
"Bloom intensity"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingBloomThreshold = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set bloom threshold" ,
"Updated the bloom threshold." ,
( advancedRendering ) = > {
advancedRendering . bloom . threshold = readNonNegativeNumberDraft (
advancedRenderingBloomThresholdDraft ,
"Bloom threshold"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingBloomRadius = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set bloom radius" ,
"Updated the bloom radius." ,
( advancedRendering ) = > {
advancedRendering . bloom . radius = readNonNegativeNumberDraft (
advancedRenderingBloomRadiusDraft ,
"Bloom radius"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-11 02:44:48 +02:00
const applyAdvancedRenderingToneMappingMode = (
mode : AdvancedRenderingToneMappingMode
) = > {
applyAdvancedRenderingSettings (
"Set tone mapping mode" ,
"Updated the tone mapping mode." ,
( advancedRendering ) = > {
advancedRendering . toneMapping . mode = mode ;
}
) ;
2026-04-02 20:53:09 +02:00
} ;
const applyAdvancedRenderingToneMappingExposure = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set tone mapping exposure" ,
"Updated the tone mapping exposure." ,
( advancedRendering ) = > {
advancedRendering . toneMapping . exposure = readPositiveNumberDraft (
advancedRenderingToneMappingExposureDraft ,
"Tone mapping exposure"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} 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 {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set focus distance" ,
"Updated the focus distance." ,
( advancedRendering ) = > {
advancedRendering . depthOfField . focusDistance =
readNonNegativeNumberDraft (
advancedRenderingDepthOfFieldFocusDistanceDraft ,
"Focus distance"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingDepthOfFieldFocalLength = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set focal length" ,
"Updated the focal length." ,
( advancedRendering ) = > {
advancedRendering . depthOfField . focalLength = readPositiveNumberDraft (
advancedRenderingDepthOfFieldFocalLengthDraft ,
"Focal length"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const applyAdvancedRenderingDepthOfFieldBokehScale = ( ) = > {
try {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set bokeh scale" ,
"Updated the bokeh scale." ,
( advancedRendering ) = > {
advancedRendering . depthOfField . bokehScale = readPositiveNumberDraft (
advancedRenderingDepthOfFieldBokehScaleDraft ,
"Bokeh scale"
) ;
}
) ;
2026-04-02 20:53:09 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
2026-04-06 08:28:59 +02:00
const applyAdvancedRenderingFogPath = ( path : BoxVolumeRenderPath ) = > {
2026-04-11 02:44:48 +02:00
applyAdvancedRenderingSettings (
"Set fog render path" ,
` Fog render path set to ${ formatBoxVolumeRenderPathLabel ( path ) } . ` ,
( advancedRendering ) = > {
advancedRendering . fogPath = path ;
}
) ;
2026-04-06 08:28:59 +02:00
} ;
const applyAdvancedRenderingWaterPath = ( path : BoxVolumeRenderPath ) = > {
applyAdvancedRenderingSettings (
"Set water render path" ,
` Water render path set to ${ formatBoxVolumeRenderPathLabel ( path ) } . ` ,
( advancedRendering ) = > {
advancedRendering . waterPath = path ;
}
) ;
} ;
2026-04-11 02:44:48 +02:00
const applyAdvancedRenderingWaterReflectionMode = (
mode : AdvancedRenderingWaterReflectionMode
) = > {
2026-04-07 06:46:20 +02:00
applyAdvancedRenderingSettings (
"Set water reflection mode" ,
` Water reflection mode set to ${ formatAdvancedRenderingWaterReflectionModeLabel ( mode ) } . ` ,
( advancedRendering ) = > {
advancedRendering . waterReflectionMode = mode ;
}
) ;
} ;
2026-03-31 04:25:19 +02:00
const applyBrushNameChange = ( ) = > {
if ( selectedBrush === null ) {
2026-04-04 19:31:06 +02:00
setStatusMessage ( "Select a whitebox box before renaming it." ) ;
2026-03-31 04:25:19 +02:00
return ;
}
const nextName = normalizeBrushName ( brushNameDraft ) ;
if ( selectedBrush . name === nextName ) {
return ;
}
try {
store . executeCommand (
createSetBoxBrushNameCommand ( {
brushId : selectedBrush.id ,
name : brushNameDraft
} )
) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
nextName === undefined
? "Cleared the authored brush name."
: ` Renamed brush to ${ nextName } . `
) ;
2026-03-31 04:25:19 +02:00
} 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
} )
) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
nextName === undefined
? "Cleared the authored entity name."
: ` Renamed entity to ${ nextName } . `
) ;
2026-04-03 01:03:58 +02:00
} 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
} )
) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
nextName === undefined
? "Cleared the authored model instance name."
: ` Renamed model instance to ${ nextName } . `
) ;
2026-04-03 01:03:58 +02:00
} 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-04-11 02:44:48 +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 ) ;
} ;
2026-04-11 02:44:48 +02:00
const handleNumberInputPointerUp = (
_event : ReactPointerEvent < HTMLInputElement > ,
applyChange : ( ) = > void
) = > {
2026-03-31 04:37:53 +02:00
scheduleDraftCommit ( applyChange ) ;
} ;
2026-04-11 02:44:48 +02:00
const handleNumberInputKeyUp = (
event : ReactKeyboardEvent < HTMLInputElement > ,
applyChange : ( ) = > void
) = > {
2026-03-31 04:37:53 +02:00
if ( ! isCommitIncrementKey ( event . key ) ) {
return ;
}
scheduleDraftCommit ( applyChange ) ;
} ;
2026-04-10 12:23:27 +02:00
const handleSaveProject = async ( ) = > {
2026-03-31 03:44:17 +02:00
try {
2026-04-10 12:23:27 +02:00
if ( ! projectAssetStorageReady && projectAssetList . length > 0 ) {
2026-04-11 02:44:48 +02:00
throw new Error (
"Project save failed: project asset storage is still initializing for this asset-backed scene."
) ;
2026-04-10 12:23:27 +02:00
}
2026-04-11 02:44:48 +02:00
const projectBytes = await saveProjectPackage (
2026-04-11 03:49:35 +02:00
editorState . projectDocument ,
2026-04-11 02:44:48 +02:00
projectAssetStorage
) ;
2026-04-10 12:27:05 +02:00
const blobBytes = new Uint8Array ( projectBytes ) ;
2026-04-11 02:44:48 +02:00
const blob = new Blob ( [ blobBytes . buffer as ArrayBuffer ] , {
type : "application/zip"
} ) ;
2026-03-31 03:44:17 +02:00
const objectUrl = URL . createObjectURL ( blob ) ;
const anchor = document . createElement ( "a" ) ;
anchor . href = objectUrl ;
2026-04-10 12:23:27 +02:00
anchor . download = createProjectDownloadName ( editorState . document . name ) ;
2026-03-31 03:44:17 +02:00
anchor . click ( ) ;
URL . revokeObjectURL ( objectUrl ) ;
2026-04-10 12:23:27 +02:00
setStatusMessage ( ` Saved project ${ anchor . download } . ` ) ;
2026-03-31 03:44:17 +02:00
} catch ( error ) {
2026-04-10 12:23:27 +02:00
setStatusMessage ( getErrorMessage ( error ) ) ;
2026-03-31 03:44:17 +02:00
}
2026-03-31 02:37:22 +02:00
} ;
2026-04-10 12:23:27 +02:00
const handleLoadProjectButtonClick = ( ) = > {
importProjectInputRef . current ? . click ( ) ;
2026-03-31 02:37:22 +02:00
} ;
2026-04-11 02:44:48 +02:00
const handleLoadProjectChange = 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 {
2026-04-10 12:23:27 +02:00
const projectBytes = new Uint8Array ( await file . arrayBuffer ( ) ) ;
2026-04-11 02:44:48 +02:00
const nextDocument = await loadProjectPackage (
projectBytes ,
projectAssetStorage
) ;
2026-04-10 12:23:27 +02:00
store . replaceDocument ( nextDocument ) ;
setStatusMessage ( ` Loaded project ${ file . name } . ` ) ;
2026-03-31 02:37:22 +02:00
} 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-04-11 02:44:48 +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 ) {
2026-04-11 02:44:48 +02:00
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-04-11 02:44:48 +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 ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Imported ${ importedModel . asset . sourceName } and placed a model instance. `
) ;
2026-03-31 17:42:43 +02:00
} catch ( error ) {
2026-03-31 17:56:40 +02:00
if ( importedModelForCleanup !== null ) {
2026-04-11 02:44:48 +02:00
await projectAssetStorage
. deleteAsset ( importedModelForCleanup . asset . storageKey )
. catch ( ( ) = > undefined ) ;
2026-03-31 17:56:40 +02:00
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-04-11 02:44:48 +02:00
const handleImportBackgroundImageChange = async (
event : ChangeEvent < HTMLInputElement >
) = > {
2026-03-31 20:14:05 +02:00
const input = event . currentTarget ;
const file = input . files ? . [ 0 ] ;
if ( file === undefined ) {
return ;
}
if ( projectAssetStorage === null ) {
2026-04-11 02:44:48 +02:00
setAssetStatusMessage (
"Imported background images require project asset storage. IndexedDB is unavailable in this browser."
) ;
2026-03-31 20:14:05 +02:00
input . value = "" ;
return ;
}
let importedImageForCleanup : ImportedImageAssetResult | null = null ;
try {
2026-04-11 02:44:48 +02:00
const importedImage = await importBackgroundImageAssetFromFile (
file ,
projectAssetStorage
) ;
2026-03-31 20:14:05 +02:00
importedImageForCleanup = importedImage ;
store . executeCommand (
createImportBackgroundImageAssetCommand ( {
asset : importedImage.asset ,
world : {
. . . editorState . document . world ,
2026-04-11 02:44:48 +02:00
background : changeWorldBackgroundMode (
editorState . document . world . background ,
"image" ,
importedImage . asset . id
)
2026-03-31 20:14:05 +02:00
} ,
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 ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Imported ${ importedImage . asset . sourceName } and set it as the world background. `
) ;
2026-03-31 20:14:05 +02:00
} catch ( error ) {
if ( importedImageForCleanup !== null ) {
2026-04-11 02:44:48 +02:00
await projectAssetStorage
. deleteAsset ( importedImageForCleanup . asset . storageKey )
. catch ( ( ) = > undefined ) ;
2026-03-31 20:14:05 +02:00
disposeLoadedImageAsset ( importedImageForCleanup . loadedAsset ) ;
}
const message = getErrorMessage ( error ) ;
setStatusMessage ( message ) ;
setAssetStatusMessage ( message ) ;
} finally {
input . value = "" ;
}
} ;
2026-04-11 02:44:48 +02:00
const handleImportAudioChange = async (
event : ChangeEvent < HTMLInputElement >
) = > {
2026-04-02 19:43:13 +02:00
const input = event . currentTarget ;
const file = input . files ? . [ 0 ] ;
if ( file === undefined ) {
return ;
}
if ( projectAssetStorage === null ) {
2026-04-11 02:44:48 +02:00
setAssetStatusMessage (
"Imported audio assets require project asset storage. IndexedDB is unavailable in this browser."
) ;
2026-04-02 19:43:13 +02:00
input . value = "" ;
return ;
}
2026-04-11 02:44:48 +02:00
let importedAudioForCleanup : {
asset : AudioAssetRecord ;
loadedAsset : LoadedAudioAsset ;
} | null = null ;
2026-04-02 19:43:13 +02:00
try {
2026-04-11 02:44:48 +02:00
const importedAudio = await importAudioAssetFromFile (
file ,
projectAssetStorage
) ;
2026-04-02 19:43:13 +02:00
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 ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Imported ${ importedAudio . asset . sourceName } and registered it as an audio asset. `
) ;
2026-04-02 19:43:13 +02:00
} catch ( error ) {
if ( importedAudioForCleanup !== null ) {
2026-04-11 02:44:48 +02:00
await projectAssetStorage
. deleteAsset ( importedAudioForCleanup . asset . storageKey )
. catch ( ( ) = > undefined ) ;
2026-04-02 19:43:13 +02:00
}
const message = getErrorMessage ( error ) ;
setStatusMessage ( message ) ;
setAssetStatusMessage ( message ) ;
} finally {
input . value = "" ;
}
} ;
2026-03-31 02:37:22 +02:00
const applyFaceMaterial = ( materialId : string ) = > {
2026-04-11 02:44:48 +02:00
if (
selectedBrush === null ||
selectedFaceId === null ||
selectedFace === null
) {
2026-03-31 02:37:22 +02:00
setStatusMessage ( "Select a single box face before applying a material." ) ;
return ;
}
if ( selectedFace . materialId === materialId ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
` ${ BOX_FACE_LABELS [ selectedFaceId ] } already uses that material. `
) ;
2026-03-31 02:37:22 +02:00
return ;
}
try {
store . executeCommand (
createSetBoxBrushFaceMaterialCommand ( {
brushId : selectedBrush.id ,
faceId : selectedFaceId ,
materialId
} )
) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Applied ${ editorState . document . materials [ materialId ] ? . name ? ? materialId } to ${ BOX_FACE_LABELS [ selectedFaceId ] } . `
) ;
2026-03-31 02:37:22 +02:00
} catch ( error ) {
setStatusMessage ( getErrorMessage ( error ) ) ;
}
} ;
const clearFaceMaterial = ( ) = > {
2026-04-11 02:44:48 +02:00
if (
selectedBrush === null ||
selectedFaceId === null ||
selectedFace === null
) {
setStatusMessage (
"Select a single box face before clearing its material."
) ;
2026-03-31 02:37:22 +02:00
return ;
}
if ( selectedFace . materialId === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
` ${ BOX_FACE_LABELS [ selectedFaceId ] } already uses the fallback face material. `
) ;
2026-03-31 02:37:22 +02:00
return ;
}
store . executeCommand (
createSetBoxBrushFaceMaterialCommand ( {
brushId : selectedBrush.id ,
faceId : selectedFaceId ,
materialId : null
} )
) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Cleared the authored material on ${ BOX_FACE_LABELS [ selectedFaceId ] } . `
) ;
2026-03-31 02:37:22 +02:00
} ;
2026-04-11 02:44:48 +02:00
const applyFaceUvState = (
uvState : FaceUvState ,
label : string ,
successMessage : string
) = > {
if (
selectedBrush === null ||
selectedFaceId === null ||
selectedFace === null
) {
2026-03-31 02:37:22 +02:00
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 ,
2026-04-11 02:44:48 +02:00
rotationQuarterTurns : rotateQuarterTurns (
selectedFace . uv . rotationQuarterTurns
)
2026-03-31 02:37:22 +02:00
} ,
"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 ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
` Run mode blocked: ${ formatSceneDiagnosticSummary ( blockingDiagnostics ) } `
) ;
2026-03-31 03:44:17 +02:00
return ;
}
2026-03-31 03:09:04 +02:00
try {
2026-04-11 02:44:48 +02:00
const nextRuntimeScene = buildRuntimeSceneFromDocument (
editorState . document ,
{
navigationMode : preferredNavigationMode ,
loadedModelAssets
}
) ;
2026-03-31 03:44:17 +02:00
const nextNavigationMode = preferredNavigationMode ;
2026-03-31 03:09:04 +02:00
setRuntimeScene ( nextRuntimeScene ) ;
2026-04-11 04:16:20 +02:00
setRuntimeMessage ( null ) ;
2026-03-31 03:09:04 +02:00
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." ) ;
} ;
2026-04-11 02:44:48 +02:00
const handleSetPreferredNavigationMode = (
navigationMode : RuntimeNavigationMode
) = > {
2026-03-31 03:09:04 +02:00
setPreferredNavigationMode ( navigationMode ) ;
2026-03-31 03:44:17 +02:00
if ( navigationMode === "firstPerson" && primaryPlayerStart === null ) {
2026-04-11 02:44:48 +02:00
setStatusMessage (
"First Person selected. Author a Player Start before running, or switch back to Orbit Visitor."
) ;
2026-03-31 03:44:17 +02:00
}
2026-03-31 03:09:04 +02:00
if ( editorState . toolMode === "play" ) {
setActiveNavigationMode ( navigationMode ) ;
2026-04-11 02:44:48 +02:00
setStatusMessage (
navigationMode === "firstPerson"
? "Runner switched to first-person navigation."
: "Runner switched to Orbit Visitor."
) ;
2026-03-31 03:09:04 +02:00
}
} ;
2026-04-11 02:44:48 +02:00
const createAssetMenuHoverHandler =
( assetId : string ) = > ( hovered : boolean ) = > {
setHoveredAssetId ( ( current ) = >
hovered ? assetId : current === assetId ? null : current
) ;
} ;
2026-04-03 00:43:25 +02:00
2026-04-11 02:44:48 +02:00
const createDisabledMenuAction = (
label : string ,
testId : string
) : HierarchicalMenuItem = > ( {
2026-04-03 00:43:25 +02:00
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" ,
2026-04-04 19:31:16 +02:00
label : "Whitebox Box" ,
2026-04-03 00:43:25 +02:00
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" ,
2026-04-11 02:44:48 +02:00
onSelect : ( ) = >
beginEntityCreation ( "soundEmitter" , {
audioAssetId : audioAssetList [ 0 ] ? . id ? ? null
} )
2026-04-03 00:43:25 +02:00
} ,
{
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
2026-04-11 02:44:48 +02:00
? [
createDisabledMenuAction (
"No imported 3D models" ,
"add-menu-assets-models-empty"
)
]
2026-04-03 00:43:25 +02:00
: 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
2026-04-11 02:44:48 +02:00
? [
createDisabledMenuAction (
"No imported environments" ,
"add-menu-assets-environments-empty"
)
]
2026-04-03 00:43:25 +02:00
: 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
2026-04-11 02:44:48 +02:00
? [
createDisabledMenuAction (
"No imported audio" ,
"add-menu-assets-audio-empty"
)
]
2026-04-03 00:43:25 +02:00
: audioAssetList . map ( ( asset ) = > ( {
kind : "action" as const ,
label : asset.sourceName ,
testId : ` add-menu-audio-asset- ${ asset . id } ` ,
2026-04-11 02:44:48 +02:00
onSelect : ( ) = >
beginEntityCreation ( "soundEmitter" , {
audioAssetId : asset.id
} ) ,
2026-04-03 00:43:25 +02:00
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-11 02:44:48 +02:00
const viewportPanelsStyle =
layoutMode === "quad"
? createViewportQuadPanelsStyle ( editorState . viewportQuadSplit )
: undefined ;
2026-04-03 01:26:21 +02:00
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-04-11 02:44:48 +02:00
< div className = "toolbar__brand" >
< div className = "toolbar__title" > WebEditor3D < / div >
< div className = "toolbar__subtitle" >
Slice 3.1 GLB / GLTF import and unified creation
2026-03-31 06:48:24 +02:00
< / div >
2026-04-11 02:44:48 +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" >
2026-04-11 02:44:48 +02:00
< button
className = "toolbar__button toolbar__button--accent"
type = "button"
data - testid = "exit-run-mode"
onClick = { handleExitPlayMode }
>
2026-03-31 03:09:04 +02:00
Return To Editor
< / button >
< / div >
< / div >
< / header >
< div className = "runner-workspace" >
< main className = "runner-region" >
< RunnerCanvas
runtimeScene = { runtimeScene }
2026-04-11 04:16:20 +02:00
sceneName = { activeProjectScene . name }
sceneLoadingScreen = { activeProjectScene . loadingScreen }
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 >
2026-04-11 02:44:48 +02:00
< div className = "value" >
{ activeNavigationMode === "firstPerson"
? "First Person"
: "Orbit Visitor" }
< / div >
2026-03-31 03:09:04 +02:00
< / div >
< div className = "stat-card" >
< div className = "label" > Spawn Source < / div >
2026-04-11 02:44:48 +02:00
< div className = "value" >
{ runtimeScene . spawn . source === "playerStart"
? "Player Start"
: "Fallback" }
< / div >
2026-03-31 03:09:04 +02:00
< / div >
< div className = "stat-card" >
< div className = "label" > Pointer Lock < / div >
< div className = "value" >
2026-04-11 02:44:48 +02:00
{ activeNavigationMode === "firstPerson"
? firstPersonTelemetry ? . pointerLocked
? "active"
: "idle"
: "not used" }
2026-03-31 03:09:04 +02:00
< / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Grounded < / div >
2026-04-11 02:44:48 +02:00
< div className = "value" >
{ firstPersonTelemetry ? . grounded
? "yes"
: activeNavigationMode === "firstPerson"
? "no"
: "n/a" }
< / div >
2026-03-31 03:09:04 +02:00
< / div >
2026-04-06 08:31:18 +02:00
< div className = "stat-card" >
< div className = "label" > Locomotion < / div >
< div className = "value" >
{ activeNavigationMode === "firstPerson"
? firstPersonTelemetry ? . locomotionState === "swimming"
? "Swimming"
: firstPersonTelemetry ? . locomotionState === "flying"
? "Flying"
: "Grounded"
: "n/a" }
< / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Water Volume < / div >
2026-04-11 02:44:48 +02:00
< div className = "value" >
{ activeNavigationMode === "firstPerson"
? firstPersonTelemetry ? . inWaterVolume
? "inside"
: "outside"
: "n/a" }
< / div >
2026-04-06 08:31:18 +02:00
< / div >
< div className = "stat-card" >
< div className = "label" > Fog Volume < / div >
2026-04-11 02:44:48 +02:00
< div className = "value" >
{ activeNavigationMode === "firstPerson"
? firstPersonTelemetry ? . inFogVolume
? "inside"
: "outside"
: "n/a" }
< / div >
2026-04-06 08:31:18 +02:00
< / div >
2026-03-31 03:09:04 +02:00
< / div >
< div className = "stat-card" >
< div className = "label" > FPS Feet Position < / div >
< div className = "value" data-testid = "runner-player-position" >
2026-04-11 02:44:48 +02:00
{ formatRunnerFeetPosition (
firstPersonTelemetry ? . feetPosition ? ?
runtimeScene . spawn . position
) }
2026-03-31 03:09:04 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< div
className = "material-summary"
data - testid = "runner-spawn-state"
>
Spawn : { " " }
{ runtimeScene . spawn . source === "playerStart"
? "Player Start"
: "Fallback" } { " " }
at { formatRunnerFeetPosition ( runtimeScene . spawn . position ) }
2026-03-31 03:09:04 +02:00
< / 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" >
2026-04-11 02:44:48 +02:00
{ activeNavigationMode === "firstPerson"
? runtimeInteractionPrompt === null
? "No target"
: "Ready"
: "Not available" }
2026-03-31 06:48:37 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< div
className = "material-summary"
data - testid = "runner-interaction-summary"
>
2026-03-31 06:48:37 +02:00
{ 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-04-11 02:44:48 +02:00
{ runtimeMessage === null ? null : (
< div className = "info-banner" > { runtimeMessage } < / div >
) }
2026-03-31 06:48:37 +02:00
{ activeNavigationMode === "firstPerson" ? (
2026-04-11 02:44:48 +02:00
< div
className = "info-banner"
data - testid = "runner-interaction-help"
>
Mouse click activates the current prompt target .
Keyboard / controller fallback is not active yet .
2026-03-31 06:48:37 +02:00
< / div >
) : null }
2026-03-31 03:09:04 +02:00
< / Panel >
< / aside >
< / div >
< footer className = "status-bar" >
2026-04-11 04:20:45 +02:00
< div
className = "status-bar__item status-bar__item--message"
title = { statusMessage }
>
2026-03-31 03:09:04 +02:00
< span className = "status-bar__strong" > Status : < / span > { statusMessage }
< / div >
2026-04-11 03:28:47 +02:00
< div className = "status-bar__item" >
2026-03-31 03:09:04 +02:00
< span className = "status-bar__strong" > Spawn : < / span > { " " }
2026-04-11 02:44:48 +02:00
{ runtimeScene . spawn . source === "playerStart"
? "Authored Player Start"
: "Fallback runtime spawn" }
2026-03-31 03:09:04 +02:00
< / div >
< / footer >
< / div >
) ;
}
2026-03-31 02:37:22 +02:00
return (
< div className = "app-shell" >
< header className = "toolbar" >
2026-04-11 03:49:37 +02:00
< div className = "toolbar__scene-controls" >
< label className = "toolbar__scene-picker" >
< span className = "visually-hidden" > Active Scene < / span >
< select
data - testid = "toolbar-scene-select"
className = "select-input toolbar__scene-select"
value = { editorState . activeSceneId }
onChange = { handleActiveSceneChange }
>
{ sceneList . map ( ( scene ) = > (
< option key = { scene . id } value = { scene . id } >
{ scene . name }
< / option >
) ) }
< / select >
< / label >
< button
className = "toolbar__button"
type = "button"
data - testid = "toolbar-new-scene"
onClick = { handleCreateScene }
>
New Scene
< / button >
< 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 >
< / div >
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-04-11 02:44:48 +02:00
< button
className = "toolbar__button"
type = "button"
data - testid = "save-project-button"
onClick = { ( ) = > void handleSaveProject ( ) }
>
2026-04-10 12:23:35 +02:00
Save Project
2026-03-31 04:50:21 +02:00
< / button >
2026-04-11 02:44:48 +02:00
< button
className = "toolbar__button"
type = "button"
data - testid = "load-project-button"
onClick = { handleLoadProjectButtonClick }
>
2026-04-10 12:23:35 +02:00
Load Project
2026-03-31 04:50:21 +02:00
< / button >
< / div >
2026-04-11 02:44:48 +02:00
< div
className = "toolbar__group"
role = "group"
aria - label = "Viewport layout mode"
>
2026-04-02 23:24:37 +02:00
{ 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-11 02:44:48 +02:00
< div
className = "toolbar__group"
role = "group"
aria - label = "Transform operations"
>
2026-04-03 02:15:38 +02:00
< button
className = { ` toolbar__button ${ transformSession . kind === "active" && transformSession . operation === "translate" ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "transform-translate-button"
2026-04-11 02:44:48 +02:00
aria - pressed = {
transformSession . kind === "active" &&
transformSession . operation === "translate"
}
disabled = {
editorState . toolMode !== "select" || ! canTranslateSelectedTarget
}
2026-04-03 02:15:38 +02:00
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"
2026-04-11 02:44:48 +02:00
aria - pressed = {
transformSession . kind === "active" &&
transformSession . operation === "rotate"
}
disabled = {
editorState . toolMode !== "select" || ! canRotateSelectedTarget
}
2026-04-03 02:15:38 +02:00
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"
2026-04-11 02:44:48 +02:00
aria - pressed = {
transformSession . kind === "active" &&
transformSession . operation === "scale"
}
disabled = {
editorState . toolMode !== "select" || ! canScaleSelectedTarget
}
2026-04-03 02:15:38 +02:00
onClick = { ( ) = > beginTransformOperation ( "scale" , "toolbar" ) }
>
Scale ( { getTransformOperationShortcut ( "scale" ) } )
< / button >
< / div >
2026-04-11 02:44:48 +02:00
< div
className = "toolbar__group"
role = "group"
aria - label = "Whitebox selection mode"
>
2026-04-04 20:15:19 +02:00
{ WHITEBOX_SELECTION_MODES . map ( ( mode ) = > (
< button
key = { mode }
className = { ` toolbar__button toolbar__button--compact ${ whiteboxSelectionMode === mode ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = { ` whitebox-selection-mode- ${ mode } ` }
aria - pressed = { whiteboxSelectionMode === mode }
onClick = { ( ) = > handleWhiteboxSelectionModeChange ( mode ) }
>
{ getWhiteboxSelectionModeLabel ( mode ) }
< / button >
) ) }
< / div >
2026-04-11 02:44:48 +02:00
< div
className = "toolbar__group"
role = "group"
aria - label = "Whitebox snap settings"
>
2026-04-11 02:40:51 +02:00
< button
className = { ` toolbar__button ${ viewportGridVisible ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "viewport-grid-toggle"
aria - pressed = { viewportGridVisible }
onClick = { handleViewportGridToggle }
>
{ viewportGridVisible ? "Grid On" : "Grid Off" }
< / button >
2026-04-04 19:31:22 +02:00
< button
className = { ` toolbar__button ${ whiteboxSnapEnabled ? "toolbar__button--active" : "" } ` }
type = "button"
data - testid = "whitebox-snap-toggle"
aria - pressed = { whiteboxSnapEnabled }
onClick = { handleWhiteboxSnapToggle }
>
{ whiteboxSnapEnabled ? "Grid Snap On" : "Grid Snap Off" }
< / button >
< label className = "toolbar__inline-field" >
< span className = "label" > Step < / span >
< input
data - testid = "whitebox-snap-step"
className = "text-input toolbar__inline-input"
type = "number"
min = "0.01"
step = "0.1"
value = { whiteboxSnapStepDraft }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
setWhiteboxSnapStepDraft ( event . currentTarget . value )
}
2026-04-04 19:31:22 +02:00
onBlur = { handleWhiteboxSnapStepBlur }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
handleWhiteboxSnapStepBlur ( ) ;
}
} }
/ >
< / label >
< / 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" >
2026-04-11 02:44:48 +02:00
< button
className = "toolbar__button"
type = "button"
disabled = { ! editorState . canUndo }
onClick = { ( ) = > store . undo ( ) }
>
2026-03-31 02:37:22 +02:00
Undo
< / button >
2026-04-11 02:44:48 +02:00
< button
className = "toolbar__button"
type = "button"
disabled = { ! editorState . canRedo }
onClick = { ( ) = > store . redo ( ) }
>
2026-03-31 02:37:22 +02:00
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 ? (
2026-04-11 02:44:48 +02:00
< div className = "outliner-empty" >
Project asset storage is unavailable . Imported assets cannot be
persisted .
< / div >
2026-03-31 17:43:20 +02:00
) : null }
2026-03-31 03:09:04 +02:00
< div className = "outliner-section" >
2026-04-04 19:31:06 +02:00
< div className = "label" > Whitebox Solids < / div >
2026-03-31 03:09:04 +02:00
{ brushList . length === 0 ? (
2026-04-11 02:44:48 +02:00
< div className = "outliner-empty" >
Use Add & gt ; Whitebox Box and click in the viewport to create
the first solid .
< / div >
2026-03-31 03:09:04 +02:00
) : (
2026-04-11 02:44:48 +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 }
2026-04-04 19:31:06 +02:00
placeholder = { ` Whitebox Box ${ brushIndex + 1 } ` }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
setBrushNameDraft ( event . currentTarget . value )
}
2026-04-03 01:04:33 +02:00
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
}
)
}
>
2026-04-11 02:44:48 +02:00
< span className = "outliner-item__title" >
{ label }
< / span >
2026-04-03 01:04:33 +02:00
< / 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 ? (
2026-04-11 02:44:48 +02:00
< div className = "outliner-empty" >
No model instances placed yet .
< / div >
2026-03-31 17:43:20 +02:00
) : (
2026-04-11 02:44:48 +02:00
< div
className = "outliner-list"
data - testid = "outliner-model-instance-list"
>
2026-04-03 01:04:33 +02:00
{ modelInstanceDisplayList . map ( ( { modelInstance , label } ) = > {
const isSelected =
2026-04-11 02:44:48 +02:00
editorState . selection . kind === "modelInstances" &&
editorState . selection . ids . includes ( modelInstance . id ) ;
2026-04-03 01:04:33 +02:00
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 }
2026-04-11 02:44:48 +02:00
placeholder = {
editorState . document . assets [
modelInstance . assetId
] ? . sourceName ? ? "Model Instance"
}
onChange = { ( event ) = >
setModelInstanceNameDraft (
event . currentTarget . value
)
}
2026-04-03 01:04:33 +02:00
onBlur = { applyModelInstanceNameChange }
onFocus = { ( event ) = > event . currentTarget . select ( ) }
onKeyDown = { ( event ) = >
handleInlineNameInputKeyDown ( event , ( ) = > {
2026-04-11 02:44:48 +02:00
setModelInstanceNameDraft (
selectedModelInstance ? . name ? ? ""
) ;
2026-04-03 01:04:33 +02:00
} )
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
}
)
}
>
2026-04-11 02:44:48 +02:00
< span className = "outliner-item__title" >
{ label }
< / span >
2026-04-03 01:04:33 +02:00
< / button >
) }
< button
className = "outliner-item__delete"
type = "button"
data - testid = { ` outliner-delete-model-instance- ${ modelInstance . id } ` }
aria - label = { ` Delete ${ label } ` }
2026-04-11 02:44:48 +02:00
onClick = { ( ) = >
handleDeleteModelInstance ( modelInstance . id )
}
2026-04-03 01:04:33 +02:00
>
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-11 02:44:48 +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 } ) = > {
2026-04-11 02:44:48 +02:00
const isSelected =
editorState . selection . kind === "entities" &&
editorState . selection . ids . includes ( entity . id ) ;
2026-04-03 01:04:33 +02:00
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 ) }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
setEntityNameDraft ( event . currentTarget . value )
}
2026-04-03 01:04:33 +02:00
onBlur = { applyEntityNameChange }
onFocus = { ( event ) = > event . currentTarget . select ( ) }
onKeyDown = { ( event ) = >
handleInlineNameInputKeyDown ( event , ( ) = > {
2026-04-11 02:44:48 +02:00
setEntityNameDraft (
selectedEntity ? . name ? ? ""
) ;
2026-04-03 01:04:33 +02:00
} )
}
/ >
) : (
< 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
>
2026-04-11 02:44:48 +02:00
< span className = "outliner-item__title" >
{ label }
< / span >
2026-04-03 01:04:33 +02:00
< / 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-11 02:44:48 +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 } ${
2026-04-11 02:44:48 +02:00
viewportQuadResizeMode === null
? ""
: "viewport-region__panels--resizing"
2026-04-03 01:26:48 +02:00
} ` .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 }
2026-04-04 20:15:19 +02:00
whiteboxSelectionMode = { whiteboxSelectionMode }
2026-04-04 19:32:19 +02:00
whiteboxSnapEnabled = { whiteboxSnapEnabled }
whiteboxSnapStep = { whiteboxSnapStep }
2026-04-11 02:40:51 +02:00
viewportGridVisible = { viewportGridVisible }
2026-04-02 22:33:53 +02:00
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-11 03:22:25 +02:00
cameraSyncRequestId = { viewportCameraSyncRequestId }
2026-04-11 02:44:48 +02:00
focusRequestId = {
focusRequest . panelId === panelId ? focusRequest.id : 0
}
2026-04-02 22:33:53 +02:00
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-11 02:44:48 +02:00
onSelectionChange = { ( selection ) = >
applySelection ( selection , "viewport" )
}
2026-04-02 22:33:53 +02:00
/ >
) ) }
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-04-11 04:16:42 +02:00
< >
< Panel title = "Scene" >
< div className = "stat-card" >
< div className = "label" > Active Scene < / div >
< div className = "value" > { activeProjectScene . name } < / div >
< div className = "material-summary" >
Runner overlay data is authored per scene and shown during
initial runtime load and future scene transitions .
< / div >
2026-04-11 02:44:48 +02:00
< / div >
2026-04-11 04:16:42 +02:00
< div className = "form-section" >
< div className = "label" > Runner Loading Overlay < / div >
< label className = "form-field" >
< span className = "label" > Fade Color < / span >
< input
data - testid = "scene-loading-color"
className = "color-input"
type = "color"
value = { activeProjectScene . loadingScreen . colorHex }
onChange = { ( event ) = >
applySceneLoadingColor ( event . currentTarget . value )
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Headline < / span >
< input
data - testid = "scene-loading-headline"
className = "text-input"
type = "text"
placeholder = "Optional hint or location title"
value = { sceneLoadingHeadlineDraft }
onChange = { ( event ) = >
setSceneLoadingHeadlineDraft ( event . currentTarget . value )
}
onBlur = { applySceneLoadingHeadline }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applySceneLoadingHeadline ( ) ;
}
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Description < / span >
< input
data - testid = "scene-loading-description"
className = "text-input"
type = "text"
placeholder = "Optional loading note or gameplay tip"
value = { sceneLoadingDescriptionDraft }
onChange = { ( event ) = >
setSceneLoadingDescriptionDraft (
event . currentTarget . value
)
}
onBlur = { applySceneLoadingDescription }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applySceneLoadingDescription ( ) ;
}
} }
/ >
< / label >
< div className = "material-summary" >
Scene name is always shown automatically . Headline and
description are optional .
< / div >
2026-04-11 02:44:48 +02:00
< / div >
2026-04-11 04:16:42 +02:00
< / Panel >
< Panel title = "World" >
< div className = "stat-card" >
< div className = "label" > Background < / div >
2026-04-11 02:44:48 +02:00
< div
2026-04-11 04:16:42 +02:00
className = "value"
data - testid = "world-background-mode-value"
2026-04-11 02:44:48 +02:00
>
2026-04-11 04:16:42 +02:00
{ formatWorldBackgroundLabel ( editorState . document . world ) }
2026-04-11 02:44:48 +02:00
< / div >
2026-04-11 04:16:42 +02:00
< div
className = "world-background-preview"
data - testid = "world-background-preview"
style = { createWorldBackgroundStyle (
editorState . document . world . background ,
editorState . document . world . background . mode === "image"
? ( loadedImageAssets [
editorState . document . world . background . assetId
] ? . sourceUrl ? ? null )
: null
) }
/ >
< div className = "material-summary" >
{ editorState . document . world . background . mode === "solid"
? editorState . document . world . background . colorHex
: 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 ) }
< / div >
{ 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 >
) }
< / div >
2026-03-31 05:13:07 +02:00
2026-04-11 04:16:42 +02:00
< 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 >
2026-04-11 04:20:45 +02:00
< button
className = { ` toolbar__button ${
editorState . document . world . background . mode ===
"verticalGradient"
? "toolbar__button--active"
: ""
} ` }
type = "button"
data - testid = "world-background-mode-gradient"
onClick = { ( ) = >
applyWorldBackgroundMode ( "verticalGradient" )
2026-04-11 02:44:48 +02:00
}
2026-04-11 04:20:45 +02:00
>
Gradient
< / button >
< 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 >
< / div >
2026-04-11 02:44:48 +02:00
< / div >
2026-04-11 04:20:45 +02:00
{ editorState . document . world . background . mode === "image" && (
< div className = "form-section" >
< div className = "label" > Environment Intensity < / div >
2026-03-31 22:59:39 +02:00
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Intensity < / span >
2026-03-31 22:59:39 +02:00
< input
2026-04-11 04:20:45 +02:00
data - testid = "world-background-environment-intensity"
className = "text-input"
type = "number"
min = "0"
step = "0.1"
value = { backgroundEnvironmentIntensityDraft }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
setBackgroundEnvironmentIntensityDraft (
event . currentTarget . value
)
}
onBlur = { applyBackgroundEnvironmentIntensity }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyBackgroundEnvironmentIntensity
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyBackgroundEnvironmentIntensity
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyBackgroundEnvironmentIntensity
)
2026-04-11 02:44:48 +02:00
}
2026-03-31 22:59:39 +02:00
/ >
< / label >
2026-04-11 04:20:45 +02:00
< / div >
) }
{ editorState . document . world . background . mode !== "image" && (
< div className = "form-section" >
< div className = "label" > Background Colors < / div >
{ editorState . document . world . background . mode === "solid" ? (
2026-04-11 02:44:48 +02:00
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Color < / span >
2026-04-11 02:44:48 +02:00
< input
2026-04-11 04:20:45 +02:00
data - testid = "world-background-solid-color"
2026-04-11 02:44:48 +02:00
className = "color-input"
type = "color"
2026-04-11 04:20:45 +02:00
value = { editorState . document . world . background . colorHex }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
applyWorldBackgroundColor ( event . currentTarget . value )
2026-04-11 02:44:48 +02:00
}
2026-04-02 20:54:20 +02:00
/ >
< / label >
2026-04-11 04:20:45 +02:00
) : (
2026-04-11 02:44:48 +02:00
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Top < / span >
2026-04-11 02:44:48 +02:00
< input
2026-04-11 04:20:45 +02:00
data - testid = "world-background-top-color"
className = "color-input"
type = "color"
2026-04-11 02:44:48 +02:00
value = {
2026-04-11 04:20:45 +02:00
editorState . document . world . background . topColorHex
2026-04-11 02:44:48 +02:00
}
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
applyWorldGradientColor (
"top" ,
2026-04-11 02:44:48 +02:00
event . currentTarget . value
)
}
/ >
< / label >
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Bottom < / span >
2026-04-11 02:44:48 +02:00
< input
2026-04-11 04:20:45 +02:00
data - testid = "world-background-bottom-color"
className = "color-input"
type = "color"
value = {
editorState . document . world . background
. bottomColorHex
}
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
applyWorldGradientColor (
"bottom" ,
2026-04-11 02:44:48 +02:00
event . currentTarget . value
)
}
/ >
< / label >
< / div >
2026-04-11 04:20:45 +02:00
) }
< / div >
) }
2026-04-02 20:54:20 +02:00
2026-04-11 04:20:45 +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 >
< 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 >
2026-04-11 02:44:48 +02:00
< input
2026-04-11 04:20:45 +02:00
type = "checkbox"
checked = { advancedRendering . shadows . enabled }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingShadowsEnabled (
event . currentTarget . checked
2026-04-11 02:44:48 +02:00
)
}
/ >
< / label >
2026-04-11 04:20:45 +02:00
< 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 >
2026-04-11 02:44:48 +02:00
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Bias < / span >
2026-04-11 02:44:48 +02:00
< input
className = "text-input"
type = "number"
2026-04-11 04:20:45 +02:00
step = "0.0001"
value = { advancedRenderingShadowBiasDraft }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
setAdvancedRenderingShadowBiasDraft (
2026-04-11 02:44:48 +02:00
event . currentTarget . value
)
}
2026-04-11 04:20:45 +02:00
onBlur = { applyAdvancedRenderingShadowBias }
2026-04-11 02:44:48 +02:00
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingShadowBias
2026-04-11 02:44:48 +02:00
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingShadowBias
2026-04-11 02:44:48 +02:00
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingShadowBias
2026-04-11 02:44:48 +02:00
)
}
/ >
< / label >
< / div >
2026-04-11 04:20:45 +02:00
< 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 }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingAmbientOcclusionEnabled (
event . currentTarget . checked
2026-04-11 02:44:48 +02:00
)
}
2026-04-11 04:20:45 +02:00
/ >
2026-04-11 02:44:48 +02:00
< / label >
2026-04-11 04:20:45 +02:00
< 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 >
2026-04-11 02:44:48 +02:00
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Samples < / span >
2026-04-11 02:44:48 +02:00
< input
className = "text-input"
type = "number"
2026-04-11 04:20:45 +02:00
min = "1"
step = "1"
value = {
advancedRenderingAmbientOcclusionSamplesDraft
}
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
setAdvancedRenderingAmbientOcclusionSamplesDraft (
2026-04-11 02:44:48 +02:00
event . currentTarget . value
)
}
2026-04-11 04:20:45 +02:00
onBlur = {
applyAdvancedRenderingAmbientOcclusionSamples
}
2026-04-11 02:44:48 +02:00
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingAmbientOcclusionSamples
2026-04-11 02:44:48 +02:00
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingAmbientOcclusionSamples
2026-04-11 02:44:48 +02:00
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingAmbientOcclusionSamples
2026-04-11 02:44:48 +02:00
)
}
/ >
< / label >
< / div >
2026-04-11 04:20:45 +02:00
< 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 >
2026-04-11 02:44:48 +02:00
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Radius < / span >
2026-04-11 02:44:48 +02:00
< input
className = "text-input"
type = "number"
min = "0"
2026-04-11 04:20:45 +02:00
step = "0.05"
value = { advancedRenderingBloomRadiusDraft }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
setAdvancedRenderingBloomRadiusDraft (
2026-04-11 02:44:48 +02:00
event . currentTarget . value
)
}
2026-04-11 04:20:45 +02:00
onBlur = { applyAdvancedRenderingBloomRadius }
2026-04-11 02:44:48 +02:00
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingBloomRadius
2026-04-11 02:44:48 +02:00
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingBloomRadius
2026-04-11 02:44:48 +02:00
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
2026-04-11 04:20:45 +02:00
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
2026-04-11 02:44:48 +02:00
)
}
/ >
< / label >
2026-04-11 04:20:45 +02:00
< 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 >
2026-04-11 02:44:48 +02:00
< label className = "form-field" >
2026-04-11 04:20:45 +02:00
< span className = "label" > Bokeh Scale < / span >
2026-04-11 02:44:48 +02:00
< input
className = "text-input"
type = "number"
min = "0.001"
2026-04-11 04:20:45 +02:00
step = "0.1"
value = { advancedRenderingDepthOfFieldBokehScaleDraft }
2026-04-11 02:44:48 +02:00
onChange = { ( event ) = >
2026-04-11 04:20:45 +02:00
setAdvancedRenderingDepthOfFieldBokehScaleDraft (
2026-04-11 02:44:48 +02:00
event . currentTarget . value
)
}
onBlur = {
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingDepthOfFieldBokehScale
2026-04-11 02:44:48 +02:00
}
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingDepthOfFieldBokehScale
2026-04-11 02:44:48 +02:00
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingDepthOfFieldBokehScale
2026-04-11 02:44:48 +02:00
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
2026-04-11 04:20:45 +02:00
applyAdvancedRenderingDepthOfFieldBokehScale
2026-04-11 02:44:48 +02:00
)
}
/ >
< / label >
< / div >
2026-04-11 04:20:45 +02:00
< div className = "form-section" >
< div className = "label" > Volume Rendering Paths < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Fog < / span >
< select
className = "select-input"
value = { advancedRendering . fogPath }
onChange = { ( event ) = >
applyAdvancedRenderingFogPath (
event . currentTarget
. value as BoxVolumeRenderPath
)
}
>
{ BOX_VOLUME_RENDER_PATHS . map ( ( path ) = > (
< option key = { path } value = { path } >
{ formatBoxVolumeRenderPathLabel ( path ) }
2026-04-11 02:44:48 +02:00
< / option >
2026-04-11 04:20:45 +02:00
) ) }
< / select >
< / label >
< label className = "form-field" >
< span className = "label" > Water < / span >
< select
className = "select-input"
value = { advancedRendering . waterPath }
onChange = { ( event ) = >
applyAdvancedRenderingWaterPath (
event . currentTarget
. value as BoxVolumeRenderPath
)
}
>
{ BOX_VOLUME_RENDER_PATHS . map ( ( path ) = > (
< option key = { path } value = { path } >
{ formatBoxVolumeRenderPathLabel ( path ) }
< / option >
) ) }
< / select >
< / label >
< / div >
{ advancedRendering . waterPath === "quality" ? (
< label className = "form-field" >
< span className = "label" > Water Reflection < / span >
< select
data - testid = "advanced-rendering-water-reflection-mode"
className = "select-input"
value = { advancedRendering . waterReflectionMode }
onChange = { ( event ) = >
applyAdvancedRenderingWaterReflectionMode (
event . currentTarget
. value as AdvancedRenderingWaterReflectionMode
)
}
>
{ ADVANCED_RENDERING_WATER_REFLECTION_MODES . map (
( mode ) = > (
< option key = { mode } value = { mode } >
{ formatAdvancedRenderingWaterReflectionModeLabel (
mode
) }
< / option >
)
) }
< / select >
< / label >
) : null }
< / div >
< / >
) }
< / div >
< / Panel >
< / >
2026-04-11 02:44:48 +02:00
) : (
< Panel title = "Inspector" >
< div className = "stat-card" >
< div className = "label" > Selection < / div >
< div className = "value" >
{ describeSelection (
editorState . selection ,
brushList ,
editorState . document . modelInstances ,
editorState . document . assets ,
editorState . document . entities
) }
< / div >
< / div >
{ selectedModelInstance !== null ? (
< >
< div className = "stat-card" >
< div className = "label" > Model Asset < / div >
< div className = "value" >
{ selectedModelAsset ? . sourceName ? ? "Missing Asset" }
< / div >
< div className = "material-summary" >
{ selectedModelAssetRecord === null
? "This model instance references an asset that is missing from the registry."
: formatModelAssetSummary ( selectedModelAssetRecord ) }
< / div >
{ selectedModelAssetRecord === null ? null : (
< div className = "material-summary" >
{ formatModelBoundingBoxLabel ( selectedModelAssetRecord ) }
< / div >
) }
2026-04-02 20:54:20 +02:00
< / div >
< div className = "form-section" >
2026-04-11 02:44:48 +02:00
< div className = "label" > Position < / div >
< div className = "vector-inputs" >
2026-04-02 20:54:20 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > X < / span >
2026-04-02 20:54:20 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "model-instance-position-x"
2026-04-02 20:54:20 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
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
)
}
2026-04-02 20:54:20 +02:00
/ >
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Y < / span >
2026-04-02 20:54:20 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "model-instance-position-y"
2026-04-02 20:54:20 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
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
)
}
2026-04-02 20:54:20 +02:00
/ >
< / label >
< / div >
< / div >
2026-04-06 09:16:05 +02:00
< div className = "form-section" >
2026-04-11 02:44:48 +02:00
< div className = "label" > Rotation < / div >
< div className = "vector-inputs" >
2026-04-06 09:16:05 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< 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
)
}
/ >
2026-04-06 09:16:05 +02:00
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< 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
)
}
/ >
2026-04-06 09:16:05 +02:00
< / label >
2026-04-07 06:46:32 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< 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
)
}
/ >
2026-04-07 06:46:32 +02:00
< / label >
2026-04-11 02:44:48 +02:00
< / div >
2026-03-31 17:43:54 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< 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 >
2026-03-31 17:43:54 +02:00
< / div >
2026-04-04 07:55:42 +02:00
2026-04-01 00:14:20 +02:00
< div className = "form-section" >
2026-04-11 02:44:48 +02:00
< div className = "label" > Collision < / div >
2026-04-01 00:14:20 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Mode < / span >
2026-04-01 00:14:20 +02:00
< select
2026-04-11 02:44:48 +02:00
data - testid = "model-instance-collision-mode"
2026-04-01 00:14:20 +02:00
className = "select-input"
2026-04-11 02:44:48 +02:00
value = { selectedModelInstance . collision . mode }
onChange = { ( event ) = > {
2026-04-01 00:14:38 +02:00
store . executeCommand (
2026-04-01 00:14:20 +02:00
createUpsertModelInstanceCommand ( {
2026-04-11 02:44:48 +02:00
modelInstance : {
. . . selectedModelInstance ,
collision : {
. . . selectedModelInstance . collision ,
mode : event.target
. value as ModelInstanceCollisionMode
}
} ,
label : "Set model collision mode"
2026-04-01 00:14:20 +02:00
} )
) ;
} }
>
2026-04-11 02:44:48 +02:00
{ MODEL_INSTANCE_COLLISION_MODES . map ( ( mode ) = > (
< option key = { mode } value = { mode } >
{ mode }
< / option >
2026-04-01 00:14:20 +02:00
) ) }
< / select >
< / label >
< label className = "form-field" >
< input
2026-04-11 02:44:48 +02:00
data - testid = "model-instance-collision-visible"
2026-04-01 00:14:20 +02:00
type = "checkbox"
2026-04-11 02:44:48 +02:00
checked = { selectedModelInstance . collision . visible }
onChange = { ( event ) = > {
2026-04-01 00:14:42 +02:00
store . executeCommand (
2026-04-01 00:14:20 +02:00
createUpsertModelInstanceCommand ( {
2026-04-11 02:44:48 +02:00
modelInstance : {
. . . selectedModelInstance ,
collision : {
. . . selectedModelInstance . collision ,
visible : event.target.checked
}
} ,
label : event.target.checked
? "Show model collision debug"
: "Hide model collision debug"
2026-04-01 00:14:20 +02:00
} )
) ;
} }
/ >
2026-04-11 02:44:48 +02:00
< span className = "label" >
Show generated collision debug
< / span >
2026-03-31 05:57:54 +02:00
< / label >
2026-04-11 02:44:48 +02:00
< div className = "material-summary" >
{ getModelInstanceCollisionModeDescription (
selectedModelInstance . collision . mode
) }
< / div >
2026-03-31 05:57:54 +02:00
< / div >
2026-04-11 02:44:48 +02:00
{ selectedModelAssetRecord !== null &&
selectedModelAssetRecord . metadata . animationNames . length >
0 && (
< div className = "form-section" >
< div className = "label" > Animation < / div >
2026-03-31 20:15:10 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Clip < / span >
< select
className = "select-input"
value = {
selectedModelInstance . animationClipName ? ? ""
}
onChange = { ( e ) = > {
const clipName = e . target . value || undefined ;
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : {
. . . selectedModelInstance ,
animationClipName : clipName
} ,
label : "Set animation clip"
} )
) ;
2026-03-31 20:15:10 +02:00
} }
2026-04-11 02:44:48 +02:00
>
< option value = "" > — none — < / option >
{ selectedModelAssetRecord . metadata . animationNames . map (
( name ) = > (
< option key = { name } value = { name } >
{ name }
< / option >
)
) }
< / select >
2026-03-31 20:15:10 +02:00
< / label >
< label className = "form-field" >
< input
2026-04-11 02:44:48 +02:00
type = "checkbox"
checked = {
selectedModelInstance . animationAutoplay ? ? false
}
onChange = { ( e ) = > {
store . executeCommand (
createUpsertModelInstanceCommand ( {
modelInstance : {
. . . selectedModelInstance ,
animationAutoplay : e.target.checked
} ,
label : "Set animation autoplay"
} )
) ;
} }
2026-03-31 20:15:10 +02:00
/ >
2026-04-11 02:44:48 +02:00
< span className = "label" > Autoplay on scene load < / span >
2026-03-31 20:15:10 +02:00
< / label >
< / div >
2026-04-11 02:44:48 +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 ? (
< >
< div className = "stat-card" >
< div className = "label" > Entity Kind < / div >
< div className = "value" >
{ getEntityKindLabel ( selectedEntity . kind ) }
2026-03-31 20:15:10 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< / div >
2026-03-31 20:15:10 +02:00
2026-04-11 02:44:48 +02:00
< div className = "form-section" >
< div className = "label" > Position < / div >
< div className = "vector-inputs" >
2026-03-31 20:15:10 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > X < / span >
2026-03-31 20:15:10 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = {
selectedEntity . kind === "playerStart"
? "player-start-position-x"
: ` ${ selectedEntity . kind } -position-x `
}
2026-03-31 20:15:10 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
step = { DEFAULT_GRID_SIZE }
value = { entityPositionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setEntityPositionDraft ( ( draft ) = > ( {
. . . draft ,
x : nextValue
} ) ) ;
} }
onBlur = { applySelectedEntityDraftChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySelectedEntityDraftChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySelectedEntityDraftChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySelectedEntityDraftChange
)
}
2026-03-31 20:15:10 +02:00
/ >
< / label >
2026-04-04 15:56:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Y < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = {
selectedEntity . kind === "playerStart"
? "player-start-position-y"
: ` ${ selectedEntity . kind } -position-y `
}
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
step = { DEFAULT_GRID_SIZE }
value = { entityPositionDraft . y }
2026-04-04 15:56:54 +02:00
onChange = { ( event ) = > {
2026-04-11 02:44:48 +02:00
const nextValue = event . currentTarget . value ;
setEntityPositionDraft ( ( draft ) = > ( {
. . . draft ,
y : nextValue
} ) ) ;
2026-04-04 15:56:54 +02:00
} }
2026-04-11 02:44:48 +02:00
onBlur = { applySelectedEntityDraftChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySelectedEntityDraftChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySelectedEntityDraftChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySelectedEntityDraftChange
)
}
/ >
2026-04-04 15:56:54 +02:00
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Z < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = {
selectedEntity . kind === "playerStart"
? "player-start-position-z"
: ` ${ selectedEntity . kind } -position-z `
}
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
step = { DEFAULT_GRID_SIZE }
value = { entityPositionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setEntityPositionDraft ( ( draft ) = > ( {
. . . draft ,
z : nextValue
} ) ) ;
} }
onBlur = { applySelectedEntityDraftChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySelectedEntityDraftChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySelectedEntityDraftChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySelectedEntityDraftChange
)
}
2026-04-04 15:56:54 +02:00
/ >
< / label >
2026-04-11 02:44:48 +02:00
< / div >
< / div >
2026-04-04 15:56:54 +02:00
2026-04-11 02:44:48 +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 ) = > {
const nextColorHex = event . currentTarget . value ;
setPointLightColorDraft ( nextColorHex ) ;
scheduleDraftCommit ( ( ) = >
applyPointLightChange ( {
colorHex : nextColorHex
} )
) ;
} }
/ >
< / label >
2026-04-04 15:56:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Intensity < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "point-light-intensity"
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
min = "0"
step = "0.1"
value = { pointLightIntensityDraft }
onChange = { ( event ) = >
setPointLightIntensityDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyPointLightChange ( ) }
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
)
}
onBlur = { ( ) = > applyPointLightChange ( ) }
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 ) = > {
const nextColorHex = event . currentTarget . value ;
setSpotLightColorDraft ( nextColorHex ) ;
scheduleDraftCommit ( ( ) = >
applySpotLightChange ( {
colorHex : nextColorHex
} )
) ;
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Intensity < / span >
< input
data - testid = "spot-light-intensity"
className = "text-input"
type = "number"
min = "0"
2026-04-04 15:56:54 +02:00
step = "0.1"
2026-04-11 02:44:48 +02:00
value = { spotLightIntensityDraft }
onChange = { ( event ) = >
setSpotLightIntensityDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applySpotLightChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySpotLightChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySpotLightChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySpotLightChange
)
}
2026-04-04 15:56:54 +02:00
/ >
< / label >
2026-04-11 02:44:48 +02:00
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Range < / div >
< div className = "vector-inputs vector-inputs--two" >
2026-04-04 15:56:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Distance < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "spot-light-distance"
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
min = "0.1"
2026-04-04 15:56:54 +02:00
step = "0.1"
2026-04-11 02:44:48 +02:00
value = { spotLightDistanceDraft }
onChange = { ( event ) = >
setSpotLightDistanceDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applySpotLightChange ( ) }
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
)
}
onBlur = { ( ) = > applySpotLightChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySpotLightChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySpotLightChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySpotLightChange
)
}
2026-04-04 15:56:54 +02:00
/ >
< / label >
< / div >
2026-04-11 02:44:48 +02:00
< / div >
2026-04-04 15:56:54 +02:00
2026-04-11 02:44:48 +02:00
< div className = "form-section" >
< div className = "label" > Direction < / div >
2026-04-04 15:56:54 +02:00
< div className = "vector-inputs" >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > X < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "spot-light-direction-x"
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
step = "0.1"
2026-04-11 02:44:48 +02:00
value = { spotLightDirectionDraft . x }
2026-04-04 15:56:54 +02:00
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
2026-04-11 02:44:48 +02:00
setSpotLightDirectionDraft ( ( draft ) = > ( {
. . . draft ,
x : nextValue
} ) ) ;
2026-04-04 15:56:54 +02:00
} }
2026-04-11 02:44:48 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySpotLightChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySpotLightChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySpotLightChange
)
}
2026-04-04 15:56:54 +02:00
/ >
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Y < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "spot-light-direction-y"
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
step = "0.1"
2026-04-11 02:44:48 +02:00
value = { spotLightDirectionDraft . y }
2026-04-04 15:56:54 +02:00
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
2026-04-11 02:44:48 +02:00
setSpotLightDirectionDraft ( ( draft ) = > ( {
. . . draft ,
y : nextValue
} ) ) ;
2026-04-04 15:56:54 +02:00
} }
2026-04-11 02:44:48 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySpotLightChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySpotLightChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySpotLightChange
)
}
2026-04-04 15:56:54 +02:00
/ >
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Z < / span >
2026-04-04 15:56:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "spot-light-direction-z"
2026-04-04 15:56:54 +02:00
className = "text-input"
type = "number"
step = "0.1"
2026-04-11 02:44:48 +02:00
value = { spotLightDirectionDraft . z }
2026-04-04 15:56:54 +02:00
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
2026-04-11 02:44:48 +02:00
setSpotLightDirectionDraft ( ( draft ) = > ( {
. . . draft ,
z : nextValue
} ) ) ;
2026-04-04 15:56:54 +02:00
} }
2026-04-11 02:44:48 +02:00
onBlur = { ( ) = > applySpotLightChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySpotLightChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySpotLightChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySpotLightChange
)
}
2026-04-04 15:56:54 +02:00
/ >
< / label >
< / div >
2026-04-02 19:41:01 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< / >
) : null }
2026-04-02 19:41:01 +02:00
2026-04-11 02:44:48 +02:00
{ selectedPlayerStart !== null ? (
< >
< div className = "form-section" >
< div className = "label" > Yaw < / div >
2026-03-31 05:57:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Degrees < / span >
2026-03-31 05:57:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "player-start-yaw"
2026-03-31 05:57:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
step = "1"
value = { playerStartYawDraft }
onChange = { ( event ) = >
setPlayerStartYawDraft ( event . currentTarget . value )
}
onBlur = { ( ) = > applyPlayerStartChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyPlayerStartChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyPlayerStartChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyPlayerStartChange
)
}
2026-03-31 05:57:54 +02:00
/ >
< / label >
2026-04-11 02:44:48 +02:00
< / div >
< div className = "form-section" >
< div className = "label" > Player Collider < / div >
2026-03-31 05:57:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Mode < / span >
< select
data - testid = "player-start-collider-mode"
className = "select-input"
value = { playerStartColliderModeDraft }
2026-03-31 05:57:54 +02:00
onChange = { ( event ) = > {
2026-04-11 02:44:48 +02:00
const nextMode = event . currentTarget
. value as PlayerStartColliderMode ;
setPlayerStartColliderModeDraft ( nextMode ) ;
scheduleDraftCommit ( ( ) = >
applyPlayerStartChange ( {
colliderMode : nextMode
} )
) ;
2026-03-31 05:57:54 +02:00
} }
2026-04-11 02:44:48 +02:00
>
{ PLAYER_START_COLLIDER_MODES . map ( ( mode ) = > (
< option key = { mode } value = { mode } >
{ mode }
< / option >
) ) }
< / select >
2026-03-31 05:57:54 +02:00
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Eye Height < / span >
2026-03-31 05:57:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "player-start-eye-height"
2026-03-31 05:57:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
min = "0.01"
step = "0.1"
value = { playerStartEyeHeightDraft }
onChange = { ( event ) = >
setPlayerStartEyeHeightDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyPlayerStartChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyPlayerStartChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyPlayerStartChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyPlayerStartChange
)
}
2026-03-31 05:57:54 +02:00
/ >
< / label >
2026-04-11 02:44:48 +02:00
{ 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
)
}
onBlur = { ( ) = > applyPlayerStartChange ( ) }
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
)
}
onBlur = { ( ) = > applyPlayerStartChange ( ) }
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
} ) ) ;
} }
onBlur = { ( ) = > applyPlayerStartChange ( ) }
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
} ) ) ;
} }
onBlur = { ( ) = > applyPlayerStartChange ( ) }
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
} ) ) ;
} }
onBlur = { ( ) = > applyPlayerStartChange ( ) }
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 >
< / >
) : null }
{ selectedSoundEmitter !== null ? (
< >
< div className = "form-section" >
< 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 >
2026-03-31 05:57:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Audio < / span >
< select
data - testid = "sound-emitter-audio-asset"
2026-03-31 05:57:54 +02:00
className = "text-input"
2026-04-11 02:44:48 +02:00
value = { soundEmitterAudioAssetIdDraft }
2026-03-31 05:57:54 +02:00
onChange = { ( event ) = > {
2026-04-11 02:44:48 +02:00
const nextAudioAssetId =
event . currentTarget . value . trim ( ) ;
setSoundEmitterAudioAssetIdDraft (
nextAudioAssetId
) ;
scheduleDraftCommit ( ( ) = >
applySoundEmitterChange ( {
audioAssetId :
nextAudioAssetId . length === 0
? null
: nextAudioAssetId
} )
) ;
2026-03-31 05:57:54 +02:00
} }
2026-04-11 02:44:48 +02:00
>
< option value = "" > — none — < / option >
{ audioAssetList . map ( ( asset ) = > (
< option key = { asset . id } value = { asset . id } >
{ asset . sourceName }
< / option >
) ) }
< / select >
2026-03-31 05:57:54 +02:00
< / label >
2026-04-11 02:44:48 +02:00
< / div >
< div className = "form-section" >
< div className = "label" > Volume < / div >
2026-03-31 05:57:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Amount < / span >
2026-03-31 05:57:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "sound-emitter-volume"
2026-03-31 05:57:54 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
min = "0"
step = "0.1"
value = { soundEmitterVolumeDraft }
onChange = { ( event ) = >
setSoundEmitterVolumeDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applySoundEmitterChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySoundEmitterChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySoundEmitterChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySoundEmitterChange
)
}
2026-03-31 05:57:54 +02:00
/ >
< / label >
< / div >
2026-03-31 06:18:46 +02:00
2026-04-11 02:44:48 +02:00
< div className = "form-section" >
< div className = "label" > Distance < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Ref Distance < / span >
< input
data - testid = "sound-emitter-ref-distance"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { soundEmitterRefDistanceDraft }
onChange = { ( event ) = >
setSoundEmitterRefDistanceDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applySoundEmitterChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySoundEmitterChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySoundEmitterChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySoundEmitterChange
)
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Max Distance < / span >
< input
data - testid = "sound-emitter-max-distance"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { soundEmitterMaxDistanceDraft }
onChange = { ( event ) = >
setSoundEmitterMaxDistanceDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applySoundEmitterChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applySoundEmitterChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applySoundEmitterChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySoundEmitterChange
)
}
/ >
< / label >
< / div >
< / div >
2026-03-31 05:57:54 +02:00
2026-04-11 02:44:48 +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 ) = > {
const nextAutoplay =
event . currentTarget . checked ;
setSoundEmitterAutoplayDraft ( nextAutoplay ) ;
scheduleDraftCommit ( ( ) = >
applySoundEmitterChange ( {
autoplay : nextAutoplay
} )
) ;
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Loop < / span >
< input
data - testid = "sound-emitter-loop"
type = "checkbox"
checked = { soundEmitterLoopDraft }
onChange = { ( event ) = > {
const nextLoop = event . currentTarget . checked ;
setSoundEmitterLoopDraft ( nextLoop ) ;
scheduleDraftCommit ( ( ) = >
applySoundEmitterChange ( { loop : nextLoop } )
) ;
} }
/ >
< / label >
< / div >
< / div >
< / >
) : null }
2026-03-31 05:57:54 +02:00
2026-04-11 02:44:48 +02:00
{ 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 >
2026-03-31 05:57:54 +02:00
< / div >
2026-04-11 02:44:48 +02:00
{ renderInteractionLinksSection (
selectedTriggerVolume ,
selectedTriggerVolumeLinks ,
"add-trigger-teleport-link" ,
"add-trigger-visibility-link" ,
"add-trigger-play-sound-link" ,
"add-trigger-stop-sound-link"
) }
< / >
) : null }
{ selectedTeleportTarget !== null ? (
2026-03-31 05:57:54 +02:00
< div className = "form-section" >
2026-04-11 02:44:48 +02:00
< div className = "label" > Yaw < / div >
2026-03-31 05:57:54 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Degrees < / span >
2026-03-31 05:57:54 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "teleport-target-yaw"
2026-03-31 05:57:54 +02:00
className = "text-input"
2026-04-11 02:44:48 +02:00
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
)
}
2026-03-31 05:57:54 +02:00
/ >
< / label >
< / div >
2026-04-11 02:44:48 +02:00
) : null }
2026-03-31 06:49:21 +02:00
2026-04-11 02:44:48 +02:00
{ selectedInteractable !== null ? (
< >
< div className = "form-section" >
< div className = "label" > Interaction < / div >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Range < / span >
< input
data - testid = "interactable-radius"
className = "text-input"
type = "number"
min = "0.1"
step = "0.1"
value = { interactableRadiusDraft }
onChange = { ( event ) = >
setInteractableRadiusDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyInteractableChange ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyInteractableChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyInteractableChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyInteractableChange
)
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Enabled < / span >
< input
data - testid = "interactable-enabled"
type = "checkbox"
checked = { interactableEnabledDraft }
onChange = { ( event ) = > {
const nextEnabled = event . currentTarget . checked ;
setInteractableEnabledDraft ( nextEnabled ) ;
scheduleDraftCommit ( ( ) = >
applyInteractableChange ( {
enabled : nextEnabled
} )
) ;
} }
/ >
< / label >
< / div >
< div className = "material-summary" >
Range defines how close the player must be before the
click prompt can activate .
< / div >
2026-04-04 20:14:35 +02:00
< / div >
2026-04-04 19:31:44 +02:00
2026-04-11 02:44:48 +02:00
< div className = "form-section" >
< div className = "label" > Prompt < / div >
2026-04-04 20:14:35 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Text < / span >
2026-04-04 20:14:35 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "interactable-prompt"
2026-04-04 20:14:35 +02:00
className = "text-input"
2026-04-11 02:44:48 +02:00
type = "text"
value = { interactablePromptDraft }
onChange = { ( event ) = >
setInteractablePromptDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyInteractableChange ( ) }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" ) {
applyInteractableChange ( ) ;
}
2026-04-04 20:14:35 +02:00
} }
/ >
< / label >
< / div >
2026-04-11 02:44:48 +02:00
{ renderInteractionLinksSection (
selectedInteractable ,
selectedInteractableLinks ,
"add-interactable-teleport-link" ,
"add-interactable-visibility-link" ,
"add-interactable-play-sound-link" ,
"add-interactable-stop-sound-link"
) }
< / >
) : null }
< / >
) : selectedBrush === null ? (
< div className = "outliner-empty" >
Select a whitebox solid or entity to edit authored properties .
< / div >
) : (
< >
< div className = "stat-card" >
< div className = "label" > Whitebox Solid Type < / div >
< div className = "value" > box < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > Selection Mode < / div >
< div className = "value" >
{ getWhiteboxSelectionModeLabel ( whiteboxSelectionMode ) }
2026-04-04 20:14:35 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< / div >
2026-04-04 20:14:35 +02:00
2026-04-11 02:44:48 +02:00
{ whiteboxSelectionMode !== "object" ? (
< div className = "outliner-empty" >
{ whiteboxSelectionMode === "face"
? "Face mode keeps whole-solid transforms out of the way. Select a face to edit its material or UV transform."
: whiteboxSelectionMode === "edge"
? "Edge mode is selection-only in this slice. Edge transforms land next."
: "Vertex mode is selection-only in this slice. Vertex transforms land next." }
2026-04-04 20:14:35 +02:00
< / div >
2026-04-11 02:44:48 +02:00
) : (
< >
< 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 = { whiteboxVectorInputStep }
value = { positionDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPositionDraft ( ( draft ) = > ( {
. . . draft ,
x : nextValue
} ) ) ;
} }
onBlur = { applyPositionChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyPositionChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyPositionChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyPositionChange
)
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "brush-center-y"
className = "text-input"
type = "number"
step = { whiteboxVectorInputStep }
value = { positionDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPositionDraft ( ( draft ) = > ( {
. . . draft ,
y : nextValue
} ) ) ;
} }
onBlur = { applyPositionChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyPositionChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyPositionChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyPositionChange
)
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "brush-center-z"
className = "text-input"
type = "number"
step = { whiteboxVectorInputStep }
value = { positionDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setPositionDraft ( ( draft ) = > ( {
. . . draft ,
z : nextValue
} ) ) ;
} }
onBlur = { applyPositionChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyPositionChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyPositionChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyPositionChange
)
}
/ >
< / label >
< / div >
< / div >
2026-04-06 08:29:39 +02:00
2026-04-11 02:44:48 +02:00
< 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 = "brush-rotation-x"
className = "text-input"
type = "number"
step = "0.1"
value = { rotationDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setRotationDraft ( ( draft ) = > ( {
. . . draft ,
x : nextValue
} ) ) ;
} }
onBlur = { applyRotationChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyRotationChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyRotationChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyRotationChange
)
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Y < / span >
< input
data - testid = "brush-rotation-y"
className = "text-input"
type = "number"
step = "0.1"
value = { rotationDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setRotationDraft ( ( draft ) = > ( {
. . . draft ,
y : nextValue
} ) ) ;
} }
onBlur = { applyRotationChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyRotationChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyRotationChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyRotationChange
)
}
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Z < / span >
< input
data - testid = "brush-rotation-z"
className = "text-input"
type = "number"
step = "0.1"
value = { rotationDraft . z }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setRotationDraft ( ( draft ) = > ( {
. . . draft ,
z : nextValue
} ) ) ;
} }
onBlur = { applyRotationChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyRotationChange
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyRotationChange
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyRotationChange
)
}
/ >
< / label >
< / div >
< / div >
2026-04-06 08:29:39 +02:00
2026-04-11 02:44:48 +02:00
< 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 = "0.01"
step = { whiteboxVectorInputStep }
value = { sizeDraft . x }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSizeDraft ( ( draft ) = > ( {
. . . draft ,
x : nextValue
} ) ) ;
} }
onBlur = { applySizeChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown ( event , applySizeChange )
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp ( event , applySizeChange )
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySizeChange
)
}
/ >
< / label >
2026-04-06 08:29:39 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Y < / span >
2026-04-06 08:29:39 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "brush-size-y"
2026-04-06 08:29:39 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
min = "0.01"
step = { whiteboxVectorInputStep }
value = { sizeDraft . y }
onChange = { ( event ) = > {
const nextValue = event . currentTarget . value ;
setSizeDraft ( ( draft ) = > ( {
. . . draft ,
y : nextValue
} ) ) ;
} }
onBlur = { applySizeChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown ( event , applySizeChange )
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp ( event , applySizeChange )
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySizeChange
)
}
2026-04-07 06:46:26 +02:00
/ >
< / label >
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Z < / span >
2026-04-07 06:46:26 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "brush-size-z"
2026-04-07 06:46:26 +02:00
className = "text-input"
type = "number"
2026-04-11 02:44:48 +02:00
min = "0.01"
step = { whiteboxVectorInputStep }
value = { sizeDraft . z }
2026-04-07 07:11:15 +02:00
onChange = { ( event ) = > {
2026-04-11 02:44:48 +02:00
const nextValue = event . currentTarget . value ;
setSizeDraft ( ( draft ) = > ( {
. . . draft ,
z : nextValue
} ) ) ;
2026-04-07 07:11:15 +02:00
} }
2026-04-11 02:44:48 +02:00
onBlur = { applySizeChange }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown ( event , applySizeChange )
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp ( event , applySizeChange )
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applySizeChange
)
}
2026-04-07 07:11:15 +02:00
/ >
< / label >
2026-04-11 02:44:48 +02:00
< / div >
< / div >
< div className = "form-section" >
< div className = "label" > Volume Mode < / div >
< label className = "form-field" >
< span className = "label" > Mode < / span >
< select
data - testid = "brush-volume-mode"
className = "select-input"
value = { boxVolumeModeDraft }
onChange = { ( event ) = > {
const nextMode = event . currentTarget
. value as BoxBrushVolumeMode ;
setBoxVolumeModeDraft ( nextMode ) ;
applyBoxVolumeModeChange ( nextMode ) ;
} }
>
{ BOX_BRUSH_VOLUME_MODES . map ( ( mode ) = > (
< option key = { mode } value = { mode } >
{ formatBoxVolumeModeLabel ( mode ) }
< / option >
) ) }
< / select >
< / label >
2026-04-06 08:29:39 +02:00
2026-04-11 02:44:48 +02:00
{ boxVolumeModeDraft === "water" ? (
< >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "brush-water-color"
className = "color-input"
type = "color"
value = { boxVolumeWaterColorDraft }
onChange = { ( event ) = > {
const nextColorHex =
event . currentTarget . value ;
setBoxVolumeWaterColorDraft ( nextColorHex ) ;
scheduleDraftCommit ( ( ) = >
applyBoxWaterColorDraft ( nextColorHex )
) ;
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Surface Opacity < / span >
< input
data - testid = "brush-water-surface-opacity"
className = "text-input"
type = "number"
min = "0"
step = "0.05"
value = { boxVolumeWaterSurfaceOpacityDraft }
onChange = { ( event ) = >
setBoxVolumeWaterSurfaceOpacityDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyBoxWaterSettings ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyBoxWaterSettings
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyBoxWaterSettings
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyBoxWaterSettings
)
}
/ >
< / label >
< / div >
< label className = "form-field" >
< span className = "label" > Wave Strength < / span >
< input
data - testid = "brush-water-wave-strength"
className = "text-input"
type = "number"
min = "0"
step = "0.05"
value = { boxVolumeWaterWaveStrengthDraft }
onChange = { ( event ) = >
setBoxVolumeWaterWaveStrengthDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyBoxWaterSettings ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyBoxWaterSettings
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyBoxWaterSettings
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyBoxWaterSettings
)
}
/ >
< / label >
2026-04-06 08:29:39 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Foam Contact Limit < / span >
< input
data - testid = "brush-water-foam-contact-limit"
className = "text-input"
type = "number"
min = "1"
max = { String (
MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT
) }
step = "1"
value = { boxVolumeWaterFoamContactLimitDraft }
onChange = { ( event ) = >
setBoxVolumeWaterFoamContactLimitDraft (
event . currentTarget . value
)
}
onBlur = { ( ) = > applyBoxWaterSettings ( ) }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyBoxWaterSettings
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyBoxWaterSettings
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyBoxWaterSettings
)
}
/ >
< / label >
< label className = "form-field form-field--toggle" >
< span className = "label" >
Vertical Surface Motion
< / span >
2026-04-06 08:29:39 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "brush-water-surface-displacement-enabled"
type = "checkbox"
checked = {
boxVolumeWaterSurfaceDisplacementEnabledDraft
}
2026-04-06 08:29:39 +02:00
onChange = { ( event ) = > {
2026-04-11 02:44:48 +02:00
const nextSurfaceDisplacementEnabled =
event . currentTarget . checked ;
setBoxVolumeWaterSurfaceDisplacementEnabledDraft (
nextSurfaceDisplacementEnabled
) ;
scheduleDraftCommit ( ( ) = >
applyBoxWaterSettings ( {
surfaceDisplacementEnabled :
nextSurfaceDisplacementEnabled
} )
) ;
2026-04-06 08:29:39 +02:00
} }
/ >
< / label >
2026-04-11 02:44:48 +02:00
< / >
) : null }
{ boxVolumeModeDraft === "fog" ? (
< >
< div className = "vector-inputs vector-inputs--two" >
< label className = "form-field" >
< span className = "label" > Color < / span >
< input
data - testid = "brush-fog-color"
className = "color-input"
type = "color"
value = { boxVolumeFogColorDraft }
onChange = { ( event ) = > {
const nextColorHex =
event . currentTarget . value ;
setBoxVolumeFogColorDraft ( nextColorHex ) ;
scheduleDraftCommit ( ( ) = >
applyBoxFogColorDraft ( nextColorHex )
) ;
} }
/ >
< / label >
< label className = "form-field" >
< span className = "label" > Density < / span >
< input
data - testid = "brush-fog-density"
className = "text-input"
type = "number"
min = "0"
step = "0.01"
value = { boxVolumeFogDensityDraft }
onChange = { ( event ) = >
setBoxVolumeFogDensityDraft (
event . currentTarget . value
)
}
onBlur = { applyBoxFogSettings }
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyBoxFogSettings
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyBoxFogSettings
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyBoxFogSettings
)
}
/ >
< / label >
< / div >
2026-04-06 08:29:39 +02:00
< label className = "form-field" >
2026-04-11 02:44:48 +02:00
< span className = "label" > Padding < / span >
2026-04-06 08:29:39 +02:00
< input
2026-04-11 02:44:48 +02:00
data - testid = "brush-fog-padding"
2026-04-06 08:29:39 +02:00
className = "text-input"
type = "number"
min = "0"
step = "0.01"
2026-04-11 02:44:48 +02:00
value = { boxVolumeFogPaddingDraft }
onChange = { ( event ) = >
setBoxVolumeFogPaddingDraft (
event . currentTarget . value
)
}
2026-04-06 08:29:39 +02:00
onBlur = { applyBoxFogSettings }
2026-04-11 02:44:48 +02:00
onKeyDown = { ( event ) = >
handleDraftVectorKeyDown (
event ,
applyBoxFogSettings
)
}
onKeyUp = { ( event ) = >
handleNumberInputKeyUp (
event ,
applyBoxFogSettings
)
}
onPointerUp = { ( event ) = >
handleNumberInputPointerUp (
event ,
applyBoxFogSettings
)
}
2026-04-06 08:29:39 +02:00
/ >
< / label >
2026-04-11 02:44:48 +02:00
< / >
) : null }
< / div >
< / >
) }
2026-03-31 02:37:22 +02:00
2026-04-11 02:44:48 +02:00
< 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 = { ( ) = > {
store . setWhiteboxSelectionMode ( "face" ) ;
applySelection (
{
kind : "brushFace" ,
brushId : selectedBrush.id ,
faceId
} ,
"inspector"
) ;
} }
>
< span className = "face-chip__title" >
{ BOX_FACE_LABELS [ faceId ] }
< / span >
< span className = "face-chip__meta" > { faceId } < / span >
< / button >
) ) }
< / div >
2026-03-31 02:37:22 +02:00
< / div >
2026-04-11 02:44:48 +02:00
{ whiteboxSelectionMode === "edge" ? (
selectedEdgeId === null ? (
< div className = "outliner-empty" >
Select an edge in the viewport to inspect it . Edge
transforms land in the next slice .
< / div >
) : (
< div className = "stat-card" >
< div className = "label" > Active Edge < / div >
< div className = "value" >
{ BOX_EDGE_LABELS [ selectedEdgeId ] }
< / div >
< div className = "material-summary" >
Edge selection is visible in the viewport . Persistent
edge transforms are still deferred .
< / div >
< / div >
)
) : whiteboxSelectionMode === "vertex" ? (
selectedVertexId === null ? (
< div className = "outliner-empty" >
Select a vertex in the viewport to inspect it . Vertex
transforms land in the next slice .
< / div >
) : (
< div className = "stat-card" >
< div className = "label" > Active Vertex < / div >
< div className = "value" >
{ BOX_VERTEX_LABELS [ selectedVertexId ] }
< / div >
< div className = "material-summary" >
Vertex selection is visible in the viewport .
Persistent vertex transforms are still deferred .
< / div >
< / div >
)
) : whiteboxSelectionMode !== "face" ? (
< div className = "outliner-empty" >
Switch to Face mode or choose a face chip to edit
materials and UVs .
2026-04-04 20:15:19 +02:00
< / div >
2026-04-11 02:44:48 +02:00
) : selectedFace === null || selectedFaceId === null ? (
< div className = "outliner-empty" >
Select a face to edit its material and UV transform .
2026-04-04 20:15:19 +02:00
< / div >
2026-04-11 02:44:48 +02:00
) : (
< >
< div className = "stat-card" >
< div className = "label" > Active Face < / div >
< div className = "value" >
{ BOX_FACE_LABELS [ selectedFaceId ] }
< / div >
< div
className = "material-summary"
data - testid = "selected-face-material-name"
>
Material : { " " }
{ selectedFaceMaterial ? . name ? ? "Fallback face color" }
< / div >
2026-03-31 02:37:22 +02:00
< / div >
2026-04-11 02:44:48 +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" >
2026-04-01 05:18:13 +02:00
< button
2026-04-11 02:44:48 +02:00
className = "toolbar__button"
2026-04-01 05:18:13 +02:00
type = "button"
2026-04-11 02:44:48 +02:00
onClick = { clearFaceMaterial }
2026-04-01 05:18:13 +02:00
>
2026-04-11 02:44:48 +02:00
Clear Material
2026-04-01 05:18:13 +02:00
< / button >
2026-04-11 02:44:48 +02:00
< / div >
2026-04-01 05:18:13 +02:00
< / div >
2026-04-11 02:44:48 +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 >
2026-03-31 02:37:22 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< 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 >
2026-03-31 02:37:22 +02:00
< / div >
2026-04-11 02:44:48 +02:00
< 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 02:37:22 +02:00
2026-04-11 02:44:48 +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 >
2026-03-31 04:26:19 +02:00
< / div >
2026-04-11 02:44:48 +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 : (
2026-04-11 02:44:48 +02:00
< HierarchicalMenu
title = "Add"
position = { addMenuPosition }
items = { addMenuItems }
onClose = { closeAddMenu }
/ >
2026-04-02 23:25:05 +02:00
) }
2026-03-31 02:37:22 +02:00
< footer className = "status-bar" >
2026-04-11 03:28:47 +02:00
< div
className = "status-bar__item status-bar__item--message"
data - testid = "status-message"
title = { statusMessage }
>
2026-03-31 02:37:22 +02:00
< span className = "status-bar__strong" > Status : < / span > { statusMessage }
< / div >
2026-04-11 02:44:48 +02:00
< div
className = "status-bar__item"
data - testid = "status-whitebox-selection-mode"
>
< span className = "status-bar__strong" > Whitebox : < / span > { " " }
{ getWhiteboxSelectionModeLabel ( whiteboxSelectionMode ) }
2026-04-04 20:15:19 +02:00
< / div >
2026-03-31 04:53:26 +02:00
< div className = "status-bar__item" data-testid = "status-document" >
2026-04-11 02:44:48 +02:00
< span className = "status-bar__strong" > Document : < / span > { " " }
{ documentStatusLabel }
2026-03-31 04:53:26 +02:00
< / 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" >
2026-04-11 02:44:48 +02:00
< span className = "status-bar__strong" > Warnings : < / span > { " " }
{ warningDiagnostics . length }
2026-03-31 04:53:26 +02:00
< / div >
2026-04-02 23:25:01 +02:00
{ hoveredAssetStatusMessage === null ? null : (
2026-04-11 02:44:48 +02:00
< div
className = "status-bar__item status-bar__item--asset"
data - testid = "status-asset-hover"
2026-04-11 03:28:47 +02:00
title = { hoveredAssetStatusMessage }
2026-04-11 02:44:48 +02:00
>
< span className = "status-bar__strong" > Asset : < / span > { " " }
{ hoveredAssetStatusMessage }
2026-04-02 23:25:01 +02:00
< / div >
) }
2026-04-11 03:28:47 +02:00
< div
className = "status-bar__item"
data - testid = "status-last-command"
title = { lastCommandLabel }
>
2026-03-31 04:53:26 +02:00
< span className = "status-bar__strong" > Last : < / span > { lastCommandLabel }
2026-03-31 02:37:22 +02:00
< / div >
< / footer >
< input
2026-04-10 12:23:35 +02:00
ref = { importProjectInputRef }
2026-03-31 02:37:22 +02:00
className = "visually-hidden"
type = "file"
2026-04-10 12:23:35 +02:00
accept = { ` ${ PROJECT_PACKAGE_FILE_EXTENSION } ,.zip,application/zip ` }
onChange = { handleLoadProjectChange }
2026-03-31 17:44:25 +02:00
/ >
< 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 >
) ;
}