import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type MouseEvent as ReactMouseEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; import { createCreateBoxBrushCommand } from "../commands/create-box-brush-command"; import { createCreateConeBrushCommand } from "../commands/create-cone-brush-command"; import { createCreateRadialPrismBrushCommand } from "../commands/create-radial-prism-brush-command"; import { createCreateSceneCommand } from "../commands/create-scene-command"; import { createCreateTorusBrushCommand } from "../commands/create-torus-brush-command"; import { createCreateWedgeBrushCommand } from "../commands/create-wedge-brush-command"; import { createDeleteBoxBrushCommand } from "../commands/delete-box-brush-command"; import { createDeleteEntityCommand } from "../commands/delete-entity-command"; import { createDeleteProjectAssetCommand } from "../commands/delete-project-asset-command"; import { createDeleteSelectionCommand } from "../commands/delete-selection-command"; import { createDeleteTerrainCommand } from "../commands/delete-terrain-command"; import { createDuplicateSelectionCommand } from "../commands/duplicate-selection-command"; import { createImportAudioAssetCommand } from "../commands/import-audio-asset-command"; import { createImportBackgroundImageAssetCommand } from "../commands/import-background-image-asset-command"; import { createImportModelAssetCommand } from "../commands/import-model-asset-command"; import { createAddPathPointCommand } from "../commands/add-path-point-command"; import { createDeletePathCommand } from "../commands/delete-path-command"; import { createDeletePathPointCommand } from "../commands/delete-path-point-command"; import { createDeleteModelInstanceCommand } from "../commands/delete-model-instance-command"; import { createCommitTransformSessionCommand } from "../commands/commit-transform-session-command"; import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command"; import { createRotateBoxBrushCommand } from "../commands/rotate-box-brush-command"; import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command"; import { createSetBoxBrushAllFaceMaterialsCommand } from "../commands/set-box-brush-all-face-materials-command"; import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command"; import { createSetBoxBrushAuthoredStateCommand } from "../commands/set-box-brush-authored-state-command"; import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command"; import { createSetBoxBrushVolumeSettingsCommand } from "../commands/set-box-brush-volume-settings-command"; import { createSetEntityAuthoredStateCommand } from "../commands/set-entity-authored-state-command"; import { createSetEntityNameCommand } from "../commands/set-entity-name-command"; import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command"; import { createUpdateBoxBrushAllFaceUvsCommand } from "../commands/update-box-brush-all-face-uvs-command"; import { createSetActiveSceneCommand } from "../commands/set-active-scene-command"; import { createDeleteInteractionLinkCommand } from "../commands/delete-interaction-link-command"; import { createSetModelInstanceAuthoredStateCommand } from "../commands/set-model-instance-authored-state-command"; import { createSetModelInstanceNameCommand } from "../commands/set-model-instance-name-command"; import { createSetPathAuthoredStateCommand } from "../commands/set-path-authored-state-command"; import { createSetPathNameCommand } from "../commands/set-path-name-command"; import { createSetProjectNameCommand } from "../commands/set-project-name-command"; import { createSetProjectSchedulerCommand } from "../commands/set-project-scheduler-command"; import { createSetProjectSequencerCommand } from "../commands/set-project-sequencer-command"; import { createSetProjectSequencesCommand } from "../commands/set-project-sequences-command"; import { createSetProjectTimeSettingsCommand } from "../commands/set-project-time-settings-command"; import { createSetSceneLoadingScreenCommand } from "../commands/set-scene-loading-screen-command"; import { createSetSceneNameCommand } from "../commands/set-scene-name-command"; import { createSetWorldSettingsCommand } from "../commands/set-world-settings-command"; import { createUpsertEntityCommand } from "../commands/upsert-entity-command"; import { createUpsertModelInstanceCommand } from "../commands/upsert-model-instance-command"; import { createUpsertPathCommand } from "../commands/upsert-path-command"; import { createUpsertTerrainCommand } from "../commands/upsert-terrain-command"; import { createUpsertInteractionLinkCommand } from "../commands/upsert-interaction-link-command"; import { applySameKindSelectionClick, getSelectedBrushEdgeId, getSelectedBrushFaceId, getSelectedBrushVertexId, getSelectionDefaultActiveId, getSingleSelectedBrushId, getSingleSelectedEntityId, getSingleSelectedModelInstanceId, getSingleSelectedPathOwnerId, getSingleSelectedPathPoint, getSingleSelectedTerrainId, isBrushFaceSelected, isBrushSelected, isPathPointSelected, isPathSelected, isSelectionActiveId, isTerrainSelected, resolveSelectionActiveId, type EditorSelection } from "../core/selection"; import { clampTerrainBrushFalloff, clampTerrainPaintLayerIndex, clampTerrainBrushRadius, clampTerrainBrushStrength, createDefaultTerrainBrushSettings, getTerrainBrushToolLabel, type ArmedTerrainBrushState, type TerrainBrushStrokeCommit, type TerrainBrushTool } from "../core/terrain-brush"; import { createTransformSession, doesTransformSessionChangeTarget, getTransformAxisLabel, getTransformAxisSpaceLabel, getTransformOperationLabel, getTransformTargetLabel, resolveTransformTarget, supportsLocalTransformAxisConstraint, supportsTransformAxisConstraint, supportsTransformOperation, supportsTransformSurfaceSnapTarget, type ActiveTransformSession, type TransformAxis, type TransformOperation, type TransformSessionSource } from "../core/transform-session"; import type { Vec2, Vec3 } from "../core/vector"; import { MODEL_INSTANCE_COLLISION_MODES, areModelInstancesEqual, createModelInstance, createModelInstancePlacementPosition, DEFAULT_MODEL_INSTANCE_POSITION, DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, DEFAULT_MODEL_INSTANCE_SCALE, normalizeModelInstanceName, type ModelInstance, type ModelInstanceCollisionMode } from "../assets/model-instances"; import { getModelInstanceDisplayLabelById, getSortedModelInstanceDisplayLabels } from "../assets/model-instance-labels"; import { importAudioAssetFromFile, loadAudioAssetFromStorage, type LoadedAudioAsset } from "../assets/audio-assets"; import { importModelAssetFromFile, importModelAssetFromFiles, loadModelAssetFromStorage, disposeModelTemplate, type ImportedModelAssetResult, type LoadedModelAsset } from "../assets/gltf-model-import"; import { importBackgroundImageAssetFromFile, loadImageAssetFromStorage, disposeLoadedImageAsset, type ImportedImageAssetResult, type LoadedImageAsset } from "../assets/image-assets"; import type { AudioAssetRecord, ImageAssetRecord, ModelAssetRecord, ProjectAssetRecord } from "../assets/project-assets"; import { getProjectAssetKindLabel } from "../assets/project-assets"; import { getWhiteboxSelectionModeLabel, type WhiteboxSelectionMode } from "../core/whitebox-selection-mode"; import { BOX_BRUSH_LIGHT_FALLOFF_MODES, BOX_BRUSH_VOLUME_MODES, DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_ROTATION_DEGREES, DEFAULT_BOX_BRUSH_SIZE, DEFAULT_CONE_SIDE_COUNT, DEFAULT_TORUS_MAJOR_SEGMENT_COUNT, DEFAULT_TORUS_TUBE_SEGMENT_COUNT, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, createDefaultBoxBrushLightSettings, createDefaultFaceUvState, normalizeBrushName, type Brush, type BoxBrush, type BoxBrushLightFalloffMode, type BoxBrushVolumeMode, type FaceUvRotationQuarterTurns, type FaceUvState } from "../document/brushes"; import { ADVANCED_RENDERING_WATER_REFLECTION_MODES, BOX_VOLUME_RENDER_PATHS, ADVANCED_RENDERING_SHADOW_MAP_SIZES, ADVANCED_RENDERING_SHADOW_TYPES, ADVANCED_RENDERING_TONE_MAPPING_MODES, areWorldSettingsEqual, changeWorldBackgroundMode, cloneWorldSettings, DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY, DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY, resolveWorldCelestialOrbitPeakDirection, syncWorldShaderSkyDayGradientToBackground, type WorldBackgroundMode, type WorldBackgroundSettings, type AdvancedRenderingSettings, type BoxVolumeRenderPath, type AdvancedRenderingWaterReflectionMode, type AdvancedRenderingShadowMapSize, type AdvancedRenderingShadowType, type AdvancedRenderingToneMappingMode, type WorldShaderSkySettings, type WorldSettings } from "../document/world-settings"; import { areSceneLoadingScreenSettingsEqual, cloneSceneLoadingScreenSettings, createSceneDocumentFromProject, DEFAULT_PROJECT_NAME, type SceneLoadingScreenSettings } from "../document/scene-document"; import { MIN_SCENE_PATH_POINT_COUNT, areScenePathsEqual, createAppendedScenePathPoint, createScenePath, getScenePathLabel, getScenePathPointIndex, getScenePaths, normalizeScenePathName, type ScenePath, type ScenePathPoint } from "../document/paths"; import { areTerrainsEqual, createTerrain, getTerrainBounds, getTerrainFootprintDepth, getTerrainFootprintWidth, getTerrainKindLabel, getTerrainLayerLabel, getTerrains, MIN_TERRAIN_SAMPLE_COUNT, resizeTerrainGrid, TERRAIN_LAYER_COUNT, type Terrain } from "../document/terrains"; import { areProjectTimeSettingsEqual, cloneProjectTimeSettings, formatTimeOfDayHours, normalizeTimeOfDayHours, type ProjectTimeSettings } from "../document/project-time-settings"; import { formatSceneDiagnosticSummary, validateProjectDocument, validateSceneDocument } from "../document/scene-document-validation"; import { cloneProjectAssetStorageRecord, getBrowserProjectAssetStorageAccess, type ProjectAssetStorage, type ProjectAssetStorageRecord } from "../assets/project-asset-storage"; import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; import { createFitToFaceBoxBrushFaceUvState, createFitToMaterialTileBoxBrushFaceUvState } from "../geometry/box-face-uvs"; import { getBrushDefaultName, getBrushEdgeLabel, getBrushFaceIds, getBrushFaceLabel, getBrushVertexLabel } from "../geometry/whitebox-topology"; import { DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE, DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED, DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES, DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED, DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES, DEFAULT_CAMERA_RIG_PRIORITY, DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS, DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE, DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS, DEFAULT_CAMERA_RIG_TARGET_OFFSET, DEFAULT_CAMERA_RIG_TRACK_END_POINT, DEFAULT_CAMERA_RIG_TRACK_START_POINT, DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS, DEFAULT_CAMERA_RIG_TRANSITION_MODE, DEFAULT_ENTITY_POSITION, DEFAULT_INTERACTABLE_PROMPT, DEFAULT_INTERACTABLE_RADIUS, DEFAULT_NPC_MODEL_ASSET_ID, DEFAULT_NPC_YAW_DEGREES, DEFAULT_POINT_LIGHT_COLOR_HEX, DEFAULT_POINT_LIGHT_DISTANCE, DEFAULT_POINT_LIGHT_INTENSITY, DEFAULT_PLAYER_START_BOX_SIZE, DEFAULT_PLAYER_START_CAPSULE_HEIGHT, DEFAULT_PLAYER_START_CAPSULE_RADIUS, DEFAULT_PLAYER_START_EYE_HEIGHT, DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH as DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH_VALUE, DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES, DEFAULT_PLAYER_START_INTERACTION_REACH_METERS, DEFAULT_PLAYER_START_NAVIGATION_MODE, DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET as DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET_VALUE, PLAYER_START_MOVEMENT_TEMPLATE_KINDS, DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID, DEFAULT_SOUND_EMITTER_VOLUME, DEFAULT_SCENE_ENTRY_YAW_DEGREES, DEFAULT_SOUND_EMITTER_REF_DISTANCE, DEFAULT_SOUND_EMITTER_MAX_DISTANCE, DEFAULT_TELEPORT_TARGET_YAW_DEGREES, PLAYER_START_COLLIDER_MODES, PLAYER_START_GAMEPAD_ACTION_BINDINGS, PLAYER_START_GAMEPAD_CAMERA_LOOK_BINDINGS, PLAYER_START_GAMEPAD_BINDINGS, PLAYER_START_LOCOMOTION_ACTIONS, PLAYER_START_MOVEMENT_ACTIONS, PLAYER_START_SYSTEM_ACTIONS, PLAYER_START_NAVIGATION_MODES, DEFAULT_SPOT_LIGHT_ANGLE_DEGREES, DEFAULT_SPOT_LIGHT_COLOR_HEX, DEFAULT_SPOT_LIGHT_DISTANCE, DEFAULT_SPOT_LIGHT_DIRECTION, DEFAULT_SPOT_LIGHT_INTENSITY, DEFAULT_TRIGGER_VOLUME_SIZE, areEntityInstancesEqual, createCameraRigActorTargetRef, createCameraRigEntity, createCameraRigEntityTargetRef, createCameraRigPlayerTargetRef, createCameraRigWorldPointTargetRef, clonePlayerStartInputBindings, clonePlayerStartMovementTemplate, createInteractableEntity, createNpcAlwaysPresence, createNpcColliderSettings, createNpcEntity, createPointLightEntity, inferPlayerStartMovementTemplateKind, createPlayerStartInputBindings, createPlayerStartMovementTemplate, createPlayerStartEntity, createSceneEntryEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity, getEntityInstances, getEntityKindLabel, getPrimaryEnabledPlayerStartEntity, getPlayerStartMouseBindingCodeForButton, isPlayerStartMouseBindingCode, normalizeEntityName, normalizeYawDegrees, normalizeInteractablePrompt, resolveCameraRigDocumentPosition, type CameraRigRailPlacementMode, type CameraRigType, type CameraRigTargetKind, type CameraRigTransitionMode, type PlayerStartGamepadActionBinding, type PlayerStartGamepadCameraLookBinding, type PlayerStartColliderMode, type PlayerStartGamepadBinding, type PlayerStartInputAction, type PlayerStartInputBindings, type PlayerStartKeyboardBindingCode, type PlayerStartLocomotionAction, type PlayerStartMovementAction, type PlayerStartSystemAction, type PlayerStartMovementTemplate, type PlayerStartNavigationMode, type EntityInstance, type EntityKind } from "../entities/entity-instances"; import { createProjectDialogue, createProjectDialogueLine } from "../dialogues/project-dialogues"; import type { ProjectDialogue, ProjectDialogueLine } from "../dialogues/project-dialogues"; import { areProjectSequenceLibrariesEqual, cloneProjectSequenceLibrary, createProjectSequence, getProjectSequences, type ProjectSequence, type ProjectSequenceLibrary } from "../sequencer/project-sequences"; import { getEntityDisplayLabelById, getSortedEntityDisplayLabels } from "../entities/entity-labels"; import { listNpcActorUsages } from "../entities/npc-actor-registry"; import { areInteractionLinksEqual, createControlInteractionLink, createPlayAnimationInteractionLink, createRunSequenceInteractionLink, createPlaySoundInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, getInteractionLinksForSource, type InteractionLink, type InteractionTriggerKind } from "../interactions/interaction-links"; import { cloneControlEffect, formatControlEffectValue, formatControlTargetRef, getControlTargetRefKey, type ControlEffect } from "../controls/control-surface"; import { STARTER_MATERIAL_LIBRARY, getStarterMaterialPreviewUrl, getStarterMaterialTileSizeMeters, type MaterialDef } from "../materials/starter-material-library"; import { RunnerCanvas } from "../runner-web/RunnerCanvas"; import type { FirstPersonTelemetry, RuntimeLocomotionState } from "../runtime-three/navigation-controller"; import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system"; import { createDefaultRuntimeGlobalState, type RuntimeGlobalState } from "../runtime-three/runtime-global-state"; import type { RuntimeSceneTransitionRequest } from "../runtime-three/runtime-host"; import { areRuntimeClockStatesEqual, createRuntimeClockState, resolveRuntimeTimeState, type RuntimeClockState } from "../runtime-three/runtime-project-time"; import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; import { EditorSimulationController, type EditorSimulationUiSnapshot } from "../runtime-three/editor-simulation-controller"; import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation"; import { EditorAutosaveController } from "../serialization/editor-autosave"; import { Panel } from "../shared-ui/Panel"; import { NpcDialoguesPanel } from "./NpcDialoguesPanel"; import { ProjectSequencerPane } from "./ProjectSequencerPane"; import { cloneSequenceEffect, getProjectSequenceHeldSteps, getProjectSequenceImpulseSteps } from "../sequencer/project-sequence-steps"; import { areProjectSchedulersEqual, cloneProjectScheduleRoutine, cloneProjectScheduler, createProjectScheduleEveryDaySelection, createProjectScheduleRoutine, removeProjectScheduleRoutine, upsertProjectScheduleRoutine, type ProjectScheduleRoutine } from "../scheduler/project-scheduler"; import { createProjectScheduleEffectFromOption, getProjectScheduleEffectOptionId, getProjectSequenceControlStepClassForEffectOptionId, listProjectInteractionControlEffectOptions, listProjectInteractionControlTargetOptions, getProjectScheduleTargetOptionByKey, listProjectScheduleEffectOptions, listProjectScheduleTargetOptions, type ProjectScheduleEffectOptionId, type ProjectScheduleTargetOption } from "../scheduler/project-schedule-control-options"; import { loadProjectPackage, PROJECT_PACKAGE_FILE_EXTENSION, saveProjectPackage } from "../serialization/project-package"; import { HierarchicalMenu, type HierarchicalMenuItem, type HierarchicalMenuPosition } from "../shared-ui/HierarchicalMenu"; import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; import { ViewportPanel } from "../viewport-three/ViewportPanel"; import type { CreationViewportToolPreview } from "../viewport-three/viewport-transient-state"; import { getViewportViewModeLabel, type ViewportViewMode } from "../viewport-three/viewport-view-modes"; import { VIEWPORT_PANEL_IDS, getViewportDisplayModeLabel, getViewportLayoutModeLabel, getViewportPanelLabel, type ViewportDisplayMode, type ViewportLayoutMode, type ViewportPanelId, type ViewportQuadSplit } from "../viewport-three/viewport-layout"; import type { EditorStore } from "./editor-store"; import { useEditorStoreState } from "./use-editor-store"; interface AppProps { store: EditorStore; initialStatusMessage?: string; } interface Vec2Draft { x: string; y: string; } interface Vec3Draft { x: string; y: string; z: string; } const DEFAULT_SCHEDULE_PANE_HEIGHT = 320; const MIN_SCHEDULE_PANE_HEIGHT = 180; const MIN_VIEWPORT_REGION_HEIGHT = 180; interface PlayerStartMovementTemplateNumberDraft { moveSpeed: string; maxSpeed: string; maxStepHeight: string; jumpSpeed: string; jumpBufferMs: string; coyoteTimeMs: string; variableJumpMaxHoldMs: string; bunnyHopBoost: string; sprintSpeedMultiplier: string; crouchSpeedMultiplier: string; } type WorldTimePhaseKey = "dawn" | "dusk"; type WorldTimePhaseColorField = | "skyTopColorHex" | "skyBottomColorHex" | "ambientColorHex" | "lightColorHex"; type WorldTimePhaseNumericField = | "ambientIntensityFactor" | "lightIntensityFactor"; type WorldNightEnvironmentColorField = "ambientColorHex" | "lightColorHex"; type WorldNightEnvironmentNumericField = | "ambientIntensityFactor" | "lightIntensityFactor"; type InteractionSourceEntity = Extract< EntityInstance, { kind: "triggerVolume" | "interactable" | "npc" } >; function getInteractionSourceEntityLabel( entity: InteractionSourceEntity ): string { switch (entity.kind) { case "triggerVolume": return "Trigger Volume"; case "interactable": return "Interactable"; case "npc": return "NPC"; } } function getModelInstanceCollisionModeDescription( mode: ModelInstanceCollisionMode ): string { switch (mode) { case "none": return "No generated collider is built for this model instance."; case "terrain": return "Builds a Rapier heightfield from a regular-grid terrain mesh. Unsupported terrain sources fail with build diagnostics."; case "static": return "Builds a fixed Rapier triangle-mesh collider from the imported model geometry."; case "static-simple": return "Builds a fixed compound collider by voxel-boxifying the imported mesh surface into merged structural slabs."; 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."; } } function getCharacterColliderModeDescription( mode: PlayerStartColliderMode, subject: "Player Start" | "NPC" ): string { switch (mode) { case "capsule": return `Uses a capsule ${subject.toLowerCase()} collider for standard grounded traversal.`; case "box": return `Uses an axis-aligned box ${subject.toLowerCase()} collider for sharper footprint bounds.`; case "none": return `Disables ${subject.toLowerCase()} collision. ${subject} still renders and keeps its authored facing.`; } } function getPlayerStartColliderModeDescription( mode: PlayerStartColliderMode ): string { return getCharacterColliderModeDescription(mode, "Player Start"); } function getNpcColliderModeDescription(mode: PlayerStartColliderMode): string { return getCharacterColliderModeDescription(mode, "NPC"); } function getPlayerStartMovementTemplateLabel( kind: PlayerStartMovementTemplate["kind"] ): string { switch (kind) { case "default": return "Default"; case "responsive": return "Responsive"; case "custom": return "Custom"; } } function getPlayerStartMovementTemplateDescription( kind: PlayerStartMovementTemplate["kind"] ): string { switch (kind) { case "default": return "Shared movement basis for First Person and Third Person with real jump, sprint, and crouch support on the runtime controller path."; case "responsive": return "Adds authored jump assists like jump buffering, coyote time, and variable jump height while keeping the same core movement basis."; case "custom": return "Uses the exact authored movement settings from the controls below."; } } function getPlayerStartInputActionLabel( action: PlayerStartInputAction ): string { switch (action) { case "moveForward": return "Forward"; case "moveBackward": return "Backward"; case "moveLeft": return "Left"; case "moveRight": return "Right"; case "jump": return "Jump"; case "sprint": return "Sprint"; case "crouch": return "Crouch"; case "interact": return "Interact"; case "clearTarget": return "Clear Target"; case "pauseTime": return "Pause"; } } function formatPlayerStartKeyboardBindingLabel( code: PlayerStartKeyboardBindingCode ): string { if (/^Key[A-Z]$/.test(code)) { return code.slice(3); } if (/^Digit[0-9]$/.test(code)) { return code.slice(5); } switch (code) { case "MouseLeft": return "Left Mouse"; case "MouseMiddle": return "Middle Mouse"; case "MouseRight": return "Right Mouse"; case "MouseBack": return "Mouse Back"; case "MouseForward": return "Mouse Forward"; case "ArrowUp": return "Arrow Up"; case "ArrowLeft": return "Arrow Left"; case "ArrowDown": return "Arrow Down"; case "ArrowRight": return "Arrow Right"; case "Space": return "Space"; case "Tab": return "Tab"; case "Enter": return "Enter"; case "Backspace": return "Backspace"; case "Escape": return "Escape"; default: return code.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); } } function formatPlayerStartGamepadBindingLabel( binding: PlayerStartGamepadBinding ): string { switch (binding) { case "leftStickUp": return "Left Stick Up"; case "leftStickDown": return "Left Stick Down"; case "leftStickLeft": return "Left Stick Left"; case "leftStickRight": return "Left Stick Right"; case "dpadUp": return "D-Pad Up"; case "dpadDown": return "D-Pad Down"; case "dpadLeft": return "D-Pad Left"; case "dpadRight": return "D-Pad Right"; } } function formatPlayerStartGamepadActionBindingLabel( binding: PlayerStartGamepadActionBinding ): string { switch (binding) { case "buttonSouth": return "South Button"; case "buttonEast": return "East Button"; case "buttonWest": return "West Button"; case "buttonNorth": return "North Button"; case "buttonMenu": return "Menu / Start"; case "leftShoulder": return "Left Shoulder"; case "rightShoulder": return "Right Shoulder"; case "leftTrigger": return "Left Trigger"; case "rightTrigger": return "Right Trigger"; case "leftStickPress": return "Left Stick Press"; case "rightStickPress": return "Right Stick Press"; } } function formatPlayerStartGamepadCameraLookBindingLabel( binding: PlayerStartGamepadCameraLookBinding ): string { switch (binding) { case "rightStick": return "Right Stick"; } } const STARTER_MATERIAL_ORDER = new Map( STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index]) ); const TERRAIN_SCULPT_BRUSH_TOOLS: Exclude[] = [ "raise", "lower", "smooth", "flatten" ]; const MIN_VIEWPORT_QUAD_SPLIT = 0.2; const MAX_VIEWPORT_QUAD_SPLIT = 0.8; type ViewportQuadResizeMode = "vertical" | "horizontal" | "center"; type NumberInputStep = number | "any"; function formatVec3(vector: Vec3): string { return `${vector.x}, ${vector.y}, ${vector.z}`; } function resolveOptionalPositiveNumber( value: string, fallback: number ): number { const parsedValue = Number(value); return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : fallback; } function getWhiteboxInputStep(enabled: boolean, step: number): NumberInputStep { return enabled ? step : "any"; } function formatDiagnosticCount(count: number, label: string): string { return `${count} ${label}${count === 1 ? "" : "s"}`; } function clampViewportQuadSplitValue(value: number): number { return Math.min( MAX_VIEWPORT_QUAD_SPLIT, Math.max(MIN_VIEWPORT_QUAD_SPLIT, value) ); } function createViewportQuadPanelsStyle( viewportQuadSplit: ViewportQuadSplit ): CSSProperties { return { "--viewport-quad-split-x": String(viewportQuadSplit.x), "--viewport-quad-split-y": String(viewportQuadSplit.y) } as CSSProperties; } function getViewportQuadResizeCursor( resizeMode: ViewportQuadResizeMode ): string { switch (resizeMode) { case "vertical": return "col-resize"; case "horizontal": return "row-resize"; case "center": return "move"; } } function createVec2Draft(vector: Vec2): Vec2Draft { return { x: String(vector.x), y: String(vector.y) }; } function createMaybeMixedVec2Draft( values: readonly Vec2[], equals: (left: number, right: number) => boolean = (left, right) => left === right ): Vec2Draft { if (values.length === 0) { return createVec2Draft(createDefaultFaceUvState().offset); } const [firstValue, ...remainingValues] = values; return { x: remainingValues.every((value) => equals(value.x, firstValue.x)) ? String(firstValue.x) : "", y: remainingValues.every((value) => equals(value.y, firstValue.y)) ? String(firstValue.y) : "" }; } function createVec3Draft(vector: Vec3): Vec3Draft { return { x: String(vector.x), y: String(vector.y), z: String(vector.z) }; } function formatRuntimeDayPhaseLabel( dayPhase: ReturnType["dayPhase"] ): string { return dayPhase.charAt(0).toUpperCase() + dayPhase.slice(1); } function createInitialEditorSimulationUiSnapshot( time: ProjectTimeSettings ): EditorSimulationUiSnapshot { return { playing: false, overrideActive: false, clock: createRuntimeClockState(time), message: null, sceneReady: false, sceneVersion: 0, frameVersion: 0 }; } function createPlayerStartMovementTemplateNumberDraft( template: PlayerStartMovementTemplate ): PlayerStartMovementTemplateNumberDraft { return { moveSpeed: String(template.moveSpeed), maxSpeed: String(template.maxSpeed), maxStepHeight: String(template.maxStepHeight), jumpSpeed: String(template.jump.speed), jumpBufferMs: String(template.jump.bufferMs), coyoteTimeMs: String(template.jump.coyoteTimeMs), variableJumpMaxHoldMs: String(template.jump.maxHoldMs), bunnyHopBoost: String(template.jump.bunnyHopBoost), sprintSpeedMultiplier: String(template.sprint.speedMultiplier), crouchSpeedMultiplier: String(template.crouch.speedMultiplier) }; } function readVec2Draft(draft: Vec2Draft, label: string): Vec2 { if (draft.x.trim().length === 0 || draft.y.trim().length === 0) { throw new Error(`${label} values must be provided.`); } const vector = { x: Number(draft.x), y: Number(draft.y) }; if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y)) { throw new Error(`${label} values must be finite numbers.`); } return vector; } function readPositiveVec2Draft(draft: Vec2Draft, label: string): Vec2 { const vector = readVec2Draft(draft, label); if (vector.x <= 0 || vector.y <= 0) { throw new Error(`${label} values must remain positive.`); } return vector; } function 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; } function readVec3Draft(draft: Vec3Draft, label: string): Vec3 { const vector = { x: Number(draft.x), y: Number(draft.y), z: Number(draft.z) }; if ( !Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z) ) { throw new Error(`${label} values must be finite numbers.`); } return vector; } 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); } function readInteractablePromptDraft(source: string): string { return normalizeInteractablePrompt(source); } function readNonNegativeNumberDraft(source: string, label: string): number { const value = Number(source); if (!Number.isFinite(value) || value < 0) { throw new Error( `${label} must be a finite number greater than or equal to zero.` ); } return value; } 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; } function readWaterFoamContactLimitDraft(source: string): number { const value = readPositiveIntegerDraft(source, "Water foam contact limit"); if (value > MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT) { throw new Error( `Water foam contact limit must be ${MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT} or less.` ); } return value; } 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; } function assertProjectTimeSettingsAreOrdered(time: ProjectTimeSettings) { if (time.sunriseTimeOfDayHours >= time.sunsetTimeOfDayHours) { throw new Error("Project sunrise must be earlier than project sunset."); } } function areVec2Equal(left: Vec2, right: Vec2): boolean { return left.x === right.x && left.y === right.y; } function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } function maybeSnapVec3(vector: Vec3, enabled: boolean, step: number): Vec3 { if (!enabled) { return vector; } return { x: Math.round(vector.x / step) * step, y: Math.round(vector.y / step) * step, z: Math.round(vector.z / step) * step }; } function maybeSnapPositiveSize( size: Vec3, enabled: boolean, step: number ): Vec3 { const clampComponent = (value: number) => Math.max(0.01, Math.abs(value)); if (!enabled) { return { x: clampComponent(size.x), y: clampComponent(size.y), z: clampComponent(size.z) }; } return { x: Math.max(0.01, Math.round(Math.abs(size.x) / step) * step), y: Math.max(0.01, Math.round(Math.abs(size.y) / step) * step), z: Math.max(0.01, Math.round(Math.abs(size.z) / step) * step) }; } function areFaceUvStatesEqual(left: FaceUvState, right: FaceUvState): boolean { return ( areVec2Equal(left.offset, right.offset) && areVec2Equal(left.scale, right.scale) && left.rotationQuarterTurns === right.rotationQuarterTurns && left.flipU === right.flipU && left.flipV === right.flipV ); } function getSharedBrushFaceMaterialId(brush: Brush): string | null | undefined { const faceIds = getBrushFaceIds(brush); const firstFace = brush.faces[faceIds[0]]; if (firstFace === undefined) { return null; } const firstMaterialId = firstFace.materialId; return faceIds .slice(1) .every((faceId) => brush.faces[faceId]?.materialId === firstMaterialId) ? firstMaterialId : undefined; } function getSelectedBoxBrush( selection: EditorSelection, brushes: Brush[] ): Brush | null { const selectedBrushId = getSingleSelectedBrushId(selection); if (selectedBrushId === null) { return null; } return brushes.find((brush) => brush.id === selectedBrushId) ?? null; } function getSelectedEntity( selection: EditorSelection, entities: EntityInstance[] ): EntityInstance | null { const selectedEntityId = getSingleSelectedEntityId(selection); if (selectedEntityId === null) { return null; } return entities.find((entity) => entity.id === selectedEntityId) ?? null; } function getSelectedModelInstance( selection: EditorSelection, modelInstances: ModelInstance[] ): ModelInstance | null { const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection); if (selectedModelInstanceId === null) { return null; } return ( modelInstances.find( (modelInstance) => modelInstance.id === selectedModelInstanceId ) ?? null ); } function getSelectedPath( selection: EditorSelection, paths: ScenePath[] ): ScenePath | null { const selectedPathId = getSingleSelectedPathOwnerId(selection); if (selectedPathId === null) { return null; } return paths.find((path) => path.id === selectedPathId) ?? null; } function getSelectedTerrain( selection: EditorSelection, terrains: Terrain[] ): Terrain | null { const selectedTerrainId = getSingleSelectedTerrainId(selection); if (selectedTerrainId === null) { return null; } return terrains.find((terrain) => terrain.id === selectedTerrainId) ?? null; } function getSelectedPathPointState( selection: EditorSelection, path: ScenePath | null ): { point: ScenePathPoint; index: number } | null { const selectedPathPoint = getSingleSelectedPathPoint(selection); if ( selectedPathPoint === null || path === null || selectedPathPoint.pathId !== path.id ) { return null; } const pointIndex = getScenePathPointIndex(path, selectedPathPoint.pointId); if (pointIndex === -1) { return null; } return { point: path.points[pointIndex], index: pointIndex }; } function isModelAsset(asset: ProjectAssetRecord): asset is ModelAssetRecord { return asset.kind === "model"; } function isImageAsset(asset: ProjectAssetRecord): asset is ImageAssetRecord { return asset.kind === "image"; } function isAudioAsset(asset: ProjectAssetRecord): asset is AudioAssetRecord { return asset.kind === "audio"; } 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; return `Bounds ${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)} m`; } function formatModelAssetSummary(asset: ModelAssetRecord): string { const details = [ asset.metadata.format.toUpperCase(), formatByteLength(asset.byteLength), `${asset.metadata.meshCount} mesh${asset.metadata.meshCount === 1 ? "" : "es"}`, `${asset.metadata.materialNames.length} material${asset.metadata.materialNames.length === 1 ? "" : "s"}`, `${asset.metadata.textureNames.length} texture${asset.metadata.textureNames.length === 1 ? "" : "s"}` ]; if (asset.metadata.animationNames.length > 0) { details.push( `${asset.metadata.animationNames.length} animation${asset.metadata.animationNames.length === 1 ? "" : "s"}` ); } return details.join(" | "); } 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(" | "); } function formatAudioAssetSummary(asset: AudioAssetRecord): string { const details = [ asset.metadata.durationSeconds === null ? "duration unavailable" : `${asset.metadata.durationSeconds.toFixed(2)}s`, asset.metadata.channelCount === null ? "channels unavailable" : `${asset.metadata.channelCount} channel${asset.metadata.channelCount === 1 ? "" : "s"}`, asset.metadata.sampleRateHz === null ? "sample rate unavailable" : `${asset.metadata.sampleRateHz} Hz`, formatByteLength(asset.byteLength) ]; return details.join(" | "); } function formatProjectAssetSummary(asset: ProjectAssetRecord): string { return asset.kind === "model" ? formatModelAssetSummary(asset) : asset.kind === "image" ? formatImageAssetSummary(asset) : formatAudioAssetSummary(asset); } 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(" | ")}`; } function getBrushLabel(brush: Brush, index: number): string { return brush.name ?? getBrushDefaultName(brush, index); } function getPathLabelById(pathId: string, paths: ScenePath[]): string { const pathIndex = paths.findIndex((path) => path.id === pathId); return pathIndex === -1 ? "Path" : getScenePathLabel(paths[pathIndex], pathIndex); } function getTerrainLabel(terrain: Terrain, index: number): string { return terrain.name ?? `Terrain ${index + 1}`; } function getTerrainLabelById(terrainId: string, terrains: Terrain[]): string { const terrainIndex = terrains.findIndex( (terrain) => terrain.id === terrainId ); return terrainIndex === -1 ? getTerrainKindLabel() : getTerrainLabel(terrains[terrainIndex], terrainIndex); } function formatAuthoredObjectStateSummary(state: { visible: boolean; enabled: boolean; }): string | null { const parts: string[] = []; if (!state.enabled) { parts.push("Disabled"); } if (!state.visible) { parts.push("Hidden"); } return parts.length === 0 ? null : parts.join(" | "); } function getBrushLabelById(brushId: string, brushes: Brush[]): string { const brushIndex = brushes.findIndex((brush) => brush.id === brushId); return brushIndex === -1 ? "Whitebox Solid" : getBrushLabel(brushes[brushIndex], brushIndex); } function getSelectedBrushLabel( selection: EditorSelection, brushes: Brush[], activeSelectionId: string | null ): string { const selectedBrushId = selection.kind === "brushes" ? resolveSelectionActiveId(selection, activeSelectionId) : getSingleSelectedBrushId(selection); if (selectedBrushId === null) { return "No solid selected"; } return getBrushLabelById(selectedBrushId, brushes); } function describeSelection( selection: EditorSelection, brushes: Brush[], terrains: Terrain[], paths: ScenePath[], modelInstances: Record, assets: Record, entities: Record, activeSelectionId: string | null ): string { switch (selection.kind) { case "none": return "No authored selection"; case "brushes": return `${selection.ids.length} solid${selection.ids.length === 1 ? "" : "s"} selected (${getSelectedBrushLabel(selection, brushes, activeSelectionId)})`; case "brushFace": { const brush = brushes.find( (candidate) => candidate.id === selection.brushId ); const faceLabel = brush === undefined ? selection.faceId : getBrushFaceLabel(brush, selection.faceId); return `1 face selected (${faceLabel} on ${getBrushLabelById(selection.brushId, brushes)})`; } case "brushEdge": { const brush = brushes.find( (candidate) => candidate.id === selection.brushId ); const edgeLabel = brush === undefined ? selection.edgeId : getBrushEdgeLabel(brush, selection.edgeId); return `1 edge selected (${edgeLabel} on ${getBrushLabelById(selection.brushId, brushes)})`; } case "brushVertex": { const brush = brushes.find( (candidate) => candidate.id === selection.brushId ); const vertexLabel = brush === undefined ? selection.vertexId : getBrushVertexLabel(brush, selection.vertexId); return `1 vertex selected (${vertexLabel} on ${getBrushLabelById(selection.brushId, brushes)})`; } case "terrains": return `${selection.ids.length} terrain${selection.ids.length === 1 ? "" : "s"} selected (${getTerrainLabelById(resolveSelectionActiveId(selection, activeSelectionId) ?? selection.ids[0], terrains)})`; case "paths": return `${selection.ids.length} path${selection.ids.length === 1 ? "" : "s"} selected (${getPathLabelById(selection.ids[0], paths)})`; case "pathPoint": { const path = paths.find( (candidatePath) => candidatePath.id === selection.pathId ); const pointIndex = path === undefined ? -1 : getScenePathPointIndex(path, selection.pointId); const pointLabel = pointIndex === -1 ? "Path Point" : `Point ${pointIndex + 1}`; return `${pointLabel} selected (${getPathLabelById(selection.pathId, paths)})`; } case "entities": return `${selection.ids.length} entit${selection.ids.length === 1 ? "y" : "ies"} selected (${getEntityDisplayLabelById(resolveSelectionActiveId(selection, activeSelectionId) ?? selection.ids[0], entities, assets)})`; case "modelInstances": return `${selection.ids.length} model instance${selection.ids.length === 1 ? "" : "s"} selected (${getModelInstanceDisplayLabelById(resolveSelectionActiveId(selection, activeSelectionId) ?? selection.ids[0], modelInstances, assets)})`; default: return "Unknown selection"; } } function getMultiSelectionSummary( selection: EditorSelection, activeSelectionId: string | null, brushes: Brush[], modelInstances: Record, assets: Record, entities: Record ): { kindLabel: string; count: number; activeId: string; activeLabel: string; selectedItems: Array<{ id: string; label: string }>; } | null { const resolvedActiveSelectionId = resolveSelectionActiveId( selection, activeSelectionId ); if (resolvedActiveSelectionId === null) { return null; } switch (selection.kind) { case "brushes": if (selection.ids.length <= 1) { return null; } return { kindLabel: "Whitebox Solids", count: selection.ids.length, activeId: resolvedActiveSelectionId, activeLabel: getBrushLabelById(resolvedActiveSelectionId, brushes), selectedItems: selection.ids.map((id) => ({ id, label: getBrushLabelById(id, brushes) })) }; case "entities": if (selection.ids.length <= 1) { return null; } return { kindLabel: "Entities", count: selection.ids.length, activeId: resolvedActiveSelectionId, activeLabel: getEntityDisplayLabelById( resolvedActiveSelectionId, entities, assets ), selectedItems: selection.ids.map((id) => ({ id, label: getEntityDisplayLabelById(id, entities, assets) })) }; case "modelInstances": if (selection.ids.length <= 1) { return null; } return { kindLabel: "Model Instances", count: selection.ids.length, activeId: resolvedActiveSelectionId, activeLabel: getModelInstanceDisplayLabelById( resolvedActiveSelectionId, modelInstances, assets ), selectedItems: selection.ids.map((id) => ({ id, label: getModelInstanceDisplayLabelById(id, modelInstances, assets) })) }; default: return null; } } 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."; } } function resolveWhiteboxSelectionModeShortcut( event: globalThis.KeyboardEvent ): WhiteboxSelectionMode | null { if (event.altKey || event.ctrlKey || event.metaKey) { return null; } if (event.key === "^" || (event.shiftKey && event.code === "Digit6")) { return "object"; } switch (event.code) { case "Digit1": return "face"; case "Digit2": return "edge"; case "Digit3": return "vertex"; default: return null; } } function getInteractionTriggerLabel(trigger: InteractionTriggerKind): string { switch (trigger) { case "enter": return "On Enter"; case "exit": return "On Exit"; case "click": return "On Click"; } } function getInteractionActionLabel(link: InteractionLink): string { switch (link.action.type) { case "teleportPlayer": return "Teleport Player"; case "toggleVisibility": return "Toggle Visibility"; case "playAnimation": return "Play Animation"; case "stopAnimation": return "Stop Animation"; case "playSound": return "Play Sound"; case "stopSound": return "Stop Sound"; case "runSequence": return "Run Sequence"; case "control": return "Control Effect"; } } function isLegacyInteractionActionType( actionType: InteractionLink["action"]["type"] ): boolean { return actionType !== "runSequence" && actionType !== "control"; } function getVisibilityModeSelectValue( visible: boolean | undefined ): "toggle" | "show" | "hide" { if (visible === true) { return "show"; } if (visible === false) { return "hide"; } return "toggle"; } function readVisibilityModeSelectValue( value: "toggle" | "show" | "hide" ): boolean | undefined { switch (value) { case "toggle": return undefined; case "show": return true; case "hide": return false; } } function commitOnEnter( event: ReactKeyboardEvent, commit: () => void ) { if (event.key !== "Enter") { return; } event.currentTarget.blur(); commit(); } function getControlEffectNumericValue(effect: ControlEffect): number | null { switch (effect.type) { case "setSoundVolume": return effect.volume; case "setLightIntensity": case "setAmbientLightIntensity": case "setSunLightIntensity": return effect.intensity; default: return null; } } function getControlEffectColorValue(effect: ControlEffect): string | null { switch (effect.type) { case "setLightColor": case "setAmbientLightColor": case "setSunLightColor": return effect.colorHex; default: return null; } } function readSequenceVisibilityTargetKey(value: string): | { kind: "brush"; brushId: string; } | { kind: "modelInstance"; modelInstanceId: string; } { if (value.startsWith("brush:")) { const brushId = value.slice("brush:".length).trim(); if (brushId.length === 0) { throw new Error("Visibility target brush id must be non-empty."); } return { kind: "brush", brushId }; } if (value.startsWith("modelInstance:")) { const modelInstanceId = value.slice("modelInstance:".length).trim(); if (modelInstanceId.length === 0) { throw new Error("Visibility target model instance id must be non-empty."); } return { kind: "modelInstance", modelInstanceId }; } throw new Error( "Sequence visibility targets must reference a brush or model instance." ); } function readSceneTransitionTargetKey(value: string): { targetSceneId: string; targetEntryEntityId: string; } { const separatorIndex = value.indexOf("::"); if (separatorIndex <= 0 || separatorIndex >= value.length - 2) { throw new Error( "Scene transition targets must reference a scene id and Scene Entry id." ); } const targetSceneId = value.slice(0, separatorIndex).trim(); const targetEntryEntityId = value.slice(separatorIndex + 2).trim(); if (targetSceneId.length === 0 || targetEntryEntityId.length === 0) { throw new Error( "Scene transition targets must reference a scene id and Scene Entry id." ); } return { targetSceneId, targetEntryEntityId }; } function getDefaultTriggerVolumeLinkTrigger( triggerOnEnter: boolean, triggerOnExit: boolean ): InteractionTriggerKind { if (triggerOnEnter) { return "enter"; } if (triggerOnExit) { return "exit"; } return "enter"; } function isInteractionSourceEntity( entity: EntityInstance | null ): entity is InteractionSourceEntity { return ( entity !== null && (entity.kind === "triggerVolume" || entity.kind === "interactable" || entity.kind === "npc") ); } function isSoundEmitterEntity( entity: EntityInstance | null ): entity is Extract { return entity !== null && entity.kind === "soundEmitter"; } function getDefaultInteractionLinkTrigger( sourceEntity: InteractionSourceEntity ): InteractionTriggerKind { return sourceEntity.kind === "triggerVolume" ? getDefaultTriggerVolumeLinkTrigger( sourceEntity.triggerOnEnter, sourceEntity.triggerOnExit ) : "click"; } function getCanonicalInteractionLinkTrigger( sourceEntity: InteractionSourceEntity, trigger: InteractionTriggerKind ): InteractionTriggerKind { if (sourceEntity.kind === "triggerVolume") { return trigger === "click" ? getDefaultInteractionLinkTrigger(sourceEntity) : trigger; } return "click"; } function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return "An unexpected error occurred."; } function isTextEntryTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false; } return ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable ); } function selectionCanBeDuplicated(selection: EditorSelection): boolean { switch (selection.kind) { case "brushes": case "paths": case "entities": case "modelInstances": return selection.ids.length > 0; case "terrains": return false; case "brushFace": case "brushEdge": case "brushVertex": return true; case "pathPoint": case "none": return false; } } function isCommitIncrementKey(key: string): boolean { return ( key === "ArrowUp" || key === "ArrowDown" || key === "PageUp" || key === "PageDown" ); } function blurActiveTextEntry() { const activeElement = document.activeElement; if ( !(activeElement instanceof HTMLElement) || !isTextEntryTarget(activeElement) ) { return; } activeElement.blur(); } function sortDocumentMaterials( materials: Record ): MaterialDef[] { return Object.values(materials).sort((left, right) => { const leftStarterIndex = STARTER_MATERIAL_ORDER.get(left.id) ?? Number.MAX_SAFE_INTEGER; const rightStarterIndex = STARTER_MATERIAL_ORDER.get(right.id) ?? Number.MAX_SAFE_INTEGER; if (leftStarterIndex !== rightStarterIndex) { return leftStarterIndex - rightStarterIndex; } return left.name.localeCompare(right.name); }); } function getMaterialPreviewStyle(material: MaterialDef): CSSProperties { return { backgroundColor: material.swatchColorHex, backgroundImage: `url("${getStarterMaterialPreviewUrl(material)}")`, backgroundPosition: "center", backgroundRepeat: "no-repeat", backgroundSize: "cover" }; } function rotateQuarterTurns( rotationQuarterTurns: FaceUvRotationQuarterTurns ): FaceUvRotationQuarterTurns { return ((rotationQuarterTurns + 1) % 4) as FaceUvRotationQuarterTurns; } function getTransformOperationPastTense(operation: TransformOperation): string { switch (operation) { case "translate": return "Moved"; case "rotate": return "Rotated"; case "scale": return "Scaled"; } } 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)}`; } function formatRunnerLocomotionMode( locomotionState: RuntimeLocomotionState | undefined ): string { switch (locomotionState?.locomotionMode) { case "grounded": return "Grounded"; case "airborne": return "Airborne"; case "flying": return "Flying"; case "swimming": return "Swimming"; default: return "n/a"; } } function formatRunnerGait( locomotionState: RuntimeLocomotionState | undefined ): string { switch (locomotionState?.gait) { case "idle": return "Idle"; case "walk": return "Walk"; case "sprint": return "Sprint"; case "crouch": return "Crouch"; default: return "n/a"; } } function formatRunnerAirborneKind( locomotionState: RuntimeLocomotionState | undefined ): string { switch (locomotionState?.airborneKind) { case "jumping": return "Jumping"; case "falling": return "Falling"; case null: case undefined: return "n/a"; } } function formatRunnerGroundContact( locomotionState: RuntimeLocomotionState | undefined ): string { if (locomotionState?.contact.groundNormal === null) { return "No ground"; } if (locomotionState === undefined) { return "n/a"; } const slope = locomotionState.contact.slopeDegrees; const distance = locomotionState.contact.groundDistance; return `${slope === null ? "?" : slope.toFixed(1)} deg @ ${distance === null ? "?" : distance.toFixed(2)}m`; } function formatRunnerMovementSignals( telemetry: FirstPersonTelemetry | null ): string { if (telemetry === null) { return "n/a"; } const activeSignals: string[] = []; if (telemetry.signals.jumpStarted) { activeSignals.push("jump"); } if (telemetry.signals.leftGround) { activeSignals.push("left ground"); } if (telemetry.signals.startedFalling) { activeSignals.push("fall"); } if (telemetry.signals.landed) { activeSignals.push("land"); } if (telemetry.signals.enteredWater) { activeSignals.push("enter water"); } if (telemetry.signals.exitedWater) { activeSignals.push("exit water"); } if (telemetry.signals.wallContactStarted) { activeSignals.push("wall"); } if (telemetry.signals.headBump) { activeSignals.push("head bump"); } return activeSignals.length > 0 ? activeSignals.join(", ") : "none"; } function formatRunnerAudioHook(telemetry: FirstPersonTelemetry | null): string { if (telemetry === null) { return "n/a"; } const underwaterAmount = telemetry.hooks.audio.underwaterAmount; if (underwaterAmount >= 0.99) { return "submerged"; } if (underwaterAmount <= 0.01) { return "dry"; } return `wet ${underwaterAmount.toFixed(2)}`; } function formatRunnerAnimationHook( telemetry: FirstPersonTelemetry | null ): string { if (telemetry === null) { return "n/a"; } const animationHook = telemetry.hooks.animation; if (animationHook.locomotionMode === "airborne") { return animationHook.airborneKind === "jumping" ? "jump" : "fall"; } if (animationHook.locomotionMode === "swimming") { return `swim ${animationHook.movementAmount.toFixed(2)}`; } return animationHook.moving ? `${animationHook.gait} ${animationHook.movementAmount.toFixed(2)}` : "idle"; } function formatWorldBackgroundLabel( background: WorldBackgroundSettings ): string { if (background.mode === "solid") { return "Solid"; } if (background.mode === "verticalGradient") { return "Vertical Gradient"; } if (background.mode === "shader") { return "Shader Sky"; } return "Image"; } function getWorldBackgroundImagePreviewUrl( background: WorldBackgroundSettings, loadedImageAssets: Record ): string | null { if (background.mode !== "image" || background.assetId.trim().length === 0) { return null; } return loadedImageAssets[background.assetId]?.previewUrl ?? null; } function describeWorldBackground( background: WorldBackgroundSettings, assets: Record, options: { emptyImageLabel?: string } = {} ): string { if (background.mode === "solid") { return background.colorHex; } if (background.mode === "verticalGradient") { return `${background.topColorHex} -> ${background.bottomColorHex}`; } if (background.mode === "shader") { return "Default Sky"; } if (background.assetId.trim().length === 0) { return options.emptyImageLabel ?? "Automatic fallback"; } return assets[background.assetId]?.sourceName ?? background.assetId; } function formatAdvancedRenderingShadowTypeLabel( type: AdvancedRenderingShadowType ): string { switch (type) { case "basic": return "Basic"; case "pcf": return "PCF"; case "pcfSoft": return "PCF Soft"; } } function formatAdvancedRenderingToneMappingLabel( mode: AdvancedRenderingToneMappingMode ): string { switch (mode) { case "none": return "None"; case "linear": return "Linear"; case "reinhard": return "Reinhard"; case "cineon": return "Cineon"; case "acesFilmic": return "ACES Filmic"; } } function formatAdvancedRenderingWaterReflectionModeLabel( mode: AdvancedRenderingWaterReflectionMode ): string { switch (mode) { case "none": return "Nothing"; case "world": return "World"; case "all": return "All"; } } function formatBoxVolumeModeLabel(mode: BoxBrushVolumeMode): string { switch (mode) { case "none": return "None"; case "water": return "Water"; case "fog": return "Fog"; case "light": return "Light"; } } function formatBoxLightFalloffLabel(mode: BoxBrushLightFalloffMode): string { switch (mode) { case "linear": return "Linear"; case "smoothstep": return "Smoothstep"; } } function formatBoxVolumeRenderPathLabel(path: BoxVolumeRenderPath): string { switch (path) { case "performance": return "Performance"; case "quality": return "Quality"; } } const DEFAULT_BOX_VOLUME_LIGHT_SETTINGS = createDefaultBoxBrushLightSettings(); function createProjectDownloadName(projectName: string): string { const slug = projectName .trim() .toLowerCase() .replace(/[^a-z0-9]+/gu, "-") .replace(/^-+|-+$/gu, ""); return `${slug.length > 0 ? slug : "project"}${PROJECT_PACKAGE_FILE_EXTENSION}`; } export function App({ store, initialStatusMessage }: AppProps) { const editorState = useEditorStoreState(store); const sceneList = Object.values(editorState.projectDocument.scenes); const activeProjectScene = editorState.projectDocument.scenes[editorState.activeSceneId]; if (activeProjectScene === undefined) { throw new Error( `Active scene ${editorState.activeSceneId} does not exist in the project document.` ); } const brushList = Object.values(editorState.document.brushes); const terrainList = getTerrains(editorState.document.terrains); const pathList = getScenePaths(editorState.document.paths); const layoutMode = editorState.viewportLayoutMode; const activePanelId = editorState.activeViewportPanelId; const viewportToolPreview = editorState.viewportTransientState.toolPreview; const transformSession = editorState.viewportTransientState.transformSession; const latestActiveTransformSessionRef = useRef( null ); const entityList = getEntityInstances(editorState.document.entities); const entityDisplayList = getSortedEntityDisplayLabels( editorState.document.entities, editorState.document.assets ); const primaryPlayerStart = getPrimaryEnabledPlayerStartEntity( editorState.document.entities ); const materialList = sortDocumentMaterials(editorState.document.materials); const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList); const selectedTerrain = getSelectedTerrain( editorState.selection, terrainList ); const selectedPath = getSelectedPath(editorState.selection, pathList); const selectedPathPointState = getSelectedPathPointState( editorState.selection, selectedPath ); const selectedPathPointIndex = selectedPathPointState?.index ?? null; const selectedEntity = getSelectedEntity(editorState.selection, entityList); const selectedModelInstance = getSelectedModelInstance( editorState.selection, Object.values(editorState.document.modelInstances) ); const multiSelectionSummary = getMultiSelectionSummary( editorState.selection, editorState.activeSelectionId, brushList, editorState.document.modelInstances, editorState.document.assets, editorState.document.entities ); const whiteboxSelectionMode = editorState.whiteboxSelectionMode; const whiteboxSnapEnabled = editorState.whiteboxSnapEnabled; const viewportGridVisible = editorState.viewportGridVisible; const selectedFaceId = getSelectedBrushFaceId(editorState.selection); const selectedEdgeId = getSelectedBrushEdgeId(editorState.selection); const selectedVertexId = getSelectedBrushVertexId(editorState.selection); const selectedFace = selectedBrush !== null && selectedFaceId !== null ? selectedBrush.faces[selectedFaceId] : null; const selectedFaceMaterial = selectedFace !== null && selectedFace.materialId !== null ? (editorState.document.materials[selectedFace.materialId] ?? null) : null; const selectedBrushSharedMaterialId = selectedBrush === null ? null : getSharedBrushFaceMaterialId(selectedBrush); const selectedBrushSharedMaterial = selectedBrushSharedMaterialId === undefined || selectedBrushSharedMaterialId === null ? null : (editorState.document.materials[selectedBrushSharedMaterialId] ?? null); const selectedBrushHasMixedFaceMaterials = selectedBrush !== null && selectedBrushSharedMaterialId === undefined; const selectedBrushFaceIds = selectedBrush === null ? [] : getBrushFaceIds(selectedBrush); const selectedBrushHasMixedFaceUvs = selectedBrush !== null ? selectedBrushFaceIds .slice(1) .some( (faceId) => !areFaceUvStatesEqual( selectedBrush.faces[selectedBrushFaceIds[0]].uv, selectedBrush.faces[faceId].uv ) ) : false; const materialInspectorScope = whiteboxSelectionMode === "object" && selectedBrush !== null ? "brush" : whiteboxSelectionMode === "face" && selectedBrush !== null && selectedFaceId !== null && selectedFace !== null ? "face" : null; const materialInspectorMaterialId = materialInspectorScope === "brush" ? selectedBrushSharedMaterialId : materialInspectorScope === "face" ? (selectedFace?.materialId ?? null) : null; const materialInspectorMaterial = materialInspectorScope === "brush" ? selectedBrushSharedMaterial : materialInspectorScope === "face" ? selectedFaceMaterial : null; const materialInspectorActiveLabel = materialInspectorScope === "brush" ? "Active Scope" : "Active Face"; const materialInspectorActiveValue = materialInspectorScope === "brush" ? "Whole Solid" : selectedFaceId === null ? null : selectedBrush === null ? selectedFaceId : getBrushFaceLabel(selectedBrush, selectedFaceId); const materialInspectorMaterialSummary = materialInspectorScope === "brush" && selectedBrushHasMixedFaceMaterials ? "Mixed across faces" : (materialInspectorMaterial?.name ?? (materialInspectorMaterialId === null ? "Fallback face color" : (materialInspectorMaterialId ?? "Fallback face color"))); const materialInspectorUvState = materialInspectorScope === "brush" && selectedBrush !== null ? selectedBrush.faces[selectedBrushFaceIds[0]].uv : materialInspectorScope === "face" ? (selectedFace?.uv ?? null) : null; const selectedModelAsset = selectedModelInstance !== null ? (editorState.document.assets[selectedModelInstance.assetId] ?? null) : null; const selectedModelAssetRecord = selectedModelAsset !== null && selectedModelAsset.kind === "model" ? selectedModelAsset : null; const selectedTerrainBounds = selectedTerrain === null ? null : getTerrainBounds(selectedTerrain); const selectedTerrainFootprint = selectedTerrain === null ? null : { width: getTerrainFootprintWidth(selectedTerrain), depth: getTerrainFootprintDepth(selectedTerrain) }; const selectedTerrainHeightRange = selectedTerrainBounds === null ? null : { min: selectedTerrainBounds.min.y, max: selectedTerrainBounds.max.y }; const selectedPlayerStart = selectedEntity?.kind === "playerStart" ? selectedEntity : null; const selectedCameraRig = selectedEntity?.kind === "cameraRig" ? selectedEntity : null; const selectedSoundEmitter = isSoundEmitterEntity(selectedEntity) ? selectedEntity : null; const selectedSoundEmitterAsset = selectedSoundEmitter === null ? null : selectedSoundEmitter.audioAssetId === null ? null : (editorState.document.assets[selectedSoundEmitter.audioAssetId] ?? null); const selectedSoundEmitterAudioAssetRecord = selectedSoundEmitterAsset !== null && selectedSoundEmitterAsset.kind === "audio" ? selectedSoundEmitterAsset : null; const selectedTriggerVolume = selectedEntity?.kind === "triggerVolume" ? selectedEntity : null; const selectedSceneEntry = selectedEntity?.kind === "sceneEntry" ? selectedEntity : null; const selectedNpc = selectedEntity?.kind === "npc" ? selectedEntity : null; const selectedNpcAsset = selectedNpc === null ? null : selectedNpc.modelAssetId === null ? null : (editorState.document.assets[selectedNpc.modelAssetId] ?? null); const selectedNpcModelAssetRecord = selectedNpcAsset !== null && selectedNpcAsset.kind === "model" ? selectedNpcAsset : null; const selectedNpcActorUsages = selectedNpc === null ? [] : listNpcActorUsages(editorState.projectDocument, selectedNpc.actorId); const selectedNpcOtherActorUsages = selectedNpc === null ? [] : selectedNpcActorUsages.filter( (usage) => !( usage.sceneId === editorState.activeSceneId && usage.entityId === selectedNpc.id ) ); const selectedNpcSameSceneActorUsages = selectedNpc === null ? [] : selectedNpcOtherActorUsages.filter( (usage) => usage.sceneId === editorState.activeSceneId ); const selectedNpcOtherSceneActorUsages = selectedNpc === null ? [] : selectedNpcOtherActorUsages.filter( (usage) => usage.sceneId !== editorState.activeSceneId ); const projectScheduleTargetOptions = listProjectScheduleTargetOptions( editorState.projectDocument ); const interactionControlTargetOptions = listProjectInteractionControlTargetOptions(projectScheduleTargetOptions); const selectedTeleportTarget = selectedEntity?.kind === "teleportTarget" ? selectedEntity : null; const selectedInteractable = selectedEntity?.kind === "interactable" ? selectedEntity : null; const projectAssetList = Object.values(editorState.document.assets); const projectAssetDisplayList = [...projectAssetList].sort( (left, right) => left.kind.localeCompare(right.kind) || left.sourceName.localeCompare(right.sourceName) ); const modelAssetList = projectAssetList.filter(isModelAsset); const imageAssetList = projectAssetList.filter(isImageAsset); const audioAssetList = projectAssetList.filter(isAudioAsset); const selectedPointLight = selectedEntity?.kind === "pointLight" ? selectedEntity : null; const selectedSpotLight = selectedEntity?.kind === "spotLight" ? selectedEntity : null; const modelInstanceDisplayList = getSortedModelInstanceDisplayLabels( editorState.document.modelInstances, editorState.document.assets ); const projectSequenceList = getProjectSequences( editorState.projectDocument.sequences ); const projectImpulseSequenceList = projectSequenceList.filter( (sequence) => getProjectSequenceImpulseSteps(sequence).length > 0 ); const npcDialogueSequenceTargetOptions = Object.values( editorState.projectDocument.scenes ) .flatMap((scene) => Object.values(scene.entities).flatMap((entity) => { if (entity.kind !== "npc" || entity.dialogues.length === 0) { return []; } const npcLabel = (entity.name?.trim() || entity.id).trim(); return [ { npcEntityId: entity.id, label: `${scene.name} · ${npcLabel}`, defaultDialogueId: entity.defaultDialogueId, dialogues: entity.dialogues.map((dialogue) => ({ dialogueId: dialogue.id, label: dialogue.title })) } ]; }) ) .sort((left, right) => left.label.localeCompare(right.label)); const selectedInteractionSource = isInteractionSourceEntity(selectedEntity) ? selectedEntity : null; const selectedTriggerVolumeLinks = selectedTriggerVolume === null ? [] : getInteractionLinksForSource( editorState.document.interactionLinks, selectedTriggerVolume.id ); const selectedInteractableLinks = selectedInteractable === null ? [] : getInteractionLinksForSource( editorState.document.interactionLinks, selectedInteractable.id ); const selectedNpcLinks = selectedNpc === null ? [] : getInteractionLinksForSource( editorState.document.interactionLinks, selectedNpc.id ); 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; label: string; }> ]; }) ) as Record< string, Array<{ entity: Extract; label: string; }> >; const teleportTargetOptions = entityDisplayList.filter( ({ entity }) => entity.kind === "teleportTarget" ); const sceneTransitionTargetOptions = sceneTargetOptions.flatMap((scene) => (sceneEntryOptionsBySceneId[scene.id] ?? []).map(({ entity, label }) => ({ targetKey: `${scene.id}::${entity.id}`, label: `${scene.name} · ${label}` })) ); const soundEmitterOptions = entityDisplayList.filter( ({ entity }) => entity.kind === "soundEmitter" ) as Array<{ entity: Extract; label: string; }>; const cameraRigActorOptions = Array.from( new Set( entityList .filter( (entity): entity is Extract => entity.kind === "npc" ) .map((entity) => entity.actorId) ) ).sort((left, right) => left.localeCompare(right)); const cameraRigEntityTargetOptions = entityDisplayList.filter( ({ entity }) => entity.id !== selectedCameraRig?.id && entity.kind !== "cameraRig" ); const cameraRigPathOptions = pathList.map((path, index) => ({ path, label: getScenePathLabel(path, index) })); const getCameraRigPathById = (pathId: string) => editorState.document.paths[pathId] ?? cameraRigPathOptions[0]?.path ?? null; const getDefaultCameraRigRailMappingDraft = (pathId: string) => { const authoredPath = getCameraRigPathById(pathId); const startPoint = authoredPath?.points[0]?.position ?? DEFAULT_CAMERA_RIG_TRACK_START_POINT; const endPoint = authoredPath?.points[authoredPath.points.length - 1]?.position ?? DEFAULT_CAMERA_RIG_TRACK_END_POINT; return { trackStartPoint: createVec3Draft(startPoint), trackEndPoint: createVec3Draft(endPoint), railStartProgress: String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS), railEndProgress: String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS) }; }; const playableSoundEmitterOptions = soundEmitterOptions.filter( ({ entity }) => { if (entity.audioAssetId === null) { return false; } return editorState.document.assets[entity.audioAssetId]?.kind === "audio"; } ); const visibilityBrushOptions = brushList.map((brush, brushIndex) => ({ brush, label: getBrushLabel(brush, brushIndex) })); const sequenceVisibilityTargetOptions = [ ...visibilityBrushOptions.map(({ brush, label }) => ({ targetKey: `brush:${brush.id}`, label: `Whitebox Solid · ${label}` })), ...modelInstanceDisplayList.map(({ modelInstance, label }) => ({ targetKey: `modelInstance:${modelInstance.id}`, label: `Model Instance · ${label}` })) ]; const [projectNameDraft, setProjectNameDraft] = useState( editorState.projectDocument.name ); const [sceneNameDraft, setSceneNameDraft] = useState( editorState.document.name ); const [sceneLoadingHeadlineDraft, setSceneLoadingHeadlineDraft] = useState( activeProjectScene.loadingScreen.headline ?? "" ); const [sceneLoadingDescriptionDraft, setSceneLoadingDescriptionDraft] = useState(activeProjectScene.loadingScreen.description ?? ""); const [brushNameDraft, setBrushNameDraft] = useState(""); const [pathNameDraft, setPathNameDraft] = useState(""); const [entityNameDraft, setEntityNameDraft] = useState(""); const [modelInstanceNameDraft, setModelInstanceNameDraft] = useState(""); const [pathPointDrafts, setPathPointDrafts] = useState([]); const [positionDraft, setPositionDraft] = useState( createVec3Draft(DEFAULT_BOX_BRUSH_CENTER) ); const [rotationDraft, setRotationDraft] = useState( createVec3Draft(DEFAULT_BOX_BRUSH_ROTATION_DEGREES) ); const [sizeDraft, setSizeDraft] = useState( createVec3Draft(DEFAULT_BOX_BRUSH_SIZE) ); const [boxVolumeModeDraft, setBoxVolumeModeDraft] = useState("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"); const [boxVolumeLightColorDraft, setBoxVolumeLightColorDraft] = useState( DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.colorHex ); const [boxVolumeLightIntensityDraft, setBoxVolumeLightIntensityDraft] = useState(String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.intensity)); const [boxVolumeLightPaddingDraft, setBoxVolumeLightPaddingDraft] = useState( String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.padding) ); const [boxVolumeLightFalloffDraft, setBoxVolumeLightFalloffDraft] = useState( DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.falloff ); const [whiteboxSnapStepDraft, setWhiteboxSnapStepDraft] = useState( String(editorState.whiteboxSnapStep) ); const [uvOffsetDraft, setUvOffsetDraft] = useState( createVec2Draft(createDefaultFaceUvState().offset) ); const [uvScaleDraft, setUvScaleDraft] = useState( createVec2Draft(createDefaultFaceUvState().scale) ); const [entityPositionDraft, setEntityPositionDraft] = useState( createVec3Draft(DEFAULT_ENTITY_POSITION) ); const [cameraRigRigTypeDraft, setCameraRigRigTypeDraft] = useState("fixed"); const [cameraRigPathIdDraft, setCameraRigPathIdDraft] = useState(""); const [cameraRigRailPlacementModeDraft, setCameraRigRailPlacementModeDraft] = useState( DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE ); const [cameraRigTrackStartPointDraft, setCameraRigTrackStartPointDraft] = useState(createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_START_POINT)); const [cameraRigTrackEndPointDraft, setCameraRigTrackEndPointDraft] = useState(createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_END_POINT)); const [cameraRigRailStartProgressDraft, setCameraRigRailStartProgressDraft] = useState(String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS)); const [cameraRigRailEndProgressDraft, setCameraRigRailEndProgressDraft] = useState(String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS)); const [cameraRigPriorityDraft, setCameraRigPriorityDraft] = useState( String(DEFAULT_CAMERA_RIG_PRIORITY) ); const [cameraRigDefaultActiveDraft, setCameraRigDefaultActiveDraft] = useState(DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE); const [cameraRigTargetKindDraft, setCameraRigTargetKindDraft] = useState("player"); const [cameraRigTargetActorIdDraft, setCameraRigTargetActorIdDraft] = useState(""); const [cameraRigTargetEntityIdDraft, setCameraRigTargetEntityIdDraft] = useState(""); const [cameraRigTargetWorldPointDraft, setCameraRigTargetWorldPointDraft] = useState(createVec3Draft(DEFAULT_ENTITY_POSITION)); const [cameraRigTargetOffsetDraft, setCameraRigTargetOffsetDraft] = useState( createVec3Draft(DEFAULT_CAMERA_RIG_TARGET_OFFSET) ); const [cameraRigTransitionModeDraft, setCameraRigTransitionModeDraft] = useState(DEFAULT_CAMERA_RIG_TRANSITION_MODE); const [ cameraRigTransitionDurationDraft, setCameraRigTransitionDurationDraft ] = useState(String(DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS)); const [cameraRigLookAroundEnabledDraft, setCameraRigLookAroundEnabledDraft] = useState(DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED); const [ cameraRigLookAroundYawLimitDraft, setCameraRigLookAroundYawLimitDraft ] = useState(String(DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES)); const [ cameraRigLookAroundPitchLimitDraft, setCameraRigLookAroundPitchLimitDraft ] = useState(String(DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES)); const [ cameraRigLookAroundRecenterSpeedDraft, setCameraRigLookAroundRecenterSpeedDraft ] = useState(String(DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED)); 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) ); const [playerStartYawDraft, setPlayerStartYawDraft] = useState("0"); const [playerStartNavigationModeDraft, setPlayerStartNavigationModeDraft] = useState(DEFAULT_PLAYER_START_NAVIGATION_MODE); const [ playerStartInteractionReachDraft, setPlayerStartInteractionReachDraft ] = useState(String(DEFAULT_PLAYER_START_INTERACTION_REACH_METERS)); const [ playerStartInteractionAngleDraft, setPlayerStartInteractionAngleDraft ] = useState(String(DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES)); const [ playerStartAllowLookInputTargetSwitchDraft, setPlayerStartAllowLookInputTargetSwitchDraft ] = useState(DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH); const [ playerStartTargetButtonCyclesActiveTargetDraft, setPlayerStartTargetButtonCyclesActiveTargetDraft ] = useState(DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET); const [ playerStartMovementTemplateDraft, setPlayerStartMovementTemplateDraft ] = useState( createPlayerStartMovementTemplate() ); const [ playerStartMovementTemplateNumberDraft, setPlayerStartMovementTemplateNumberDraft ] = useState( createPlayerStartMovementTemplateNumberDraft( createPlayerStartMovementTemplate() ) ); const [playerStartColliderModeDraft, setPlayerStartColliderModeDraft] = useState("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 [playerStartInputBindingsDraft, setPlayerStartInputBindingsDraft] = useState(createPlayerStartInputBindings()); const [ playerStartKeyboardCaptureAction, setPlayerStartKeyboardCaptureAction ] = useState(null); 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); const [soundEmitterLoopDraft, setSoundEmitterLoopDraft] = useState(false); const [triggerVolumeSizeDraft, setTriggerVolumeSizeDraft] = useState( createVec3Draft(DEFAULT_TRIGGER_VOLUME_SIZE) ); const [sceneEntryYawDraft, setSceneEntryYawDraft] = useState( String(DEFAULT_SCENE_ENTRY_YAW_DEGREES) ); const [npcActorIdDraft, setNpcActorIdDraft] = useState(""); const [npcYawDraft, setNpcYawDraft] = useState( String(DEFAULT_NPC_YAW_DEGREES) ); const [npcColliderModeDraft, setNpcColliderModeDraft] = useState(createNpcColliderSettings().mode); const [npcEyeHeightDraft, setNpcEyeHeightDraft] = useState( String(createNpcColliderSettings().eyeHeight) ); const [npcCapsuleRadiusDraft, setNpcCapsuleRadiusDraft] = useState( String(createNpcColliderSettings().capsuleRadius) ); const [npcCapsuleHeightDraft, setNpcCapsuleHeightDraft] = useState( String(createNpcColliderSettings().capsuleHeight) ); const [npcBoxSizeDraft, setNpcBoxSizeDraft] = useState( createVec3Draft(createNpcColliderSettings().boxSize) ); const [npcModelAssetIdDraft, setNpcModelAssetIdDraft] = useState( DEFAULT_NPC_MODEL_ASSET_ID ?? "" ); const [teleportTargetYawDraft, setTeleportTargetYawDraft] = useState( String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES) ); const [interactableRadiusDraft, setInteractableRadiusDraft] = useState( String(DEFAULT_INTERACTABLE_RADIUS) ); const [interactablePromptDraft, setInteractablePromptDraft] = useState( DEFAULT_INTERACTABLE_PROMPT ); const [interactableEnabledDraft, setInteractableEnabledDraft] = useState(true); 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 [armedTerrainBrushTool, setArmedTerrainBrushTool] = useState(null); const [activeTerrainPaintLayerIndex, setActiveTerrainPaintLayerIndex] = useState(0); const [terrainBrushSettings, setTerrainBrushSettings] = useState( createDefaultTerrainBrushSettings() ); const [terrainSampleCountXDraft, setTerrainSampleCountXDraft] = useState("9"); const [terrainSampleCountZDraft, setTerrainSampleCountZDraft] = useState("9"); const [terrainCellSizeDraft, setTerrainCellSizeDraft] = useState("1"); const activeTerrainBrushState: ArmedTerrainBrushState | null = selectedTerrain === null || armedTerrainBrushTool === null ? null : armedTerrainBrushTool === "paint" ? { terrainId: selectedTerrain.id, tool: "paint", layerIndex: clampTerrainPaintLayerIndex( activeTerrainPaintLayerIndex ), radius: terrainBrushSettings.radius, strength: terrainBrushSettings.strength, falloff: terrainBrushSettings.falloff } : { terrainId: selectedTerrain.id, tool: armedTerrainBrushTool, radius: terrainBrushSettings.radius, strength: terrainBrushSettings.strength, falloff: terrainBrushSettings.falloff }; const resolvedTerrainPaintLayerIndex = clampTerrainPaintLayerIndex( activeTerrainPaintLayerIndex ); const selectedTerrainActivePaintLayer = selectedTerrain?.layers[resolvedTerrainPaintLayerIndex] ?? null; const selectedTerrainActivePaintMaterial = selectedTerrainActivePaintLayer?.materialId === null || selectedTerrainActivePaintLayer === null ? null : (editorState.document.materials[ selectedTerrainActivePaintLayer.materialId ] ?? null); const [ambientLightIntensityDraft, setAmbientLightIntensityDraft] = useState( String(editorState.document.world.ambientLight.intensity) ); const [sunLightIntensityDraft, setSunLightIntensityDraft] = useState( String(editorState.document.world.sunLight.intensity) ); const [projectTimeStartDayNumberDraft, setProjectTimeStartDayNumberDraft] = useState(String(editorState.projectDocument.time.startDayNumber)); const [projectTimeStartTimeOfDayDraft, setProjectTimeStartTimeOfDayDraft] = useState(String(editorState.projectDocument.time.startTimeOfDayHours)); const [ projectTimeDayLengthMinutesDraft, setProjectTimeDayLengthMinutesDraft ] = useState(String(editorState.projectDocument.time.dayLengthMinutes)); const [ projectTimeSunriseTimeOfDayDraft, setProjectTimeSunriseTimeOfDayDraft ] = useState(String(editorState.projectDocument.time.sunriseTimeOfDayHours)); const [projectTimeSunsetTimeOfDayDraft, setProjectTimeSunsetTimeOfDayDraft] = useState(String(editorState.projectDocument.time.sunsetTimeOfDayHours)); const [ projectTimeDawnDurationHoursDraft, setProjectTimeDawnDurationHoursDraft ] = useState(String(editorState.projectDocument.time.dawnDurationHours)); const [ projectTimeDuskDurationHoursDraft, setProjectTimeDuskDurationHoursDraft ] = useState(String(editorState.projectDocument.time.duskDurationHours)); const [schedulePaneOpen, setSchedulePaneOpen] = useState(false); const [sequencerMode, setSequencerMode] = useState<"timeline" | "sequence">( "timeline" ); const [selectedScheduleRoutineId, setSelectedScheduleRoutineId] = useState< string | null >(null); const [selectedNpcDialogueId, setSelectedNpcDialogueId] = useState< string | null >(null); const [selectedSequenceId, setSelectedSequenceId] = useState( null ); const [ worldDawnAmbientIntensityFactorDraft, setWorldDawnAmbientIntensityFactorDraft ] = useState( String(editorState.document.world.timeOfDay.dawn.ambientIntensityFactor) ); const [ worldDawnLightIntensityFactorDraft, setWorldDawnLightIntensityFactorDraft ] = useState( String(editorState.document.world.timeOfDay.dawn.lightIntensityFactor) ); const [ worldDawnBackgroundEnvironmentIntensityDraft, setWorldDawnBackgroundEnvironmentIntensityDraft ] = useState( editorState.document.world.timeOfDay.dawn.background.mode === "image" ? String( editorState.document.world.timeOfDay.dawn.background .environmentIntensity ) : String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY) ); const [ worldDuskAmbientIntensityFactorDraft, setWorldDuskAmbientIntensityFactorDraft ] = useState( String(editorState.document.world.timeOfDay.dusk.ambientIntensityFactor) ); const [ worldDuskLightIntensityFactorDraft, setWorldDuskLightIntensityFactorDraft ] = useState( String(editorState.document.world.timeOfDay.dusk.lightIntensityFactor) ); const [ worldDuskBackgroundEnvironmentIntensityDraft, setWorldDuskBackgroundEnvironmentIntensityDraft ] = useState( editorState.document.world.timeOfDay.dusk.background.mode === "image" ? String( editorState.document.world.timeOfDay.dusk.background .environmentIntensity ) : String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY) ); const [ worldNightAmbientIntensityFactorDraft, setWorldNightAmbientIntensityFactorDraft ] = useState( String(editorState.document.world.timeOfDay.night.ambientIntensityFactor) ); const [ worldNightLightIntensityFactorDraft, setWorldNightLightIntensityFactorDraft ] = useState( String(editorState.document.world.timeOfDay.night.lightIntensityFactor) ); const [ worldNightBackgroundEnvironmentIntensityDraft, setWorldNightBackgroundEnvironmentIntensityDraft ] = useState( editorState.document.world.timeOfDay.night.background.mode === "image" ? String( editorState.document.world.timeOfDay.night.background .environmentIntensity ) : String(DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY) ); const [ backgroundEnvironmentIntensityDraft, setBackgroundEnvironmentIntensityDraft ] = useState( editorState.document.world.background.mode === "image" ? String(editorState.document.world.background.environmentIntensity) : "0.5" ); const [ advancedRenderingShadowBiasDraft, setAdvancedRenderingShadowBiasDraft ] = useState( String(editorState.document.world.advancedRendering.shadows.bias) ); const [ advancedRenderingAmbientOcclusionIntensityDraft, setAdvancedRenderingAmbientOcclusionIntensityDraft ] = useState( String( editorState.document.world.advancedRendering.ambientOcclusion.intensity ) ); const [ advancedRenderingAmbientOcclusionRadiusDraft, setAdvancedRenderingAmbientOcclusionRadiusDraft ] = useState( String(editorState.document.world.advancedRendering.ambientOcclusion.radius) ); const [ advancedRenderingAmbientOcclusionSamplesDraft, setAdvancedRenderingAmbientOcclusionSamplesDraft ] = useState( String( editorState.document.world.advancedRendering.ambientOcclusion.samples ) ); const [ advancedRenderingBloomIntensityDraft, setAdvancedRenderingBloomIntensityDraft ] = useState( String(editorState.document.world.advancedRendering.bloom.intensity) ); const [ advancedRenderingBloomThresholdDraft, setAdvancedRenderingBloomThresholdDraft ] = useState( String(editorState.document.world.advancedRendering.bloom.threshold) ); const [ advancedRenderingBloomRadiusDraft, setAdvancedRenderingBloomRadiusDraft ] = useState( String(editorState.document.world.advancedRendering.bloom.radius) ); const [ advancedRenderingToneMappingExposureDraft, setAdvancedRenderingToneMappingExposureDraft ] = useState( String(editorState.document.world.advancedRendering.toneMapping.exposure) ); const [ advancedRenderingDepthOfFieldFocusDistanceDraft, setAdvancedRenderingDepthOfFieldFocusDistanceDraft ] = useState( String( editorState.document.world.advancedRendering.depthOfField.focusDistance ) ); const [ advancedRenderingDepthOfFieldFocalLengthDraft, setAdvancedRenderingDepthOfFieldFocalLengthDraft ] = useState( String( editorState.document.world.advancedRendering.depthOfField.focalLength ) ); const [ advancedRenderingDepthOfFieldBokehScaleDraft, setAdvancedRenderingDepthOfFieldBokehScaleDraft ] = useState( String(editorState.document.world.advancedRendering.depthOfField.bokehScale) ); const [ advancedRenderingWhiteboxBevelEdgeWidthDraft, setAdvancedRenderingWhiteboxBevelEdgeWidthDraft ] = useState( String(editorState.document.world.advancedRendering.whiteboxBevel.edgeWidth) ); const [ advancedRenderingWhiteboxBevelNormalStrengthDraft, setAdvancedRenderingWhiteboxBevelNormalStrengthDraft ] = useState( String( editorState.document.world.advancedRendering.whiteboxBevel.normalStrength ) ); const [statusMessage, setStatusMessage] = useState( initialStatusMessage ?? "Slice 3.5 advanced rendering ready." ); const [assetStatusMessage, setAssetStatusMessage] = useState( null ); const [hoveredAssetId, setHoveredAssetId] = useState(null); const [hoveredViewportPanelId, setHoveredViewportPanelId] = useState(null); const [addMenuPosition, setAddMenuPosition] = useState(null); const [activeNavigationMode, setActiveNavigationMode] = useState("thirdPerson"); const [projectAssetStorage, setProjectAssetStorage] = useState(null); const [projectAssetStorageReady, setProjectAssetStorageReady] = useState(false); const [runtimeScene, setRuntimeScene] = useState(null); const [runtimeSceneId, setRuntimeSceneId] = useState(null); const [runtimeSceneName, setRuntimeSceneName] = useState(null); const [runtimeSceneLoadingScreen, setRuntimeSceneLoadingScreen] = useState(null); const [runtimeGlobalState, setRuntimeGlobalState] = useState( createDefaultRuntimeGlobalState(editorState.projectDocument.time) ); const [runtimeMessage, setRuntimeMessage] = useState(null); const [editorSimulationSnapshot, setEditorSimulationSnapshot] = useState(() => createInitialEditorSimulationUiSnapshot(editorState.projectDocument.time) ); const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); const [runtimeInteractionPrompt, setRuntimeInteractionPrompt] = useState(null); const [loadedModelAssets, setLoadedModelAssets] = useState< Record >({}); const [loadedImageAssets, setLoadedImageAssets] = useState< Record >({}); const [loadedAudioAssets, setLoadedAudioAssets] = useState< Record >({}); const [focusRequest, setFocusRequest] = useState<{ id: number; selection: EditorSelection; panelId: ViewportPanelId; }>({ id: 0, panelId: "topLeft", selection: { kind: "none" } }); const importProjectInputRef = useRef(null); const importModelInputRef = useRef(null); const importBackgroundImageInputRef = useRef(null); const importAudioInputRef = useRef(null); const viewportPanelsRef = useRef(null); const editorMainRegionRef = useRef(null); const loadedModelAssetsRef = useRef>({}); const loadedImageAssetsRef = useRef>({}); const loadedAudioAssetsRef = useRef>({}); const previousProjectAssetsRef = useRef>( editorState.document.assets ); const deletedProjectAssetStorageRecordsRef = useRef< Record >({}); const autosaveControllerRef = useRef(null); const editorSimulationControllerRef = useRef(null); const lastAutosaveErrorRef = useRef(null); const viewportQuadSplitRef = useRef(editorState.viewportQuadSplit); const lastPointerPositionRef = useRef({ x: Math.round(window.innerWidth * 0.5), y: Math.round(window.innerHeight * 0.5) }); const [viewportQuadResizeMode, setViewportQuadResizeMode] = useState(null); const [schedulePaneHeight, setSchedulePaneHeight] = useState( DEFAULT_SCHEDULE_PANE_HEIGHT ); const schedulePaneHeightRef = useRef(DEFAULT_SCHEDULE_PANE_HEIGHT); const schedulePaneResizeStartRef = useRef<{ startY: number; startHeight: number; } | null>(null); const [schedulePaneResizeActive, setSchedulePaneResizeActive] = useState(false); if (editorSimulationControllerRef.current === null) { editorSimulationControllerRef.current = new EditorSimulationController(); } const editorSimulationController = editorSimulationControllerRef.current; const documentValidation = validateSceneDocument(editorState.document); const projectValidation = validateProjectDocument( editorState.projectDocument ); const activeSceneProjectDiagnostics = projectValidation.diagnostics.filter( (diagnostic) => diagnostic.path?.startsWith(`scenes.${editorState.activeSceneId}.`) && (diagnostic.code === "missing-scene-exit-target-scene" || diagnostic.code === "missing-scene-exit-target-entry" || diagnostic.code === "scene-exit-target-entry-kind-mismatch") ); const authoredNavigationMode: RuntimeNavigationMode = primaryPlayerStart?.navigationMode ?? "thirdPerson"; const runValidation = validateRuntimeSceneBuild(editorState.document, { navigationMode: authoredNavigationMode, loadedModelAssets }); const diagnostics = [ ...documentValidation.errors, ...documentValidation.warnings, ...activeSceneProjectDiagnostics, ...runValidation.errors, ...runValidation.warnings ]; const blockingDiagnostics = diagnostics.filter( (diagnostic) => diagnostic.severity === "error" ); const warningDiagnostics = diagnostics.filter( (diagnostic) => diagnostic.severity === "warning" ); const documentStatusLabel = documentValidation.errors.length === 0 ? "Valid" : formatDiagnosticCount(documentValidation.errors.length, "error"); const lastCommandLabel = editorState.lastCommandLabel ?? "No commands yet"; const runReadyLabel = blockingDiagnostics.length > 0 ? "Blocked" : authoredNavigationMode === "firstPerson" ? "Ready for First Person" : "Ready for Third Person"; const editorSimulationClock = editorSimulationSnapshot.clock ?? createRuntimeClockState(editorState.projectDocument.time); const editorSimulationPlaying = editorSimulationSnapshot.playing; const editorSimulationOverrideActive = editorSimulationSnapshot.overrideActive; const editorSimulationMessage = editorSimulationSnapshot.message; const editorSimulationTimeState = resolveRuntimeTimeState( editorState.projectDocument.time, editorSimulationClock ); const advancedRendering = editorState.document.world.advancedRendering; 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, editorState.activeSelectionId ).target; const canTranslateSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation(selectedTransformTarget, "translate"); const canRotateSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation(selectedTransformTarget, "rotate"); const canScaleSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation(selectedTransformTarget, "scale"); const surfaceSnapTransformTarget = transformSession.kind === "active" ? transformSession.target : selectedTransformTarget; const canSurfaceSnapTransformTarget = surfaceSnapTransformTarget !== null && supportsTransformSurfaceSnapTarget(surfaceSnapTransformTarget); const whiteboxSnapStep = editorState.whiteboxSnapStep; const whiteboxVectorInputStep = getWhiteboxInputStep( whiteboxSnapEnabled, whiteboxSnapStep ); useEffect(() => { if (transformSession.kind === "none") { latestActiveTransformSessionRef.current = null; return; } if ( latestActiveTransformSessionRef.current === null || latestActiveTransformSessionRef.current.id !== transformSession.id ) { latestActiveTransformSessionRef.current = transformSession; } }, [transformSession]); const clampSchedulePaneHeight = (nextHeight: number) => { const editorMainRegionHeight = editorMainRegionRef.current?.clientHeight ?? DEFAULT_SCHEDULE_PANE_HEIGHT + MIN_VIEWPORT_REGION_HEIGHT; const maxHeight = Math.max( 96, editorMainRegionHeight - MIN_VIEWPORT_REGION_HEIGHT ); const minHeight = Math.min(MIN_SCHEDULE_PANE_HEIGHT, maxHeight); return Math.min(Math.max(nextHeight, minHeight), maxHeight); }; const commitSchedulePaneHeight = ( nextHeight: number | ((previousHeight: number) => number) ) => { setSchedulePaneHeight((previousHeight) => { const resolvedHeight = typeof nextHeight === "function" ? nextHeight(previousHeight) : nextHeight; const clampedHeight = clampSchedulePaneHeight(resolvedHeight); schedulePaneHeightRef.current = clampedHeight; return clampedHeight; }); }; useEffect(() => { setPlayerStartKeyboardCaptureAction(null); }, [selectedPlayerStart?.id]); useEffect(() => { if (selectedNpc === null) { setSelectedNpcDialogueId(null); return; } if ( selectedNpcDialogueId !== null && selectedNpc.dialogues.some( (dialogue) => dialogue.id === selectedNpcDialogueId ) ) { return; } setSelectedNpcDialogueId( selectedNpc.defaultDialogueId ?? selectedNpc.dialogues[0]?.id ?? null ); }, [selectedNpc, selectedNpcDialogueId]); useEffect(() => { if ( selectedSequenceId !== null && editorState.projectDocument.sequences.sequences[selectedSequenceId] !== undefined ) { return; } setSelectedSequenceId(projectSequenceList[0]?.id ?? null); }, [ editorState.projectDocument.sequences.sequences, projectSequenceList, selectedSequenceId ]); useEffect(() => { schedulePaneHeightRef.current = schedulePaneHeight; }, [schedulePaneHeight]); useEffect(() => { if (!schedulePaneOpen) { setSchedulePaneResizeActive(false); schedulePaneResizeStartRef.current = null; return; } const syncSchedulePaneHeight = () => { commitSchedulePaneHeight(schedulePaneHeightRef.current); }; syncSchedulePaneHeight(); window.addEventListener("resize", syncSchedulePaneHeight); return () => { window.removeEventListener("resize", syncSchedulePaneHeight); }; }, [schedulePaneOpen]); useEffect(() => { if (!schedulePaneOpen || !schedulePaneResizeActive) { return; } const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; document.body.style.cursor = "row-resize"; document.body.style.userSelect = "none"; const handlePointerMove = (event: globalThis.PointerEvent) => { const resizeStart = schedulePaneResizeStartRef.current; if (resizeStart === null) { return; } commitSchedulePaneHeight( resizeStart.startHeight - (event.clientY - resizeStart.startY) ); }; const stopSchedulePaneResize = () => { schedulePaneResizeStartRef.current = null; setSchedulePaneResizeActive(false); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", stopSchedulePaneResize); window.addEventListener("pointercancel", stopSchedulePaneResize); return () => { document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", stopSchedulePaneResize); window.removeEventListener("pointercancel", stopSchedulePaneResize); }; }, [schedulePaneOpen, schedulePaneResizeActive]); useEffect(() => { if (playerStartKeyboardCaptureAction === null) { return; } const handleWindowKeyCapture = (event: globalThis.KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); if (event.repeat) { return; } if ( event.code === "Escape" && playerStartKeyboardCaptureAction !== "clearTarget" ) { setPlayerStartKeyboardCaptureAction(null); setStatusMessage("Cancelled Player Start key capture."); return; } const capturedCode = event.code.trim(); if (capturedCode.length === 0) { return; } handlePlayerStartKeyboardBindingChange( playerStartKeyboardCaptureAction, capturedCode ); setPlayerStartKeyboardCaptureAction(null); setStatusMessage( `Bound ${getPlayerStartInputActionLabel(playerStartKeyboardCaptureAction)} to ${formatPlayerStartKeyboardBindingLabel(capturedCode)}.` ); }; const handleWindowPointerCapture = (event: globalThis.PointerEvent) => { const capturedCode = getPlayerStartMouseBindingCodeForButton( event.button ); if (capturedCode === null) { return; } event.preventDefault(); event.stopPropagation(); handlePlayerStartKeyboardBindingChange( playerStartKeyboardCaptureAction, capturedCode ); setPlayerStartKeyboardCaptureAction(null); setStatusMessage( `Bound ${getPlayerStartInputActionLabel(playerStartKeyboardCaptureAction)} to ${formatPlayerStartKeyboardBindingLabel(capturedCode)}.` ); }; window.addEventListener("keydown", handleWindowKeyCapture, true); window.addEventListener("pointerdown", handleWindowPointerCapture, true); return () => { window.removeEventListener("keydown", handleWindowKeyCapture, true); window.removeEventListener( "pointerdown", handleWindowPointerCapture, true ); }; }, [playerStartKeyboardCaptureAction]); useEffect(() => { setProjectNameDraft(editorState.projectDocument.name); }, [editorState.projectDocument.name]); useEffect(() => { setSceneNameDraft(editorState.document.name); }, [editorState.document.name]); useEffect(() => { setWhiteboxSnapStepDraft(String(editorState.whiteboxSnapStep)); }, [editorState.activeSceneId, editorState.whiteboxSnapStep]); useEffect(() => { setSceneLoadingHeadlineDraft( activeProjectScene.loadingScreen.headline ?? "" ); setSceneLoadingDescriptionDraft( activeProjectScene.loadingScreen.description ?? "" ); }, [ activeProjectScene.id, activeProjectScene.loadingScreen.headline, activeProjectScene.loadingScreen.description ]); useEffect(() => { setBrushNameDraft(selectedBrush?.name ?? ""); }, [selectedBrush]); useEffect(() => { setPathNameDraft(selectedPath?.name ?? ""); }, [selectedPath]); useEffect(() => { setEntityNameDraft(selectedEntity?.name ?? ""); }, [selectedEntity]); useEffect(() => { setModelInstanceNameDraft(selectedModelInstance?.name ?? ""); }, [selectedModelInstance]); useEffect(() => { setPathPointDrafts( selectedPath === null ? [] : selectedPath.points.map((point) => createVec3Draft(point.position)) ); }, [selectedPath]); useEffect(() => { if (selectedBrush === null) { setPositionDraft(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); setRotationDraft(createVec3Draft(DEFAULT_BOX_BRUSH_ROTATION_DEGREES)); setSizeDraft(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); setBoxVolumeModeDraft("none"); setBoxVolumeWaterFoamContactLimitDraft( String(DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT) ); setBoxVolumeWaterSurfaceDisplacementEnabledDraft(false); setBoxVolumeLightColorDraft(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.colorHex); setBoxVolumeLightIntensityDraft( String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.intensity) ); setBoxVolumeLightPaddingDraft( String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.padding) ); setBoxVolumeLightFalloffDraft(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.falloff); return; } setPositionDraft(createVec3Draft(selectedBrush.center)); setRotationDraft(createVec3Draft(selectedBrush.rotationDegrees)); setSizeDraft(createVec3Draft(selectedBrush.size)); setBoxVolumeModeDraft(selectedBrush.volume.mode); if (selectedBrush.volume.mode === "water") { setBoxVolumeWaterColorDraft(selectedBrush.volume.water.colorHex); setBoxVolumeWaterSurfaceOpacityDraft( String(selectedBrush.volume.water.surfaceOpacity) ); setBoxVolumeWaterWaveStrengthDraft( String(selectedBrush.volume.water.waveStrength) ); setBoxVolumeWaterFoamContactLimitDraft( String(selectedBrush.volume.water.foamContactLimit) ); setBoxVolumeWaterSurfaceDisplacementEnabledDraft( selectedBrush.volume.water.surfaceDisplacementEnabled ); } if (selectedBrush.volume.mode === "fog") { setBoxVolumeFogColorDraft(selectedBrush.volume.fog.colorHex); setBoxVolumeFogDensityDraft(String(selectedBrush.volume.fog.density)); setBoxVolumeFogPaddingDraft(String(selectedBrush.volume.fog.padding)); } if (selectedBrush.volume.mode === "light") { setBoxVolumeLightColorDraft(selectedBrush.volume.light.colorHex); setBoxVolumeLightIntensityDraft( String(selectedBrush.volume.light.intensity) ); setBoxVolumeLightPaddingDraft(String(selectedBrush.volume.light.padding)); setBoxVolumeLightFalloffDraft(selectedBrush.volume.light.falloff); } }, [selectedBrush]); useEffect(() => { if ( whiteboxSelectionMode === "object" && selectedBrush !== null && selectedFace === null ) { setUvOffsetDraft( createMaybeMixedVec2Draft( selectedBrushFaceIds.map( (faceId) => selectedBrush.faces[faceId].uv.offset ) ) ); setUvScaleDraft( createMaybeMixedVec2Draft( selectedBrushFaceIds.map( (faceId) => selectedBrush.faces[faceId].uv.scale ) ) ); return; } 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)); }, [ selectedBrush, selectedBrushFaceIds, selectedFace, whiteboxSelectionMode ]); useEffect(() => { if (selectedEntity === null) { setEntityPositionDraft(createVec3Draft(DEFAULT_ENTITY_POSITION)); setCameraRigRigTypeDraft("fixed"); setCameraRigPathIdDraft(""); setCameraRigRailPlacementModeDraft( DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE ); setCameraRigTrackStartPointDraft( createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_START_POINT) ); setCameraRigTrackEndPointDraft( createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_END_POINT) ); setCameraRigRailStartProgressDraft( String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS) ); setCameraRigRailEndProgressDraft( String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS) ); setCameraRigPriorityDraft(String(DEFAULT_CAMERA_RIG_PRIORITY)); setCameraRigDefaultActiveDraft(DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE); setCameraRigTargetKindDraft("player"); setCameraRigTargetActorIdDraft(""); setCameraRigTargetEntityIdDraft(""); setCameraRigTargetWorldPointDraft( createVec3Draft(DEFAULT_ENTITY_POSITION) ); setCameraRigTargetOffsetDraft( createVec3Draft(DEFAULT_CAMERA_RIG_TARGET_OFFSET) ); setCameraRigTransitionModeDraft(DEFAULT_CAMERA_RIG_TRANSITION_MODE); setCameraRigTransitionDurationDraft( String(DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS) ); setCameraRigLookAroundEnabledDraft( DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED ); setCameraRigLookAroundYawLimitDraft( String(DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES) ); setCameraRigLookAroundPitchLimitDraft( String(DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES) ); setCameraRigLookAroundRecenterSpeedDraft( String(DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED) ); 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)); setPlayerStartYawDraft("0"); setPlayerStartNavigationModeDraft(DEFAULT_PLAYER_START_NAVIGATION_MODE); setPlayerStartInteractionReachDraft( String(DEFAULT_PLAYER_START_INTERACTION_REACH_METERS) ); setPlayerStartInteractionAngleDraft( String(DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES) ); setPlayerStartAllowLookInputTargetSwitchDraft( DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH ); setPlayerStartTargetButtonCyclesActiveTargetDraft( DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET ); setPlayerStartMovementTemplateDraft(createPlayerStartMovementTemplate()); setPlayerStartMovementTemplateNumberDraft( createPlayerStartMovementTemplateNumberDraft( createPlayerStartMovementTemplate() ) ); setPlayerStartColliderModeDraft("capsule"); setPlayerStartEyeHeightDraft(String(DEFAULT_PLAYER_START_EYE_HEIGHT)); setPlayerStartCapsuleRadiusDraft( String(DEFAULT_PLAYER_START_CAPSULE_RADIUS) ); setPlayerStartCapsuleHeightDraft( String(DEFAULT_PLAYER_START_CAPSULE_HEIGHT) ); setPlayerStartBoxSizeDraft( createVec3Draft(DEFAULT_PLAYER_START_BOX_SIZE) ); setPlayerStartInputBindingsDraft(createPlayerStartInputBindings()); setSoundEmitterAudioAssetIdDraft( DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ?? "" ); setSoundEmitterVolumeDraft(String(DEFAULT_SOUND_EMITTER_VOLUME)); setSoundEmitterRefDistanceDraft( String(DEFAULT_SOUND_EMITTER_REF_DISTANCE) ); setSoundEmitterMaxDistanceDraft( String(DEFAULT_SOUND_EMITTER_MAX_DISTANCE) ); setSoundEmitterAutoplayDraft(false); setSoundEmitterLoopDraft(false); setTriggerVolumeSizeDraft(createVec3Draft(DEFAULT_TRIGGER_VOLUME_SIZE)); setSceneEntryYawDraft(String(DEFAULT_SCENE_ENTRY_YAW_DEGREES)); setNpcActorIdDraft(""); setNpcYawDraft(String(DEFAULT_NPC_YAW_DEGREES)); setNpcColliderModeDraft(createNpcColliderSettings().mode); setNpcEyeHeightDraft(String(createNpcColliderSettings().eyeHeight)); setNpcCapsuleRadiusDraft( String(createNpcColliderSettings().capsuleRadius) ); setNpcCapsuleHeightDraft( String(createNpcColliderSettings().capsuleHeight) ); setNpcBoxSizeDraft(createVec3Draft(createNpcColliderSettings().boxSize)); setNpcModelAssetIdDraft(DEFAULT_NPC_MODEL_ASSET_ID ?? ""); setTeleportTargetYawDraft(String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES)); setInteractableRadiusDraft(String(DEFAULT_INTERACTABLE_RADIUS)); setInteractablePromptDraft(DEFAULT_INTERACTABLE_PROMPT); setInteractableEnabledDraft(true); return; } const selectedEntityPosition = selectedEntity.kind === "cameraRig" ? (resolveCameraRigDocumentPosition( selectedEntity, editorState.document.entities, editorState.document.paths, { fallbackToPathStart: true } ) ?? DEFAULT_ENTITY_POSITION) : selectedEntity.position; setEntityPositionDraft(createVec3Draft(selectedEntityPosition)); switch (selectedEntity.kind) { 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; case "cameraRig": setCameraRigRigTypeDraft(selectedEntity.rigType); setCameraRigPathIdDraft( selectedEntity.rigType === "rail" ? selectedEntity.pathId : "" ); setCameraRigRailPlacementModeDraft( selectedEntity.rigType === "rail" ? selectedEntity.railPlacementMode : DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE ); setCameraRigTrackStartPointDraft( selectedEntity.rigType === "rail" && selectedEntity.railPlacementMode === "mapTargetBetweenPoints" ? createVec3Draft(selectedEntity.trackStartPoint) : getDefaultCameraRigRailMappingDraft( selectedEntity.rigType === "rail" ? selectedEntity.pathId : "" ).trackStartPoint ); setCameraRigTrackEndPointDraft( selectedEntity.rigType === "rail" && selectedEntity.railPlacementMode === "mapTargetBetweenPoints" ? createVec3Draft(selectedEntity.trackEndPoint) : getDefaultCameraRigRailMappingDraft( selectedEntity.rigType === "rail" ? selectedEntity.pathId : "" ).trackEndPoint ); setCameraRigRailStartProgressDraft( selectedEntity.rigType === "rail" && selectedEntity.railPlacementMode === "mapTargetBetweenPoints" ? String(selectedEntity.railStartProgress) : String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS) ); setCameraRigRailEndProgressDraft( selectedEntity.rigType === "rail" && selectedEntity.railPlacementMode === "mapTargetBetweenPoints" ? String(selectedEntity.railEndProgress) : String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS) ); setCameraRigPriorityDraft(String(selectedEntity.priority)); setCameraRigDefaultActiveDraft(selectedEntity.defaultActive); setCameraRigTargetKindDraft(selectedEntity.target.kind); setCameraRigTargetActorIdDraft( selectedEntity.target.kind === "actor" ? selectedEntity.target.actorId : "" ); setCameraRigTargetEntityIdDraft( selectedEntity.target.kind === "entity" ? selectedEntity.target.entityId : "" ); setCameraRigTargetWorldPointDraft( createVec3Draft( selectedEntity.target.kind === "worldPoint" ? selectedEntity.target.point : DEFAULT_ENTITY_POSITION ) ); setCameraRigTargetOffsetDraft( createVec3Draft(selectedEntity.targetOffset) ); setCameraRigTransitionModeDraft(selectedEntity.transitionMode); setCameraRigTransitionDurationDraft( String(selectedEntity.transitionDurationSeconds) ); setCameraRigLookAroundEnabledDraft(selectedEntity.lookAround.enabled); setCameraRigLookAroundYawLimitDraft( String(selectedEntity.lookAround.yawLimitDegrees) ); setCameraRigLookAroundPitchLimitDraft( String(selectedEntity.lookAround.pitchLimitDegrees) ); setCameraRigLookAroundRecenterSpeedDraft( String(selectedEntity.lookAround.recenterSpeed) ); break; case "playerStart": setPlayerStartYawDraft(String(selectedEntity.yawDegrees)); setPlayerStartNavigationModeDraft(selectedEntity.navigationMode); setPlayerStartInteractionReachDraft( String(selectedEntity.interactionReachMeters) ); setPlayerStartInteractionAngleDraft( String(selectedEntity.interactionAngleDegrees) ); setPlayerStartAllowLookInputTargetSwitchDraft( selectedEntity.allowLookInputTargetSwitch ); setPlayerStartTargetButtonCyclesActiveTargetDraft( selectedEntity.targetButtonCyclesActiveTarget ); setPlayerStartMovementTemplateDraft( clonePlayerStartMovementTemplate(selectedEntity.movementTemplate) ); setPlayerStartMovementTemplateNumberDraft( createPlayerStartMovementTemplateNumberDraft( selectedEntity.movementTemplate ) ); setPlayerStartColliderModeDraft(selectedEntity.collider.mode); setPlayerStartEyeHeightDraft(String(selectedEntity.collider.eyeHeight)); setPlayerStartCapsuleRadiusDraft( String(selectedEntity.collider.capsuleRadius) ); setPlayerStartCapsuleHeightDraft( String(selectedEntity.collider.capsuleHeight) ); setPlayerStartBoxSizeDraft( createVec3Draft(selectedEntity.collider.boxSize) ); setPlayerStartInputBindingsDraft( clonePlayerStartInputBindings(selectedEntity.inputBindings) ); break; case "sceneEntry": setSceneEntryYawDraft(String(selectedEntity.yawDegrees)); break; case "npc": setNpcActorIdDraft(selectedEntity.actorId); setNpcYawDraft(String(selectedEntity.yawDegrees)); setNpcColliderModeDraft(selectedEntity.collider.mode); setNpcEyeHeightDraft(String(selectedEntity.collider.eyeHeight)); setNpcCapsuleRadiusDraft(String(selectedEntity.collider.capsuleRadius)); setNpcCapsuleHeightDraft(String(selectedEntity.collider.capsuleHeight)); setNpcBoxSizeDraft(createVec3Draft(selectedEntity.collider.boxSize)); setNpcModelAssetIdDraft(selectedEntity.modelAssetId ?? ""); break; case "soundEmitter": setSoundEmitterAudioAssetIdDraft(selectedEntity.audioAssetId ?? ""); setSoundEmitterVolumeDraft(String(selectedEntity.volume)); setSoundEmitterRefDistanceDraft(String(selectedEntity.refDistance)); setSoundEmitterMaxDistanceDraft(String(selectedEntity.maxDistance)); 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.interactionEnabled); break; } }, [editorState.projectDocument, selectedEntity]); useEffect(() => { if (selectedModelInstance === null) { setModelPositionDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_POSITION)); setModelRotationDraft( createVec3Draft(DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES) ); setModelScaleDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_SCALE)); return; } setModelPositionDraft(createVec3Draft(selectedModelInstance.position)); setModelRotationDraft( createVec3Draft(selectedModelInstance.rotationDegrees) ); setModelScaleDraft(createVec3Draft(selectedModelInstance.scale)); }, [selectedModelInstance]); useEffect(() => { if (selectedTerrain === null) { setTerrainSampleCountXDraft("9"); setTerrainSampleCountZDraft("9"); setTerrainCellSizeDraft("1"); return; } setTerrainSampleCountXDraft(String(selectedTerrain.sampleCountX)); setTerrainSampleCountZDraft(String(selectedTerrain.sampleCountZ)); setTerrainCellSizeDraft(String(selectedTerrain.cellSize)); }, [selectedTerrain]); useEffect(() => { const projectTime = editorState.projectDocument.time; setProjectTimeStartDayNumberDraft(String(projectTime.startDayNumber)); setProjectTimeStartTimeOfDayDraft(String(projectTime.startTimeOfDayHours)); setProjectTimeDayLengthMinutesDraft(String(projectTime.dayLengthMinutes)); setProjectTimeSunriseTimeOfDayDraft( String(projectTime.sunriseTimeOfDayHours) ); setProjectTimeSunsetTimeOfDayDraft( String(projectTime.sunsetTimeOfDayHours) ); setProjectTimeDawnDurationHoursDraft(String(projectTime.dawnDurationHours)); setProjectTimeDuskDurationHoursDraft(String(projectTime.duskDurationHours)); }, [editorState.projectDocument.time]); useEffect(() => { return editorSimulationController.subscribeUiSnapshot( setEditorSimulationSnapshot ); }, [editorSimulationController]); useEffect(() => { editorSimulationController.updateInputs({ document: editorState.document, loadedModelAssets }); }, [editorSimulationController, editorState.document, loadedModelAssets]); useEffect(() => { if (editorState.toolMode === "play") { editorSimulationController.pause(); } }, [editorSimulationController, editorState.toolMode]); useEffect(() => { return () => { editorSimulationController.dispose(); }; }, [editorSimulationController]); useEffect(() => { if (selectedScheduleRoutineId === null) { return; } if ( editorState.projectDocument.scheduler.routines[ selectedScheduleRoutineId ] !== undefined ) { return; } setSelectedScheduleRoutineId(null); }, [editorState.projectDocument.scheduler, selectedScheduleRoutineId]); useEffect(() => { const worldTimeOfDay = editorState.document.world.timeOfDay; setWorldDawnAmbientIntensityFactorDraft( String(worldTimeOfDay.dawn.ambientIntensityFactor) ); setWorldDawnLightIntensityFactorDraft( String(worldTimeOfDay.dawn.lightIntensityFactor) ); setWorldDawnBackgroundEnvironmentIntensityDraft( worldTimeOfDay.dawn.background.mode === "image" ? String(worldTimeOfDay.dawn.background.environmentIntensity) : String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY) ); setWorldDuskAmbientIntensityFactorDraft( String(worldTimeOfDay.dusk.ambientIntensityFactor) ); setWorldDuskLightIntensityFactorDraft( String(worldTimeOfDay.dusk.lightIntensityFactor) ); setWorldDuskBackgroundEnvironmentIntensityDraft( worldTimeOfDay.dusk.background.mode === "image" ? String(worldTimeOfDay.dusk.background.environmentIntensity) : String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY) ); setWorldNightAmbientIntensityFactorDraft( String(worldTimeOfDay.night.ambientIntensityFactor) ); setWorldNightLightIntensityFactorDraft( String(worldTimeOfDay.night.lightIntensityFactor) ); setWorldNightBackgroundEnvironmentIntensityDraft( worldTimeOfDay.night.background.mode === "image" ? String(worldTimeOfDay.night.background.environmentIntensity) : String(DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY) ); }, [editorState.document.world.timeOfDay]); useEffect(() => { setAmbientLightIntensityDraft( String(editorState.document.world.ambientLight.intensity) ); }, [editorState.document.world.ambientLight.intensity]); useEffect(() => { if (editorState.document.world.background.mode === "image") { setBackgroundEnvironmentIntensityDraft( String(editorState.document.world.background.environmentIntensity) ); } }, [editorState.document.world.background]); useEffect(() => { setSunLightIntensityDraft( String(editorState.document.world.sunLight.intensity) ); }, [editorState.document.world.sunLight.intensity]); useEffect(() => { const advancedRendering = editorState.document.world.advancedRendering; setAdvancedRenderingShadowBiasDraft(String(advancedRendering.shadows.bias)); setAdvancedRenderingAmbientOcclusionIntensityDraft( String(advancedRendering.ambientOcclusion.intensity) ); setAdvancedRenderingAmbientOcclusionRadiusDraft( String(advancedRendering.ambientOcclusion.radius) ); setAdvancedRenderingAmbientOcclusionSamplesDraft( String(advancedRendering.ambientOcclusion.samples) ); setAdvancedRenderingBloomIntensityDraft( String(advancedRendering.bloom.intensity) ); setAdvancedRenderingBloomThresholdDraft( String(advancedRendering.bloom.threshold) ); setAdvancedRenderingBloomRadiusDraft( String(advancedRendering.bloom.radius) ); setAdvancedRenderingToneMappingExposureDraft( String(advancedRendering.toneMapping.exposure) ); setAdvancedRenderingDepthOfFieldFocusDistanceDraft( String(advancedRendering.depthOfField.focusDistance) ); setAdvancedRenderingDepthOfFieldFocalLengthDraft( String(advancedRendering.depthOfField.focalLength) ); setAdvancedRenderingDepthOfFieldBokehScaleDraft( String(advancedRendering.depthOfField.bokehScale) ); setAdvancedRenderingWhiteboxBevelEdgeWidthDraft( String(advancedRendering.whiteboxBevel.edgeWidth) ); setAdvancedRenderingWhiteboxBevelNormalStrengthDraft( String(advancedRendering.whiteboxBevel.normalStrength) ); }, [editorState.document.world.advancedRendering]); useEffect(() => { loadedImageAssetsRef.current = loadedImageAssets; }, [loadedImageAssets]); useEffect(() => { loadedModelAssetsRef.current = loadedModelAssets; }, [loadedModelAssets]); useEffect(() => { loadedAudioAssetsRef.current = loadedAudioAssets; }, [loadedAudioAssets]); useEffect(() => { viewportQuadSplitRef.current = editorState.viewportQuadSplit; }, [editorState.viewportQuadSplit]); useEffect(() => { let cancelled = false; void (async () => { const access = await getBrowserProjectAssetStorageAccess(); if (cancelled) { return; } setProjectAssetStorage(access.storage); setAssetStatusMessage(access.diagnostic); setProjectAssetStorageReady(true); })().catch((error) => { if (cancelled) { return; } setProjectAssetStorage(null); setProjectAssetStorageReady(true); setAssetStatusMessage(getErrorMessage(error)); }); return () => { cancelled = true; }; }, []); useEffect(() => { 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]); useEffect(() => { if (!projectAssetStorageReady) { return; } let cancelled = false; const currentAssets = editorState.document.assets; const previousAssets = previousProjectAssetsRef.current; const previousLoadedModelAssets = loadedModelAssetsRef.current; const previousLoadedImageAssets = loadedImageAssetsRef.current; const previousLoadedAudioAssets = loadedAudioAssetsRef.current; const previousLoadedModelAssetIds = new Set( Object.keys(previousLoadedModelAssets) ); const previousLoadedImageAssetIds = new Set( Object.keys(previousLoadedImageAssets) ); const previousLoadedAudioAssetIds = new Set( Object.keys(previousLoadedAudioAssets) ); const nextLoadedModelAssets: Record = {}; const nextLoadedImageAssets: Record = {}; const nextLoadedAudioAssets: Record = {}; const syncErrorMessages: string[] = []; const restoreDeletedStoredAsset = async ( storage: ProjectAssetStorage, asset: ProjectAssetRecord ) => { const deletedRecord = deletedProjectAssetStorageRecordsRef.current[asset.storageKey]; if (deletedRecord === undefined) { return false; } try { await storage.putAsset( asset.storageKey, cloneProjectAssetStorageRecord(deletedRecord) ); delete deletedProjectAssetStorageRecordsRef.current[asset.storageKey]; return true; } catch (error) { syncErrorMessages.push( `Stored data for ${asset.sourceName} could not be restored: ${getErrorMessage(error)}` ); return false; } }; const syncModelAsset = async ( storage: ProjectAssetStorage, asset: ModelAssetRecord ) => { try { return await loadModelAssetFromStorage(storage, asset); } catch (error) { if (await restoreDeletedStoredAsset(storage, asset)) { return loadModelAssetFromStorage(storage, asset); } throw error; } }; const syncImageAsset = async ( storage: ProjectAssetStorage | null, asset: ImageAssetRecord ) => { try { return await loadImageAssetFromStorage(storage, asset); } catch (error) { if ( storage !== null && (await restoreDeletedStoredAsset(storage, asset)) ) { return loadImageAssetFromStorage(storage, asset); } throw error; } }; const syncAudioAsset = async ( storage: ProjectAssetStorage, asset: AudioAssetRecord ) => { try { return await loadAudioAssetFromStorage(storage, asset); } catch (error) { if (await restoreDeletedStoredAsset(storage, asset)) { return loadAudioAssetFromStorage(storage, asset); } throw error; } }; const syncAssets = async () => { const storage = projectAssetStorage; for (const asset of Object.values(currentAssets)) { if (isModelAsset(asset)) { if (storage === null) { continue; } previousLoadedModelAssetIds.delete(asset.id); const cachedLoadedAsset = previousLoadedModelAssets[asset.id]; if ( cachedLoadedAsset !== undefined && cachedLoadedAsset.storageKey === asset.storageKey ) { nextLoadedModelAssets[asset.id] = cachedLoadedAsset; continue; } try { nextLoadedModelAssets[asset.id] = await syncModelAsset( storage, asset ); } catch (error) { syncErrorMessages.push( `Model asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}` ); } continue; } if (isImageAsset(asset)) { previousLoadedImageAssetIds.delete(asset.id); const cachedLoadedAsset = previousLoadedImageAssets[asset.id]; if ( cachedLoadedAsset !== undefined && cachedLoadedAsset.storageKey === asset.storageKey ) { nextLoadedImageAssets[asset.id] = cachedLoadedAsset; continue; } try { nextLoadedImageAssets[asset.id] = await syncImageAsset( storage, asset ); } catch (error) { syncErrorMessages.push( `Image asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}` ); } continue; } if (isAudioAsset(asset)) { if (storage === null) { continue; } previousLoadedAudioAssetIds.delete(asset.id); const cachedLoadedAsset = previousLoadedAudioAssets[asset.id]; if ( cachedLoadedAsset !== undefined && cachedLoadedAsset.storageKey === asset.storageKey ) { nextLoadedAudioAssets[asset.id] = cachedLoadedAsset; continue; } try { nextLoadedAudioAssets[asset.id] = await syncAudioAsset( storage, asset ); } catch (error) { syncErrorMessages.push( `Audio asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}` ); } } } if (cancelled) { for (const loadedAsset of Object.values(nextLoadedModelAssets)) { if (previousLoadedModelAssets[loadedAsset.assetId] !== loadedAsset) { disposeModelTemplate(loadedAsset.template); } } for (const loadedAsset of Object.values(nextLoadedImageAssets)) { if (previousLoadedImageAssets[loadedAsset.assetId] !== loadedAsset) { disposeLoadedImageAsset(loadedAsset); } } return; } const removedAssets = Object.values(previousAssets).filter( (asset) => currentAssets[asset.id] === undefined ); if (storage === null) { for (const assetId of previousLoadedModelAssetIds) { const removedAsset = previousLoadedModelAssets[assetId]; if (removedAsset !== undefined) { disposeModelTemplate(removedAsset.template); } } for (const assetId of previousLoadedImageAssetIds) { const removedAsset = previousLoadedImageAssets[assetId]; if (removedAsset !== undefined) { disposeLoadedImageAsset(removedAsset); } } loadedModelAssetsRef.current = nextLoadedModelAssets; loadedImageAssetsRef.current = nextLoadedImageAssets; loadedAudioAssetsRef.current = nextLoadedAudioAssets; previousProjectAssetsRef.current = currentAssets; setLoadedModelAssets(nextLoadedModelAssets); setLoadedImageAssets(nextLoadedImageAssets); setLoadedAudioAssets(nextLoadedAudioAssets); setAssetStatusMessage( syncErrorMessages.length === 0 ? null : syncErrorMessages.join(" | ") ); return; } for (const removedAsset of removedAssets) { try { const storedAsset = await storage.getAsset(removedAsset.storageKey); if (storedAsset !== null) { deletedProjectAssetStorageRecordsRef.current[ removedAsset.storageKey ] = cloneProjectAssetStorageRecord(storedAsset); await storage.deleteAsset(removedAsset.storageKey); } } catch (error) { syncErrorMessages.push( `Stored data for ${removedAsset.sourceName} could not be deleted: ${getErrorMessage(error)}` ); } } for (const assetId of previousLoadedModelAssetIds) { const removedAsset = previousLoadedModelAssets[assetId]; if (removedAsset !== undefined) { disposeModelTemplate(removedAsset.template); } } for (const assetId of previousLoadedImageAssetIds) { const removedAsset = previousLoadedImageAssets[assetId]; if (removedAsset !== undefined) { disposeLoadedImageAsset(removedAsset); } } loadedModelAssetsRef.current = nextLoadedModelAssets; loadedImageAssetsRef.current = nextLoadedImageAssets; loadedAudioAssetsRef.current = nextLoadedAudioAssets; previousProjectAssetsRef.current = currentAssets; setLoadedModelAssets(nextLoadedModelAssets); setLoadedImageAssets(nextLoadedImageAssets); setLoadedAudioAssets(nextLoadedAudioAssets); setAssetStatusMessage( syncErrorMessages.length === 0 ? null : syncErrorMessages.join(" | ") ); }; void syncAssets(); return () => { cancelled = true; }; }, [ editorState.document.assets, projectAssetStorage, projectAssetStorageReady ]); useEffect(() => { if (editorState.toolMode === "play") { return; } const handleWindowPointerMove = (event: globalThis.PointerEvent) => { lastPointerPositionRef.current = { x: event.clientX, y: event.clientY }; const hoveredViewportPanelElement = event.target instanceof Element ? event.target.closest("[data-viewport-panel-id]") : null; const hoveredPanelId = hoveredViewportPanelElement?.dataset.viewportPanelId; setHoveredViewportPanelId( hoveredPanelId === "topLeft" || hoveredPanelId === "topRight" || hoveredPanelId === "bottomLeft" || hoveredPanelId === "bottomRight" ? hoveredPanelId : null ); }; const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (isTextEntryTarget(event.target)) { return; } const hasPrimaryModifier = (event.metaKey || event.ctrlKey) && !event.altKey; if (hasPrimaryModifier && event.code === "KeyR" && !event.shiftKey) { event.preventDefault(); handleEnterPlayMode(); return; } if (hasPrimaryModifier && event.code === "KeyS") { event.preventDefault(); void handleSaveProject(); return; } if (hasPrimaryModifier && event.code === "KeyZ") { event.preventDefault(); if (event.shiftKey) { handleRedo(); } else { handleUndo(); } return; } if (hasPrimaryModifier && event.code === "KeyY") { event.preventDefault(); handleRedo(); return; } if (event.key === "Escape" && addMenuPosition !== null) { event.preventDefault(); setAddMenuPosition(null); return; } if (transformSession.kind === "active") { if (event.key === "Escape") { event.preventDefault(); cancelTransformSession(); return; } if (event.key === "Enter") { event.preventDefault(); const latestTransformSession = latestActiveTransformSessionRef.current; commitTransformSession( latestTransformSession?.id === transformSession.id ? latestTransformSession : 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; } } } if (event.key === "Escape" && editorState.toolMode === "create") { event.preventDefault(); store.setToolMode("select"); setStatusMessage("Cancelled the current creation preview."); return; } if (event.shiftKey && event.code === "KeyA") { event.preventDefault(); setAddMenuPosition({ x: lastPointerPositionRef.current.x, y: lastPointerPositionRef.current.y }); return; } 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; } const whiteboxSelectionModeShortcut = resolveWhiteboxSelectionModeShortcut(event); if (whiteboxSelectionModeShortcut !== null) { event.preventDefault(); handleWhiteboxSelectionModeChange(whiteboxSelectionModeShortcut); return; } } const isDeletionKey = event.key === "Delete" || event.key === "Backspace"; 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"; if (addMenuPosition !== null) { if (isDeletionKey) { event.preventDefault(); } return; } if (isDuplicateShortcut) { const duplicated = handleDuplicateSelection(); if (duplicated) { event.preventDefault(); } return; } const isAppendPathPointShortcut = event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && event.code === "KeyW"; if (isAppendPathPointShortcut) { if (editorState.toolMode === "select" && selectedPath !== null) { event.preventDefault(); handleAddPathPoint(); } return; } if (isDeleteShortcut) { if (editorState.toolMode !== "create") { const deleted = handleDeleteSelectedSceneItem(); if (deleted || isDeletionKey) { event.preventDefault(); } } else if (isDeletionKey) { event.preventDefault(); } return; } if ( event.code !== "NumpadComma" && !( event.key === "," && event.location === globalThis.KeyboardEvent.DOM_KEY_LOCATION_NUMPAD ) ) { return; } event.preventDefault(); if ( editorState.selection.kind === "none" && brushList.length === 0 && entityList.length === 0 ) { setStatusMessage("Nothing authored yet to frame in the viewport."); return; } setFocusRequest((current) => ({ id: current.id + 1, panelId: activePanelId, selection: editorState.selection })); setStatusMessage( editorState.selection.kind === "none" ? "Framed the authored scene in the viewport." : "Framed the current selection." ); }; document.addEventListener("pointermove", handleWindowPointerMove); window.addEventListener("pointermove", handleWindowPointerMove); window.addEventListener("keydown", handleWindowKeyDown); return () => { document.removeEventListener("pointermove", handleWindowPointerMove); window.removeEventListener("pointermove", handleWindowPointerMove); window.removeEventListener("keydown", handleWindowKeyDown); }; }, [ activePanelId, addMenuPosition, brushList.length, editorState.document, editorState.selection, editorState.toolMode, entityList.length, hoveredViewportPanelId, layoutMode, projectAssetStorage, projectAssetStorageReady, transformSession ]); useEffect(() => { if (layoutMode === "quad" || viewportQuadResizeMode === null) { return; } setViewportQuadResizeMode(null); }, [layoutMode, viewportQuadResizeMode]); useEffect(() => { if (layoutMode !== "quad" || viewportQuadResizeMode === null) { return; } const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; document.body.style.cursor = getViewportQuadResizeCursor( viewportQuadResizeMode ); document.body.style.userSelect = "none"; const handlePointerMove = (event: globalThis.PointerEvent) => { const viewportPanels = viewportPanelsRef.current; if (viewportPanels === null) { return; } const rect = viewportPanels.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return; } const nextViewportQuadSplit = { ...viewportQuadSplitRef.current }; if (viewportQuadResizeMode !== "horizontal") { nextViewportQuadSplit.x = clampViewportQuadSplitValue( (event.clientX - rect.left) / rect.width ); } if (viewportQuadResizeMode !== "vertical") { nextViewportQuadSplit.y = clampViewportQuadSplitValue( (event.clientY - rect.top) / rect.height ); } store.setViewportQuadSplit(nextViewportQuadSplit); }; const stopViewportResize = () => { setViewportQuadResizeMode(null); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", stopViewportResize); window.addEventListener("pointercancel", stopViewportResize); return () => { document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", stopViewportResize); window.removeEventListener("pointercancel", stopViewportResize); }; }, [layoutMode, store, viewportQuadResizeMode]); useEffect(() => { if (editorState.toolMode !== "play") { return; } const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (isTextEntryTarget(event.target)) { return; } if (event.key !== "Escape") { return; } const pointerCaptured = activeNavigationMode === "firstPerson" && firstPersonTelemetry?.pointerLocked === true; if (pointerCaptured) { return; } event.preventDefault(); handleExitPlayMode(); }; window.addEventListener("keydown", handleWindowKeyDown); return () => { window.removeEventListener("keydown", handleWindowKeyDown); }; }, [activeNavigationMode, editorState.toolMode, firstPersonTelemetry]); const applyProjectName = () => { const normalizedName = projectNameDraft.trim() || DEFAULT_PROJECT_NAME; if (normalizedName === editorState.projectDocument.name) { return; } store.executeCommand(createSetProjectNameCommand(normalizedName)); setStatusMessage(`Project renamed to ${normalizedName}.`); }; const applyProjectTimeSettings = ( nextTime: ProjectTimeSettings, label: string, successMessage: string, options: { resetEditorSimulation?: boolean; } = {} ) => { if ( areProjectTimeSettingsEqual(editorState.projectDocument.time, nextTime) ) { return; } try { store.executeCommand( createSetProjectTimeSettingsCommand({ label, time: nextTime }) ); if (options.resetEditorSimulation === true && !editorSimulationPlaying) { editorSimulationController.reset(); } setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyProjectScheduler = ( nextScheduler: typeof editorState.projectDocument.scheduler, label: string, successMessage: string ) => { if ( areProjectSchedulersEqual( editorState.projectDocument.scheduler, nextScheduler ) ) { return; } try { store.executeCommand( createSetProjectSchedulerCommand({ label, scheduler: nextScheduler }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyProjectSequences = ( nextSequences: ProjectSequenceLibrary, label: string, successMessage: string ) => { if ( areProjectSequenceLibrariesEqual( editorState.projectDocument.sequences, nextSequences ) ) { return; } try { store.executeCommand( createSetProjectSequencesCommand({ label, sequences: nextSequences }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyProjectSequencerState = ( nextScheduler: typeof editorState.projectDocument.scheduler, nextSequences: ProjectSequenceLibrary, label: string, successMessage: string ) => { if ( areProjectSchedulersEqual( editorState.projectDocument.scheduler, nextScheduler ) && areProjectSequenceLibrariesEqual( editorState.projectDocument.sequences, nextSequences ) ) { return; } try { store.executeCommand( createSetProjectSequencerCommand({ label, scheduler: nextScheduler, sequences: nextSequences }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const updateProjectSequences = ( label: string, successMessage: string, mutate: (sequences: ProjectSequenceLibrary) => void ) => { try { const nextSequences = cloneProjectSequenceLibrary( editorState.projectDocument.sequences ); mutate(nextSequences); applyProjectSequences(nextSequences, label, successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const updateProjectSequencerState = ( label: string, successMessage: string, mutate: ( scheduler: typeof editorState.projectDocument.scheduler, sequences: ProjectSequenceLibrary ) => void ) => { try { const nextScheduler = cloneProjectScheduler( editorState.projectDocument.scheduler ); const nextSequences = cloneProjectSequenceLibrary( editorState.projectDocument.sequences ); mutate(nextScheduler, nextSequences); applyProjectSequencerState( nextScheduler, nextSequences, label, successMessage ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const updateProjectTimeSettings = ( label: string, successMessage: string, mutate: (time: ProjectTimeSettings) => void, options: { resetEditorSimulation?: boolean; } = {} ) => { try { const nextTime = cloneProjectTimeSettings( editorState.projectDocument.time ); mutate(nextTime); assertProjectTimeSettingsAreOrdered(nextTime); applyProjectTimeSettings(nextTime, label, successMessage, options); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyProjectTimeStartDayNumber = () => { updateProjectTimeSettings( "Set project start day", `Runner sessions will begin on day ${projectTimeStartDayNumberDraft}.`, (time) => { time.startDayNumber = readPositiveIntegerDraft( projectTimeStartDayNumberDraft, "Project start day" ); }, { resetEditorSimulation: true } ); }; const applyProjectTimeStartTimeOfDay = () => { updateProjectTimeSettings( "Set project start time", `Project time will start at ${formatTimeOfDayHours(Number(projectTimeStartTimeOfDayDraft))}.`, (time) => { time.startTimeOfDayHours = normalizeTimeOfDayHours( readFiniteNumberDraft( projectTimeStartTimeOfDayDraft, "Project start time" ) ); }, { resetEditorSimulation: true } ); }; const applyProjectTimeDayLengthMinutes = () => { updateProjectTimeSettings( "Set project day length", `Project time will advance one full day every ${projectTimeDayLengthMinutesDraft} real minutes.`, (time) => { time.dayLengthMinutes = readPositiveNumberDraft( projectTimeDayLengthMinutesDraft, "Project day duration" ); } ); }; const applyProjectTimeSunriseTimeOfDay = () => { updateProjectTimeSettings( "Set project sunrise", `Project sunrise will occur at ${formatTimeOfDayHours(Number(projectTimeSunriseTimeOfDayDraft))}.`, (time) => { time.sunriseTimeOfDayHours = normalizeTimeOfDayHours( readFiniteNumberDraft( projectTimeSunriseTimeOfDayDraft, "Project sunrise" ) ); } ); }; const applyProjectTimeSunsetTimeOfDay = () => { updateProjectTimeSettings( "Set project sunset", `Project sunset will occur at ${formatTimeOfDayHours(Number(projectTimeSunsetTimeOfDayDraft))}.`, (time) => { time.sunsetTimeOfDayHours = normalizeTimeOfDayHours( readFiniteNumberDraft( projectTimeSunsetTimeOfDayDraft, "Project sunset" ) ); } ); }; const applyProjectTimeDawnDurationHours = () => { updateProjectTimeSettings( "Set project dawn duration", `Project dawn will blend over ${projectTimeDawnDurationHoursDraft} in-game hours.`, (time) => { time.dawnDurationHours = readPositiveNumberDraft( projectTimeDawnDurationHoursDraft, "Project dawn duration" ); } ); }; const applyProjectTimeDuskDurationHours = () => { updateProjectTimeSettings( "Set project dusk duration", `Project dusk will blend over ${projectTimeDuskDurationHoursDraft} in-game hours.`, (time) => { time.duskDurationHours = readPositiveNumberDraft( projectTimeDuskDurationHoursDraft, "Project dusk duration" ); } ); }; const updateProjectScheduleRoutine = ( routineId: string, label: string, successMessage: string, mutate: (routine: ProjectScheduleRoutine) => void ) => { const currentRoutine = editorState.projectDocument.scheduler.routines[routineId] ?? null; if (currentRoutine === null) { setStatusMessage("Selected sequence placement no longer exists."); return; } try { const draftRoutine = cloneProjectScheduleRoutine(currentRoutine); mutate(draftRoutine); const nextRoutine = createProjectScheduleRoutine(draftRoutine); const nextScheduler = upsertProjectScheduleRoutine( cloneProjectScheduler(editorState.projectDocument.scheduler), nextRoutine ); applyProjectScheduler(nextScheduler, label, successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const resolveProjectScheduleTargetOption = (targetKey: string) => getProjectScheduleTargetOptionByKey( projectScheduleTargetOptions, targetKey ); const createDefaultSequenceEffectsForTarget = ( targetOption: ProjectScheduleTargetOption ): ProjectSequence["effects"] => { if ( targetOption.target.kind === "global" || targetOption.target.kind === "actor" ) { return []; } const effectOption = listProjectScheduleEffectOptions(targetOption)[0] ?? null; if (effectOption === null) { throw new Error( "This target does not expose any direct sequencer effects in the current slice." ); } return [ { stepClass: "held", type: "controlEffect", effect: createProjectScheduleEffectFromOption({ targetOption, effectOptionId: effectOption.id }) } ]; }; const createAttachedSequenceForRoutine = (options: { title: string; targetOption: ProjectScheduleTargetOption; routine?: ProjectScheduleRoutine; }): ProjectSequence => { const authoredEffects = options.routine === undefined ? [] : options.routine.effects .map((effect) => ({ stepClass: "held" as const, type: "controlEffect" as const, effect: cloneControlEffect(effect) })) .filter((step) => step.effect.type !== "setActorPresence"); return createProjectSequence({ title: options.title, effects: authoredEffects.length > 0 ? authoredEffects : createDefaultSequenceEffectsForTarget(options.targetOption) }); }; const cloneSequenceForRetargetedPlacement = (options: { sequence: ProjectSequence; targetOption: ProjectScheduleTargetOption; previousTargetKey: string; }): ProjectSequence => { return createProjectSequence({ title: options.sequence.title, effects: options.sequence.effects.map((effect) => { if ( effect.type !== "controlEffect" || getControlTargetRefKey(effect.effect.target) !== options.previousTargetKey ) { return cloneSequenceEffect(effect); } return { stepClass: effect.stepClass, type: "controlEffect" as const, effect: createProjectScheduleEffectFromOption({ targetOption: options.targetOption, effectOptionId: getProjectScheduleEffectOptionId(effect.effect), previousEffect: effect.effect }) }; }) }); }; const handleCreateScheduleRoutine = (targetKey: string) => { const targetOption = resolveProjectScheduleTargetOption(targetKey); if (targetOption === null) { setStatusMessage( "Author a sequencer-addressable control target before creating a sequence placement." ); return; } try { const nextSequence = createAttachedSequenceForRoutine({ title: targetOption.label, targetOption }); const nextRoutine = createProjectScheduleRoutine({ title: targetOption.label, target: targetOption.target, sequenceId: nextSequence.id, days: createProjectScheduleEveryDaySelection(), startHour: 9, endHour: 17, priority: 0, effects: [] }); updateProjectSequencerState( "Create sequence placement", `Created a sequence placement for ${targetOption.label}.`, (scheduler, sequences) => { sequences.sequences[nextSequence.id] = nextSequence; scheduler.routines[nextRoutine.id] = nextRoutine; } ); setSchedulePaneOpen(true); setSequencerMode("timeline"); setSelectedScheduleRoutineId(nextRoutine.id); setSelectedSequenceId(nextSequence.id); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleDeleteScheduleRoutine = (routineId: string) => { const nextScheduler = removeProjectScheduleRoutine( cloneProjectScheduler(editorState.projectDocument.scheduler), routineId ); applyProjectScheduler( nextScheduler, "Delete sequence placement", "Deleted sequence placement." ); if (selectedScheduleRoutineId === routineId) { setSelectedScheduleRoutineId(null); } }; const handleCreateAttachedSequenceForRoutine = (routineId: string) => { const routine = editorState.projectDocument.scheduler.routines[routineId] ?? null; if (routine === null) { setStatusMessage("Selected sequence placement no longer exists."); return; } const targetOption = resolveProjectScheduleTargetOption( getControlTargetRefKey(routine.target) ); if (targetOption === null) { setStatusMessage("Selected sequencer target no longer exists."); return; } try { const nextSequence = createAttachedSequenceForRoutine({ title: routine.title, targetOption, routine }); updateProjectSequencerState( "Create attached sequence", "Created an attached sequence from this placement.", (scheduler, sequences) => { sequences.sequences[nextSequence.id] = nextSequence; const draftRoutine = scheduler.routines[routineId]; if (draftRoutine === undefined) { throw new Error("Selected sequence placement no longer exists."); } draftRoutine.sequenceId = nextSequence.id; draftRoutine.effects = []; } ); setSelectedSequenceId(nextSequence.id); setSequencerMode("timeline"); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const resolveSequenceControlTargetOption = (targetKey: string) => getProjectScheduleTargetOptionByKey( projectScheduleTargetOptions, targetKey ); const createDefaultProjectSequenceControlStep = ( targetKey: string, previousStep?: Extract< ProjectSequence["effects"][number], { type: "controlEffect" } > | null ): Extract => { const targetOption = resolveSequenceControlTargetOption(targetKey); if (targetOption === null) { throw new Error("The selected sequence control target no longer exists."); } const effectOption = listProjectScheduleEffectOptions(targetOption)[0] ?? null; if (effectOption === null) { throw new Error( "The selected target does not expose any direct sequence effects yet." ); } return { stepClass: getProjectSequenceControlStepClassForEffectOptionId( effectOption.id ), type: "controlEffect", effect: createProjectScheduleEffectFromOption({ targetOption, effectOptionId: effectOption.id, previousEffect: previousStep?.effect ?? null }) }; }; const createProjectSequenceControlStepFromOption = ( targetKey: string, effectOptionId: ProjectScheduleEffectOptionId, previousStep?: Extract< ProjectSequence["effects"][number], { type: "controlEffect" } > | null ): Extract => { const targetOption = resolveSequenceControlTargetOption(targetKey); if (targetOption === null) { throw new Error("The selected sequence control target no longer exists."); } return { stepClass: getProjectSequenceControlStepClassForEffectOptionId(effectOptionId), type: "controlEffect", effect: createProjectScheduleEffectFromOption({ targetOption, effectOptionId, previousEffect: previousStep?.effect ?? null }) }; }; const updateProjectSequence = ( sequenceId: string, label: string, successMessage: string, mutate: (sequence: ProjectSequence) => void ) => { updateProjectSequences(label, successMessage, (sequences) => { const sequence = sequences.sequences[sequenceId]; if (sequence === undefined) { throw new Error("Selected project sequence no longer exists."); } mutate(sequence); }); }; const updateProjectSequenceStep = ( sequenceId: string, stepIndex: number, label: string, successMessage: string, mutate: (step: ProjectSequence["effects"][number]) => void ) => { updateProjectSequence(sequenceId, label, successMessage, (sequence) => { const step = sequence.effects[stepIndex]; if (step === undefined) { throw new Error("Selected project sequence effect no longer exists."); } mutate(step); }); }; const handleAddProjectSequence = () => { const nextSequence = createProjectSequence(); updateProjectSequences( "Add project sequence", "Added project sequence.", (sequences) => { sequences.sequences[nextSequence.id] = nextSequence; } ); setSelectedSequenceId(nextSequence.id); }; const handleDeleteProjectSequence = (sequenceId: string) => { updateProjectSequences( "Delete project sequence", "Deleted project sequence.", (sequences) => { delete sequences.sequences[sequenceId]; } ); if (selectedSequenceId === sequenceId) { setSelectedSequenceId(null); } }; const openSequencerSequenceEditor = (sequenceId?: string | null) => { setSchedulePaneOpen(true); setSequencerMode("sequence"); if (sequenceId !== undefined) { setSelectedSequenceId(sequenceId); } }; const handleAddNpcDialogue = () => { const nextDialogue = createProjectDialogue(); updateSelectedNpcDialogues( "Add NPC dialogue", "Added NPC dialogue.", (dialogues, defaultDialogueId) => ({ dialogues: [...dialogues, nextDialogue], defaultDialogueId: defaultDialogueId ?? nextDialogue.id }) ); setSelectedNpcDialogueId(nextDialogue.id); }; const handleDeleteNpcDialogue = (dialogueId: string) => { updateSelectedNpcDialogues( "Delete NPC dialogue", "Deleted NPC dialogue.", (dialogues, defaultDialogueId) => { const nextDialogues = dialogues.filter( (dialogue) => dialogue.id !== dialogueId ); return { dialogues: nextDialogues, defaultDialogueId: defaultDialogueId === dialogueId ? (nextDialogues[0]?.id ?? null) : defaultDialogueId }; } ); if (selectedNpcDialogueId === dialogueId) { setSelectedNpcDialogueId(null); } }; const updateNpcDialogue = ( dialogueId: string, label: string, successMessage: string, mutate: (dialogue: ProjectDialogue) => void ) => { updateSelectedNpcDialogues( label, successMessage, (dialogues, defaultDialogueId) => { const nextDialogues = dialogues.map((dialogue) => { if (dialogue.id !== dialogueId) { return dialogue; } const nextDialogue: ProjectDialogue = { ...dialogue, lines: dialogue.lines.map((line) => ({ ...line })) }; mutate(nextDialogue); return nextDialogue; }); return { dialogues: nextDialogues, defaultDialogueId }; } ); }; const updateNpcDialogueLine = ( dialogueId: string, lineId: string, label: string, successMessage: string, mutate: (line: ProjectDialogueLine) => void ) => { updateNpcDialogue(dialogueId, label, successMessage, (dialogue) => { const line = dialogue.lines.find((candidate) => candidate.id === lineId); if (line === undefined) { throw new Error("Selected dialogue line no longer exists."); } mutate(line); }); }; const handleAddProjectSequenceControlEffect = ( sequenceId: string, targetKey: string, effectOptionId: ProjectScheduleEffectOptionId ) => { updateProjectSequence( sequenceId, "Add project sequence effect", "Added sequence effect.", (sequence) => { sequence.effects.push( createProjectSequenceControlStepFromOption(targetKey, effectOptionId) ); } ); }; const handleAddProjectSequenceNpcTalkStep = ( sequenceId: string, npcEntityId: string, dialogueId: string | null ) => { updateProjectSequence( sequenceId, "Add project sequence NPC talk effect", "Added NPC talk effect.", (sequence) => { sequence.effects.push({ stepClass: "impulse", type: "makeNpcTalk", npcEntityId, dialogueId }); } ); }; const handleAddProjectSequenceTeleportStep = ( sequenceId: string, targetEntityId: string ) => { updateProjectSequence( sequenceId, "Add project sequence teleport effect", "Added teleport effect.", (sequence) => { sequence.effects.push({ stepClass: "impulse", type: "teleportPlayer", targetEntityId }); } ); }; const handleAddProjectSequenceSceneTransitionStep = ( sequenceId: string, targetKey: string ) => { const target = readSceneTransitionTargetKey(targetKey); updateProjectSequence( sequenceId, "Add project sequence scene transition effect", "Added scene transition effect.", (sequence) => { sequence.effects.push({ stepClass: "impulse", type: "startSceneTransition", targetSceneId: target.targetSceneId, targetEntryEntityId: target.targetEntryEntityId }); } ); }; const handleAddProjectSequenceVisibilityStep = ( sequenceId: string, targetKey: string ) => { const target = readSequenceVisibilityTargetKey(targetKey); updateProjectSequence( sequenceId, "Add project sequence visibility effect", "Added visibility effect.", (sequence) => { sequence.effects.push({ stepClass: "impulse", type: "setVisibility", target, mode: "toggle" }); } ); }; const handleDeleteProjectSequenceStep = ( sequenceId: string, stepIndex: number ) => { updateProjectSequence( sequenceId, "Delete project sequence effect", "Deleted sequence effect.", (sequence) => { sequence.effects.splice(stepIndex, 1); } ); }; const updateProjectSequenceControlStepTarget = ( sequenceId: string, stepIndex: number, targetKey: string ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence control target", "Updated sequence control target.", (step) => { if (step.type !== "controlEffect") { throw new Error("Only control steps expose a control target."); } const replacement = createDefaultProjectSequenceControlStep( targetKey, step ); step.effect = replacement.effect; } ); }; const updateProjectSequenceControlStepEffectOption = ( sequenceId: string, stepIndex: number, effectOptionId: ProjectScheduleEffectOptionId ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence control effect", "Updated sequence control effect.", (step) => { if (step.type !== "controlEffect") { throw new Error("Only control steps expose a control effect."); } const targetOption = resolveSequenceControlTargetOption( getControlTargetRefKey(step.effect.target) ); if (targetOption === null) { throw new Error( "The current sequence control target no longer exists." ); } step.effect = createProjectScheduleEffectFromOption({ targetOption, effectOptionId, previousEffect: step.effect }); step.stepClass = getProjectSequenceControlStepClassForEffectOptionId(effectOptionId); } ); }; const updateProjectSequenceControlStepNumericValue = ( sequenceId: string, stepIndex: number, value: number ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence numeric value", "Updated sequence numeric value.", (step) => { if (step.type !== "controlEffect") { throw new Error("Only control steps expose numeric values."); } if (!Number.isFinite(value) || value < 0) { throw new Error( "Sequence numeric values must be finite and zero or greater." ); } switch (step.effect.type) { case "setSoundVolume": step.effect.volume = value; return; case "setLightIntensity": case "setAmbientLightIntensity": case "setSunLightIntensity": step.effect.intensity = value; return; default: throw new Error( "The current sequence control effect does not expose a numeric value." ); } } ); }; const updateProjectSequenceControlStepColorValue = ( sequenceId: string, stepIndex: number, colorHex: string ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence color value", "Updated sequence color value.", (step) => { if (step.type !== "controlEffect") { throw new Error("Only control steps expose color values."); } switch (step.effect.type) { case "setLightColor": case "setAmbientLightColor": case "setSunLightColor": step.effect.colorHex = colorHex; return; default: throw new Error( "The current sequence control effect does not expose a color value." ); } } ); }; const updateProjectSequenceControlStepAnimationClip = ( sequenceId: string, stepIndex: number, clipName: string ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence animation clip", "Updated sequence animation clip.", (step) => { if ( step.type !== "controlEffect" || (step.effect.type !== "playModelAnimation" && step.effect.type !== "playActorAnimation") ) { throw new Error( "The current sequence control step does not expose an animation clip." ); } step.effect.clipName = clipName; } ); }; const updateProjectSequenceControlStepAnimationLoop = ( sequenceId: string, stepIndex: number, loop: boolean ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence animation loop", loop ? "Sequence animation now loops." : "Sequence animation now plays once.", (step) => { if ( step.type !== "controlEffect" || (step.effect.type !== "playModelAnimation" && step.effect.type !== "playActorAnimation") ) { throw new Error( "The current sequence control step does not expose animation looping." ); } step.effect.loop = loop; } ); }; const updateProjectSequenceControlStepPathId = ( sequenceId: string, stepIndex: number, pathId: string ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence actor path", "Updated sequence actor path.", (step) => { if ( step.type !== "controlEffect" || step.effect.type !== "followActorPath" ) { throw new Error( "The current sequence control step does not expose an actor path." ); } step.effect.pathId = pathId; } ); }; const updateProjectSequenceControlStepPathSpeed = ( sequenceId: string, stepIndex: number, speed: number ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence actor path speed", "Updated sequence actor path speed.", (step) => { if ( step.type !== "controlEffect" || step.effect.type !== "followActorPath" ) { throw new Error( "The current sequence control step does not expose an actor path speed." ); } if (!Number.isFinite(speed) || speed <= 0) { throw new Error( "Actor path speed must be a finite number greater than zero." ); } step.effect.speed = speed; } ); }; const updateProjectSequenceControlStepPathLoop = ( sequenceId: string, stepIndex: number, loop: boolean ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence actor path loop", loop ? "Sequence actor path now loops." : "Sequence actor path now clamps at the end.", (step) => { if ( step.type !== "controlEffect" || step.effect.type !== "followActorPath" ) { throw new Error( "The current sequence control step does not expose actor path looping." ); } step.effect.loop = loop; } ); }; const updateProjectSequenceControlStepPathSmooth = ( sequenceId: string, stepIndex: number, smoothPath: boolean ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence actor path smoothing", smoothPath ? "Sequence actor path now uses smoothing." : "Sequence actor path now follows authored corners directly.", (step) => { if ( step.type !== "controlEffect" || step.effect.type !== "followActorPath" ) { throw new Error( "The current sequence control step does not expose actor path smoothing." ); } step.effect.smoothPath = smoothPath; } ); }; const updateProjectSequenceNpcTalkStepNpcEntityId = ( sequenceId: string, stepIndex: number, npcEntityId: string ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence talk NPC", "Updated talking NPC.", (step) => { if (step.type !== "makeNpcTalk") { throw new Error("Only NPC talk effects expose a talking NPC."); } const npcTargetOption = npcDialogueSequenceTargetOptions.find( (option) => option.npcEntityId === npcEntityId ) ?? null; step.npcEntityId = npcEntityId; step.dialogueId = npcTargetOption?.defaultDialogueId ?? npcTargetOption?.dialogues[0]?.dialogueId ?? null; } ); }; const updateProjectSequenceNpcTalkStepDialogueId = ( sequenceId: string, stepIndex: number, dialogueId: string | null ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence NPC dialogue", "Updated NPC dialogue.", (step) => { if (step.type !== "makeNpcTalk") { throw new Error("Only NPC talk effects expose an NPC dialogue."); } step.dialogueId = dialogueId; } ); }; const updateProjectSequenceTeleportStepTarget = ( sequenceId: string, stepIndex: number, targetEntityId: string ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence teleport target", "Updated teleport target.", (step) => { if (step.type !== "teleportPlayer") { throw new Error("Only teleport effects expose a teleport target."); } step.targetEntityId = targetEntityId; } ); }; const updateProjectSequenceSceneTransitionStepTarget = ( sequenceId: string, stepIndex: number, targetKey: string ) => { const target = readSceneTransitionTargetKey(targetKey); updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence scene transition target", "Updated scene transition target.", (step) => { if (step.type !== "startSceneTransition") { throw new Error( "Only scene transition effects expose a scene transition target." ); } step.targetSceneId = target.targetSceneId; step.targetEntryEntityId = target.targetEntryEntityId; } ); }; const updateProjectSequenceVisibilityStepTarget = ( sequenceId: string, stepIndex: number, targetKey: string ) => { const target = readSequenceVisibilityTargetKey(targetKey); updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence visibility target", "Updated visibility target.", (step) => { if (step.type !== "setVisibility") { throw new Error( "Only visibility effects expose a whitebox solid target." ); } step.target = target; } ); }; const updateProjectSequenceVisibilityStepMode = ( sequenceId: string, stepIndex: number, mode: "toggle" | "show" | "hide" ) => { updateProjectSequenceStep( sequenceId, stepIndex, "Set project sequence visibility mode", "Updated visibility mode.", (step) => { if (step.type !== "setVisibility") { throw new Error("Only visibility effects expose a visibility mode."); } step.mode = mode; } ); }; const updateWorldTimeOfDaySettings = ( label: string, successMessage: string, mutate: (world: WorldSettings) => void ) => { const nextWorld = cloneWorldSettings(editorState.document.world); mutate(nextWorld); applyWorldSettings(nextWorld, label, successMessage); }; const applyWorldTimePhaseColor = ( phase: WorldTimePhaseKey, field: WorldTimePhaseColorField, colorHex: string, label: string, successMessage: string ) => { updateWorldTimeOfDaySettings(label, successMessage, (world) => { world.timeOfDay[phase][field] = colorHex; if ( (field === "skyTopColorHex" || field === "skyBottomColorHex") && world.timeOfDay[phase].background.mode === "verticalGradient" ) { world.timeOfDay[phase].background = field === "skyTopColorHex" ? { ...world.timeOfDay[phase].background, topColorHex: colorHex } : { ...world.timeOfDay[phase].background, bottomColorHex: colorHex }; } }); }; const applyWorldTimePhaseNumericField = ( phase: WorldTimePhaseKey, field: WorldTimePhaseNumericField, draftValue: string, label: string, draftLabel: string, successMessage: string ) => { try { updateWorldTimeOfDaySettings(label, successMessage, (world) => { world.timeOfDay[phase][field] = readNonNegativeNumberDraft( draftValue, draftLabel ); }); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyWorldTimePhaseBackgroundMode = ( phase: WorldTimePhaseKey, mode: WorldBackgroundMode, imageAssetId?: string ) => { const currentBackground = editorState.document.world.timeOfDay[phase].background; if (mode === "image") { const currentBackgroundAssetId = currentBackground.mode === "image" ? currentBackground.assetId : ""; const nextImageAssetId = imageAssetId ?? (currentBackgroundAssetId.trim().length > 0 && editorState.document.assets[currentBackgroundAssetId]?.kind === "image" ? currentBackgroundAssetId : (imageAssetList[0]?.id ?? "")); updateWorldTimeOfDaySettings( `Set ${phase} background image mode`, nextImageAssetId.trim().length > 0 ? `${phase[0].toUpperCase()}${phase.slice(1)} background set to ${ editorState.document.assets[nextImageAssetId]?.sourceName ?? nextImageAssetId }.` : `${phase[0].toUpperCase()}${phase.slice(1)} background now falls back to available day/night images when possible.`, (world) => { const profile = world.timeOfDay[phase]; profile.background = { mode: "image", assetId: nextImageAssetId, environmentIntensity: currentBackground.mode === "image" ? currentBackground.environmentIntensity : DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY }; } ); return; } updateWorldTimeOfDaySettings( `Set ${phase} background mode`, mode === "solid" ? `${phase[0].toUpperCase()}${phase.slice(1)} background set to a solid color.` : `${phase[0].toUpperCase()}${phase.slice(1)} background set to a vertical gradient.`, (world) => { const profile = world.timeOfDay[phase]; if (mode === "solid") { profile.background = { mode: "solid", colorHex: profile.background.mode === "solid" ? profile.background.colorHex : profile.background.mode === "verticalGradient" ? profile.background.topColorHex : profile.skyTopColorHex }; return; } profile.background = { mode: "verticalGradient", topColorHex: profile.skyTopColorHex, bottomColorHex: profile.skyBottomColorHex }; } ); }; const applyWorldTimePhaseBackgroundColor = ( phase: WorldTimePhaseKey, colorHex: string ) => { if ( editorState.document.world.timeOfDay[phase].background.mode !== "solid" ) { return; } updateWorldTimeOfDaySettings( `Set ${phase} background color`, `Updated the ${phase} background color.`, (world) => { world.timeOfDay[phase].background = { mode: "solid", colorHex }; } ); }; const applyWorldTimePhaseGradientColor = ( phase: WorldTimePhaseKey, edge: "top" | "bottom", colorHex: string ) => { if ( editorState.document.world.timeOfDay[phase].background.mode !== "verticalGradient" ) { return; } updateWorldTimeOfDaySettings( edge === "top" ? `Set ${phase} gradient top color` : `Set ${phase} gradient bottom color`, edge === "top" ? `Updated the ${phase} gradient top color.` : `Updated the ${phase} gradient bottom color.`, (world) => { const profile = world.timeOfDay[phase]; if (profile.background.mode !== "verticalGradient") { return; } profile.background = edge === "top" ? { ...profile.background, topColorHex: colorHex } : { ...profile.background, bottomColorHex: colorHex }; if (edge === "top") { profile.skyTopColorHex = colorHex; } else { profile.skyBottomColorHex = colorHex; } } ); }; const applyWorldTimePhaseBackgroundEnvironmentIntensity = ( phase: WorldTimePhaseKey, draftValue: string ) => { if ( editorState.document.world.timeOfDay[phase].background.mode !== "image" ) { return; } try { updateWorldTimeOfDaySettings( `Set ${phase} background environment intensity`, `Updated the ${phase} background environment intensity.`, (world) => { if (world.timeOfDay[phase].background.mode !== "image") { return; } world.timeOfDay[phase].background = { ...world.timeOfDay[phase].background, environmentIntensity: readNonNegativeNumberDraft( draftValue, `${phase[0].toUpperCase()}${phase.slice(1)} background environment intensity` ) }; } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyWorldNightEnvironmentColor = ( field: WorldNightEnvironmentColorField, colorHex: string, label: string, successMessage: string ) => { updateWorldTimeOfDaySettings(label, successMessage, (world) => { world.timeOfDay.night[field] = colorHex; }); }; const applyWorldNightEnvironmentNumericField = ( field: WorldNightEnvironmentNumericField, draftValue: string, label: string, draftLabel: string, successMessage: string ) => { try { updateWorldTimeOfDaySettings(label, successMessage, (world) => { world.timeOfDay.night[field] = readNonNegativeNumberDraft( draftValue, draftLabel ); }); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyNightBackgroundMode = ( mode: WorldBackgroundMode, imageAssetId?: string ) => { const currentBackground = editorState.document.world.timeOfDay.night.background; if (mode === "image") { const currentBackgroundAssetId = currentBackground.mode === "image" ? currentBackground.assetId : null; const nextImageAssetId = imageAssetId ?? (currentBackgroundAssetId !== null && editorState.document.assets[currentBackgroundAssetId]?.kind === "image" ? currentBackgroundAssetId : imageAssetList[0]?.id); if (nextImageAssetId === undefined) { setStatusMessage( "Import an image asset before using a night image background." ); return; } updateWorldTimeOfDaySettings( "Set night background image", `Night background set to ${ editorState.document.assets[nextImageAssetId]?.sourceName ?? nextImageAssetId }.`, (world) => { world.timeOfDay.night.background = changeWorldBackgroundMode( world.timeOfDay.night.background, "image", nextImageAssetId, DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY ); } ); return; } updateWorldTimeOfDaySettings( "Set night background mode", mode === "solid" ? "Night background set to a solid color." : "Night background set to a vertical gradient.", (world) => { world.timeOfDay.night.background = changeWorldBackgroundMode( world.timeOfDay.night.background, mode, undefined, DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY ); } ); }; const applyNightBackgroundColor = (colorHex: string) => { if ( editorState.document.world.timeOfDay.night.background.mode !== "solid" ) { return; } updateWorldTimeOfDaySettings( "Set night background color", "Updated the night background color.", (world) => { world.timeOfDay.night.background = { mode: "solid", colorHex }; } ); }; const applyNightGradientColor = ( edge: "top" | "bottom", colorHex: string ) => { if ( editorState.document.world.timeOfDay.night.background.mode !== "verticalGradient" ) { return; } updateWorldTimeOfDaySettings( edge === "top" ? "Set night gradient top color" : "Set night gradient bottom color", edge === "top" ? "Updated the night gradient top color." : "Updated the night gradient bottom color.", (world) => { if (world.timeOfDay.night.background.mode !== "verticalGradient") { return; } world.timeOfDay.night.background = edge === "top" ? { ...world.timeOfDay.night.background, topColorHex: colorHex } : { ...world.timeOfDay.night.background, bottomColorHex: colorHex }; } ); }; const applyNightBackgroundEnvironmentIntensity = () => { if ( editorState.document.world.timeOfDay.night.background.mode !== "image" ) { return; } try { updateWorldTimeOfDaySettings( "Set night background environment intensity", "Updated the night background environment intensity.", (world) => { if (world.timeOfDay.night.background.mode !== "image") { return; } world.timeOfDay.night.background = { ...world.timeOfDay.night.background, environmentIntensity: readNonNegativeNumberDraft( worldNightBackgroundEnvironmentIntensityDraft, "Night background environment intensity" ) }; } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySceneName = () => { const normalizedName = sceneNameDraft.trim() || "Untitled Scene"; if (normalizedName === editorState.document.name) { return; } store.executeCommand(createSetSceneNameCommand(normalizedName)); setStatusMessage(`Scene renamed to ${normalizedName}.`); }; const handleCreateScene = () => { store.executeCommand(createCreateSceneCommand()); setStatusMessage("Created a new scene."); }; const handleActiveSceneChange = (event: ChangeEvent) => { 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}.`); }; 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." ); }; const requestViewportFocus = ( selection: EditorSelection, status?: string ) => { setFocusRequest((current) => ({ id: current.id + 1, panelId: activePanelId, selection })); if (status !== undefined) { setStatusMessage(status); } }; const openAddMenuAt = (position: HierarchicalMenuPosition) => { setHoveredAssetId(null); setAddMenuPosition(position); }; const closeAddMenu = () => { setHoveredAssetId(null); setAddMenuPosition(null); }; const handleOpenAddMenuFromButton = ( event: ReactMouseEvent ) => { const rect = event.currentTarget.getBoundingClientRect(); openAddMenuAt({ x: rect.left, y: rect.bottom + 8 }); }; const handleSetViewportLayoutMode = (nextLayoutMode: ViewportLayoutMode) => { if (editorState.viewportLayoutMode === nextLayoutMode) { return; } blurActiveTextEntry(); store.setViewportLayoutMode(nextLayoutMode); setStatusMessage( `Switched the viewport to ${getViewportLayoutModeLabel(nextLayoutMode)}.` ); }; const handleActivateViewportPanel = (panelId: ViewportPanelId) => { if (editorState.activeViewportPanelId === panelId) { return; } blurActiveTextEntry(); store.setActiveViewportPanel(panelId); setStatusMessage("Activated the viewport panel."); }; const handleSetViewportPanelViewMode = ( panelId: ViewportPanelId, nextViewMode: ViewportViewMode ) => { if (editorState.viewportPanels[panelId].viewMode === nextViewMode) { return; } blurActiveTextEntry(); store.setViewportPanelViewMode(panelId, nextViewMode); setStatusMessage( `Set the viewport panel to ${getViewportViewModeLabel(nextViewMode)} view.` ); }; const handleSetViewportPanelDisplayMode = ( panelId: ViewportPanelId, nextDisplayMode: ViewportDisplayMode ) => { if (editorState.viewportPanels[panelId].displayMode === nextDisplayMode) { return; } blurActiveTextEntry(); store.setViewportPanelDisplayMode(panelId, nextDisplayMode); setStatusMessage( `Set the viewport panel to ${getViewportDisplayModeLabel(nextDisplayMode)} display.` ); }; const handleUndo = () => { if (store.undo()) { setStatusMessage("Undid the last action."); } else { setStatusMessage("Nothing to undo."); } }; const handleRedo = () => { if (store.redo()) { setStatusMessage("Redid the last action."); } else { setStatusMessage("Nothing to redo."); } }; const beginTransformOperation = ( operation: TransformOperation, source: TransformSessionSource ) => { if (editorState.toolMode !== "select") { return; } const transformSourcePanelId = layoutMode === "quad" ? (hoveredViewportPanelId ?? activePanelId) : activePanelId; const transformTargetResult = resolveTransformTarget( editorState.document, editorState.selection, whiteboxSelectionMode, editorState.activeSelectionId ); const transformTarget = transformTargetResult.target; if (transformTarget === null) { setStatusMessage( transformTargetResult.message ?? "Select a single brush, entity, or model instance before transforming it." ); return; } if (!supportsTransformOperation(transformTarget, operation)) { setStatusMessage( `${getTransformOperationLabel(operation)} is not supported for ${getTransformTargetLabel(transformTarget)}.` ); return; } blurActiveTextEntry(); closeAddMenu(); if (editorState.activeViewportPanelId !== transformSourcePanelId) { store.setActiveViewportPanel(transformSourcePanelId); } const nextTransformSession = createTransformSession({ source, sourcePanelId: transformSourcePanelId, operation, target: transformTarget }); latestActiveTransformSessionRef.current = nextTransformSession; store.setTransformSession(nextTransformSession); setStatusMessage( `${getTransformOperationLabel(operation)} ${getTransformTargetLabel(transformTarget).toLowerCase()} in ${getViewportPanelLabel( transformSourcePanelId )}. 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.` ); }; const cancelTransformSession = ( status = "Cancelled the current transform." ) => { if (transformSession.kind === "none") { return; } latestActiveTransformSessionRef.current = null; store.clearTransformSession(); setStatusMessage(status); }; const commitTransformSession = ( activeTransformSession: ActiveTransformSession ) => { if (!doesTransformSessionChangeTarget(activeTransformSession)) { latestActiveTransformSessionRef.current = null; store.clearTransformSession(); setStatusMessage("No transform change was committed."); return; } try { latestActiveTransformSessionRef.current = null; store.clearTransformSession(); store.executeCommand( createCommitTransformSessionCommand( editorState.document, activeTransformSession ) ); setStatusMessage( `${getTransformOperationPastTense(activeTransformSession.operation)} ${getTransformTargetLabel(activeTransformSession.target).toLowerCase()}.` ); } catch (error) { latestActiveTransformSessionRef.current = null; store.clearTransformSession(); setStatusMessage(getErrorMessage(error)); } }; const applyTransformAxisConstraint = (axis: TransformAxis) => { if (transformSession.kind !== "active") { return; } if (!supportsTransformAxisConstraint(transformSession, axis)) { const supportedAxes = (["x", "y", "z"] as const) .filter((candidateAxis) => supportsTransformAxisConstraint(transformSession, candidateAxis) ) .map((candidateAxis) => candidateAxis.toUpperCase()) .join("/"); setStatusMessage( supportedAxes.length === 0 ? `${getTransformOperationLabel(transformSession.operation)} does not support axis constraints for ${getTransformTargetLabel(transformSession.target)}.` : `${getTransformOperationLabel(transformSession.operation)} on ${getTransformTargetLabel(transformSession.target)} only supports ${supportedAxes}.` ); return; } const nextAxisConstraintSpace = transformSession.axisConstraint === axis ? transformSession.axisConstraintSpace === "world" ? "local" : "world" : "world"; if ( nextAxisConstraintSpace === "local" && !supportsLocalTransformAxisConstraint(transformSession, axis) ) { 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)}.` ); }; const toggleTransformSurfaceSnap = () => { if ( transformSession.kind !== "active" || transformSession.operation !== "translate" ) { return; } if (!supportsTransformSurfaceSnapTarget(transformSession.target)) { setStatusMessage( `Surface Snap Move is not available for ${getTransformTargetLabel(transformSession.target).toLowerCase()}.` ); return; } const nextSurfaceSnapEnabled = !transformSession.surfaceSnapEnabled; store.setTransformSession({ ...transformSession, surfaceSnapEnabled: nextSurfaceSnapEnabled }); setStatusMessage( nextSurfaceSnapEnabled ? "Enabled Surface Snap Move." : "Disabled Surface Snap Move." ); }; const handleViewportQuadResizeStart = (resizeMode: ViewportQuadResizeMode) => (event: ReactPointerEvent) => { if (layoutMode !== "quad") { return; } const viewportPanels = viewportPanelsRef.current; if (viewportPanels === null) { return; } event.preventDefault(); event.stopPropagation(); blurActiveTextEntry(); const rect = viewportPanels.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { const nextViewportQuadSplit = { ...viewportQuadSplitRef.current }; if (resizeMode !== "horizontal") { nextViewportQuadSplit.x = clampViewportQuadSplitValue( (event.clientX - rect.left) / rect.width ); } if (resizeMode !== "vertical") { nextViewportQuadSplit.y = clampViewportQuadSplitValue( (event.clientY - rect.top) / rect.height ); } store.setViewportQuadSplit(nextViewportQuadSplit); } setViewportQuadResizeMode(resizeMode); }; const handleSchedulePaneResizeStart = ( event: ReactPointerEvent ) => { if (!schedulePaneOpen) { return; } event.preventDefault(); event.stopPropagation(); blurActiveTextEntry(); schedulePaneResizeStartRef.current = { startY: event.clientY, startHeight: schedulePaneHeightRef.current }; setSchedulePaneResizeActive(true); }; const handleSchedulePaneResizeKeyDown = ( event: ReactKeyboardEvent ) => { if (!schedulePaneOpen) { return; } if (event.key !== "ArrowUp" && event.key !== "ArrowDown") { return; } event.preventDefault(); const delta = event.key === "ArrowUp" ? 24 : -24; commitSchedulePaneHeight(schedulePaneHeightRef.current + delta); }; const beginCreation = ( toolPreview: CreationViewportToolPreview, status: string ) => { blurActiveTextEntry(); closeAddMenu(); store.setToolMode("create"); store.setViewportToolPreview(toolPreview); setStatusMessage(status); }; const completeCreation = (status: string) => { store.setToolMode("select"); store.clearViewportToolPreview(); setStatusMessage(status); }; const beginBoxCreation = () => { beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "box-brush" }, center: null }, `Previewing a whitebox box. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.` ); }; const beginWedgeCreation = () => { beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "wedge-brush" }, center: null }, `Previewing a whitebox wedge. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.` ); }; const beginCylinderCreation = () => { beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "cylinder-brush", sideCount: 12 }, center: null }, `Previewing a whitebox cylinder. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.` ); }; const beginConeCreation = () => { beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "cone-brush", sideCount: DEFAULT_CONE_SIDE_COUNT }, center: null }, `Previewing a whitebox cone. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.` ); }; const beginTorusCreation = () => { beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "torus-brush", majorSegmentCount: DEFAULT_TORUS_MAJOR_SEGMENT_COUNT, tubeSegmentCount: DEFAULT_TORUS_TUBE_SEGMENT_COUNT }, center: null }, `Previewing a whitebox torus. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.` ); }; const handleWhiteboxSnapToggle = () => { const nextEnabled = !whiteboxSnapEnabled; store.setWhiteboxSnapEnabled(nextEnabled); setStatusMessage( nextEnabled ? `Grid snap enabled at ${whiteboxSnapStep}m.` : "Grid snap disabled for whitebox transforms." ); }; const handleViewportGridToggle = () => { const nextVisible = !viewportGridVisible; store.setViewportGridVisible(nextVisible); setStatusMessage( nextVisible ? "Viewport grid enabled." : "Viewport grid hidden." ); }; const handleWhiteboxSnapStepBlur = () => { const normalizedStep = resolveOptionalPositiveNumber( whiteboxSnapStepDraft, DEFAULT_GRID_SIZE ); setWhiteboxSnapStepDraft(String(normalizedStep)); store.setWhiteboxSnapStep(normalizedStep); }; const handleWhiteboxSelectionModeChange = (mode: WhiteboxSelectionMode) => { if (whiteboxSelectionMode === mode) { return; } blurActiveTextEntry(); store.setWhiteboxSelectionMode(mode); setStatusMessage(getWhiteboxSelectionModeStatus(mode)); }; const applySelection = ( selection: EditorSelection, source: "outliner" | "viewport" | "inspector" | "runner", options: { focusViewport?: boolean } = {} ) => { blurActiveTextEntry(); store.setSelection(selection); const activeSelectionId = getSelectionDefaultActiveId(selection); const suffix = source === "outliner" && options.focusViewport ? " and framed it in the viewport" : ""; switch (selection.kind) { case "none": setStatusMessage( `${source === "viewport" ? "Viewport" : "Editor"} selection cleared${suffix}.` ); break; case "brushes": setStatusMessage( selection.ids.length === 1 ? `Selected ${getBrushLabelById(selection.ids[0], brushList)} from the ${source}${suffix}.` : `Selected ${selection.ids.length} whitebox solids from the ${source}${suffix}. Active: ${getBrushLabelById(activeSelectionId ?? selection.ids.at(-1) ?? selection.ids[0], brushList)}.` ); break; case "brushFace": setStatusMessage( `Selected ${ brushList.find((brush) => brush.id === selection.brushId) === undefined ? selection.faceId : getBrushFaceLabel( brushList.find((brush) => brush.id === selection.brushId)!, selection.faceId ) } on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.` ); break; case "brushEdge": setStatusMessage( `Selected ${ brushList.find((brush) => brush.id === selection.brushId) === undefined ? selection.edgeId : getBrushEdgeLabel( brushList.find((brush) => brush.id === selection.brushId)!, selection.edgeId ) } on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.` ); break; case "brushVertex": setStatusMessage( `Selected ${ brushList.find((brush) => brush.id === selection.brushId) === undefined ? selection.vertexId : getBrushVertexLabel( brushList.find((brush) => brush.id === selection.brushId)!, selection.vertexId ) } on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.` ); break; case "terrains": setStatusMessage( selection.ids.length === 1 ? `Selected ${getTerrainLabelById(selection.ids[0], terrainList)} from the ${source}${suffix}.` : `Selected ${selection.ids.length} terrains from the ${source}${suffix}.` ); break; case "paths": setStatusMessage( `Selected ${getPathLabelById(selection.ids[0], pathList)} from the ${source}${suffix}.` ); break; case "pathPoint": { const pointIndex = selectedPath !== null && selectedPath.id === selection.pathId ? getScenePathPointIndex(selectedPath, selection.pointId) : getScenePathPointIndex( editorState.document.paths[selection.pathId] ?? { points: [] }, selection.pointId ); setStatusMessage( `Selected path point ${pointIndex === -1 ? selection.pointId : pointIndex + 1} on ${getPathLabelById(selection.pathId, pathList)} from the ${source}${suffix}.` ); break; } case "entities": setStatusMessage( selection.ids.length === 1 ? `Selected ${getEntityDisplayLabelById(selection.ids[0], editorState.document.entities, editorState.document.assets)} from the ${source}${suffix}.` : `Selected ${selection.ids.length} entities from the ${source}${suffix}. Active: ${getEntityDisplayLabelById(activeSelectionId ?? selection.ids.at(-1) ?? selection.ids[0], editorState.document.entities, editorState.document.assets)}.` ); break; case "modelInstances": setStatusMessage( selection.ids.length === 1 ? `Selected ${getModelInstanceDisplayLabelById(selection.ids[0], editorState.document.modelInstances, editorState.document.assets)} from the ${source}${suffix}.` : `Selected ${selection.ids.length} model instances from the ${source}${suffix}. Active: ${getModelInstanceDisplayLabelById(activeSelectionId ?? selection.ids.at(-1) ?? selection.ids[0], editorState.document.modelInstances, editorState.document.assets)}.` ); break; default: setStatusMessage(`Selection updated from the ${source}${suffix}.`); break; } if (options.focusViewport) { requestViewportFocus(selection); } }; const applyPositionChange = () => { if ( selectedBrush === null || editorState.selection.kind !== "brushes" || whiteboxSelectionMode !== "object" ) { setStatusMessage( "Switch to Object mode and select a whitebox box before moving it." ); return; } try { const nextCenter = maybeSnapVec3( readVec3Draft(positionDraft, "Whitebox box position"), whiteboxSnapEnabled, whiteboxSnapStep ); if (areVec3Equal(nextCenter, selectedBrush.center)) { return; } store.executeCommand( createMoveBoxBrushCommand({ brushId: selectedBrush.id, center: nextCenter, snapToGrid: false }) ); setStatusMessage("Moved selected whitebox box."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyRotationChange = () => { if ( selectedBrush === null || editorState.selection.kind !== "brushes" || whiteboxSelectionMode !== "object" ) { setStatusMessage( "Switch to Object mode and select a whitebox box before rotating it." ); return; } try { const nextRotationDegrees = readVec3Draft( rotationDraft, "Whitebox box rotation" ); if (areVec3Equal(nextRotationDegrees, selectedBrush.rotationDegrees)) { return; } store.executeCommand( createRotateBoxBrushCommand({ brushId: selectedBrush.id, rotationDegrees: nextRotationDegrees }) ); setStatusMessage("Rotated selected whitebox box."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySizeChange = () => { if ( selectedBrush === null || editorState.selection.kind !== "brushes" || whiteboxSelectionMode !== "object" ) { setStatusMessage( "Switch to Object mode and select a whitebox box before scaling it." ); return; } try { const nextSize = maybeSnapPositiveSize( readVec3Draft(sizeDraft, "Whitebox box size"), whiteboxSnapEnabled, whiteboxSnapStep ); if (areVec3Equal(nextSize, selectedBrush.size)) { return; } store.executeCommand( createResizeBoxBrushCommand({ brushId: selectedBrush.id, size: nextSize, snapToGrid: false }) ); setStatusMessage("Scaled selected whitebox box."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyBoxVolumeSettings = ( mutate: (next: BoxBrush["volume"]) => BoxBrush["volume"], label: string, successMessage: string ) => { if (selectedBrush === null || selectedBrush.kind !== "box") { setStatusMessage( "Select a whitebox box before editing box 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) { setStatusMessage( "Select a whitebox box before changing the volume mode." ); return; } if (selectedBrush.volume.mode === mode) { return; } applyBoxVolumeSettings( (currentVolume) => { switch (mode) { case "none": return { mode: "none" }; case "water": return currentVolume.mode === "water" ? currentVolume : { mode: "water", water: { colorHex: boxVolumeWaterColorDraft, surfaceOpacity: readNonNegativeNumberDraft( boxVolumeWaterSurfaceOpacityDraft, "Water surface opacity" ), waveStrength: readNonNegativeNumberDraft( boxVolumeWaterWaveStrengthDraft, "Water wave strength" ), foamContactLimit: readWaterFoamContactLimitDraft( boxVolumeWaterFoamContactLimitDraft ), surfaceDisplacementEnabled: boxVolumeWaterSurfaceDisplacementEnabledDraft } }; case "fog": return currentVolume.mode === "fog" ? currentVolume : { mode: "fog", fog: { colorHex: boxVolumeFogColorDraft, density: readNonNegativeNumberDraft( boxVolumeFogDensityDraft, "Fog density" ), padding: readNonNegativeNumberDraft( boxVolumeFogPaddingDraft, "Fog padding" ) } }; case "light": return currentVolume.mode === "light" ? currentVolume : { mode: "light", light: { colorHex: boxVolumeLightColorDraft, intensity: readNonNegativeNumberDraft( boxVolumeLightIntensityDraft, "Light intensity" ), padding: readNonNegativeNumberDraft( boxVolumeLightPaddingDraft, "Light padding" ), falloff: boxVolumeLightFalloffDraft } }; } }, `Set box volume mode to ${mode}`, `Set selected whitebox box volume mode to ${formatBoxVolumeModeLabel(mode)}.` ); }; const resolveDraftBoxWaterSettings = ( overrides: { colorHex?: string; surfaceOpacity?: number; waveStrength?: number; foamContactLimit?: number; surfaceDisplacementEnabled?: boolean; } = {} ) => ({ colorHex: overrides.colorHex ?? boxVolumeWaterColorDraft, surfaceOpacity: overrides.surfaceOpacity ?? readNonNegativeNumberDraft( boxVolumeWaterSurfaceOpacityDraft, "Water surface opacity" ), waveStrength: overrides.waveStrength ?? readNonNegativeNumberDraft( boxVolumeWaterWaveStrengthDraft, "Water wave strength" ), foamContactLimit: overrides.foamContactLimit ?? readWaterFoamContactLimitDraft(boxVolumeWaterFoamContactLimitDraft), surfaceDisplacementEnabled: overrides.surfaceDisplacementEnabled ?? boxVolumeWaterSurfaceDisplacementEnabledDraft }); const applyBoxWaterSettings = ( overrides: { colorHex?: string; surfaceOpacity?: number; waveStrength?: number; foamContactLimit?: number; surfaceDisplacementEnabled?: boolean; } = {} ) => { if (selectedBrush === null || selectedBrush.volume.mode !== "water") { return; } applyBoxVolumeSettings( () => ({ mode: "water", water: resolveDraftBoxWaterSettings(overrides) }), "Set box water settings", "Updated selected whitebox water settings." ); }; const applyBoxWaterColorDraft = (colorHex: string) => { if (selectedBrush === null || selectedBrush.volume.mode !== "water") { return; } applyBoxVolumeSettings( () => ({ mode: "water", water: resolveDraftBoxWaterSettings({ colorHex }) }), "Set box water color", "Updated selected whitebox water color." ); }; const applyBoxFogSettings = () => { if (selectedBrush === null || selectedBrush.volume.mode !== "fog") { return; } applyBoxVolumeSettings( () => ({ mode: "fog", fog: { colorHex: boxVolumeFogColorDraft, density: readNonNegativeNumberDraft( boxVolumeFogDensityDraft, "Fog density" ), padding: readNonNegativeNumberDraft( boxVolumeFogPaddingDraft, "Fog padding" ) } }), "Set box fog settings", "Updated selected whitebox fog settings." ); }; const applyBoxFogColorDraft = (colorHex: string) => { if (selectedBrush === null || selectedBrush.volume.mode !== "fog") { return; } applyBoxVolumeSettings( () => ({ mode: "fog", fog: { colorHex, density: readNonNegativeNumberDraft( boxVolumeFogDensityDraft, "Fog density" ), padding: readNonNegativeNumberDraft( boxVolumeFogPaddingDraft, "Fog padding" ) } }), "Set box fog color", "Updated selected whitebox fog color." ); }; const resolveDraftBoxLightSettings = ( overrides: { colorHex?: string; intensity?: number; padding?: number; falloff?: BoxBrushLightFalloffMode; } = {} ) => ({ colorHex: overrides.colorHex ?? boxVolumeLightColorDraft, intensity: overrides.intensity ?? readNonNegativeNumberDraft( boxVolumeLightIntensityDraft, "Light intensity" ), padding: overrides.padding ?? readNonNegativeNumberDraft(boxVolumeLightPaddingDraft, "Light padding"), falloff: overrides.falloff ?? boxVolumeLightFalloffDraft }); const applyBoxLightSettings = ( overrides: { colorHex?: string; intensity?: number; padding?: number; falloff?: BoxBrushLightFalloffMode; } = {} ) => { if (selectedBrush === null || selectedBrush.volume.mode !== "light") { return; } applyBoxVolumeSettings( () => ({ mode: "light", light: resolveDraftBoxLightSettings(overrides) }), "Set box light settings", "Updated selected whitebox light settings." ); }; const applyBoxLightColorDraft = (colorHex: string) => { if (selectedBrush === null || selectedBrush.volume.mode !== "light") { return; } applyBoxVolumeSettings( () => ({ mode: "light", light: resolveDraftBoxLightSettings({ colorHex }) }), "Set box light color", "Updated selected whitebox light color." ); }; const commitEntityChange = ( currentEntity: EntityInstance, nextEntity: EntityInstance, successMessage: string ) => { if (areEntityInstancesEqual(currentEntity, nextEntity)) { return; } store.executeCommand( createUpsertEntityCommand({ entity: nextEntity, label: `Update ${getEntityKindLabel(nextEntity.kind).toLowerCase()}` }) ); setStatusMessage(successMessage); }; const beginEntityCreation = ( kind: EntityKind, options: { audioAssetId?: string | null; modelAssetId?: string | null; } = {} ) => { beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "entity", entityKind: kind, audioAssetId: options.audioAssetId ?? null, modelAssetId: options.modelAssetId ?? null }, center: null }, `Previewing ${getEntityKindLabel(kind)}. Click in the viewport to place it.` ); }; const beginModelInstanceCreation = (assetId: string) => { const asset = editorState.document.assets[assetId]; if (asset === undefined || asset.kind !== "model") { setStatusMessage("Select a model asset before placing a model instance."); return; } beginCreation( { kind: "create", sourcePanelId: activePanelId, target: { kind: "model-instance", assetId: asset.id }, center: null }, `Previewing ${asset.sourceName}. Click in the viewport to place it.` ); }; const buildRuntimeSceneForProjectScene = ( sceneId: string, options: { sceneEntryId?: string | null } = {} ): RuntimeSceneDefinition => { const sceneDocument = createSceneDocumentFromProject( editorState.projectDocument, sceneId ); return buildRuntimeSceneFromDocument(sceneDocument, { loadedModelAssets, runtimeClock: runtimeGlobalState.clock, sceneEntryId: options.sceneEntryId }); }; const applyRuntimeSceneSession = ( sceneId: string, nextRuntimeScene: RuntimeSceneDefinition ) => { const projectScene = editorState.projectDocument.scenes[sceneId]; if (projectScene === undefined) { throw new Error(`Project scene ${sceneId} does not exist.`); } setRuntimeScene(nextRuntimeScene); setRuntimeSceneId(projectScene.id); setRuntimeSceneName(projectScene.name); setRuntimeSceneLoadingScreen( cloneSceneLoadingScreenSettings(projectScene.loadingScreen) ); setActiveNavigationMode(nextRuntimeScene.navigationMode); }; const handleRuntimeClockChange = (clock: RuntimeClockState) => { setRuntimeGlobalState((currentState) => areRuntimeClockStatesEqual(currentState.clock, clock) ? currentState : { ...currentState, clock } ); }; const handlePlayEditorSimulation = () => { editorSimulationController.play(); }; const handlePauseEditorSimulation = () => { editorSimulationController.pause(); }; const handleResetEditorSimulation = () => { editorSimulationController.reset(); }; const handleStepEditorSimulation = (deltaHours: number) => { editorSimulationController.stepHours(deltaHours); }; const handleRunnerSceneTransitionActivated = ( request: RuntimeSceneTransitionRequest ) => { if (runtimeSceneId === null || runtimeSceneName === null) { setRuntimeMessage("Scene transition failed: run mode is not active."); return; } const sourceScene = editorState.projectDocument.scenes[runtimeSceneId]; const targetScene = editorState.projectDocument.scenes[request.targetSceneId]; if (sourceScene === undefined) { setRuntimeMessage( `Scene transition failed: source scene ${runtimeSceneId} is no longer available.` ); return; } if (targetScene === undefined) { setRuntimeMessage( `Scene transition failed: target scene ${request.targetSceneId} does not exist.` ); return; } const targetEntry = targetScene.entities[request.targetEntryEntityId]; if (targetEntry === undefined) { setRuntimeMessage( `Scene transition failed: target Scene Entry ${request.targetEntryEntityId} does not exist in ${targetScene.name}.` ); return; } if (targetEntry.kind !== "sceneEntry") { setRuntimeMessage( `Scene transition failed: target ${request.targetEntryEntityId} in ${targetScene.name} is not a Scene Entry.` ); return; } try { const nextRuntimeScene = buildRuntimeSceneForProjectScene( targetScene.id, { sceneEntryId: targetEntry.id } ); applyRuntimeSceneSession(targetScene.id, nextRuntimeScene); setRuntimeMessage(null); setFirstPersonTelemetry(null); setRuntimeInteractionPrompt(null); setRuntimeGlobalState((currentState) => ({ ...currentState, transitionCount: currentState.transitionCount + 1, lastSceneTransition: { fromSceneId: sourceScene.id, fromSceneName: sourceScene.name, toSceneId: targetScene.id, toSceneName: targetScene.name, sourceEntityId: request.sourceEntityId, targetEntryEntityId: targetEntry.id } })); setStatusMessage(`Loaded ${targetScene.name}.`); } catch (error) { const message = getErrorMessage(error); setRuntimeMessage(`Scene transition failed: ${message}`); setStatusMessage(`Scene transition failed: ${message}`); } }; const handleCommitCreation = ( creationPreview: CreationViewportToolPreview ): boolean => { try { if (creationPreview.target.kind === "box-brush") { const center = creationPreview.center === null ? undefined : creationPreview.center; store.executeCommand( createCreateBoxBrushCommand( center === undefined ? { snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } : { center, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } ) ); completeCreation( center === undefined ? 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)}.` ); return true; } if (creationPreview.target.kind === "wedge-brush") { const center = creationPreview.center === null ? undefined : creationPreview.center; store.executeCommand( createCreateWedgeBrushCommand( center === undefined ? { snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } : { center, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } ) ); completeCreation( center === undefined ? whiteboxSnapEnabled ? `Created a whitebox wedge on the ${whiteboxSnapStep}m grid.` : "Created a whitebox wedge." : whiteboxSnapEnabled ? `Created a whitebox wedge at snapped center ${formatVec3(center)}.` : `Created a whitebox wedge at ${formatVec3(center)}.` ); return true; } if (creationPreview.target.kind === "cylinder-brush") { const center = creationPreview.center === null ? undefined : creationPreview.center; store.executeCommand( createCreateRadialPrismBrushCommand( center === undefined ? { sideCount: creationPreview.target.sideCount, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } : { center, sideCount: creationPreview.target.sideCount, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } ) ); completeCreation( center === undefined ? whiteboxSnapEnabled ? `Created a whitebox cylinder on the ${whiteboxSnapStep}m grid.` : "Created a whitebox cylinder." : whiteboxSnapEnabled ? `Created a whitebox cylinder at snapped center ${formatVec3(center)}.` : `Created a whitebox cylinder at ${formatVec3(center)}.` ); return true; } if (creationPreview.target.kind === "cone-brush") { const center = creationPreview.center === null ? undefined : creationPreview.center; store.executeCommand( createCreateConeBrushCommand( center === undefined ? { sideCount: creationPreview.target.sideCount, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } : { center, sideCount: creationPreview.target.sideCount, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } ) ); completeCreation( center === undefined ? whiteboxSnapEnabled ? `Created a whitebox cone on the ${whiteboxSnapStep}m grid.` : "Created a whitebox cone." : whiteboxSnapEnabled ? `Created a whitebox cone at snapped center ${formatVec3(center)}.` : `Created a whitebox cone at ${formatVec3(center)}.` ); return true; } if (creationPreview.target.kind === "torus-brush") { const center = creationPreview.center === null ? undefined : creationPreview.center; store.executeCommand( createCreateTorusBrushCommand( center === undefined ? { majorSegmentCount: creationPreview.target.majorSegmentCount, tubeSegmentCount: creationPreview.target.tubeSegmentCount, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } : { center, majorSegmentCount: creationPreview.target.majorSegmentCount, tubeSegmentCount: creationPreview.target.tubeSegmentCount, snapToGrid: whiteboxSnapEnabled, gridSize: whiteboxSnapStep } ) ); completeCreation( center === undefined ? whiteboxSnapEnabled ? `Created a whitebox torus on the ${whiteboxSnapStep}m grid.` : "Created a whitebox torus." : whiteboxSnapEnabled ? `Created a whitebox torus at snapped center ${formatVec3(center)}.` : `Created a whitebox torus at ${formatVec3(center)}.` ); return true; } if (creationPreview.target.kind === "model-instance") { const asset = editorState.document.assets[creationPreview.target.assetId]; if (asset === undefined || asset.kind !== "model") { setStatusMessage( "Select a model asset before placing a model instance." ); return false; } const nextModelInstance = createModelInstance({ assetId: asset.id, position: creationPreview.center === null ? createModelInstancePlacementPosition(asset, null) : creationPreview.center, rotationDegrees: DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, scale: DEFAULT_MODEL_INSTANCE_SCALE }); store.executeCommand( createUpsertModelInstanceCommand({ modelInstance: nextModelInstance, label: `Place ${asset.sourceName}` }) ); completeCreation(`Placed ${asset.sourceName}.`); return true; } const position = creationPreview.center ?? DEFAULT_ENTITY_POSITION; switch (creationPreview.target.entityKind) { case "pointLight": store.executeCommand( createUpsertEntityCommand({ entity: createPointLightEntity({ position }), label: "Place point light" }) ); completeCreation("Placed Point Light."); return true; case "spotLight": store.executeCommand( createUpsertEntityCommand({ entity: createSpotLightEntity({ position }), label: "Place spot light" }) ); completeCreation("Placed Spot Light."); return true; case "cameraRig": store.executeCommand( createUpsertEntityCommand({ entity: createCameraRigEntity({ position }), label: "Place camera rig" }) ); completeCreation("Placed Camera Rig."); return true; case "playerStart": store.executeCommand( createUpsertEntityCommand({ entity: createPlayerStartEntity({ position }), label: "Place player start" }) ); completeCreation("Placed Player Start."); return true; case "sceneEntry": store.executeCommand( createUpsertEntityCommand({ entity: createSceneEntryEntity({ position }), label: "Place scene entry" }) ); completeCreation("Placed Scene Entry."); return true; case "npc": { const placedModelAssetId = creationPreview.target.modelAssetId ?? null; store.executeCommand( createUpsertEntityCommand({ entity: createNpcEntity({ position, modelAssetId: placedModelAssetId }), label: "Place npc" }) ); completeCreation( placedModelAssetId === null ? "Placed NPC." : `Placed NPC using ${editorState.document.assets[placedModelAssetId]?.sourceName ?? "the authored model asset"}.` ); return true; } case "soundEmitter": { const placedAudioAssetId = creationPreview.target.audioAssetId ?? audioAssetList[0]?.id ?? null; store.executeCommand( createUpsertEntityCommand({ entity: createSoundEmitterEntity({ position, audioAssetId: placedAudioAssetId }), label: "Place sound emitter" }) ); completeCreation( placedAudioAssetId === null ? "Placed Sound Emitter." : `Placed Sound Emitter using ${editorState.document.assets[placedAudioAssetId]?.sourceName ?? "the authored audio asset"}.` ); return true; } case "triggerVolume": store.executeCommand( createUpsertEntityCommand({ entity: createTriggerVolumeEntity({ position }), label: "Place trigger volume" }) ); completeCreation("Placed Trigger Volume."); return true; case "teleportTarget": store.executeCommand( createUpsertEntityCommand({ entity: createTeleportTargetEntity({ position }), label: "Place teleport target" }) ); completeCreation("Placed Teleport Target."); return true; case "interactable": store.executeCommand( createUpsertEntityCommand({ entity: createInteractableEntity({ position }), label: "Place interactable" }) ); completeCreation("Placed Interactable."); return true; } } catch (error) { setStatusMessage(getErrorMessage(error)); } return false; }; const handleCreatePath = () => { try { const nextPath = createScenePath(); store.executeCommand( createUpsertPathCommand({ path: nextPath, label: "Create path" }) ); requestViewportFocus({ kind: "paths", ids: [nextPath.id] }); setStatusMessage("Created Path and framed it in the viewport."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleCreateTerrain = () => { try { const nextTerrain = createTerrain(); store.executeCommand( createUpsertTerrainCommand({ terrain: nextTerrain, label: "Create terrain" }) ); requestViewportFocus({ kind: "terrains", ids: [nextTerrain.id] }); setStatusMessage("Created Terrain and framed it in the viewport."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleArmTerrainBrushTool = (tool: TerrainBrushTool) => { if (selectedTerrain === null) { return; } if (armedTerrainBrushTool === tool) { setArmedTerrainBrushTool(null); setStatusMessage( `Disarmed terrain brush editing for ${getTerrainLabelById(selectedTerrain.id, terrainList)}.` ); return; } setArmedTerrainBrushTool(tool); const paintLayerLabel = tool === "paint" ? ` ${getTerrainLayerLabel( clampTerrainPaintLayerIndex(activeTerrainPaintLayerIndex) ).toLowerCase()}` : ""; setStatusMessage( `Armed ${getTerrainBrushToolLabel(tool)} terrain brush${paintLayerLabel} for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Drag in the viewport to edit the selected terrain.` ); }; const handleTerrainBrushRadiusChange = (value: string) => { setTerrainBrushSettings((currentSettings) => ({ ...currentSettings, radius: clampTerrainBrushRadius(Number(value)) })); }; const handleTerrainBrushStrengthChange = (value: string) => { setTerrainBrushSettings((currentSettings) => ({ ...currentSettings, strength: clampTerrainBrushStrength(Number(value)) })); }; const handleTerrainBrushFalloffChange = (value: string) => { setTerrainBrushSettings((currentSettings) => ({ ...currentSettings, falloff: clampTerrainBrushFalloff(Number(value)) })); }; const handleTerrainPaintLayerChange = (value: string) => { setActiveTerrainPaintLayerIndex(clampTerrainPaintLayerIndex(Number(value))); }; const handleTerrainLayerMaterialChange = ( layerIndex: number, materialId: string ) => { if (selectedTerrain === null) { return; } const nextMaterialId = materialId === "" ? null : materialId; const currentMaterialId = selectedTerrain.layers[layerIndex]?.materialId ?? null; if (currentMaterialId === nextMaterialId) { return; } try { const nextTerrain = createTerrain({ ...selectedTerrain, layers: selectedTerrain.layers.map((layer, currentLayerIndex) => ({ materialId: currentLayerIndex === layerIndex ? nextMaterialId : layer.materialId })) }); store.executeCommand( createUpsertTerrainCommand({ terrain: nextTerrain, label: `Set ${getTerrainLayerLabel(layerIndex).toLowerCase()} material` }) ); setStatusMessage( `${getTerrainLayerLabel(layerIndex)} now uses ${ nextMaterialId === null ? "no assigned material" : (editorState.document.materials[nextMaterialId]?.name ?? nextMaterialId) }.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleCommitTerrainBrushStroke = ( commit: TerrainBrushStrokeCommit ): boolean => { try { store.executeCommand( createUpsertTerrainCommand({ terrain: commit.terrain, label: commit.commandLabel }) ); setStatusMessage( `${commit.commandLabel} on ${getTerrainLabelById(commit.terrain.id, terrainList)}.` ); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const commitPathChange = ( currentPath: ScenePath, nextPath: ScenePath, successMessage: string ) => { if (areScenePathsEqual(currentPath, nextPath)) { return; } store.executeCommand( createUpsertPathCommand({ path: nextPath, label: "Update path" }) ); setStatusMessage(successMessage); }; const applyPathChange = () => { if (selectedPath === null) { setStatusMessage("Select a path before editing it."); return; } try { const nextPath = createScenePath({ id: selectedPath.id, name: selectedPath.name, visible: selectedPath.visible, enabled: selectedPath.enabled, loop: selectedPath.loop, points: selectedPath.points.map((point, index) => ({ id: point.id, position: readVec3Draft( pathPointDrafts[index] ?? createVec3Draft(point.position), `Path point ${index + 1}` ) })) }); commitPathChange(selectedPath, nextPath, "Updated Path points."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handlePathLoopChange = (loop: boolean) => { if (selectedPath === null) { setStatusMessage("Select a path before changing its loop state."); return; } try { const nextPath = createScenePath({ ...selectedPath, loop }); commitPathChange( selectedPath, nextPath, loop ? "Enabled Path looping." : "Disabled Path looping." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handlePathPointDraftChange = ( pointIndex: number, axis: keyof Vec3Draft, value: string ) => { setPathPointDrafts((currentDrafts) => currentDrafts.map((draft, index) => index === pointIndex ? { ...draft, [axis]: value } : draft ) ); }; const handleAddPathPoint = () => { if (selectedPath === null) { setStatusMessage("Select a path before adding a point."); return; } try { const nextPoint = createAppendedScenePathPoint(selectedPath); store.executeCommand( createAddPathPointCommand({ pathId: selectedPath.id, point: nextPoint, label: "Add path point" }) ); requestViewportFocus( { kind: "pathPoint", pathId: selectedPath.id, pointId: nextPoint.id }, `Appended path point ${selectedPath.points.length + 1} to ${getPathLabelById(selectedPath.id, pathList)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleDeletePathPoint = (pointId: string) => { if (selectedPath === null) { setStatusMessage("Select a path before removing a point."); return; } try { store.executeCommand( createDeletePathPointCommand({ pathId: selectedPath.id, pointId, label: "Delete path point" }) ); setStatusMessage("Removed the selected path point."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const commitModelInstanceChange = ( currentModelInstance: ModelInstance, nextModelInstance: ModelInstance, successMessage: string ) => { if (areModelInstancesEqual(currentModelInstance, nextModelInstance)) { return; } store.executeCommand( createUpsertModelInstanceCommand({ modelInstance: nextModelInstance, label: `Update ${getModelInstanceDisplayLabelById(currentModelInstance.id, editorState.document.modelInstances, editorState.document.assets).toLowerCase()}` }) ); setStatusMessage(successMessage); }; const commitTerrainChange = ( currentTerrain: Terrain, nextTerrain: Terrain, commandLabel: string, successMessage: string ): boolean => { if (areTerrainsEqual(currentTerrain, nextTerrain)) { return false; } store.executeCommand( createUpsertTerrainCommand({ terrain: nextTerrain, label: commandLabel }) ); setStatusMessage(successMessage); return true; }; 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, collision: selectedModelInstance.collision, position: readVec3Draft(modelPositionDraft, "Model instance position"), rotationDegrees: readVec3Draft( modelRotationDraft, "Model instance rotation" ), scale: readPositiveVec3Draft(modelScaleDraft, "Model instance scale"), animationClipName: selectedModelInstance.animationClipName, animationAutoplay: selectedModelInstance.animationAutoplay }); commitModelInstanceChange( selectedModelInstance, nextModelInstance, "Updated model instance." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyTerrainGridChange = () => { if (selectedTerrain === null) { setStatusMessage("Select a terrain before resizing its grid."); return; } try { const nextTerrain = resizeTerrainGrid(selectedTerrain, { sampleCountX: Number(terrainSampleCountXDraft), sampleCountZ: Number(terrainSampleCountZDraft), cellSize: Number(terrainCellSizeDraft) }); const terrainLabel = getTerrainLabelById(selectedTerrain.id, terrainList); commitTerrainChange( selectedTerrain, nextTerrain, "Resize terrain grid", `Resampled ${terrainLabel} to ${nextTerrain.sampleCountX} x ${nextTerrain.sampleCountZ} samples with ${nextTerrain.cellSize}m square cells.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleTerrainCollisionEnabledChange = (enabled: boolean) => { if ( selectedTerrain === null || selectedTerrain.collisionEnabled === enabled ) { return; } try { const nextTerrain = createTerrain({ ...selectedTerrain, collisionEnabled: enabled }); const terrainLabel = getTerrainLabelById(selectedTerrain.id, terrainList); commitTerrainChange( selectedTerrain, nextTerrain, enabled ? "Enable terrain collision" : "Disable terrain collision", enabled ? `${terrainLabel} now participates in runner collision as a heightfield.` : `${terrainLabel} no longer contributes runner collision.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const setPlayerStartMovementTemplateEditorDraft = ( template: PlayerStartMovementTemplate ) => { setPlayerStartMovementTemplateDraft(template); setPlayerStartMovementTemplateNumberDraft( createPlayerStartMovementTemplateNumberDraft(template) ); }; const buildPlayerStartMovementTemplateFromDraft = ( overrides: { kind?: PlayerStartMovementTemplate["kind"]; capabilities?: Partial; jump?: Partial; sprint?: Partial; crouch?: Partial; } = {} ): PlayerStartMovementTemplate => { const rawTemplate = createPlayerStartMovementTemplate({ kind: overrides.kind ?? playerStartMovementTemplateDraft.kind, moveSpeed: readPositiveNumberDraft( playerStartMovementTemplateNumberDraft.moveSpeed, "Player Start move speed" ), maxSpeed: readNonNegativeNumberDraft( playerStartMovementTemplateNumberDraft.maxSpeed, "Player Start max speed" ), maxStepHeight: readNonNegativeNumberDraft( playerStartMovementTemplateNumberDraft.maxStepHeight, "Player Start max step height" ), capabilities: { ...playerStartMovementTemplateDraft.capabilities, ...overrides.capabilities }, jump: { ...playerStartMovementTemplateDraft.jump, ...overrides.jump, speed: readPositiveNumberDraft( playerStartMovementTemplateNumberDraft.jumpSpeed, "Player Start jump speed" ), bufferMs: readNonNegativeNumberDraft( playerStartMovementTemplateNumberDraft.jumpBufferMs, "Player Start jump buffer" ), coyoteTimeMs: readNonNegativeNumberDraft( playerStartMovementTemplateNumberDraft.coyoteTimeMs, "Player Start coyote time" ), maxHoldMs: readPositiveNumberDraft( playerStartMovementTemplateNumberDraft.variableJumpMaxHoldMs, "Player Start variable jump max hold" ), bunnyHopBoost: readNonNegativeNumberDraft( playerStartMovementTemplateNumberDraft.bunnyHopBoost, "Player Start bunny hop boost" ) }, sprint: { ...playerStartMovementTemplateDraft.sprint, ...overrides.sprint, speedMultiplier: readPositiveNumberDraft( playerStartMovementTemplateNumberDraft.sprintSpeedMultiplier, "Player Start sprint speed multiplier" ) }, crouch: { ...playerStartMovementTemplateDraft.crouch, ...overrides.crouch, speedMultiplier: readPositiveNumberDraft( playerStartMovementTemplateNumberDraft.crouchSpeedMultiplier, "Player Start crouch speed multiplier" ) } }); return createPlayerStartMovementTemplate({ ...rawTemplate, kind: overrides.kind ?? inferPlayerStartMovementTemplateKind(rawTemplate) }); }; const commitPlayerStartMovementTemplateDraft = ( overrides: { kind?: PlayerStartMovementTemplate["kind"]; capabilities?: Partial; jump?: Partial; sprint?: Partial; crouch?: Partial; } = {}, options: { schedule?: boolean; } = {} ) => { try { const nextTemplate = buildPlayerStartMovementTemplateFromDraft(overrides); setPlayerStartMovementTemplateDraft(nextTemplate); if (options.schedule === true) { scheduleDraftCommit(() => applyPlayerStartChange({ movementTemplate: nextTemplate }) ); return; } applyPlayerStartChange({ movementTemplate: nextTemplate }); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyCameraRigChange = ( overrides: { rigType?: CameraRigType; pathId?: string; railPlacementMode?: CameraRigRailPlacementMode; defaultActive?: boolean; targetKind?: CameraRigTargetKind; transitionMode?: CameraRigTransitionMode; lookAroundEnabled?: boolean; } = {} ) => { if (selectedCameraRig === null) { setStatusMessage("Select a Camera Rig before editing it."); return; } try { const rigType = overrides.rigType ?? cameraRigRigTypeDraft; const pathId = (overrides.pathId ?? cameraRigPathIdDraft).trim() || cameraRigPathOptions[0]?.path.id || ""; const railPlacementMode = overrides.railPlacementMode ?? cameraRigRailPlacementModeDraft; const targetKind = overrides.targetKind ?? cameraRigTargetKindDraft; const targetActorId = cameraRigTargetActorIdDraft.trim() || cameraRigActorOptions[0] || ""; const targetEntityId = cameraRigTargetEntityIdDraft.trim() || cameraRigEntityTargetOptions[0]?.entity.id || ""; const target = targetKind === "player" ? createCameraRigPlayerTargetRef() : targetKind === "actor" ? createCameraRigActorTargetRef(targetActorId) : targetKind === "entity" ? createCameraRigEntityTargetRef(targetEntityId) : createCameraRigWorldPointTargetRef( readVec3Draft( cameraRigTargetWorldPointDraft, "Camera Rig world target" ) ); const fixedPosition = snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Camera Rig position"), DEFAULT_GRID_SIZE ); const railStartProgress = readNonNegativeNumberDraft( cameraRigRailStartProgressDraft, "Camera Rig rail start progress" ); const railEndProgress = readNonNegativeNumberDraft( cameraRigRailEndProgressDraft, "Camera Rig rail end progress" ); if (railStartProgress > 1 || railEndProgress > 1) { throw new Error( "Camera Rig mapped rail progress values must remain between 0 and 1." ); } const nextEntity = createCameraRigEntity({ id: selectedCameraRig.id, name: selectedCameraRig.name, visible: selectedCameraRig.visible, enabled: selectedCameraRig.enabled, ...(rigType === "fixed" ? { rigType: "fixed" as const, position: fixedPosition } : railPlacementMode === "mapTargetBetweenPoints" ? { rigType: "rail" as const, pathId, railPlacementMode: "mapTargetBetweenPoints" as const, trackStartPoint: readVec3Draft( cameraRigTrackStartPointDraft, "Camera Rig track start point" ), trackEndPoint: readVec3Draft( cameraRigTrackEndPointDraft, "Camera Rig track end point" ), railStartProgress, railEndProgress } : { rigType: "rail" as const, pathId, railPlacementMode: "nearestToTarget" as const }), priority: readNonNegativeNumberDraft( cameraRigPriorityDraft, "Camera Rig priority" ), defaultActive: overrides.defaultActive ?? cameraRigDefaultActiveDraft, target, targetOffset: readVec3Draft( cameraRigTargetOffsetDraft, "Camera Rig target offset" ), transitionMode: overrides.transitionMode ?? cameraRigTransitionModeDraft, transitionDurationSeconds: readNonNegativeNumberDraft( cameraRigTransitionDurationDraft, "Camera Rig transition duration" ), lookAround: { enabled: overrides.lookAroundEnabled ?? cameraRigLookAroundEnabledDraft, yawLimitDegrees: readNonNegativeNumberDraft( cameraRigLookAroundYawLimitDraft, "Camera Rig look-around yaw limit" ), pitchLimitDegrees: readNonNegativeNumberDraft( cameraRigLookAroundPitchLimitDraft, "Camera Rig look-around pitch limit" ), recenterSpeed: readNonNegativeNumberDraft( cameraRigLookAroundRecenterSpeedDraft, "Camera Rig look-around recenter speed" ) } }); commitEntityChange(selectedCameraRig, nextEntity, "Updated Camera Rig."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyPlayerStartChange = ( overrides: { colliderMode?: PlayerStartColliderMode; movementTemplate?: PlayerStartMovementTemplate; navigationMode?: PlayerStartNavigationMode; inputBindings?: PlayerStartInputBindings; } = {} ) => { if (selectedPlayerStart === null) { setStatusMessage("Select a Player Start before editing it."); return; } try { const snappedPosition = snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Player Start position"), DEFAULT_GRID_SIZE ); const yawDegrees = readYawDegreesDraft(playerStartYawDraft); const navigationMode = overrides.navigationMode ?? playerStartNavigationModeDraft; const movementTemplate = overrides.movementTemplate ?? playerStartMovementTemplateDraft; const colliderMode = overrides.colliderMode ?? playerStartColliderModeDraft; const inputBindings = overrides.inputBindings ?? playerStartInputBindingsDraft; const interactionReachMeters = readPositiveNumberDraft( playerStartInteractionReachDraft, "Player Start interaction reach" ); const interactionAngleDegrees = readFiniteNumberDraft( playerStartInteractionAngleDraft, "Player Start interaction angle" ); const nextEntity = createPlayerStartEntity({ id: selectedPlayerStart.id, name: selectedPlayerStart.name, position: snappedPosition, yawDegrees, navigationMode, interactionReachMeters, interactionAngleDegrees, allowLookInputTargetSwitch: playerStartAllowLookInputTargetSwitchDraft, targetButtonCyclesActiveTarget: playerStartTargetButtonCyclesActiveTargetDraft, movementTemplate, inputBindings, collider: { mode: colliderMode, eyeHeight: readPositiveNumberDraft( playerStartEyeHeightDraft, "Player Start eye height" ), capsuleRadius: readPositiveNumberDraft( playerStartCapsuleRadiusDraft, "Player Start capsule radius" ), capsuleHeight: readPositiveNumberDraft( playerStartCapsuleHeightDraft, "Player Start capsule height" ), boxSize: readPositiveVec3Draft( playerStartBoxSizeDraft, "Player Start box size" ) } }); commitEntityChange( selectedPlayerStart, nextEntity, "Updated Player Start." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const beginPlayerStartKeyboardCapture = (action: PlayerStartInputAction) => { if (playerStartKeyboardCaptureAction === action) { setPlayerStartKeyboardCaptureAction(null); setStatusMessage("Cancelled Player Start key capture."); return; } setPlayerStartKeyboardCaptureAction(action); setStatusMessage( action === "clearTarget" ? `Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Click the binding button again to cancel.` : `Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Press Escape to cancel.` ); }; const handlePlayerStartKeyboardBindingChange = ( action: PlayerStartInputAction, nextCode: PlayerStartKeyboardBindingCode ) => { const nextBindings = createPlayerStartInputBindings({ keyboard: { ...playerStartInputBindingsDraft.keyboard, [action]: nextCode } as PlayerStartInputBindings["keyboard"], gamepad: playerStartInputBindingsDraft.gamepad }); setPlayerStartInputBindingsDraft(nextBindings); scheduleDraftCommit(() => applyPlayerStartChange({ inputBindings: nextBindings }) ); }; const handlePlayerStartMovementGamepadBindingChange = ( action: PlayerStartMovementAction, nextBinding: PlayerStartGamepadBinding ) => { const nextBindings = createPlayerStartInputBindings({ keyboard: playerStartInputBindingsDraft.keyboard, gamepad: { ...playerStartInputBindingsDraft.gamepad, [action]: nextBinding } as PlayerStartInputBindings["gamepad"] }); setPlayerStartInputBindingsDraft(nextBindings); scheduleDraftCommit(() => applyPlayerStartChange({ inputBindings: nextBindings }) ); }; const handlePlayerStartGamepadActionBindingChange = ( action: PlayerStartLocomotionAction | PlayerStartSystemAction, nextBinding: PlayerStartGamepadActionBinding ) => { const nextBindings = createPlayerStartInputBindings({ keyboard: playerStartInputBindingsDraft.keyboard, gamepad: { ...playerStartInputBindingsDraft.gamepad, [action]: nextBinding } as PlayerStartInputBindings["gamepad"] }); setPlayerStartInputBindingsDraft(nextBindings); scheduleDraftCommit(() => applyPlayerStartChange({ inputBindings: nextBindings }) ); }; const handlePlayerStartGamepadCameraLookBindingChange = ( nextBinding: PlayerStartGamepadCameraLookBinding ) => { const nextBindings = createPlayerStartInputBindings({ keyboard: playerStartInputBindingsDraft.keyboard, gamepad: { ...playerStartInputBindingsDraft.gamepad, cameraLook: nextBinding } }); setPlayerStartInputBindingsDraft(nextBindings); scheduleDraftCommit(() => applyPlayerStartChange({ inputBindings: nextBindings }) ); }; const applySceneEntryChange = () => { if (selectedSceneEntry === null) { setStatusMessage("Select a Scene Entry before editing it."); return; } try { const nextEntity = createSceneEntryEntity({ id: selectedSceneEntry.id, name: selectedSceneEntry.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Scene Entry position"), DEFAULT_GRID_SIZE ), yawDegrees: readYawDegreesDraft(sceneEntryYawDraft) }); commitEntityChange( selectedSceneEntry, nextEntity, "Updated Scene Entry." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyNpcChange = ( overrides: { modelAssetId?: string | null; colliderMode?: PlayerStartColliderMode; } = {} ) => { if (selectedNpc === null) { setStatusMessage("Select an NPC before editing it."); return; } try { const trimmedModelAssetId = npcModelAssetIdDraft.trim(); const colliderMode = overrides.colliderMode ?? npcColliderModeDraft; const nextEntity = createNpcEntity({ id: selectedNpc.id, name: selectedNpc.name, actorId: npcActorIdDraft, presence: createNpcAlwaysPresence(), position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "NPC position"), DEFAULT_GRID_SIZE ), yawDegrees: readYawDegreesDraft(npcYawDraft), modelAssetId: overrides.modelAssetId !== undefined ? overrides.modelAssetId : trimmedModelAssetId.length === 0 ? null : trimmedModelAssetId, dialogues: selectedNpc.dialogues, defaultDialogueId: selectedNpc.defaultDialogueId, collider: { mode: colliderMode, eyeHeight: readPositiveNumberDraft( npcEyeHeightDraft, "NPC eye height" ), capsuleRadius: readPositiveNumberDraft( npcCapsuleRadiusDraft, "NPC capsule radius" ), capsuleHeight: readPositiveNumberDraft( npcCapsuleHeightDraft, "NPC capsule height" ), boxSize: readPositiveVec3Draft(npcBoxSizeDraft, "NPC box size") } }); commitEntityChange(selectedNpc, nextEntity, "Updated NPC."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const updateSelectedNpcDialogues = ( _label: string, successMessage: string, mutate: ( dialogues: ProjectDialogue[], defaultDialogueId: string | null ) => { dialogues: ProjectDialogue[]; defaultDialogueId: string | null; } ) => { if (selectedNpc === null) { setStatusMessage("Select an NPC before editing its dialogues."); return; } try { const nextState = mutate( selectedNpc.dialogues.map((dialogue) => ({ ...dialogue, lines: dialogue.lines.map((line) => ({ ...line })) })), selectedNpc.defaultDialogueId ); const nextEntity = createNpcEntity({ ...selectedNpc, dialogues: nextState.dialogues, defaultDialogueId: nextState.defaultDialogueId }); commitEntityChange(selectedNpc, nextEntity, successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyPointLightChange = (overrides: { colorHex?: string } = {}) => { if (selectedPointLight === null) { setStatusMessage("Select a Point Light before editing it."); return; } try { const nextEntity = createPointLightEntity({ id: selectedPointLight.id, name: selectedPointLight.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Point Light position"), DEFAULT_GRID_SIZE ), colorHex: overrides.colorHex ?? pointLightColorDraft, intensity: readNonNegativeNumberDraft( pointLightIntensityDraft, "Point Light intensity" ), distance: readPositiveNumberDraft( pointLightDistanceDraft, "Point Light distance" ) }); commitEntityChange( selectedPointLight, nextEntity, "Updated Point Light." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySpotLightChange = (overrides: { colorHex?: string } = {}) => { if (selectedSpotLight === null) { setStatusMessage("Select a Spot Light before editing it."); return; } try { const nextEntity = createSpotLightEntity({ id: selectedSpotLight.id, name: selectedSpotLight.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Spot Light position"), DEFAULT_GRID_SIZE ), direction: readVec3Draft( spotLightDirectionDraft, "Spot Light direction" ), colorHex: overrides.colorHex ?? spotLightColorDraft, intensity: readNonNegativeNumberDraft( spotLightIntensityDraft, "Spot Light intensity" ), distance: readPositiveNumberDraft( spotLightDistanceDraft, "Spot Light distance" ), angleDegrees: readPositiveNumberDraft( spotLightAngleDraft, "Spot Light angle" ) }); commitEntityChange(selectedSpotLight, nextEntity, "Updated Spot Light."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySelectedEntityDraftChange = () => { if (selectedEntity === null) { return; } switch (selectedEntity.kind) { case "pointLight": applyPointLightChange(); break; case "spotLight": applySpotLightChange(); break; case "cameraRig": applyCameraRigChange(); break; case "playerStart": applyPlayerStartChange(); break; case "sceneEntry": applySceneEntryChange(); break; case "npc": applyNpcChange(); break; case "soundEmitter": applySoundEmitterChange(); break; case "triggerVolume": applyTriggerVolumeChange(); break; case "teleportTarget": applyTeleportTargetChange(); break; case "interactable": applyInteractableChange(); break; } }; const applySoundEmitterChange = ( overrides: { audioAssetId?: string | null; autoplay?: boolean; loop?: boolean; } = {} ) => { if (selectedSoundEmitter === null) { setStatusMessage("Select a Sound Emitter before editing it."); return; } try { const trimmedAudioAssetId = soundEmitterAudioAssetIdDraft.trim(); const nextAudioAssetId = overrides.audioAssetId !== undefined ? overrides.audioAssetId : trimmedAudioAssetId.length === 0 ? null : trimmedAudioAssetId; const nextEntity = createSoundEmitterEntity({ id: selectedSoundEmitter.id, name: selectedSoundEmitter.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Sound Emitter position"), DEFAULT_GRID_SIZE ), audioAssetId: nextAudioAssetId, volume: readNonNegativeNumberDraft( soundEmitterVolumeDraft, "Sound Emitter volume" ), refDistance: readPositiveNumberDraft( soundEmitterRefDistanceDraft, "Sound Emitter ref distance" ), maxDistance: readPositiveNumberDraft( soundEmitterMaxDistanceDraft, "Sound Emitter max distance" ), autoplay: overrides.autoplay ?? soundEmitterAutoplayDraft, loop: overrides.loop ?? soundEmitterLoopDraft }); commitEntityChange( selectedSoundEmitter, nextEntity, "Updated Sound Emitter." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyTriggerVolumeChange = () => { if (selectedTriggerVolume === null) { setStatusMessage("Select a Trigger Volume before editing it."); return; } try { // Derive triggerOnEnter/triggerOnExit from the actual links so the flags // stay in sync automatically — no manual checkbox needed. const links = getInteractionLinksForSource( editorState.document.interactionLinks, selectedTriggerVolume.id ); const triggerOnEnter = links.some((l) => l.trigger === "enter"); const triggerOnExit = links.some((l) => l.trigger === "exit"); const nextEntity = createTriggerVolumeEntity({ id: selectedTriggerVolume.id, name: selectedTriggerVolume.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Trigger Volume position"), DEFAULT_GRID_SIZE ), size: snapPositiveSizeToGrid( readVec3Draft(triggerVolumeSizeDraft, "Trigger Volume size"), DEFAULT_GRID_SIZE ), triggerOnEnter, triggerOnExit }); commitEntityChange( selectedTriggerVolume, nextEntity, "Updated Trigger Volume." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyTeleportTargetChange = () => { if (selectedTeleportTarget === null) { setStatusMessage("Select a Teleport Target before editing it."); return; } try { const nextEntity = createTeleportTargetEntity({ id: selectedTeleportTarget.id, name: selectedTeleportTarget.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Teleport Target position"), DEFAULT_GRID_SIZE ), yawDegrees: readYawDegreesDraft(teleportTargetYawDraft) }); commitEntityChange( selectedTeleportTarget, nextEntity, "Updated Teleport Target." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyInteractableChange = ( overrides: { interactionEnabled?: boolean } = {} ) => { if (selectedInteractable === null) { setStatusMessage("Select an Interactable before editing it."); return; } try { const nextEntity = createInteractableEntity({ id: selectedInteractable.id, name: selectedInteractable.name, position: snapVec3ToGrid( readVec3Draft(entityPositionDraft, "Interactable position"), DEFAULT_GRID_SIZE ), radius: readPositiveNumberDraft( interactableRadiusDraft, "Interactable radius" ), prompt: readInteractablePromptDraft(interactablePromptDraft), interactionEnabled: overrides.interactionEnabled ?? interactableEnabledDraft }); commitEntityChange( selectedInteractable, nextEntity, "Updated Interactable." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const commitInteractionLinkChange = ( currentLink: InteractionLink, nextLink: InteractionLink, successMessage: string, label = "Update interaction link" ) => { if (areInteractionLinksEqual(currentLink, nextLink)) { return; } store.executeCommand( createUpsertInteractionLinkCommand({ link: nextLink, label }) ); setStatusMessage(successMessage); }; const getInteractionSourceEntityForLink = ( link: InteractionLink ): InteractionSourceEntity | null => { const sourceEntity = editorState.document.entities[link.sourceEntityId]; return sourceEntity?.kind === "triggerVolume" || sourceEntity?.kind === "interactable" || sourceEntity?.kind === "npc" ? sourceEntity : null; }; const resolveInteractionControlTargetOption = (targetKey: string) => getProjectScheduleTargetOptionByKey( projectScheduleTargetOptions, targetKey ); const resolveDefaultInteractionControlTargetOption = () => interactionControlTargetOptions.find( (targetOption) => targetOption.target.kind === "entity" && targetOption.target.entityKind === "cameraRig" ) ?? interactionControlTargetOptions[0] ?? null; const createInteractionControlLinkFromTargetOption = (options: { id?: string; sourceEntity: InteractionSourceEntity; trigger: InteractionTriggerKind; targetOption: ProjectScheduleTargetOption; effectOptionId?: ProjectScheduleEffectOptionId; previousEffect?: ControlEffect | null; }): InteractionLink => { const effectOptions = listProjectInteractionControlEffectOptions( options.targetOption ); const effectOptionId = options.effectOptionId ?? effectOptions.find((effectOption) => { if ( options.previousEffect === undefined || options.previousEffect === null ) { return false; } try { return ( effectOption.id === getProjectScheduleEffectOptionId(options.previousEffect) ); } catch { return false; } })?.id ?? effectOptions[0]?.id; if (effectOptionId === undefined) { throw new Error( "The selected control target does not expose any interaction control effects." ); } return createControlInteractionLink({ id: options.id, sourceEntityId: options.sourceEntity.id, trigger: getCanonicalInteractionLinkTrigger( options.sourceEntity, options.trigger ), effect: createProjectScheduleEffectFromOption({ targetOption: options.targetOption, effectOptionId, previousEffect: options.previousEffect ?? null }) }); }; const updateControlInteractionLinkEffect = ( link: InteractionLink, successMessage: string, mutate: (effect: ControlEffect) => void, label = "Update interaction control link" ) => { if (link.action.type !== "control") { return; } try { const nextEffect = cloneControlEffect(link.action.effect); mutate(nextEffect); commitInteractionLinkChange( link, createControlInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, effect: nextEffect }), successMessage, label ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleAddSequenceInteractionLink = () => { if (selectedInteractionSource === null) { setStatusMessage( "Select a Trigger Volume, Interactable, or NPC before adding links." ); return; } const defaultSequence = projectImpulseSequenceList[0] ?? null; if (defaultSequence === null) { openSequencerSequenceEditor(); setStatusMessage( "Open the Sequencer sequence editor and author a sequence with at least one start effect before adding a sequence link." ); return; } store.executeCommand( createUpsertInteractionLinkCommand({ link: createRunSequenceInteractionLink({ sourceEntityId: selectedInteractionSource.id, trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource), sequenceId: defaultSequence.id }), label: "Add run sequence interaction link" }) ); setStatusMessage( `Added a sequence link to the selected ${getInteractionSourceEntityLabel(selectedInteractionSource)}.` ); }; const handleAddControlInteractionLink = () => { if (selectedInteractionSource === null) { setStatusMessage( "Select a Trigger Volume, Interactable, or NPC before adding links." ); return; } const targetOption = resolveDefaultInteractionControlTargetOption(); if (targetOption === null) { setStatusMessage( "Author a control-addressable target before adding a control link." ); return; } try { store.executeCommand( createUpsertInteractionLinkCommand({ link: createInteractionControlLinkFromTargetOption({ sourceEntity: selectedInteractionSource, trigger: getDefaultInteractionLinkTrigger( selectedInteractionSource ), targetOption }), label: "Add control interaction link" }) ); setStatusMessage( `Added a control link to the selected ${getInteractionSourceEntityLabel(selectedInteractionSource)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleDeleteInteractionLink = (linkId: string) => { try { store.executeCommand(createDeleteInteractionLinkCommand(linkId)); setStatusMessage("Deleted interaction link."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const confirmDeleteSceneItem = (label: string) => globalThis.window.confirm( `Delete ${label}?\n\nThis can be undone with Undo.` ); const confirmDeleteProjectAsset = (label: string) => globalThis.window.confirm( `Delete ${label} and remove its project-wide references?\n\nThis also deletes the stored binary asset data, and it can be undone with Undo.` ); const handleSetPathVisible = (path: ScenePath, visible: boolean) => { try { store.executeCommand( createSetPathAuthoredStateCommand({ pathId: path.id, visible }) ); setStatusMessage( visible ? `Shown ${getPathLabelById(path.id, pathList)} in the editor.` : `Hidden ${getPathLabelById(path.id, pathList)} in the editor.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetPathEnabled = (path: ScenePath, enabled: boolean) => { try { store.executeCommand( createSetPathAuthoredStateCommand({ pathId: path.id, enabled }) ); setStatusMessage( enabled ? `Enabled ${getPathLabelById(path.id, pathList)}.` : `Disabled ${getPathLabelById(path.id, pathList)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetBrushVisible = (brush: Brush, visible: boolean) => { try { store.executeCommand( createSetBoxBrushAuthoredStateCommand({ brushId: brush.id, visible }) ); setStatusMessage( visible ? `Shown ${getBrushLabelById(brush.id, brushList)} in the editor and runner.` : `Hidden ${getBrushLabelById(brush.id, brushList)} in the editor and runner.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetBrushEnabled = (brush: Brush, enabled: boolean) => { try { store.executeCommand( createSetBoxBrushAuthoredStateCommand({ brushId: brush.id, enabled }) ); setStatusMessage( enabled ? `Enabled ${getBrushLabelById(brush.id, brushList)}.` : `Disabled ${getBrushLabelById(brush.id, brushList)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetModelInstanceVisible = ( modelInstance: ModelInstance, visible: boolean ) => { try { store.executeCommand( createSetModelInstanceAuthoredStateCommand({ modelInstanceId: modelInstance.id, visible }) ); setStatusMessage( visible ? `Shown ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)} in the editor and runner.` : `Hidden ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)} in the editor and runner.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetModelInstanceEnabled = ( modelInstance: ModelInstance, enabled: boolean ) => { try { store.executeCommand( createSetModelInstanceAuthoredStateCommand({ modelInstanceId: modelInstance.id, enabled }) ); setStatusMessage( enabled ? `Enabled ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)}.` : `Disabled ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetEntityVisible = (entity: EntityInstance, visible: boolean) => { try { store.executeCommand( createSetEntityAuthoredStateCommand({ entityId: entity.id, visible }) ); setStatusMessage( visible ? `Shown ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)} in the editor and runner.` : `Hidden ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)} in the editor and runner.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleSetEntityEnabled = (entity: EntityInstance, enabled: boolean) => { try { store.executeCommand( createSetEntityAuthoredStateCommand({ entityId: entity.id, enabled }) ); setStatusMessage( enabled ? `Enabled ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)}.` : `Disabled ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleDeleteBrush = (brushId: string) => { const label = getBrushLabelById(brushId, brushList); if (!confirmDeleteSceneItem(label)) { return false; } try { store.executeCommand(createDeleteBoxBrushCommand(brushId)); setStatusMessage(`Deleted ${label}.`); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const handleDeletePath = (pathId: string) => { const label = getPathLabelById(pathId, pathList); if (!confirmDeleteSceneItem(label)) { return false; } try { store.executeCommand(createDeletePathCommand(pathId)); setStatusMessage(`Deleted ${label}.`); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const handleDeleteTerrain = (terrainId: string) => { const label = getTerrainLabelById(terrainId, terrainList); if (!confirmDeleteSceneItem(label)) { return false; } try { store.executeCommand(createDeleteTerrainCommand(terrainId)); setStatusMessage(`Deleted ${label}.`); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const handleDeleteEntity = (entityId: string) => { const label = getEntityDisplayLabelById( entityId, editorState.document.entities, editorState.document.assets ); if (!confirmDeleteSceneItem(label)) { return false; } try { store.executeCommand(createDeleteEntityCommand(entityId)); setStatusMessage(`Deleted ${label}.`); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const handleDeleteModelInstance = (modelInstanceId: string) => { const label = getModelInstanceDisplayLabelById( modelInstanceId, editorState.document.modelInstances, editorState.document.assets ); if (!confirmDeleteSceneItem(label)) { return false; } try { store.executeCommand(createDeleteModelInstanceCommand(modelInstanceId)); setStatusMessage(`Deleted ${label}.`); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const handleDeleteProjectAsset = (asset: ProjectAssetRecord) => { if (!confirmDeleteProjectAsset(asset.sourceName)) { return false; } try { store.executeCommand(createDeleteProjectAssetCommand(asset.id)); setStatusMessage( `Deleted ${asset.sourceName} and cleaned up project references.` ); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const handleDeleteSelectedSceneItem = () => { const selectedPathPoint = getSingleSelectedPathPoint(editorState.selection); if (selectedPathPoint !== null) { handleDeletePathPoint(selectedPathPoint.pointId); return true; } if (editorState.selection.kind === "brushes") { try { store.executeCommand( createDeleteSelectionCommand( editorState.selection, editorState.selection.ids.length === 1 ? "Delete whitebox solid" : "Delete whitebox solids" ) ); setStatusMessage( editorState.selection.ids.length === 1 ? `Deleted ${getBrushLabelById( editorState.selection.ids[0], brushList )}.` : `Deleted ${editorState.selection.ids.length} whitebox solids.` ); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } } const selectedBrushId = getSingleSelectedBrushId(editorState.selection); if (selectedBrushId !== null) { return handleDeleteBrush(selectedBrushId); } const selectedPathId = getSingleSelectedPathOwnerId(editorState.selection); if (selectedPathId !== null) { return handleDeletePath(selectedPathId); } const selectedTerrainId = getSingleSelectedTerrainId(editorState.selection); if (selectedTerrainId !== null) { return handleDeleteTerrain(selectedTerrainId); } if (editorState.selection.kind === "entities") { try { store.executeCommand( createDeleteSelectionCommand( editorState.selection, editorState.selection.ids.length === 1 ? "Delete entity" : "Delete entities" ) ); setStatusMessage( editorState.selection.ids.length === 1 ? `Deleted ${getEntityDisplayLabelById( editorState.selection.ids[0], editorState.document.entities, editorState.document.assets )}.` : `Deleted ${editorState.selection.ids.length} entities.` ); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } } const selectedEntityId = getSingleSelectedEntityId(editorState.selection); if (selectedEntityId !== null) { return handleDeleteEntity(selectedEntityId); } const selectedModelInstanceId = getSingleSelectedModelInstanceId( editorState.selection ); if (editorState.selection.kind === "modelInstances") { try { store.executeCommand( createDeleteSelectionCommand( editorState.selection, editorState.selection.ids.length === 1 ? "Delete model instance" : "Delete model instances" ) ); setStatusMessage( editorState.selection.ids.length === 1 ? `Deleted ${getModelInstanceDisplayLabelById( editorState.selection.ids[0], editorState.document.modelInstances, editorState.document.assets )}.` : `Deleted ${editorState.selection.ids.length} model instances.` ); return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } } if (selectedModelInstanceId !== null) { return handleDeleteModelInstance(selectedModelInstanceId); } return false; }; const handleDuplicateSelection = () => { if (!selectionCanBeDuplicated(editorState.selection)) { return false; } try { store.executeCommand(createDuplicateSelectionCommand()); const duplicatedState = store.getState(); const duplicatedSelection = duplicatedState.selection; const canGrabDuplicatedSelection = (duplicatedSelection.kind === "brushes" || duplicatedSelection.kind === "entities" || duplicatedSelection.kind === "modelInstances") && duplicatedSelection.ids.length === 1; if (canGrabDuplicatedSelection) { const transformSourcePanelId = layoutMode === "quad" ? (hoveredViewportPanelId ?? activePanelId) : activePanelId; const transformTargetResult = resolveTransformTarget( duplicatedState.document, duplicatedSelection, whiteboxSelectionMode, duplicatedState.activeSelectionId ); const transformTarget = transformTargetResult.target; if (transformTarget === null) { setStatusMessage( transformTargetResult.message ?? "Duplicated selection, but could not start move transform." ); 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 )}. 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.` ); } else { setStatusMessage("Duplicated selection."); } return true; } catch (error) { setStatusMessage(getErrorMessage(error)); return false; } }; const updateInteractionLinkTrigger = ( link: InteractionLink, trigger: InteractionTriggerKind ) => { const sourceEntity = getInteractionSourceEntityForLink(link); if (sourceEntity?.kind === "interactable" && trigger !== "click") { setStatusMessage("Interactable links always use the click trigger."); return; } if (sourceEntity?.kind === "triggerVolume" && trigger === "click") { setStatusMessage( "Trigger Volume links may only use enter or exit triggers." ); return; } 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; 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; case "runSequence": nextLink = createRunSequenceInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger, sequenceId: link.action.sequenceId }); break; case "control": nextLink = createControlInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger, effect: link.action.effect }); break; } commitInteractionLinkChange( link, nextLink, `Updated ${getInteractionTriggerLabel(trigger).toLowerCase()} trigger link.` ); }; const updateInteractionLinkActionType = ( link: InteractionLink, actionType: InteractionLink["action"]["type"] ) => { const sourceEntity = getInteractionSourceEntityForLink(link); if (sourceEntity === null || link.action.type === actionType) { return; } if (actionType === "control") { const targetOption = resolveDefaultInteractionControlTargetOption(); if (targetOption === null) { setStatusMessage( "Author a control-addressable target before switching this link to a control effect." ); return; } commitInteractionLinkChange( link, createInteractionControlLinkFromTargetOption({ id: link.id, sourceEntity, trigger: link.trigger, targetOption }), "Switched link action to a control effect." ); return; } if (actionType === "runSequence") { const defaultSequence = projectImpulseSequenceList[0] ?? null; if (defaultSequence === null) { openSequencerSequenceEditor(); setStatusMessage( "Open the Sequencer sequence editor and author a sequence with at least one start effect before switching this link to run sequence." ); return; } commitInteractionLinkChange( link, createRunSequenceInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: getCanonicalInteractionLinkTrigger( sourceEntity, link.trigger ), sequenceId: defaultSequence.id }), "Switched link action to run sequence." ); return; } if (actionType === "teleportPlayer") { const defaultTarget = teleportTargetOptions[0]?.entity; if ( defaultTarget === undefined || defaultTarget.kind !== "teleportTarget" ) { setStatusMessage( "Author a Teleport Target before switching this link to teleport." ); return; } commitInteractionLinkChange( link, createTeleportPlayerInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: link.trigger, targetEntityId: defaultTarget.id }), "Switched link action to teleport player." ); return; } if (actionType === "playAnimation") { const targetModelInstance = (link.action.type === "playAnimation" || link.action.type === "stopAnimation" ? editorState.document.modelInstances[ link.action.targetModelInstanceId ] : undefined) ?? modelInstanceDisplayList[0]?.modelInstance; if (targetModelInstance === undefined) { setStatusMessage( "Place a model instance before switching this link to play animation." ); return; } const asset = editorState.document.assets[targetModelInstance.assetId]; const firstClip = asset?.kind === "model" ? (asset.metadata.animationNames[0] ?? "") : ""; if (firstClip === "") { setStatusMessage("The model instance has no animation clips."); return; } commitInteractionLinkChange( link, createPlayAnimationInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: link.trigger, targetModelInstanceId: targetModelInstance.id, clipName: firstClip }), "Switched link action to play animation." ); return; } if (actionType === "stopAnimation") { const targetModelInstance = (link.action.type === "playAnimation" || link.action.type === "stopAnimation" ? editorState.document.modelInstances[ link.action.targetModelInstanceId ] : undefined) ?? modelInstanceDisplayList[0]?.modelInstance; if (targetModelInstance === undefined) { setStatusMessage( "Place a model instance before switching this link to stop animation." ); return; } commitInteractionLinkChange( link, createStopAnimationInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: link.trigger, targetModelInstanceId: targetModelInstance.id }), "Switched link action to stop animation." ); return; } if (actionType === "playSound" || actionType === "stopSound") { const targetSoundEmitter = (link.action.type === "playSound" || link.action.type === "stopSound" ? editorState.document.entities[link.action.targetSoundEmitterId] : undefined) ?? playableSoundEmitterOptions[0]?.entity; if ( targetSoundEmitter === undefined || targetSoundEmitter.kind !== "soundEmitter" ) { setStatusMessage( "Author a Sound Emitter with an audio asset before switching this link to sound playback." ); return; } if (actionType === "playSound") { commitInteractionLinkChange( link, createPlaySoundInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: link.trigger, targetSoundEmitterId: targetSoundEmitter.id }), "Switched link action to play sound." ); } else { commitInteractionLinkChange( link, createStopSoundInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: link.trigger, targetSoundEmitterId: targetSoundEmitter.id }), "Switched link action to stop sound." ); } return; } const defaultBrush = visibilityBrushOptions[0]?.brush; if (defaultBrush === undefined) { setStatusMessage( "Author at least one whitebox solid before switching this link to visibility." ); return; } commitInteractionLinkChange( link, createToggleVisibilityInteractionLink({ id: link.id, sourceEntityId: sourceEntity.id, trigger: link.trigger, targetBrushId: defaultBrush.id }), "Switched link action to toggle visibility." ); }; const updateControlInteractionLinkTarget = ( link: InteractionLink, targetKey: string ) => { if (link.action.type !== "control") { return; } const sourceEntity = getInteractionSourceEntityForLink(link); if (sourceEntity === null) { setStatusMessage("Selected interaction source no longer exists."); return; } const targetOption = resolveInteractionControlTargetOption(targetKey); if (targetOption === null) { setStatusMessage("Selected control target no longer exists."); return; } try { commitInteractionLinkChange( link, createInteractionControlLinkFromTargetOption({ id: link.id, sourceEntity, trigger: link.trigger, targetOption, previousEffect: link.action.effect }), "Updated control link target." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const updateControlInteractionLinkEffectOption = ( link: InteractionLink, effectOptionId: ProjectScheduleEffectOptionId ) => { if (link.action.type !== "control") { return; } const targetOption = resolveInteractionControlTargetOption( getControlTargetRefKey(link.action.effect.target) ); if (targetOption === null) { setStatusMessage("Selected control target no longer exists."); return; } const effectOptions = listProjectInteractionControlEffectOptions(targetOption); if ( !effectOptions.some((effectOption) => effectOption.id === effectOptionId) ) { setStatusMessage( "Selected control effect is not available for the current target." ); return; } commitInteractionLinkChange( link, createControlInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, effect: createProjectScheduleEffectFromOption({ targetOption, effectOptionId, previousEffect: link.action.effect }) }), "Updated control link effect." ); }; const updateControlInteractionLinkNumericValue = ( link: InteractionLink, value: number ) => { if (!Number.isFinite(value) || value < 0) { setStatusMessage( "Control numeric values must be finite and zero or greater." ); return; } updateControlInteractionLinkEffect( link, "Updated control link value.", (effect) => { switch (effect.type) { case "setSoundVolume": effect.volume = value; return; case "setLightIntensity": case "setAmbientLightIntensity": case "setSunLightIntensity": effect.intensity = value; return; default: throw new Error( "The current control link effect does not expose a numeric value." ); } } ); }; const updateControlInteractionLinkColorValue = ( link: InteractionLink, colorHex: string ) => { updateControlInteractionLinkEffect( link, "Updated control link color.", (effect) => { switch (effect.type) { case "setLightColor": case "setAmbientLightColor": case "setSunLightColor": effect.colorHex = colorHex; return; default: throw new Error( "The current control link effect does not expose a color value." ); } } ); }; const updateControlInteractionLinkAnimationClip = ( link: InteractionLink, clipName: string ) => { updateControlInteractionLinkEffect( link, "Updated control link animation clip.", (effect) => { if ( effect.type !== "playModelAnimation" && effect.type !== "playActorAnimation" ) { throw new Error( "The current control link effect does not expose an animation clip." ); } effect.clipName = clipName; } ); }; const updateControlInteractionLinkAnimationLoop = ( link: InteractionLink, loop: boolean ) => { updateControlInteractionLinkEffect( link, loop ? "Control link animation now loops." : "Control link animation now plays once.", (effect) => { if ( effect.type !== "playModelAnimation" && effect.type !== "playActorAnimation" ) { throw new Error( "The current control link effect does not expose animation looping." ); } effect.loop = loop; } ); }; const updateSequenceInteractionLinkTarget = ( link: InteractionLink, sequenceId: string ) => { if (link.action.type !== "runSequence") { return; } commitInteractionLinkChange( link, createRunSequenceInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, sequenceId }), "Updated sequence link target." ); }; const updateTeleportInteractionLinkTarget = ( link: InteractionLink, targetEntityId: string ) => { if (link.action.type !== "teleportPlayer") { return; } commitInteractionLinkChange( link, createTeleportPlayerInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetEntityId }), "Updated teleport link target." ); }; const updateVisibilityInteractionLinkTarget = ( link: InteractionLink, targetBrushId: string ) => { if (link.action.type !== "toggleVisibility") { return; } commitInteractionLinkChange( link, createToggleVisibilityInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetBrushId, visible: link.action.visible }), "Updated visibility link target." ); }; const updateVisibilityInteractionMode = ( link: InteractionLink, mode: "toggle" | "show" | "hide" ) => { if (link.action.type !== "toggleVisibility") { return; } commitInteractionLinkChange( link, createToggleVisibilityInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetBrushId: link.action.targetBrushId, visible: readVisibilityModeSelectValue(mode) }), "Updated visibility link mode." ); }; const updateSoundInteractionLinkTarget = ( link: InteractionLink, targetSoundEmitterId: string ) => { if (link.action.type !== "playSound" && link.action.type !== "stopSound") { return; } if (link.action.type === "playSound") { commitInteractionLinkChange( link, createPlaySoundInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetSoundEmitterId }), "Updated play sound link target." ); } else { commitInteractionLinkChange( link, createStopSoundInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetSoundEmitterId }), "Updated stop sound link target." ); } }; const updateAnimationInteractionLinkTarget = ( link: InteractionLink, targetModelInstanceId: string ) => { if ( link.action.type !== "playAnimation" && link.action.type !== "stopAnimation" ) { return; } if (link.action.type === "playAnimation") { commitInteractionLinkChange( link, createPlayAnimationInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetModelInstanceId, clipName: link.action.clipName, loop: link.action.loop }), "Updated play animation link target." ); } else { commitInteractionLinkChange( link, createStopAnimationInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetModelInstanceId }), "Updated stop animation link target." ); } }; const updatePlayAnimationLinkClip = ( link: InteractionLink, clipName: string ) => { if (link.action.type !== "playAnimation") { return; } commitInteractionLinkChange( link, createPlayAnimationInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetModelInstanceId: link.action.targetModelInstanceId, clipName, loop: link.action.loop }), "Updated play animation clip." ); }; const updatePlayAnimationLinkLoop = ( link: InteractionLink, loop: boolean ) => { if (link.action.type !== "playAnimation") { return; } commitInteractionLinkChange( link, createPlayAnimationInteractionLink({ id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, targetModelInstanceId: link.action.targetModelInstanceId, clipName: link.action.clipName, loop }), "Updated play animation loop setting." ); }; const renderInteractionLinksSection = ( sourceEntity: InteractionSourceEntity, links: InteractionLink[] ) => (
Links
Links can either run a reusable sequence or dispatch a shared control effect directly. Use sequences for multi-step reusable behavior. Use control links for small direct state changes such as camera overrides.
{links.length === 0 ? (
{`No links authored for this ${getInteractionSourceEntityLabel(sourceEntity)} yet.`}
) : (
{links.map((link, index) => (
{`Link ${index + 1}`} {getInteractionActionLabel(link)}
{isLegacyInteractionActionType(link.action.type) ? (
This link still uses a legacy direct action. New authoring should move this behavior into a reusable sequence effect and switch the link to Run Sequence.
) : null} {link.action.type === "teleportPlayer" ? (
) : link.action.type === "toggleVisibility" ? (
) : link.action.type === "playAnimation" ? (
) : link.action.type === "playSound" || link.action.type === "stopSound" ? (
) : link.action.type === "runSequence" ? ( (() => { const sequenceId = link.action.sequenceId; return (
Run Sequence links can reference any authored sequence, and interactions fire that sequence from the start.
); })() ) : link.action.type === "stopAnimation" ? (
) : link.action.type === "control" ? ( (() => { const targetOption = resolveInteractionControlTargetOption( getControlTargetRefKey(link.action.effect.target) ); const effectOptions = targetOption === null ? [] : listProjectInteractionControlEffectOptions( targetOption ); const effectOptionId = targetOption === null ? null : (() => { try { const nextEffectOptionId = getProjectScheduleEffectOptionId( link.action.effect ); return effectOptions.some( (effectOption) => effectOption.id === nextEffectOptionId ) ? nextEffectOptionId : null; } catch { return null; } })(); const selectedEffectOption = effectOptionId === null ? null : (effectOptions.find( (effectOption) => effectOption.id === effectOptionId ) ?? null); if (targetOption === null || selectedEffectOption === null) { return (
{`${formatControlEffectValue(link.action.effect)}. This control link is preserved, but the current inspector can only edit effects exposed through the shared control catalog.`}
); } return (
{selectedEffectOption.valueKind === "number" ? ( ) : null} {selectedEffectOption.valueKind === "color" ? ( ) : null} {link.action.effect.type === "playModelAnimation" || link.action.effect.type === "playActorAnimation" ? ( <> ) : null}
); })() ) : null}
))}
)}
); const applyWorldSettings = ( nextWorld: WorldSettings, label: string, successMessage: string ) => { if (areWorldSettingsEqual(editorState.document.world, nextWorld)) { return; } try { store.executeCommand( createSetWorldSettingsCommand({ label, world: nextWorld }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySceneProjectTimeLightingEnabled = (enabled: boolean) => { applyWorldSettings( { ...editorState.document.world, projectTimeLightingEnabled: enabled }, enabled ? "Enable scene project time lighting" : "Disable scene project time lighting", enabled ? "This scene now follows the global project time profile." : "This scene now keeps its authored world lighting and sky." ); }; const applySceneShowCelestialBodies = (enabled: boolean) => { applyWorldSettings( { ...editorState.document.world, showCelestialBodies: enabled }, enabled ? "Enable celestial body visuals" : "Disable celestial body visuals", enabled ? "Sun and moon visuals now render in the active sky." : "Sun and moon visuals are now hidden." ); }; const applyShaderSkySettings = ( label: string, successMessage: string, mutate: (shaderSky: WorldShaderSkySettings) => void ) => { const nextWorld = cloneWorldSettings(editorState.document.world); mutate(nextWorld.shaderSky); applyWorldSettings(nextWorld, label, successMessage); }; const applyAdvancedRenderingSettings = ( label: string, successMessage: string, mutate: (advancedRendering: AdvancedRenderingSettings) => void ) => { const nextWorld = cloneWorldSettings(editorState.document.world); mutate(nextWorld.advancedRendering); applyWorldSettings(nextWorld, label, successMessage); }; const applyWorldBackgroundMode = ( mode: WorldBackgroundMode, imageAssetId?: string ) => { const nextWorld = cloneWorldSettings(editorState.document.world); if ( mode === "shader" && editorState.document.world.background.mode !== "shader" ) { nextWorld.shaderSky = syncWorldShaderSkyDayGradientToBackground( nextWorld.shaderSky, editorState.document.world.background ); } if (mode === "image") { const currentBackgroundAssetId = editorState.document.world.background.mode === "image" ? editorState.document.world.background.assetId : null; const nextImageAssetId = imageAssetId ?? (currentBackgroundAssetId !== null && editorState.document.assets[currentBackgroundAssetId]?.kind === "image" ? currentBackgroundAssetId : imageAssetList[0]?.id); if (nextImageAssetId === undefined) { setStatusMessage( "Import an image asset before using an image background." ); return; } applyWorldSettings( { ...nextWorld, background: changeWorldBackgroundMode( editorState.document.world.background, "image", nextImageAssetId, DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY, nextWorld.shaderSky ) }, "Set world background image", `World background set to ${editorState.document.assets[nextImageAssetId]?.sourceName ?? nextImageAssetId}.` ); return; } const nextBackground = changeWorldBackgroundMode( editorState.document.world.background, mode, undefined, DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY, nextWorld.shaderSky ); applyWorldSettings( { ...nextWorld, background: nextBackground }, "Set world background mode", mode === "solid" ? "World background set to a solid color." : mode === "verticalGradient" ? "World background set to a vertical gradient." : "World background set to the default shader sky." ); }; const applyWorldBackgroundColor = (colorHex: string) => { if (editorState.document.world.background.mode !== "solid") { return; } applyWorldSettings( { ...editorState.document.world, background: { mode: "solid", colorHex } }, "Set world background color", "Updated the world background color." ); }; const applyWorldGradientColor = ( edge: "top" | "bottom", colorHex: string ) => { if (editorState.document.world.background.mode !== "verticalGradient") { return; } applyWorldSettings( { ...editorState.document.world, background: edge === "top" ? { ...editorState.document.world.background, topColorHex: colorHex } : { ...editorState.document.world.background, bottomColorHex: colorHex } }, edge === "top" ? "Set world gradient top color" : "Set world gradient bottom color", edge === "top" ? "Updated the world gradient top color." : "Updated the world gradient bottom color." ); }; const applyShaderSkyNumericSetting = ( label: string, successMessage: string, value: number, mutate: (shaderSky: WorldShaderSkySettings, nextValue: number) => void ) => { if (!Number.isFinite(value)) { return; } applyShaderSkySettings(label, successMessage, (shaderSky) => mutate(shaderSky, value) ); }; const applyShaderSkyDayColor = (edge: "top" | "bottom", colorHex: string) => { applyShaderSkySettings( edge === "top" ? "Set shader sky day top color" : "Set shader sky day bottom color", edge === "top" ? "Updated the shader sky day top color." : "Updated the shader sky day bottom color.", (shaderSky) => { if (edge === "top") { shaderSky.dayTopColorHex = colorHex; return; } shaderSky.dayBottomColorHex = colorHex; } ); }; const applyShaderSkyAuroraEnabled = (enabled: boolean) => { applyShaderSkySettings( enabled ? "Enable shader sky aurora" : "Disable shader sky aurora", enabled ? "Shader sky aurora is now active." : "Shader sky aurora is now hidden.", (shaderSky) => { shaderSky.aurora.enabled = enabled; } ); }; const applyCelestialOrbitSettings = ( label: string, successMessage: string, mutate: (world: WorldSettings) => void ) => { const nextWorld = cloneWorldSettings(editorState.document.world); mutate(nextWorld); nextWorld.sunLight.direction = resolveWorldCelestialOrbitPeakDirection( nextWorld.celestialOrbits.sun ); applyWorldSettings(nextWorld, label, successMessage); }; const applyCelestialOrbitNumericSetting = ( label: string, successMessage: string, value: number, mutate: (world: WorldSettings, nextValue: number) => void ) => { if (!Number.isFinite(value)) { return; } applyCelestialOrbitSettings(label, successMessage, (world) => mutate(world, value) ); }; const applyBackgroundEnvironmentIntensity = () => { if (editorState.document.world.background.mode !== "image") { return; } const intensity = readNonNegativeNumberDraft( backgroundEnvironmentIntensityDraft, "Environment intensity" ); applyWorldSettings( { ...editorState.document.world, background: { ...editorState.document.world.background, environmentIntensity: intensity } }, "Set background environment intensity", "Updated the background environment intensity." ); }; const applyAmbientLightColor = (colorHex: string) => { applyWorldSettings( { ...editorState.document.world, ambientLight: { ...editorState.document.world.ambientLight, colorHex } }, "Set world ambient light color", "Updated the world ambient light color." ); }; const applyAmbientLightIntensity = () => { try { applyWorldSettings( { ...editorState.document.world, ambientLight: { ...editorState.document.world.ambientLight, intensity: readNonNegativeNumberDraft( ambientLightIntensityDraft, "Ambient light intensity" ) } }, "Set world ambient light intensity", "Updated the world ambient light intensity." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySunLightColor = (colorHex: string) => { applyWorldSettings( { ...editorState.document.world, sunLight: { ...editorState.document.world.sunLight, colorHex } }, "Set world sun color", "Updated the world sun color." ); }; const applySunLightIntensity = () => { try { applyWorldSettings( { ...editorState.document.world, sunLight: { ...editorState.document.world.sunLight, intensity: readNonNegativeNumberDraft( sunLightIntensityDraft, "Sun intensity" ) } }, "Set world sun intensity", "Updated the world sun intensity." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingEnabled = (enabled: boolean) => { applyAdvancedRenderingSettings( "Set advanced rendering", enabled ? "Advanced rendering enabled." : "Advanced rendering disabled.", (advancedRendering) => { advancedRendering.enabled = enabled; } ); }; const applyAdvancedRenderingShadowsEnabled = (enabled: boolean) => { applyAdvancedRenderingSettings( "Set advanced rendering shadows", enabled ? "Advanced rendering shadows enabled." : "Advanced rendering shadows disabled.", (advancedRendering) => { advancedRendering.shadows.enabled = enabled; } ); }; const applyAdvancedRenderingShadowMapSize = ( shadowMapSize: AdvancedRenderingShadowMapSize ) => { applyAdvancedRenderingSettings( "Set advanced rendering shadow map size", "Updated the shadow map size.", (advancedRendering) => { advancedRendering.shadows.mapSize = shadowMapSize; } ); }; const applyAdvancedRenderingShadowType = ( shadowType: AdvancedRenderingShadowType ) => { applyAdvancedRenderingSettings( "Set advanced rendering shadow type", "Updated the shadow map type.", (advancedRendering) => { advancedRendering.shadows.type = shadowType; } ); }; const applyAdvancedRenderingShadowBias = () => { try { applyAdvancedRenderingSettings( "Set advanced rendering shadow bias", "Updated the shadow bias.", (advancedRendering) => { advancedRendering.shadows.bias = readFiniteNumberDraft( advancedRenderingShadowBiasDraft, "Shadow bias" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingAmbientOcclusionEnabled = (enabled: boolean) => { applyAdvancedRenderingSettings( "Set ambient occlusion", enabled ? "Ambient occlusion enabled." : "Ambient occlusion disabled.", (advancedRendering) => { advancedRendering.ambientOcclusion.enabled = enabled; } ); }; const applyAdvancedRenderingAmbientOcclusionIntensity = () => { try { applyAdvancedRenderingSettings( "Set ambient occlusion intensity", "Updated the ambient occlusion intensity.", (advancedRendering) => { advancedRendering.ambientOcclusion.intensity = readNonNegativeNumberDraft( advancedRenderingAmbientOcclusionIntensityDraft, "Ambient occlusion intensity" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingAmbientOcclusionRadius = () => { try { applyAdvancedRenderingSettings( "Set ambient occlusion radius", "Updated the ambient occlusion radius.", (advancedRendering) => { advancedRendering.ambientOcclusion.radius = readNonNegativeNumberDraft( advancedRenderingAmbientOcclusionRadiusDraft, "Ambient occlusion radius" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingAmbientOcclusionSamples = () => { try { applyAdvancedRenderingSettings( "Set ambient occlusion samples", "Updated the ambient occlusion samples.", (advancedRendering) => { advancedRendering.ambientOcclusion.samples = readPositiveIntegerDraft( advancedRenderingAmbientOcclusionSamplesDraft, "Ambient occlusion samples" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingBloomEnabled = (enabled: boolean) => { applyAdvancedRenderingSettings( "Set bloom", enabled ? "Bloom enabled." : "Bloom disabled.", (advancedRendering) => { advancedRendering.bloom.enabled = enabled; } ); }; const applyAdvancedRenderingBloomIntensity = () => { try { applyAdvancedRenderingSettings( "Set bloom intensity", "Updated the bloom intensity.", (advancedRendering) => { advancedRendering.bloom.intensity = readNonNegativeNumberDraft( advancedRenderingBloomIntensityDraft, "Bloom intensity" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingBloomThreshold = () => { try { applyAdvancedRenderingSettings( "Set bloom threshold", "Updated the bloom threshold.", (advancedRendering) => { advancedRendering.bloom.threshold = readNonNegativeNumberDraft( advancedRenderingBloomThresholdDraft, "Bloom threshold" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingBloomRadius = () => { try { applyAdvancedRenderingSettings( "Set bloom radius", "Updated the bloom radius.", (advancedRendering) => { advancedRendering.bloom.radius = readNonNegativeNumberDraft( advancedRenderingBloomRadiusDraft, "Bloom radius" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingToneMappingMode = ( mode: AdvancedRenderingToneMappingMode ) => { applyAdvancedRenderingSettings( "Set tone mapping mode", "Updated the tone mapping mode.", (advancedRendering) => { advancedRendering.toneMapping.mode = mode; } ); }; const applyAdvancedRenderingToneMappingExposure = () => { try { applyAdvancedRenderingSettings( "Set tone mapping exposure", "Updated the tone mapping exposure.", (advancedRendering) => { advancedRendering.toneMapping.exposure = readPositiveNumberDraft( advancedRenderingToneMappingExposureDraft, "Tone mapping exposure" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingDepthOfFieldEnabled = (enabled: boolean) => { applyAdvancedRenderingSettings( "Set depth of field", enabled ? "Depth of field enabled." : "Depth of field disabled.", (advancedRendering) => { advancedRendering.depthOfField.enabled = enabled; } ); }; const applyAdvancedRenderingDepthOfFieldFocusDistance = () => { try { applyAdvancedRenderingSettings( "Set focus distance", "Updated the focus distance.", (advancedRendering) => { advancedRendering.depthOfField.focusDistance = readNonNegativeNumberDraft( advancedRenderingDepthOfFieldFocusDistanceDraft, "Focus distance" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingDepthOfFieldFocalLength = () => { try { applyAdvancedRenderingSettings( "Set focal length", "Updated the focal length.", (advancedRendering) => { advancedRendering.depthOfField.focalLength = readPositiveNumberDraft( advancedRenderingDepthOfFieldFocalLengthDraft, "Focal length" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingDepthOfFieldBokehScale = () => { try { applyAdvancedRenderingSettings( "Set bokeh scale", "Updated the bokeh scale.", (advancedRendering) => { advancedRendering.depthOfField.bokehScale = readPositiveNumberDraft( advancedRenderingDepthOfFieldBokehScaleDraft, "Bokeh scale" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingWhiteboxBevelEnabled = (enabled: boolean) => { applyAdvancedRenderingSettings( "Set whitebox bevel", enabled ? "Whitebox bevel enabled." : "Whitebox bevel disabled.", (advancedRendering) => { advancedRendering.whiteboxBevel.enabled = enabled; } ); }; const applyAdvancedRenderingWhiteboxBevelEdgeWidth = () => { try { applyAdvancedRenderingSettings( "Set whitebox bevel edge width", "Updated the whitebox bevel edge width.", (advancedRendering) => { advancedRendering.whiteboxBevel.edgeWidth = readNonNegativeNumberDraft( advancedRenderingWhiteboxBevelEdgeWidthDraft, "Whitebox bevel edge width" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingWhiteboxBevelNormalStrength = () => { try { applyAdvancedRenderingSettings( "Set whitebox bevel normal strength", "Updated the whitebox bevel normal strength.", (advancedRendering) => { advancedRendering.whiteboxBevel.normalStrength = readNonNegativeNumberDraft( advancedRenderingWhiteboxBevelNormalStrengthDraft, "Whitebox bevel normal strength" ); } ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyAdvancedRenderingFogPath = (path: BoxVolumeRenderPath) => { applyAdvancedRenderingSettings( "Set fog render path", `Fog render path set to ${formatBoxVolumeRenderPathLabel(path)}.`, (advancedRendering) => { advancedRendering.fogPath = path; } ); }; const applyAdvancedRenderingWaterPath = (path: BoxVolumeRenderPath) => { applyAdvancedRenderingSettings( "Set water render path", `Water render path set to ${formatBoxVolumeRenderPathLabel(path)}.`, (advancedRendering) => { advancedRendering.waterPath = path; } ); }; const applyAdvancedRenderingWaterReflectionMode = ( mode: AdvancedRenderingWaterReflectionMode ) => { applyAdvancedRenderingSettings( "Set water reflection mode", `Water reflection mode set to ${formatAdvancedRenderingWaterReflectionModeLabel(mode)}.`, (advancedRendering) => { advancedRendering.waterReflectionMode = mode; } ); }; const applyBrushNameChange = () => { if (selectedBrush === null) { setStatusMessage("Select a whitebox box before renaming it."); return; } const nextName = normalizeBrushName(brushNameDraft); if (selectedBrush.name === nextName) { return; } try { store.executeCommand( createSetBoxBrushNameCommand({ brushId: selectedBrush.id, name: brushNameDraft }) ); setStatusMessage( nextName === undefined ? "Cleared the authored brush name." : `Renamed brush to ${nextName}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyPathNameChange = () => { if (selectedPath === null) { setStatusMessage("Select a path before renaming it."); return; } const nextName = normalizeScenePathName(pathNameDraft); if (selectedPath.name === nextName) { return; } try { store.executeCommand( createSetPathNameCommand({ pathId: selectedPath.id, name: pathNameDraft }) ); setStatusMessage( nextName === undefined ? "Cleared the authored path name." : `Renamed path to ${nextName}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyEntityNameChange = () => { if (selectedEntity === null) { setStatusMessage("Select an entity before renaming it."); return; } const nextName = normalizeEntityName(entityNameDraft); if (selectedEntity.name === nextName) { return; } try { store.executeCommand( createSetEntityNameCommand({ entityId: selectedEntity.id, name: entityNameDraft }) ); setStatusMessage( nextName === undefined ? "Cleared the authored entity name." : `Renamed entity to ${nextName}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyModelInstanceNameChange = () => { if (selectedModelInstance === null) { setStatusMessage("Select a model instance before renaming it."); return; } const nextName = normalizeModelInstanceName(modelInstanceNameDraft); if (selectedModelInstance.name === nextName) { return; } try { store.executeCommand( createSetModelInstanceNameCommand({ modelInstanceId: selectedModelInstance.id, name: modelInstanceNameDraft }) ); setStatusMessage( nextName === undefined ? "Cleared the authored model instance name." : `Renamed model instance to ${nextName}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleInlineNameInputKeyDown = ( event: ReactKeyboardEvent, resetDraft: () => void ) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); return; } if (event.key === "Escape") { event.preventDefault(); resetDraft(); event.currentTarget.blur(); } }; const handleDraftVectorKeyDown = ( event: ReactKeyboardEvent, applyChange: () => void ) => { if (event.key === "Enter") { applyChange(); } }; const scheduleDraftCommit = (applyChange: () => void) => { window.setTimeout(() => { applyChange(); }, 0); }; const handleNumberInputPointerUp = ( _event: ReactPointerEvent, applyChange: () => void ) => { scheduleDraftCommit(applyChange); }; const handleNumberInputKeyUp = ( event: ReactKeyboardEvent, applyChange: () => void ) => { if (!isCommitIncrementKey(event.key)) { return; } scheduleDraftCommit(applyChange); }; const handleSaveProject = async () => { try { if (!projectAssetStorageReady && projectAssetList.length > 0) { throw new Error( "Project save failed: project asset storage is still initializing for this asset-backed scene." ); } const projectBytes = await saveProjectPackage( editorState.projectDocument, projectAssetStorage ); const blobBytes = new Uint8Array(projectBytes); const blob = new Blob([blobBytes.buffer as ArrayBuffer], { type: "application/zip" }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = createProjectDownloadName( editorState.projectDocument.name ); anchor.click(); URL.revokeObjectURL(objectUrl); setStatusMessage(`Saved project ${anchor.download}.`); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleLoadProjectButtonClick = () => { importProjectInputRef.current?.click(); }; const handleLoadProjectChange = async ( event: ChangeEvent ) => { const input = event.currentTarget; const file = input.files?.[0]; if (file === undefined) { return; } try { const projectBytes = new Uint8Array(await file.arrayBuffer()); const nextDocument = await loadProjectPackage( projectBytes, projectAssetStorage ); store.replaceDocument(nextDocument); setStatusMessage(`Loaded project ${file.name}.`); } catch (error) { setStatusMessage(getErrorMessage(error)); } finally { input.value = ""; } }; const handleImportModelButtonClick = () => { importModelInputRef.current?.click(); }; const handleImportBackgroundImageButtonClick = () => { importBackgroundImageInputRef.current?.click(); }; const handleImportAudioButtonClick = () => { importAudioInputRef.current?.click(); }; const handleImportModelChange = async ( event: ChangeEvent ) => { const input = event.currentTarget; const files = Array.from(input.files ?? []); if (files.length === 0) { return; } if (projectAssetStorage === null) { setAssetStatusMessage( "Imported model assets require project asset storage. IndexedDB is unavailable in this browser." ); input.value = ""; return; } let importedModelForCleanup: ImportedModelAssetResult | null = null; try { const importedModel = files.length === 1 ? await importModelAssetFromFile(files[0], projectAssetStorage) : await importModelAssetFromFiles(files, projectAssetStorage); importedModelForCleanup = importedModel; store.executeCommand( createImportModelAssetCommand({ asset: importedModel.asset, modelInstance: importedModel.modelInstance, label: `Import ${importedModel.asset.sourceName}` }) ); loadedModelAssetsRef.current = { ...loadedModelAssetsRef.current, [importedModel.asset.id]: importedModel.loadedAsset }; setLoadedModelAssets((currentLoadedAssets) => ({ ...currentLoadedAssets, [importedModel.asset.id]: importedModel.loadedAsset })); setAssetStatusMessage(null); setStatusMessage( `Imported ${importedModel.asset.sourceName} and placed a model instance.` ); } catch (error) { if (importedModelForCleanup !== null) { await projectAssetStorage .deleteAsset(importedModelForCleanup.asset.storageKey) .catch(() => undefined); disposeModelTemplate(importedModelForCleanup.loadedAsset.template); } const message = getErrorMessage(error); setStatusMessage(message); setAssetStatusMessage(message); } finally { input.value = ""; } }; const handleImportBackgroundImageChange = async ( event: ChangeEvent ) => { const input = event.currentTarget; const file = input.files?.[0]; if (file === undefined) { return; } if (projectAssetStorage === null) { setAssetStatusMessage( "Imported background images require project asset storage. IndexedDB is unavailable in this browser." ); input.value = ""; return; } let importedImageForCleanup: ImportedImageAssetResult | null = null; try { const importedImage = await importBackgroundImageAssetFromFile( file, projectAssetStorage ); importedImageForCleanup = importedImage; store.executeCommand( createImportBackgroundImageAssetCommand({ asset: importedImage.asset, world: { ...editorState.document.world, background: changeWorldBackgroundMode( editorState.document.world.background, "image", importedImage.asset.id ) }, label: `Import ${importedImage.asset.sourceName} as background` }) ); loadedImageAssetsRef.current = { ...loadedImageAssetsRef.current, [importedImage.asset.id]: importedImage.loadedAsset }; setLoadedImageAssets((currentLoadedAssets) => ({ ...currentLoadedAssets, [importedImage.asset.id]: importedImage.loadedAsset })); setAssetStatusMessage(null); setStatusMessage( `Imported ${importedImage.asset.sourceName} and set it as the world background.` ); } catch (error) { if (importedImageForCleanup !== null) { await projectAssetStorage .deleteAsset(importedImageForCleanup.asset.storageKey) .catch(() => undefined); disposeLoadedImageAsset(importedImageForCleanup.loadedAsset); } const message = getErrorMessage(error); setStatusMessage(message); setAssetStatusMessage(message); } finally { input.value = ""; } }; const handleImportAudioChange = async ( event: ChangeEvent ) => { const input = event.currentTarget; const file = input.files?.[0]; if (file === undefined) { return; } if (projectAssetStorage === null) { setAssetStatusMessage( "Imported audio assets require project asset storage. IndexedDB is unavailable in this browser." ); input.value = ""; return; } let importedAudioForCleanup: { asset: AudioAssetRecord; loadedAsset: LoadedAudioAsset; } | null = null; try { const importedAudio = await importAudioAssetFromFile( file, projectAssetStorage ); importedAudioForCleanup = importedAudio; store.executeCommand( createImportAudioAssetCommand({ asset: importedAudio.asset, label: `Import ${importedAudio.asset.sourceName}` }) ); loadedAudioAssetsRef.current = { ...loadedAudioAssetsRef.current, [importedAudio.asset.id]: importedAudio.loadedAsset }; setLoadedAudioAssets((currentLoadedAssets) => ({ ...currentLoadedAssets, [importedAudio.asset.id]: importedAudio.loadedAsset })); setAssetStatusMessage(null); setStatusMessage( `Imported ${importedAudio.asset.sourceName} and registered it as an audio asset.` ); } catch (error) { if (importedAudioForCleanup !== null) { await projectAssetStorage .deleteAsset(importedAudioForCleanup.asset.storageKey) .catch(() => undefined); } const message = getErrorMessage(error); setStatusMessage(message); setAssetStatusMessage(message); } finally { input.value = ""; } }; const applyFaceMaterial = (materialId: string) => { if ( selectedBrush !== null && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { if (selectedBrushSharedMaterialId === materialId) { setStatusMessage( "All faces on the selected whitebox already use that material." ); return; } try { store.executeCommand( createSetBoxBrushAllFaceMaterialsCommand({ brushId: selectedBrush.id, materialId }) ); setStatusMessage( `Applied ${editorState.document.materials[materialId]?.name ?? materialId} to all faces on the selected whitebox.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } return; } if ( selectedBrush === null || selectedFaceId === null || selectedFace === null ) { setStatusMessage("Select a single box face before applying a material."); return; } if (selectedFace.materialId === materialId) { setStatusMessage( `${getBrushFaceLabel(selectedBrush, selectedFaceId)} already uses that material.` ); return; } try { store.executeCommand( createSetBoxBrushFaceMaterialCommand({ brushId: selectedBrush.id, faceId: selectedFaceId, materialId }) ); setStatusMessage( `Applied ${editorState.document.materials[materialId]?.name ?? materialId} to ${getBrushFaceLabel(selectedBrush, selectedFaceId)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const clearFaceMaterial = () => { if ( selectedBrush !== null && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { if ( selectedBrushSharedMaterialId === null && !selectedBrushHasMixedFaceMaterials ) { setStatusMessage( "All faces on the selected whitebox already use the fallback face material." ); return; } store.executeCommand( createSetBoxBrushAllFaceMaterialsCommand({ brushId: selectedBrush.id, materialId: null }) ); setStatusMessage( "Cleared the authored material on every face of the selected whitebox." ); return; } if ( selectedBrush === null || selectedFaceId === null || selectedFace === null ) { setStatusMessage( "Select a single box face before clearing its material." ); return; } if (selectedFace.materialId === null) { setStatusMessage( `${getBrushFaceLabel(selectedBrush, selectedFaceId)} already uses the fallback face material.` ); return; } store.executeCommand( createSetBoxBrushFaceMaterialCommand({ brushId: selectedBrush.id, faceId: selectedFaceId, materialId: null }) ); setStatusMessage( `Cleared the authored material on ${getBrushFaceLabel(selectedBrush, selectedFaceId)}.` ); }; const applyFaceUvState = ( uvState: FaceUvState, label: string, successMessage: string ) => { if ( selectedBrush !== null && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { const allFacesAlreadyMatch = selectedBrushFaceIds.every((faceId) => areFaceUvStatesEqual(selectedBrush.faces[faceId].uv, uvState) ); if (allFacesAlreadyMatch) { setStatusMessage("All face UVs on that whitebox are already current."); return; } try { store.executeCommand( createUpdateBoxBrushAllFaceUvsCommand({ brushId: selectedBrush.id, label, updateUvState: () => uvState }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } return; } if ( selectedBrush === null || selectedFaceId === null || selectedFace === null ) { setStatusMessage("Select a single box face before editing UVs."); return; } if (areFaceUvStatesEqual(selectedFace.uv, uvState)) { setStatusMessage("That face UV state is already current."); return; } try { store.executeCommand( createSetBoxBrushFaceUvStateCommand({ brushId: selectedBrush.id, faceId: selectedFaceId, uvState, label }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleApplyUvDraft = () => { if ( selectedBrush !== null && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { try { const offset = readVec2Draft(uvOffsetDraft, "Face UV offset"); const scale = readPositiveVec2Draft(uvScaleDraft, "Face UV scale"); store.executeCommand( createUpdateBoxBrushAllFaceUvsCommand({ brushId: selectedBrush.id, label: "Set solid face UV offset and scale", updateUvState: (uvState) => ({ ...uvState, offset, scale }) }) ); setStatusMessage( "Updated face UV offset and scale across the selected whitebox." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } return; } 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 ( selectedBrush !== null && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { store.executeCommand( createUpdateBoxBrushAllFaceUvsCommand({ brushId: selectedBrush.id, label: "Rotate solid face UVs 90 degrees", updateUvState: (uvState) => ({ ...uvState, rotationQuarterTurns: rotateQuarterTurns( uvState.rotationQuarterTurns ) }) }) ); setStatusMessage( "Rotated all face UVs 90 degrees on the selected whitebox." ); return; } if (selectedFace === null) { setStatusMessage("Select a single box face before rotating UVs."); return; } applyFaceUvState( { ...selectedFace.uv, rotationQuarterTurns: rotateQuarterTurns( selectedFace.uv.rotationQuarterTurns ) }, "Rotate face UV 90 degrees", "Rotated face UVs 90 degrees." ); }; const handleFlipUv = (axis: "u" | "v") => { if ( selectedBrush !== null && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { store.executeCommand( createUpdateBoxBrushAllFaceUvsCommand({ brushId: selectedBrush.id, label: axis === "u" ? "Flip solid face UV U" : "Flip solid face UV V", updateUvState: (uvState) => ({ ...uvState, flipU: axis === "u" ? !uvState.flipU : uvState.flipU, flipV: axis === "v" ? !uvState.flipV : uvState.flipV }) }) ); setStatusMessage( axis === "u" ? "Flipped U across all faces on the selected whitebox." : "Flipped V across all faces on the selected whitebox." ); return; } 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 && whiteboxSelectionMode === "object" && materialInspectorScope === "brush" ) { store.executeCommand( createUpdateBoxBrushAllFaceUvsCommand({ brushId: selectedBrush.id, label: "Fit solid face UVs to face", updateUvState: (_, faceId) => { const currentMaterialId = selectedBrush.faces[faceId].materialId; const currentMaterial = currentMaterialId === null ? null : (editorState.document.materials[currentMaterialId] ?? null); return currentMaterial === null ? createFitToFaceBoxBrushFaceUvState(selectedBrush, faceId) : createFitToMaterialTileBoxBrushFaceUvState( selectedBrush, faceId, getStarterMaterialTileSizeMeters(currentMaterial) ); } }) ); setStatusMessage( "Fit all face UVs on the selected whitebox to their face bounds and tile size." ); return; } if (selectedBrush === null || selectedFaceId === null) { setStatusMessage("Select a single box face before fitting UVs."); return; } applyFaceUvState( selectedFaceMaterial === undefined || selectedFaceMaterial === null ? createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId) : createFitToMaterialTileBoxBrushFaceUvState( selectedBrush, selectedFaceId, getStarterMaterialTileSizeMeters(selectedFaceMaterial) ), "Fit face UV to face", "Fit the selected face UVs to the face bounds." ); }; const handleEnterPlayMode = () => { if (blockingDiagnostics.length > 0) { setStatusMessage( `Run mode blocked: ${formatSceneDiagnosticSummary(blockingDiagnostics)}` ); return; } try { const nextRuntimeScene = buildRuntimeSceneForProjectScene( editorState.activeSceneId ); applyRuntimeSceneSession(editorState.activeSceneId, nextRuntimeScene); setRuntimeMessage(null); setFirstPersonTelemetry(null); setRuntimeInteractionPrompt(null); setRuntimeGlobalState( createDefaultRuntimeGlobalState(editorState.projectDocument.time) ); store.enterPlayMode(); setStatusMessage( nextRuntimeScene.navigationMode === "firstPerson" ? "Entered run mode with first-person navigation." : "Entered run mode with third-person navigation." ); } catch (error) { setStatusMessage(`Run mode could not start: ${getErrorMessage(error)}`); } }; const handleExitPlayMode = () => { setRuntimeScene(null); setRuntimeSceneId(null); setRuntimeSceneName(null); setRuntimeSceneLoadingScreen(null); setRuntimeGlobalState( createDefaultRuntimeGlobalState(editorState.projectDocument.time) ); setRuntimeMessage(null); setFirstPersonTelemetry(null); setRuntimeInteractionPrompt(null); setActiveNavigationMode("thirdPerson"); store.exitPlayMode(); setStatusMessage("Returned to editor mode."); }; const createAssetMenuHoverHandler = (assetId: string) => (hovered: boolean) => { setHoveredAssetId((current) => hovered ? assetId : current === assetId ? null : current ); }; const createDisabledMenuAction = ( label: string, testId: string ): HierarchicalMenuItem => ({ kind: "action", label, testId, disabled: true, onSelect: () => undefined }); const addMenuItems: HierarchicalMenuItem[] = [ { kind: "group", label: "Whitebox Primitives", testId: "add-menu-whitebox-primitives", children: [ { kind: "action", label: "Box", testId: "add-menu-box", onSelect: beginBoxCreation }, { kind: "action", label: "Wedge", testId: "add-menu-wedge", onSelect: beginWedgeCreation }, { kind: "action", label: "Cylinder", testId: "add-menu-cylinder", onSelect: beginCylinderCreation }, { kind: "action", label: "Cone", testId: "add-menu-cone", onSelect: beginConeCreation }, { kind: "action", label: "Torus", testId: "add-menu-torus", onSelect: beginTorusCreation } ] }, { kind: "action", label: "Terrain", testId: "add-menu-terrain", onSelect: handleCreateTerrain }, { kind: "action", label: "Path", testId: "add-menu-path", onSelect: handleCreatePath }, { kind: "group", label: "Entities", testId: "add-menu-entities", children: [ { kind: "action", label: "Camera Rig", testId: "add-menu-camera-rig", onSelect: () => beginEntityCreation("cameraRig") }, { kind: "action", label: "Player Start", testId: "add-menu-player-start", onSelect: () => beginEntityCreation("playerStart") }, { kind: "action", label: "Scene Entry", testId: "add-menu-scene-entry", onSelect: () => beginEntityCreation("sceneEntry") }, { kind: "action", label: "NPC", testId: "add-menu-npc", onSelect: () => beginEntityCreation("npc", { modelAssetId: modelAssetList[0]?.id ?? null }) }, { kind: "action", label: "Sound Emitter", testId: "add-menu-sound-emitter", onSelect: () => beginEntityCreation("soundEmitter", { audioAssetId: audioAssetList[0]?.id ?? null }) }, { kind: "action", label: "Trigger Volume", testId: "add-menu-trigger-volume", onSelect: () => beginEntityCreation("triggerVolume") }, { kind: "action", label: "Teleport Target", testId: "add-menu-teleport-target", onSelect: () => beginEntityCreation("teleportTarget") }, { kind: "action", label: "Interactable", testId: "add-menu-interactable", onSelect: () => beginEntityCreation("interactable") } ] }, { kind: "group", label: "Lights", testId: "add-menu-lights", children: [ { kind: "action", label: "Point Light", testId: "add-menu-point-light", onSelect: () => beginEntityCreation("pointLight") }, { kind: "action", label: "Spot Light", testId: "add-menu-spot-light", onSelect: () => beginEntityCreation("spotLight") } ] }, { kind: "group", label: "Assets", testId: "add-menu-assets", children: [ { kind: "group", label: "3D Models", testId: "add-menu-assets-models", children: modelAssetList.length === 0 ? [ createDisabledMenuAction( "No imported 3D models", "add-menu-assets-models-empty" ) ] : modelAssetList.map((asset) => ({ kind: "action" as const, label: asset.sourceName, testId: `add-menu-model-asset-${asset.id}`, onSelect: () => beginModelInstanceCreation(asset.id), onHoverChange: createAssetMenuHoverHandler(asset.id) })) }, { kind: "group", label: "Environments", testId: "add-menu-assets-environments", children: imageAssetList.length === 0 ? [ createDisabledMenuAction( "No imported environments", "add-menu-assets-environments-empty" ) ] : imageAssetList.map((asset) => ({ kind: "action" as const, label: asset.sourceName, testId: `add-menu-image-asset-${asset.id}`, onSelect: () => applyWorldBackgroundMode("image", asset.id), onHoverChange: createAssetMenuHoverHandler(asset.id) })) }, { kind: "group", label: "Audio", testId: "add-menu-assets-audio", children: audioAssetList.length === 0 ? [ createDisabledMenuAction( "No imported audio", "add-menu-assets-audio-empty" ) ] : audioAssetList.map((asset) => ({ kind: "action" as const, label: asset.sourceName, testId: `add-menu-audio-asset-${asset.id}`, onSelect: () => beginEntityCreation("soundEmitter", { audioAssetId: asset.id }), onHoverChange: createAssetMenuHoverHandler(asset.id) })) } ] }, { kind: "group", label: "Import", testId: "add-menu-import", children: [ { kind: "action", label: "3D Model (GLB/GLTF)", testId: "import-menu-model", disabled: !projectAssetStorageReady || projectAssetStorage === null, onSelect: handleImportModelButtonClick }, { kind: "action", label: "Environment", testId: "import-menu-environment", disabled: !projectAssetStorageReady || projectAssetStorage === null, onSelect: handleImportBackgroundImageButtonClick }, { kind: "action", label: "Audio", testId: "import-menu-audio", disabled: !projectAssetStorageReady || projectAssetStorage === null, onSelect: handleImportAudioButtonClick } ] } ]; const viewportPanelsStyle = layoutMode === "quad" ? createViewportQuadPanelsStyle(editorState.viewportQuadSplit) : undefined; if (editorState.toolMode === "play" && runtimeScene !== null) { const runtimeInteractKeyboardBinding = runtimeScene.playerInputBindings.keyboard.interact; const runtimeInteractKeyboardInstruction = isPlayerStartMouseBindingCode( runtimeInteractKeyboardBinding ) ? `Click ${formatPlayerStartKeyboardBindingLabel(runtimeInteractKeyboardBinding)}` : `Press ${formatPlayerStartKeyboardBindingLabel(runtimeInteractKeyboardBinding)}`; const runtimeInteractGamepadInstruction = `press ${formatPlayerStartGamepadActionBindingLabel( runtimeScene.playerInputBindings.gamepad.interact )}`; const runtimeInteractInstruction = `${runtimeInteractKeyboardInstruction} or ${runtimeInteractGamepadInstruction}`; return (
WebEditor3D
Slice 3.1 GLB/GLTF import and unified creation
Status: {statusMessage}
Spawn:{" "} {runtimeScene.spawn.source === "playerStart" ? "Authored Player Start" : runtimeScene.spawn.source === "sceneEntry" ? "Scene Entry arrival" : "Fallback runtime spawn"}
); } return (
{VIEWPORT_PANEL_IDS.map((panelId) => ( { store.setViewportPanelCameraState(panelId, cameraState); }} onToolPreviewChange={(toolPreview) => { store.setViewportToolPreview(toolPreview); }} onBeginTransformOperation={(operation) => beginTransformOperation(operation, "toolbar") } onToggleTransformSurfaceSnap={toggleTransformSurfaceSnap} onWhiteboxSelectionModeChange={ handleWhiteboxSelectionModeChange } onViewportGridToggle={handleViewportGridToggle} onWhiteboxSnapToggle={handleWhiteboxSnapToggle} onWhiteboxSnapStepDraftChange={setWhiteboxSnapStepDraft} onWhiteboxSnapStepBlur={handleWhiteboxSnapStepBlur} onTransformSessionChange={(nextTransformSession) => { latestActiveTransformSessionRef.current = nextTransformSession.kind === "active" ? nextTransformSession : null; store.setTransformSession(nextTransformSession); }} onTransformPreviewChange={(nextTransformSession) => { latestActiveTransformSessionRef.current = nextTransformSession; }} onTransformCommit={commitTransformSession} onTransformCancel={() => cancelTransformSession()} onSelectionChange={(selection) => applySelection(selection, "viewport") } /> ))} {layoutMode !== "quad" ? null : ( <>
)}
{schedulePaneOpen ? ( <>
({ entityId: entity.id, label }) )} sceneTransitionTargetOptions={sceneTransitionTargetOptions} visibilityTargetOptions={sequenceVisibilityTargetOptions} scheduler={editorState.projectDocument.scheduler} sequences={editorState.projectDocument.sequences} npcTalkTargetOptions={npcDialogueSequenceTargetOptions} selectedRoutineId={selectedScheduleRoutineId} selectedSequenceId={selectedSequenceId} onSelectRoutine={setSelectedScheduleRoutineId} onSelectSequence={setSelectedSequenceId} onAddRoutine={handleCreateScheduleRoutine} onAddSequence={handleAddProjectSequence} onDeleteRoutine={handleDeleteScheduleRoutine} onDeleteSequence={handleDeleteProjectSequence} onClose={() => setSchedulePaneOpen(false)} onCreateRoutineSequence={ handleCreateAttachedSequenceForRoutine } onSetRoutineTarget={(routineId, targetKey) => updateProjectSequencerState( "Set project sequencer target", "Retargeted sequence placement.", (scheduler, sequences) => { const routine = scheduler.routines[routineId]; if (routine === undefined) { throw new Error( "Selected sequence placement no longer exists." ); } const targetOption = resolveProjectScheduleTargetOption(targetKey); if (targetOption === null) { throw new Error( "Selected sequencer target no longer exists." ); } const previousTargetKey = getControlTargetRefKey( routine.target ); if (targetOption.target.kind === "actor") { const attachedSequence = routine.sequenceId === null ? null : (sequences.sequences[routine.sequenceId] ?? null); routine.target = targetOption.target; routine.effects = []; if ( attachedSequence !== null && getProjectSequenceHeldSteps(attachedSequence) .length > 0 ) { const retargetedSequence = cloneSequenceForRetargetedPlacement({ sequence: attachedSequence, targetOption, previousTargetKey }); sequences.sequences[retargetedSequence.id] = retargetedSequence; routine.sequenceId = retargetedSequence.id; setSelectedSequenceId(retargetedSequence.id); } else { const nextSequence = createAttachedSequenceForRoutine({ title: routine.title, targetOption, routine }); sequences.sequences[nextSequence.id] = nextSequence; routine.sequenceId = nextSequence.id; setSelectedSequenceId(nextSequence.id); } return; } if (targetOption.target.kind === "global") { const attachedSequence = routine.sequenceId === null ? null : (sequences.sequences[routine.sequenceId] ?? null); routine.target = targetOption.target; routine.effects = []; const nextSequence = createProjectSequence({ title: attachedSequence?.title ?? routine.title, effects: attachedSequence === null ? [] : attachedSequence.effects .filter( (effect) => effect.stepClass === "impulse" ) .map(cloneSequenceEffect) }); sequences.sequences[nextSequence.id] = nextSequence; routine.sequenceId = nextSequence.id; setSelectedSequenceId(nextSequence.id); return; } const effectOptions = listProjectScheduleEffectOptions(targetOption); const currentPrimaryEffect = routine.effects[0] ?? null; const nextEffectOptionId = currentPrimaryEffect === null ? effectOptions[0]?.id : (effectOptions.find( (effectOption) => effectOption.id === getProjectScheduleEffectOptionId( currentPrimaryEffect ) )?.id ?? effectOptions[0]?.id); if (nextEffectOptionId === undefined) { throw new Error( "Selected sequencer target does not expose a schedulable effect." ); } const attachedSequence = routine.sequenceId === null ? null : (sequences.sequences[routine.sequenceId] ?? null); routine.target = targetOption.target; routine.effects = []; if ( attachedSequence !== null && getProjectSequenceHeldSteps(attachedSequence).length > 0 ) { const retargetedSequence = cloneSequenceForRetargetedPlacement({ sequence: attachedSequence, targetOption, previousTargetKey }); sequences.sequences[retargetedSequence.id] = retargetedSequence; routine.sequenceId = retargetedSequence.id; setSelectedSequenceId(retargetedSequence.id); return; } const nextSequence = createProjectSequence({ title: attachedSequence?.title ?? routine.title, effects: [ { stepClass: "held", type: "controlEffect", effect: createProjectScheduleEffectFromOption({ targetOption, effectOptionId: nextEffectOptionId, previousEffect: currentPrimaryEffect }) } ] }); sequences.sequences[nextSequence.id] = nextSequence; routine.sequenceId = nextSequence.id; setSelectedSequenceId(nextSequence.id); } ) } onSetRoutineTitle={(routineId, title) => updateProjectScheduleRoutine( routineId, "Rename sequence placement", "Updated sequence placement title.", (routine) => { routine.title = title.trim(); } ) } onSetRoutineEnabled={(routineId, enabled) => updateProjectScheduleRoutine( routineId, "Toggle sequence placement", enabled ? "Enabled sequence placement." : "Disabled sequence placement.", (routine) => { routine.enabled = enabled; } ) } onSetRoutineStartHour={(routineId, startHour) => updateProjectScheduleRoutine( routineId, "Set project sequencer start time", "Updated sequence placement start time.", (routine) => { routine.startHour = normalizeTimeOfDayHours(startHour); if (routine.startHour === routine.endHour) { throw new Error( "Sequencer clip start and end hours must differ." ); } } ) } onSetRoutineEndHour={(routineId, endHour) => updateProjectScheduleRoutine( routineId, "Set project sequencer end time", "Updated sequence placement end time.", (routine) => { routine.endHour = normalizeTimeOfDayHours(endHour); if (routine.startHour === routine.endHour) { throw new Error( "Sequencer clip start and end hours must differ." ); } } ) } onSetRoutinePriority={(routineId, priority) => updateProjectScheduleRoutine( routineId, "Set project sequencer priority", "Updated sequence placement priority.", (routine) => { if (!Number.isFinite(priority)) { throw new Error( "Sequencer clip priority must be a finite number." ); } routine.priority = Math.trunc(priority); } ) } onSetRoutineSequenceId={(routineId, sequenceId) => updateProjectScheduleRoutine( routineId, "Set project sequencer sequence", "Timeline clip now resolves a project sequence.", (routine) => { routine.sequenceId = sequenceId; } ) } onSetSequenceTitle={(sequenceId, title) => updateProjectSequence( sequenceId, "Rename project sequence", "Updated project sequence title.", (sequence) => { sequence.title = title.trim() || "Sequence"; } ) } onAddControlEffect={handleAddProjectSequenceControlEffect} onAddNpcTalkEffect={handleAddProjectSequenceNpcTalkStep} onAddTeleportStep={handleAddProjectSequenceTeleportStep} onAddSceneTransitionStep={ handleAddProjectSequenceSceneTransitionStep } onAddVisibilityStep={handleAddProjectSequenceVisibilityStep} onDeleteStep={handleDeleteProjectSequenceStep} onSetControlStepTarget={ updateProjectSequenceControlStepTarget } onSetControlStepEffectOption={ updateProjectSequenceControlStepEffectOption } onSetControlStepNumericValue={ updateProjectSequenceControlStepNumericValue } onSetControlStepColorValue={ updateProjectSequenceControlStepColorValue } onSetControlStepAnimationClip={ updateProjectSequenceControlStepAnimationClip } onSetControlStepAnimationLoop={ updateProjectSequenceControlStepAnimationLoop } onSetControlStepPathId={ updateProjectSequenceControlStepPathId } onSetControlStepPathSpeed={ updateProjectSequenceControlStepPathSpeed } onSetControlStepPathLoop={ updateProjectSequenceControlStepPathLoop } onSetControlStepPathSmooth={ updateProjectSequenceControlStepPathSmooth } onSetNpcTalkStepNpcEntityId={ updateProjectSequenceNpcTalkStepNpcEntityId } onSetNpcTalkStepDialogueId={ updateProjectSequenceNpcTalkStepDialogueId } onSetTeleportStepTarget={ updateProjectSequenceTeleportStepTarget } onSetSceneTransitionStepTarget={ updateProjectSequenceSceneTransitionStepTarget } onSetVisibilityStepTarget={ updateProjectSequenceVisibilityStepTarget } onSetVisibilityStepMode={ updateProjectSequenceVisibilityStepMode } />
) : null}
{addMenuPosition === null ? null : ( )}
Status: {statusMessage}
Whitebox:{" "} {getWhiteboxSelectionModeLabel(whiteboxSelectionMode)}
Document:{" "} {documentStatusLabel}
Run: {runReadyLabel}
Warnings:{" "} {warningDiagnostics.length}
{hoveredAssetStatusMessage === null ? null : (
Asset:{" "} {hoveredAssetStatusMessage}
)}
Last: {lastCommandLabel}
); }