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 { 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 { createMoveBoxBrushCommand } from "../commands/move-box-brush-command"; import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command"; import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command"; import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command"; import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command"; import { createDeleteInteractionLinkCommand } from "../commands/delete-interaction-link-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 { createUpsertInteractionLinkCommand } from "../commands/upsert-interaction-link-command"; import { getSelectedBrushFaceId, getSingleSelectedBrushId, getSingleSelectedEntityId, getSingleSelectedModelInstanceId, isBrushFaceSelected, isBrushSelected, type EditorSelection } from "../core/selection"; import type { Vec2, Vec3 } from "../core/vector"; import { areModelInstancesEqual, createModelInstance, DEFAULT_MODEL_INSTANCE_POSITION, DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, DEFAULT_MODEL_INSTANCE_SCALE, type ModelInstance } from "../assets/model-instances"; import { getModelInstanceDisplayLabelById, getSortedModelInstanceDisplayLabels } from "../assets/model-instance-labels"; import { 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 { BOX_FACE_IDS, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE, createDefaultFaceUvState, normalizeBrushName, type BoxBrush, type BoxFaceId, type FaceUvRotationQuarterTurns, type FaceUvState } from "../document/brushes"; import { ADVANCED_RENDERING_SHADOW_MAP_SIZES, ADVANCED_RENDERING_SHADOW_TYPES, ADVANCED_RENDERING_TONE_MAPPING_MODES, areWorldSettingsEqual, changeWorldBackgroundMode, cloneWorldSettings, type WorldBackgroundMode, type AdvancedRenderingSettings, type AdvancedRenderingShadowMapSize, type AdvancedRenderingShadowType, type AdvancedRenderingToneMappingMode, type WorldSettings } from "../document/world-settings"; import { formatSceneDiagnosticSummary, validateSceneDocument } from "../document/scene-document-validation"; import { getBrowserProjectAssetStorageAccess, type ProjectAssetStorage } from "../assets/project-asset-storage"; import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs"; import { DEFAULT_ENTITY_POSITION, DEFAULT_INTERACTABLE_PROMPT, DEFAULT_INTERACTABLE_RADIUS, DEFAULT_POINT_LIGHT_COLOR_HEX, DEFAULT_POINT_LIGHT_DISTANCE, DEFAULT_POINT_LIGHT_INTENSITY, DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID, DEFAULT_SOUND_EMITTER_VOLUME, DEFAULT_SOUND_EMITTER_REF_DISTANCE, DEFAULT_SOUND_EMITTER_MAX_DISTANCE, DEFAULT_TELEPORT_TARGET_YAW_DEGREES, 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, createInteractableEntity, createPointLightEntity, createPlayerStartEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity, getEntityInstances, getEntityKindLabel, getPrimaryPlayerStartEntity, normalizeYawDegrees, normalizeInteractablePrompt, type EntityInstance, type EntityKind } from "../entities/entity-instances"; import { getEntityDisplayLabelById, getSortedEntityDisplayLabels } from "../entities/entity-labels"; import { areInteractionLinksEqual, createPlayAnimationInteractionLink, createPlaySoundInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, getInteractionLinksForSource, type InteractionLink, type InteractionTriggerKind } from "../interactions/interaction-links"; import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library"; import { RunnerCanvas } from "../runner-web/RunnerCanvas"; import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller"; import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system"; import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation"; import { Panel } from "../shared-ui/Panel"; 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 { getViewportViewModeLabel, type ViewportViewMode } from "../viewport-three/viewport-view-modes"; import { VIEWPORT_LAYOUT_MODES, VIEWPORT_PANEL_IDS, getViewportDisplayModeLabel, getViewportLayoutModeLabel, getViewportPanelLabel, type ViewportDisplayMode, type ViewportLayoutMode, type ViewportPanelId } 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; } type InteractionSourceEntity = Extract; const FACE_LABELS: Record = { posX: "+X Right", negX: "-X Left", posY: "+Y Top", negY: "-Y Bottom", posZ: "+Z Front", negZ: "-Z Back" }; const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index])); function formatVec3(vector: Vec3): string { return `${vector.x}, ${vector.y}, ${vector.z}`; } function formatDiagnosticCount(count: number, label: string): string { return `${count} ${label}${count === 1 ? "" : "s"}`; } function getViewportCaption( toolMode: "select" | "box-create" | "play", layoutMode: ViewportLayoutMode ): string { if (toolMode === "play") { return "Runner is active."; } if (toolMode === "box-create") { return `${getViewportLayoutModeLabel(layoutMode)} active. Hover any viewport to update the shared preview; click to place.`; } return `${getViewportLayoutModeLabel(layoutMode)} active.`; } function createVec2Draft(vector: Vec2): Vec2Draft { return { x: String(vector.x), y: String(vector.y) }; } function createVec3Draft(vector: Vec3): Vec3Draft { return { x: String(vector.x), y: String(vector.y), z: String(vector.z) }; } function readVec2Draft(draft: Vec2Draft, label: string): Vec2 { const vector = { x: Number(draft.x), y: Number(draft.y) }; if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y)) { throw new Error(`${label} values must be finite numbers.`); } return vector; } function readPositiveVec2Draft(draft: Vec2Draft, label: string): Vec2 { const vector = readVec2Draft(draft, label); if (vector.x <= 0 || vector.y <= 0) { throw new Error(`${label} values must remain positive.`); } return vector; } function 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 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 areVec2Equal(left: Vec2, right: Vec2): boolean { return left.x === right.x && left.y === right.y; } function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } function areFaceUvStatesEqual(left: FaceUvState, right: FaceUvState): boolean { return ( areVec2Equal(left.offset, right.offset) && areVec2Equal(left.scale, right.scale) && left.rotationQuarterTurns === right.rotationQuarterTurns && left.flipU === right.flipU && left.flipV === right.flipV ); } function getSelectedBoxBrush(selection: EditorSelection, brushes: BoxBrush[]): BoxBrush | null { const selectedBrushId = getSingleSelectedBrushId(selection); if (selectedBrushId === null) { return null; } return brushes.find((brush) => brush.id === selectedBrushId) ?? null; } 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 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 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 createModelInstancePlacementPosition(asset: ModelAssetRecord, anchor: Vec3 | null): Vec3 { const boundingBox = asset.metadata.boundingBox; if (anchor !== null) { const floorOffset = boundingBox === null ? 0 : -boundingBox.min.y; return { x: anchor.x, y: anchor.y + floorOffset, z: anchor.z }; } return { x: DEFAULT_MODEL_INSTANCE_POSITION.x, y: boundingBox === null ? DEFAULT_MODEL_INSTANCE_POSITION.y : Math.max(DEFAULT_MODEL_INSTANCE_POSITION.y, -boundingBox.min.y), z: DEFAULT_MODEL_INSTANCE_POSITION.z }; } function getBrushLabel(brush: BoxBrush, index: number): string { return brush.name ?? `Box Brush ${index + 1}`; } function getBrushLabelById(brushId: string, brushes: BoxBrush[]): string { const brushIndex = brushes.findIndex((brush) => brush.id === brushId); return brushIndex === -1 ? "Box Brush" : getBrushLabel(brushes[brushIndex], brushIndex); } function getSelectedBrushLabel(selection: EditorSelection, brushes: BoxBrush[]): string { const selectedBrushId = getSingleSelectedBrushId(selection); if (selectedBrushId === null) { return "No brush selected"; } return getBrushLabelById(selectedBrushId, brushes); } function describeSelection( selection: EditorSelection, brushes: BoxBrush[], modelInstances: Record, assets: Record, entities: Record ): string { switch (selection.kind) { case "none": return "No authored selection"; case "brushes": return `${selection.ids.length} brush selected (${getSelectedBrushLabel(selection, brushes)})`; case "brushFace": return `1 face selected (${FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushes)})`; case "entities": return `${selection.ids.length} entity selected (${getEntityDisplayLabelById(selection.ids[0], entities, assets)})`; case "modelInstances": return `${selection.ids.length} model instance${selection.ids.length === 1 ? "" : "s"} selected (${getModelInstanceDisplayLabelById(selection.ids[0], modelInstances, assets)})`; default: return "Unknown selection"; } } 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"; } } 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 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"); } 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 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 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 { switch (material.pattern) { case "grid": return { backgroundColor: material.baseColorHex, backgroundImage: `linear-gradient(${material.accentColorHex} 2px, transparent 2px), linear-gradient(90deg, ${material.accentColorHex} 2px, transparent 2px)`, backgroundSize: "18px 18px" }; case "checker": return { backgroundColor: material.baseColorHex, backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex}), linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex})`, backgroundPosition: "0 0, 9px 9px", backgroundSize: "18px 18px" }; case "stripes": return { backgroundColor: material.baseColorHex, backgroundImage: `repeating-linear-gradient(135deg, ${material.accentColorHex} 0 9px, transparent 9px 18px)` }; case "diamond": return { backgroundColor: material.baseColorHex, backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%), linear-gradient(-45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%)`, backgroundSize: "22px 22px" }; } } function rotateQuarterTurns(rotationQuarterTurns: FaceUvRotationQuarterTurns): FaceUvRotationQuarterTurns { return ((rotationQuarterTurns + 1) % 4) as FaceUvRotationQuarterTurns; } 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 formatWorldBackgroundLabel(world: WorldSettings): string { if (world.background.mode === "solid") { return "Solid"; } if (world.background.mode === "verticalGradient") { return "Vertical Gradient"; } return "Image"; } 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"; } } export function App({ store, initialStatusMessage }: AppProps) { const editorState = useEditorStoreState(store); const brushList = Object.values(editorState.document.brushes); const layoutMode = editorState.viewportLayoutMode; const activePanelId = editorState.activeViewportPanelId; const activePanelState = editorState.viewportPanels[activePanelId]; const activePanelLabel = getViewportPanelLabel(activePanelId); const activePanelDisplaySummary = `${getViewportViewModeLabel(activePanelState.viewMode)} / ${getViewportDisplayModeLabel(activePanelState.displayMode)}`; const viewportToolPreview = editorState.viewportTransientState.toolPreview; const entityList = getEntityInstances(editorState.document.entities); const entityDisplayList = getSortedEntityDisplayLabels(editorState.document.entities, editorState.document.assets); const primaryPlayerStart = getPrimaryPlayerStartEntity(editorState.document.entities); const materialList = sortDocumentMaterials(editorState.document.materials); const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList); const selectedEntity = getSelectedEntity(editorState.selection, entityList); const selectedModelInstance = getSelectedModelInstance(editorState.selection, Object.values(editorState.document.modelInstances)); const selectedFaceId = getSelectedBrushFaceId(editorState.selection); const selectedFace = selectedBrush !== null && selectedFaceId !== null ? selectedBrush.faces[selectedFaceId] : null; const selectedFaceMaterial = selectedFace !== null && selectedFace.materialId !== null ? editorState.document.materials[selectedFace.materialId] ?? null : null; const selectedModelAsset = selectedModelInstance !== null ? (editorState.document.assets[selectedModelInstance.assetId] ?? null) : null; const selectedModelAssetRecord = selectedModelAsset !== null && selectedModelAsset.kind === "model" ? selectedModelAsset : null; const selectedPlayerStart = selectedEntity?.kind === "playerStart" ? selectedEntity : null; const selectedSoundEmitter = isSoundEmitterEntity(selectedEntity) ? selectedEntity : null; 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 selectedTeleportTarget = selectedEntity?.kind === "teleportTarget" ? selectedEntity : null; const selectedInteractable = selectedEntity?.kind === "interactable" ? selectedEntity : null; const projectAssetList = Object.values(editorState.document.assets); 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 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 teleportTargetOptions = entityDisplayList.filter(({ entity }) => entity.kind === "teleportTarget"); const soundEmitterOptions = entityDisplayList.filter(({ entity }) => entity.kind === "soundEmitter") as Array<{ entity: Extract; label: string; }>; 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 [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name); const [brushNameDraft, setBrushNameDraft] = useState(""); const [positionDraft, setPositionDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); const [sizeDraft, setSizeDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); const [uvOffsetDraft, setUvOffsetDraft] = useState(createVec2Draft(createDefaultFaceUvState().offset)); const [uvScaleDraft, setUvScaleDraft] = useState(createVec2Draft(createDefaultFaceUvState().scale)); const [entityPositionDraft, setEntityPositionDraft] = useState(createVec3Draft(DEFAULT_ENTITY_POSITION)); const [pointLightColorDraft, setPointLightColorDraft] = useState(DEFAULT_POINT_LIGHT_COLOR_HEX); const [pointLightIntensityDraft, setPointLightIntensityDraft] = useState(String(DEFAULT_POINT_LIGHT_INTENSITY)); const [pointLightDistanceDraft, setPointLightDistanceDraft] = useState(String(DEFAULT_POINT_LIGHT_DISTANCE)); const [spotLightColorDraft, setSpotLightColorDraft] = useState(DEFAULT_SPOT_LIGHT_COLOR_HEX); const [spotLightIntensityDraft, setSpotLightIntensityDraft] = useState(String(DEFAULT_SPOT_LIGHT_INTENSITY)); const [spotLightDistanceDraft, setSpotLightDistanceDraft] = useState(String(DEFAULT_SPOT_LIGHT_DISTANCE)); const [spotLightAngleDraft, setSpotLightAngleDraft] = useState(String(DEFAULT_SPOT_LIGHT_ANGLE_DEGREES)); const [spotLightDirectionDraft, setSpotLightDirectionDraft] = useState(createVec3Draft(DEFAULT_SPOT_LIGHT_DIRECTION)); const [playerStartYawDraft, setPlayerStartYawDraft] = useState("0"); 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 [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 [ambientLightIntensityDraft, setAmbientLightIntensityDraft] = useState(String(editorState.document.world.ambientLight.intensity)); const [sunLightIntensityDraft, setSunLightIntensityDraft] = useState(String(editorState.document.world.sunLight.intensity)); const [sunDirectionDraft, setSunDirectionDraft] = useState(createVec3Draft(editorState.document.world.sunLight.direction)); const [backgroundEnvironmentIntensityDraft, setBackgroundEnvironmentIntensityDraft] = useState( editorState.document.world.background.mode === "image" ? String(editorState.document.world.background.environmentIntensity) : "0.5" ); const [advancedRenderingShadowBiasDraft, setAdvancedRenderingShadowBiasDraft] = useState( String(editorState.document.world.advancedRendering.shadows.bias) ); const [advancedRenderingAmbientOcclusionIntensityDraft, setAdvancedRenderingAmbientOcclusionIntensityDraft] = useState( String(editorState.document.world.advancedRendering.ambientOcclusion.intensity) ); const [advancedRenderingAmbientOcclusionRadiusDraft, setAdvancedRenderingAmbientOcclusionRadiusDraft] = useState( String(editorState.document.world.advancedRendering.ambientOcclusion.radius) ); const [advancedRenderingAmbientOcclusionSamplesDraft, setAdvancedRenderingAmbientOcclusionSamplesDraft] = useState( String(editorState.document.world.advancedRendering.ambientOcclusion.samples) ); const [advancedRenderingBloomIntensityDraft, setAdvancedRenderingBloomIntensityDraft] = useState( String(editorState.document.world.advancedRendering.bloom.intensity) ); const [advancedRenderingBloomThresholdDraft, setAdvancedRenderingBloomThresholdDraft] = useState( String(editorState.document.world.advancedRendering.bloom.threshold) ); const [advancedRenderingBloomRadiusDraft, setAdvancedRenderingBloomRadiusDraft] = useState( String(editorState.document.world.advancedRendering.bloom.radius) ); const [advancedRenderingToneMappingExposureDraft, setAdvancedRenderingToneMappingExposureDraft] = useState( String(editorState.document.world.advancedRendering.toneMapping.exposure) ); const [advancedRenderingDepthOfFieldFocusDistanceDraft, setAdvancedRenderingDepthOfFieldFocusDistanceDraft] = useState( String(editorState.document.world.advancedRendering.depthOfField.focusDistance) ); const [advancedRenderingDepthOfFieldFocalLengthDraft, setAdvancedRenderingDepthOfFieldFocalLengthDraft] = useState( String(editorState.document.world.advancedRendering.depthOfField.focalLength) ); const [advancedRenderingDepthOfFieldBokehScaleDraft, setAdvancedRenderingDepthOfFieldBokehScaleDraft] = useState( String(editorState.document.world.advancedRendering.depthOfField.bokehScale) ); const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Slice 3.5 advanced rendering ready."); const [assetStatusMessage, setAssetStatusMessage] = useState(null); const [preferredNavigationMode, setPreferredNavigationMode] = useState( primaryPlayerStart === null ? "orbitVisitor" : "firstPerson" ); const [activeNavigationMode, setActiveNavigationMode] = useState( primaryPlayerStart === null ? "orbitVisitor" : "firstPerson" ); const [projectAssetStorage, setProjectAssetStorage] = useState(null); const [projectAssetStorageReady, setProjectAssetStorageReady] = useState(false); const [runtimeScene, setRuntimeScene] = useState(null); const [runtimeMessage, setRuntimeMessage] = useState(null); const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); const [runtimeInteractionPrompt, setRuntimeInteractionPrompt] = useState(null); const [loadedModelAssets, setLoadedModelAssets] = useState>({}); const [loadedImageAssets, setLoadedImageAssets] = useState>({}); const [loadedAudioAssets, setLoadedAudioAssets] = useState>({}); const [focusRequest, setFocusRequest] = useState<{ id: number; selection: EditorSelection; panelId: ViewportPanelId }>({ id: 0, panelId: "topLeft", selection: { kind: "none" } }); const importInputRef = useRef(null); const importModelInputRef = useRef(null); const importBackgroundImageInputRef = useRef(null); const importAudioInputRef = useRef(null); const loadedModelAssetsRef = useRef>({}); const loadedImageAssetsRef = useRef>({}); const loadedAudioAssetsRef = useRef>({}); const documentValidation = validateSceneDocument(editorState.document); const runValidation = validateRuntimeSceneBuild(editorState.document, preferredNavigationMode); const diagnostics = [...documentValidation.errors, ...documentValidation.warnings, ...runValidation.errors, ...runValidation.warnings]; const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error"); const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning"); const documentStatusLabel = documentValidation.errors.length === 0 ? "Valid" : formatDiagnosticCount(documentValidation.errors.length, "error"); const lastCommandLabel = editorState.lastCommandLabel ?? "No commands yet"; const runReadyLabel = blockingDiagnostics.length > 0 ? "Blocked" : preferredNavigationMode === "firstPerson" ? "Ready for First Person" : "Ready for Orbit Visitor"; const advancedRendering = editorState.document.world.advancedRendering; useEffect(() => { setSceneNameDraft(editorState.document.name); }, [editorState.document.name]); useEffect(() => { setBrushNameDraft(selectedBrush?.name ?? ""); }, [selectedBrush]); useEffect(() => { if (selectedBrush === null) { setPositionDraft(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); setSizeDraft(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); return; } setPositionDraft(createVec3Draft(selectedBrush.center)); setSizeDraft(createVec3Draft(selectedBrush.size)); }, [selectedBrush]); useEffect(() => { if (selectedFace === null) { const defaultUvState = createDefaultFaceUvState(); setUvOffsetDraft(createVec2Draft(defaultUvState.offset)); setUvScaleDraft(createVec2Draft(defaultUvState.scale)); return; } setUvOffsetDraft(createVec2Draft(selectedFace.uv.offset)); setUvScaleDraft(createVec2Draft(selectedFace.uv.scale)); }, [selectedFace]); useEffect(() => { if (selectedEntity === null) { setEntityPositionDraft(createVec3Draft(DEFAULT_ENTITY_POSITION)); 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"); 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)); setTeleportTargetYawDraft(String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES)); setInteractableRadiusDraft(String(DEFAULT_INTERACTABLE_RADIUS)); setInteractablePromptDraft(DEFAULT_INTERACTABLE_PROMPT); setInteractableEnabledDraft(true); return; } setEntityPositionDraft(createVec3Draft(selectedEntity.position)); 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 "playerStart": setPlayerStartYawDraft(String(selectedEntity.yawDegrees)); 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.enabled); break; } }, [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(() => { 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(() => { setSunDirectionDraft(createVec3Draft(editorState.document.world.sunLight.direction)); }, [editorState.document.world.sunLight.direction]); useEffect(() => { const advancedRendering = editorState.document.world.advancedRendering; setAdvancedRenderingShadowBiasDraft(String(advancedRendering.shadows.bias)); setAdvancedRenderingAmbientOcclusionIntensityDraft(String(advancedRendering.ambientOcclusion.intensity)); setAdvancedRenderingAmbientOcclusionRadiusDraft(String(advancedRendering.ambientOcclusion.radius)); setAdvancedRenderingAmbientOcclusionSamplesDraft(String(advancedRendering.ambientOcclusion.samples)); setAdvancedRenderingBloomIntensityDraft(String(advancedRendering.bloom.intensity)); setAdvancedRenderingBloomThresholdDraft(String(advancedRendering.bloom.threshold)); setAdvancedRenderingBloomRadiusDraft(String(advancedRendering.bloom.radius)); setAdvancedRenderingToneMappingExposureDraft(String(advancedRendering.toneMapping.exposure)); setAdvancedRenderingDepthOfFieldFocusDistanceDraft(String(advancedRendering.depthOfField.focusDistance)); setAdvancedRenderingDepthOfFieldFocalLengthDraft(String(advancedRendering.depthOfField.focalLength)); setAdvancedRenderingDepthOfFieldBokehScaleDraft(String(advancedRendering.depthOfField.bokehScale)); }, [editorState.document.world.advancedRendering]); useEffect(() => { loadedImageAssetsRef.current = loadedImageAssets; }, [loadedImageAssets]); useEffect(() => { loadedModelAssetsRef.current = loadedModelAssets; }, [loadedModelAssets]); useEffect(() => { loadedAudioAssetsRef.current = loadedAudioAssets; }, [loadedAudioAssets]); useEffect(() => { let cancelled = false; void (async () => { const access = await getBrowserProjectAssetStorageAccess(); if (cancelled) { return; } setProjectAssetStorage(access.storage); setAssetStatusMessage(access.diagnostic); setProjectAssetStorageReady(true); })().catch((error) => { if (cancelled) { return; } setProjectAssetStorage(null); setProjectAssetStorageReady(true); setAssetStatusMessage(getErrorMessage(error)); }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!projectAssetStorageReady) { return; } let cancelled = false; const currentAssets = editorState.document.assets; 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 syncAssets = async () => { if (projectAssetStorage === null) { for (const loadedAsset of Object.values(previousLoadedModelAssets)) { disposeModelTemplate(loadedAsset.template); } for (const loadedAsset of Object.values(previousLoadedImageAssets)) { disposeLoadedImageAsset(loadedAsset); } if (!cancelled) { loadedModelAssetsRef.current = {}; loadedImageAssetsRef.current = {}; loadedAudioAssetsRef.current = {}; setLoadedModelAssets({}); setLoadedImageAssets({}); setLoadedAudioAssets({}); } return; } for (const asset of Object.values(currentAssets)) { if (isModelAsset(asset)) { 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 loadModelAssetFromStorage(projectAssetStorage, 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 loadImageAssetFromStorage(projectAssetStorage, asset); } catch (error) { syncErrorMessages.push(`Image asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`); } continue; } if (isAudioAsset(asset)) { previousLoadedAudioAssetIds.delete(asset.id); const cachedLoadedAsset = previousLoadedAudioAssets[asset.id]; if (cachedLoadedAsset !== undefined && cachedLoadedAsset.storageKey === asset.storageKey) { nextLoadedAudioAssets[asset.id] = cachedLoadedAsset; continue; } try { nextLoadedAudioAssets[asset.id] = await loadAudioAssetFromStorage(projectAssetStorage, asset); } catch (error) { syncErrorMessages.push(`Audio asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`); } } } 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; } 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; 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 handleWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (isTextEntryTarget(event.target)) { 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."); }; window.addEventListener("keydown", handleWindowKeyDown); return () => { window.removeEventListener("keydown", handleWindowKeyDown); }; }, [activePanelId, editorState.selection, editorState.toolMode, brushList.length, entityList.length]); const applySceneName = () => { const normalizedName = sceneNameDraft.trim() || "Untitled Scene"; if (normalizedName === editorState.document.name) { return; } store.executeCommand(createSetSceneNameCommand(normalizedName)); setStatusMessage(`Scene renamed to ${normalizedName}.`); }; const requestViewportFocus = (selection: EditorSelection, status?: string) => { setFocusRequest((current) => ({ id: current.id + 1, panelId: activePanelId, selection })); if (status !== undefined) { setStatusMessage(status); } }; const handleSetViewportLayoutMode = (nextLayoutMode: ViewportLayoutMode) => { if (editorState.viewportLayoutMode === nextLayoutMode) { return; } if ( nextLayoutMode === "single" && editorState.viewportTransientState.toolPreview.kind === "box-create" && editorState.viewportTransientState.toolPreview.sourcePanelId !== activePanelId ) { store.clearViewportToolPreview(editorState.viewportTransientState.toolPreview.sourcePanelId); } 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 ${getViewportPanelLabel(panelId)} viewport panel.`); }; const handleSetViewportPanelViewMode = (panelId: ViewportPanelId, nextViewMode: ViewportViewMode) => { if (editorState.viewportPanels[panelId].viewMode === nextViewMode) { return; } if ( editorState.viewportTransientState.toolPreview.kind === "box-create" && editorState.viewportTransientState.toolPreview.sourcePanelId === panelId ) { store.clearViewportToolPreview(panelId); } blurActiveTextEntry(); store.setViewportPanelViewMode(panelId, nextViewMode); setStatusMessage(`Set the ${getViewportPanelLabel(panelId)} 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 ${getViewportPanelLabel(panelId)} panel to ${getViewportDisplayModeLabel(nextDisplayMode)} display.`); }; const handleCreateBoxBrush = (center?: Vec3) => { try { store.executeCommand(createCreateBoxBrushCommand(center === undefined ? {} : { center })); setStatusMessage( center === undefined ? `Created a box brush snapped to the ${DEFAULT_GRID_SIZE}m grid.` : `Created a box brush at snapped center ${formatVec3(center)}.` ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySelection = ( selection: EditorSelection, source: "outliner" | "viewport" | "inspector" | "runner", options: { focusViewport?: boolean } = {} ) => { blurActiveTextEntry(); store.setSelection(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(`Selected ${getBrushLabelById(selection.ids[0], brushList)} from the ${source}${suffix}.`); break; case "brushFace": setStatusMessage( `Selected ${FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.` ); break; case "entities": setStatusMessage(`Selected ${getEntityDisplayLabelById(selection.ids[0], editorState.document.entities, editorState.document.assets)} from the ${source}${suffix}.`); break; case "modelInstances": setStatusMessage( `Selected ${getModelInstanceDisplayLabelById(selection.ids[0], editorState.document.modelInstances, editorState.document.assets)} from the ${source}${suffix}.` ); break; default: setStatusMessage(`Selection updated from the ${source}${suffix}.`); break; } if (options.focusViewport) { requestViewportFocus(selection); } }; const applyPositionChange = () => { if (selectedBrush === null) { setStatusMessage("Select a box brush before moving it."); return; } try { const snappedCenter = snapVec3ToGrid(readVec3Draft(positionDraft, "Box brush position"), DEFAULT_GRID_SIZE); if (areVec3Equal(snappedCenter, selectedBrush.center)) { return; } store.executeCommand( createMoveBoxBrushCommand({ brushId: selectedBrush.id, center: snappedCenter }) ); setStatusMessage("Moved selected box brush."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applySizeChange = () => { if (selectedBrush === null) { setStatusMessage("Select a box brush before resizing it."); return; } try { const snappedSize = snapPositiveSizeToGrid(readVec3Draft(sizeDraft, "Box brush size"), DEFAULT_GRID_SIZE); if (areVec3Equal(snappedSize, selectedBrush.size)) { return; } store.executeCommand( createResizeBoxBrushCommand({ brushId: selectedBrush.id, size: snappedSize }) ); setStatusMessage("Resized selected box brush."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; 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 resolveNewEntityPosition = (kind: EntityKind): Vec3 => { if (selectedBrush !== null) { if (kind === "triggerVolume") { return snapVec3ToGrid(selectedBrush.center, DEFAULT_GRID_SIZE); } return snapVec3ToGrid( { x: selectedBrush.center.x, y: selectedBrush.center.y + selectedBrush.size.y * 0.5, z: selectedBrush.center.z }, DEFAULT_GRID_SIZE ); } if (selectedEntity !== null) { return snapVec3ToGrid(selectedEntity.position, DEFAULT_GRID_SIZE); } return snapVec3ToGrid(DEFAULT_ENTITY_POSITION, DEFAULT_GRID_SIZE); }; const handlePlaceEntity = (kind: EntityKind) => { try { const basePosition = resolveNewEntityPosition(kind); let nextEntity: EntityInstance; switch (kind) { case "pointLight": nextEntity = createPointLightEntity({ position: basePosition }); break; case "spotLight": nextEntity = createSpotLightEntity({ position: basePosition }); break; case "playerStart": nextEntity = createPlayerStartEntity({ position: basePosition }); break; case "soundEmitter": nextEntity = createSoundEmitterEntity({ position: basePosition, audioAssetId: audioAssetList[0]?.id ?? undefined }); break; case "triggerVolume": nextEntity = selectedBrush === null ? createTriggerVolumeEntity({ position: basePosition }) : createTriggerVolumeEntity({ position: basePosition, size: selectedBrush.size }); break; case "teleportTarget": nextEntity = createTeleportTargetEntity({ position: basePosition }); break; case "interactable": nextEntity = createInteractableEntity({ position: basePosition }); break; } store.executeCommand( createUpsertEntityCommand({ entity: nextEntity, label: `Place ${getEntityKindLabel(kind).toLowerCase()}` }) ); setStatusMessage(`Placed ${getEntityKindLabel(kind)}.`); } 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 handlePlaceModelInstance = (assetId: string) => { const asset = editorState.document.assets[assetId]; if (asset === undefined || asset.kind !== "model") { setStatusMessage("Select a model asset before placing a model instance."); return; } try { const anchorPosition = selectedBrush !== null ? { x: selectedBrush.center.x, y: selectedBrush.center.y + selectedBrush.size.y * 0.5, z: selectedBrush.center.z } : selectedModelInstance?.position ?? null; const nextModelInstance = createModelInstance({ assetId: asset.id, position: createModelInstancePlacementPosition(asset, anchorPosition), rotationDegrees: DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, scale: DEFAULT_MODEL_INSTANCE_SCALE }); store.executeCommand( createUpsertModelInstanceCommand({ modelInstance: nextModelInstance, label: `Place ${asset.sourceName}` }) ); setStatusMessage(`Placed ${asset.sourceName}.`); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyModelInstanceChange = () => { if (selectedModelInstance === null) { setStatusMessage("Select a model instance before editing it."); return; } try { const nextModelInstance = createModelInstance({ id: selectedModelInstance.id, assetId: selectedModelInstance.assetId, name: selectedModelInstance.name, position: readVec3Draft(modelPositionDraft, "Model instance position"), rotationDegrees: readVec3Draft(modelRotationDraft, "Model instance rotation"), scale: readPositiveVec3Draft(modelScaleDraft, "Model instance scale") }); commitModelInstanceChange(selectedModelInstance, nextModelInstance, "Updated model instance."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyPlayerStartChange = () => { if (selectedPlayerStart === null) { setStatusMessage("Select a Player Start before editing it."); return; } try { const snappedPosition = snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Player Start position"), DEFAULT_GRID_SIZE); const yawDegrees = readYawDegreesDraft(playerStartYawDraft); const nextEntity = createPlayerStartEntity({ id: selectedPlayerStart.id, position: snappedPosition, yawDegrees }); commitEntityChange(selectedPlayerStart, nextEntity, "Updated Player Start."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const applyPointLightChange = (overrides: { colorHex?: string } = {}) => { if (selectedPointLight === null) { setStatusMessage("Select a Point Light before editing it."); return; } try { const nextEntity = createPointLightEntity({ id: selectedPointLight.id, position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Point Light position"), DEFAULT_GRID_SIZE), colorHex: 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, 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 "playerStart": applyPlayerStartChange(); 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, 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, 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, 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: { enabled?: boolean } = {}) => { if (selectedInteractable === null) { setStatusMessage("Select an Interactable before editing it."); return; } try { const nextEntity = createInteractableEntity({ id: selectedInteractable.id, position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Interactable position"), DEFAULT_GRID_SIZE), radius: readPositiveNumberDraft(interactableRadiusDraft, "Interactable radius"), prompt: readInteractablePromptDraft(interactablePromptDraft), enabled: overrides.enabled ?? 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 : null; }; const handleAddTeleportInteractionLink = () => { if (selectedInteractionSource === null) { setStatusMessage("Select a Trigger Volume or Interactable before adding links."); return; } const defaultTarget = teleportTargetOptions[0]?.entity; if (defaultTarget === undefined || defaultTarget.kind !== "teleportTarget") { setStatusMessage("Author a Teleport Target before adding a teleport link."); return; } store.executeCommand( createUpsertInteractionLinkCommand({ link: createTeleportPlayerInteractionLink({ sourceEntityId: selectedInteractionSource.id, trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource), targetEntityId: defaultTarget.id }), label: "Add teleport interaction link" }) ); setStatusMessage(`Added a teleport link to the selected ${selectedInteractionSource.kind === "triggerVolume" ? "Trigger Volume" : "Interactable"}.`); }; const handleAddVisibilityInteractionLink = () => { if (selectedInteractionSource === null) { setStatusMessage("Select a Trigger Volume or Interactable before adding links."); return; } const defaultTarget = visibilityBrushOptions[0]?.brush; if (defaultTarget === undefined) { setStatusMessage("Author at least one brush before adding a visibility link."); return; } store.executeCommand( createUpsertInteractionLinkCommand({ link: createToggleVisibilityInteractionLink({ sourceEntityId: selectedInteractionSource.id, trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource), targetBrushId: defaultTarget.id }), label: "Add visibility interaction link" }) ); setStatusMessage(`Added a visibility link to the selected ${selectedInteractionSource.kind === "triggerVolume" ? "Trigger Volume" : "Interactable"}.`); }; const handleAddSoundInteractionLink = (actionType: "playSound" | "stopSound") => { if (selectedInteractionSource === null) { setStatusMessage("Select a Trigger Volume or Interactable before adding links."); return; } const defaultTarget = playableSoundEmitterOptions[0]?.entity; if (defaultTarget === undefined) { setStatusMessage("Author a Sound Emitter with an audio asset before adding sound links."); return; } const link = actionType === "playSound" ? createPlaySoundInteractionLink({ sourceEntityId: selectedInteractionSource.id, trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource), targetSoundEmitterId: defaultTarget.id }) : createStopSoundInteractionLink({ sourceEntityId: selectedInteractionSource.id, trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource), targetSoundEmitterId: defaultTarget.id }); store.executeCommand( createUpsertInteractionLinkCommand({ link, label: actionType === "playSound" ? "Add play sound link" : "Add stop sound link" }) ); setStatusMessage( `Added a ${actionType === "playSound" ? "play sound" : "stop sound"} link to the selected ${selectedInteractionSource.kind === "triggerVolume" ? "Trigger Volume" : "Interactable"}.` ); }; const handleDeleteInteractionLink = (linkId: string) => { try { store.executeCommand(createDeleteInteractionLinkCommand(linkId)); setStatusMessage("Deleted interaction link."); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; 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; } 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 === "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 brush 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 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 handleAddPlayAnimationLink = (sourceEntity: InteractionSourceEntity) => { const firstInstance = modelInstanceDisplayList[0]; if (firstInstance === undefined) { setStatusMessage("Place a model instance before adding an animation link."); return; } const asset = editorState.document.assets[firstInstance.modelInstance.assetId]; const firstClip = asset?.kind === "model" ? (asset.metadata.animationNames[0] ?? "") : ""; if (firstClip === "") { setStatusMessage("The model instance has no animation clips."); return; } store.executeCommand( createUpsertInteractionLinkCommand({ link: createPlayAnimationInteractionLink({ sourceEntityId: sourceEntity.id, trigger: getDefaultInteractionLinkTrigger(sourceEntity), targetModelInstanceId: firstInstance.modelInstance.id, clipName: firstClip }), label: "Add play animation link" }) ); setStatusMessage("Added a play animation link."); }; const handleAddStopAnimationLink = (sourceEntity: InteractionSourceEntity) => { const firstInstance = modelInstanceDisplayList[0]; if (firstInstance === undefined) { setStatusMessage("Place a model instance before adding an animation link."); return; } store.executeCommand( createUpsertInteractionLinkCommand({ link: createStopAnimationInteractionLink({ sourceEntityId: sourceEntity.id, trigger: getDefaultInteractionLinkTrigger(sourceEntity), targetModelInstanceId: firstInstance.modelInstance.id }), label: "Add stop animation link" }) ); setStatusMessage("Added a stop animation link."); }; const renderInteractionLinksSection = ( sourceEntity: InteractionSourceEntity, links: InteractionLink[], addTeleportTestId: string, addVisibilityTestId: string, addPlaySoundTestId: string, addStopSoundTestId: string ) => (
Links
{links.length === 0 ? (
{sourceEntity.kind === "triggerVolume" ? "No trigger links authored yet." : "No click links authored yet."}
) : (
{links.map((link, index) => (
{`Link ${index + 1}`} {getInteractionActionLabel(link)}
{link.action.type === "teleportPlayer" ? (
) : link.action.type === "toggleVisibility" ? (
) : link.action.type === "playAnimation" ? (
) : link.action.type === "playSound" || link.action.type === "stopSound" ? (
) : (
)}
))}
)}
); 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 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) => { 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( { ...editorState.document.world, background: changeWorldBackgroundMode(editorState.document.world.background, "image", nextImageAssetId) }, "Set world background image", `World background set to ${editorState.document.assets[nextImageAssetId]?.sourceName ?? nextImageAssetId}.` ); return; } applyWorldSettings( { ...editorState.document.world, background: changeWorldBackgroundMode(editorState.document.world.background, mode) }, "Set world background mode", mode === "solid" ? "World background set to a solid color." : "World background set to a vertical gradient." ); }; const applyWorldBackgroundColor = (colorHex: string) => { if (editorState.document.world.background.mode !== "solid") { return; } applyWorldSettings( { ...editorState.document.world, background: { mode: "solid", colorHex } }, "Set world background color", "Updated the world background color." ); }; const applyWorldGradientColor = (edge: "top" | "bottom", colorHex: string) => { if (editorState.document.world.background.mode !== "verticalGradient") { return; } applyWorldSettings( { ...editorState.document.world, background: edge === "top" ? { ...editorState.document.world.background, topColorHex: colorHex } : { ...editorState.document.world.background, bottomColorHex: colorHex } }, edge === "top" ? "Set world gradient top color" : "Set world gradient bottom color", edge === "top" ? "Updated the world gradient top color." : "Updated the world gradient bottom color." ); }; const 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 applySunLightDirection = () => { try { const direction = readVec3Draft(sunDirectionDraft, "Sun direction"); if (direction.x === 0 && direction.y === 0 && direction.z === 0) { throw new Error("Sun direction must not be the zero vector."); } applyWorldSettings( { ...editorState.document.world, sunLight: { ...editorState.document.world.sunLight, direction } }, "Set world sun direction", "Updated the world sun direction." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; 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 applyBrushNameChange = () => { if (selectedBrush === null) { setStatusMessage("Select a box brush before renaming it."); return; } const nextName = normalizeBrushName(brushNameDraft); if (selectedBrush.name === nextName) { return; } try { store.executeCommand( createSetBoxBrushNameCommand({ brushId: selectedBrush.id, name: brushNameDraft }) ); setStatusMessage(nextName === undefined ? "Cleared the authored brush name." : `Renamed brush to ${nextName}.`); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; 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 handleSaveDraft = () => { const result = store.saveDraft(); setStatusMessage(result.message); }; const handleLoadDraft = () => { const result = store.loadDraft(); setStatusMessage(result.message); }; const handleExportJson = () => { try { const exportedJson = store.exportDocumentJson(); const blob = new Blob([exportedJson], { type: "application/json" }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = `${editorState.document.name.replace(/\s+/g, "-").toLowerCase() || "scene"}.json`; anchor.click(); URL.revokeObjectURL(objectUrl); setStatusMessage("Scene document exported as JSON."); } catch (error) { const message = getErrorMessage(error); setStatusMessage(message); } }; const handleImportJsonButtonClick = () => { importInputRef.current?.click(); }; const handleImportJsonChange = async (event: ChangeEvent) => { const input = event.currentTarget; const file = input.files?.[0]; if (file === undefined) { return; } try { const source = await file.text(); store.importDocumentJson(source); setStatusMessage(`Imported ${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 || selectedFaceId === null || selectedFace === null) { setStatusMessage("Select a single box face before applying a material."); return; } if (selectedFace.materialId === materialId) { setStatusMessage(`${FACE_LABELS[selectedFaceId]} already uses that material.`); return; } try { store.executeCommand( createSetBoxBrushFaceMaterialCommand({ brushId: selectedBrush.id, faceId: selectedFaceId, materialId }) ); setStatusMessage(`Applied ${editorState.document.materials[materialId]?.name ?? materialId} to ${FACE_LABELS[selectedFaceId]}.`); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const clearFaceMaterial = () => { if (selectedBrush === null || selectedFaceId === null || selectedFace === null) { setStatusMessage("Select a single box face before clearing its material."); return; } if (selectedFace.materialId === null) { setStatusMessage(`${FACE_LABELS[selectedFaceId]} already uses the fallback face material.`); return; } store.executeCommand( createSetBoxBrushFaceMaterialCommand({ brushId: selectedBrush.id, faceId: selectedFaceId, materialId: null }) ); setStatusMessage(`Cleared the authored material on ${FACE_LABELS[selectedFaceId]}.`); }; const applyFaceUvState = (uvState: FaceUvState, label: string, successMessage: string) => { if (selectedBrush === null || selectedFaceId === null || selectedFace === null) { setStatusMessage("Select a single box face before editing UVs."); return; } if (areFaceUvStatesEqual(selectedFace.uv, uvState)) { setStatusMessage("That face UV state is already current."); return; } try { store.executeCommand( createSetBoxBrushFaceUvStateCommand({ brushId: selectedBrush.id, faceId: selectedFaceId, uvState, label }) ); setStatusMessage(successMessage); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleApplyUvDraft = () => { if (selectedFace === null) { setStatusMessage("Select a single box face before editing UVs."); return; } try { applyFaceUvState( { ...selectedFace.uv, offset: readVec2Draft(uvOffsetDraft, "Face UV offset"), scale: readPositiveVec2Draft(uvScaleDraft, "Face UV scale") }, "Set face UV offset and scale", "Updated face UV offset and scale." ); } catch (error) { setStatusMessage(getErrorMessage(error)); } }; const handleRotateUv = () => { if (selectedFace === null) { setStatusMessage("Select a single box face before rotating UVs."); return; } applyFaceUvState( { ...selectedFace.uv, rotationQuarterTurns: rotateQuarterTurns(selectedFace.uv.rotationQuarterTurns) }, "Rotate face UV 90 degrees", "Rotated face UVs 90 degrees." ); }; const handleFlipUv = (axis: "u" | "v") => { if (selectedFace === null) { setStatusMessage("Select a single box face before flipping UVs."); return; } applyFaceUvState( { ...selectedFace.uv, flipU: axis === "u" ? !selectedFace.uv.flipU : selectedFace.uv.flipU, flipV: axis === "v" ? !selectedFace.uv.flipV : selectedFace.uv.flipV }, axis === "u" ? "Flip face UV U" : "Flip face UV V", axis === "u" ? "Flipped face UVs on U." : "Flipped face UVs on V." ); }; const handleFitUvToFace = () => { if (selectedBrush === null || selectedFaceId === null) { setStatusMessage("Select a single box face before fitting UVs."); return; } applyFaceUvState( createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId), "Fit face UV to face", "Fit the selected face UVs to the face bounds." ); }; const handleEnterPlayMode = () => { if (blockingDiagnostics.length > 0) { setStatusMessage(`Run mode blocked: ${formatSceneDiagnosticSummary(blockingDiagnostics)}`); return; } try { const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document, { navigationMode: preferredNavigationMode }); const nextNavigationMode = preferredNavigationMode; setRuntimeScene(nextRuntimeScene); setRuntimeMessage( nextRuntimeScene.spawn.source === "playerStart" ? "Running from the authored Player Start." : "No Player Start is authored yet. Orbit Visitor opened first, with a fallback FPS spawn still available." ); setFirstPersonTelemetry(null); setRuntimeInteractionPrompt(null); setActiveNavigationMode(nextNavigationMode); store.enterPlayMode(); setStatusMessage( nextNavigationMode === "firstPerson" ? "Entered run mode with first-person navigation." : "Entered run mode with Orbit Visitor." ); } catch (error) { setStatusMessage(`Run mode could not start: ${getErrorMessage(error)}`); } }; const handleExitPlayMode = () => { setRuntimeScene(null); setRuntimeMessage(null); setFirstPersonTelemetry(null); setRuntimeInteractionPrompt(null); store.exitPlayMode(); setStatusMessage("Returned to editor mode."); }; const handleSetPreferredNavigationMode = (navigationMode: RuntimeNavigationMode) => { setPreferredNavigationMode(navigationMode); if (navigationMode === "firstPerson" && primaryPlayerStart === null) { setStatusMessage("First Person selected. Author a Player Start before running, or switch back to Orbit Visitor."); } if (editorState.toolMode === "play") { setActiveNavigationMode(navigationMode); setStatusMessage(navigationMode === "firstPerson" ? "Runner switched to first-person navigation." : "Runner switched to Orbit Visitor."); } }; if (editorState.toolMode === "play" && runtimeScene !== null) { return (
WebEditor3D
Slice 3.1 GLB/GLTF import and model placement
Status: {statusMessage}
Spawn:{" "} {runtimeScene.spawn.source === "playerStart" ? "Authored Player Start" : "Fallback runtime spawn"}
); } return (
Viewport
{getViewportCaption(editorState.toolMode, layoutMode)}
Active panel: {activePanelLabel} ({activePanelDisplaySummary})
{VIEWPORT_LAYOUT_MODES.map((mode) => ( ))}
{VIEWPORT_PANEL_IDS.map((panelId) => ( { store.setViewportToolPreview(toolPreview); }} onSelectionChange={(selection) => applySelection(selection, "viewport")} onCreateBoxBrush={handleCreateBoxBrush} /> ))}
Status: {statusMessage}
Document: {documentStatusLabel}
Run: {runReadyLabel}
Warnings: {warningDiagnostics.length}
Last: {lastCommandLabel}
); }