From 0b54fa14c9f8a2c34496f8d6e9680fcc47640323 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sun, 5 Apr 2026 02:25:08 +0200 Subject: [PATCH] auto-git: [add] playwright.config.js [add] src/app/App.js [add] src/app/editor-store.js [add] src/app/use-editor-store.js [add] src/assets/audio-assets.js [add] src/assets/gltf-model-import.js [add] src/assets/image-assets.js [add] src/assets/model-instance-labels.js [add] src/assets/model-instance-rendering.js [add] src/assets/model-instances.js [add] src/assets/project-asset-storage.js [add] src/assets/project-assets.js [add] src/commands/brush-command-helpers.js [add] src/commands/command-history.js [add] src/commands/command.js [add] src/commands/commit-transform-session-command.js [add] src/commands/create-box-brush-command.js [add] src/commands/delete-box-brush-command.js [add] src/commands/delete-entity-command.js [add] src/commands/delete-interaction-link-command.js [add] src/commands/delete-model-instance-command.js [add] src/commands/import-audio-asset-command.js [add] src/commands/import-background-image-asset-command.js [add] src/commands/import-model-asset-command.js [add] src/commands/move-box-brush-command.js [add] src/commands/resize-box-brush-command.js [add] src/commands/rotate-box-brush-command.js [add] src/commands/set-box-brush-face-material-command.js [add] src/commands/set-box-brush-face-uv-state-command.js [add] src/commands/set-box-brush-name-command.js [add] src/commands/set-box-brush-transform-command.js [add] src/commands/set-entity-name-command.js [add] src/commands/set-model-instance-name-command.js [add] src/commands/set-player-start-command.js [add] src/commands/set-scene-name-command.js [add] src/commands/set-world-settings-command.js [add] src/commands/upsert-entity-command.js [add] src/commands/upsert-interaction-link-command.js [add] src/commands/upsert-model-instance-command.js [add] src/core/ids.js [add] src/core/selection.js [add] src/core/tool-mode.js [add] src/core/transform-session.js [add] src/core/vector.js [add] src/core/whitebox-selection-feedback.js [add] src/core/whitebox-selection-mode.js [add] src/document/brushes.js [add] src/document/migrate-scene-document.js [add] src/document/scene-document-validation.js [add] src/document/scene-document.js [add] src/document/world-settings.js [add] src/entities/entity-instances.js [add] src/entities/entity-labels.js [add] src/geometry/box-brush-components.js [add] src/geometry/box-brush-mesh.js [add] src/geometry/box-brush.js [add] src/geometry/box-face-uvs.js [add] src/geometry/grid-snapping.js [add] src/geometry/model-instance-collider-debug-mesh.js [add] src/geometry/model-instance-collider-generation.js [add] src/interactions/interaction-links.js [add] src/main.js [add] src/materials/starter-material-library.js [add] src/materials/starter-material-textures.js [add] src/rendering/advanced-rendering.js [add] src/runner-web/RunnerCanvas.js [add] src/runtime-three/first-person-navigation-controller.js [add] src/runtime-three/navigation-controller.js [add] src/runtime-three/orbit-visitor-navigation-controller.js [add] src/runtime-three/player-collision.js [add] src/runtime-three/rapier-collision-world.js [add] src/runtime-three/runtime-audio-system.js [add] src/runtime-three/runtime-host.js [add] src/runtime-three/runtime-interaction-system.js [add] src/runtime-three/runtime-scene-build.js [add] src/runtime-three/runtime-scene-validation.js [add] src/serialization/local-draft-storage.js [add] src/serialization/scene-document-json.js [add] src/shared-ui/HierarchicalMenu.js [add] src/shared-ui/Panel.js [add] src/shared-ui/world-background-style.js [add] src/viewport-three/ViewportCanvas.js [add] src/viewport-three/ViewportPanel.js [add] src/viewport-three/viewport-entity-markers.js [add] src/viewport-three/viewport-focus.js [add] src/viewport-three/viewport-host.js [add] src/viewport-three/viewport-layout.js [add] src/viewport-three/viewport-transient-state.js [add] src/viewport-three/viewport-view-modes.js [add] tests/domain/box-brush-face-editing.command.test.js [add] tests/domain/build-runtime-scene.test.js [add] tests/domain/create-box-brush.command.test.js [add] tests/domain/create-empty-scene-document.test.js [add] tests/domain/editor-store.test.js [add] tests/domain/entity.command.test.js [add] tests/domain/interaction-links.validation.test.js [add] tests/domain/model-import.test.js [add] tests/domain/model-instance.command.test.js [add] tests/domain/player-start.command.test.js [add] tests/domain/rapier-collision-world.test.js [add] tests/domain/runtime-audio-system.test.js [add] tests/domain/runtime-interaction-system.test.js [add] tests/domain/runtime-scene-validation.test.js [add] tests/domain/scene-document-validation.test.js [add] tests/domain/transform-session.command.test.js [add] tests/domain/world-settings.command.test.js [add] tests/domain/world-settings.test.js [add] tests/e2e/app-smoke.e2e.js [add] tests/e2e/box-brush-authoring.e2e.js [add] tests/e2e/entities-foundation.e2e.js [add] tests/e2e/face-material-authoring.e2e.js [add] tests/e2e/first-room-workflow.e2e.js [add] tests/e2e/import-draco-model-asset.e2e.js [add] tests/e2e/import-external-model-asset.e2e.js [add] tests/e2e/import-model-asset.e2e.js [add] tests/e2e/local-lights-and-background.e2e.js [add] tests/e2e/orthographic-views.e2e.js [add] tests/e2e/runner-v1.e2e.js [add] tests/e2e/runtime-click-interaction.e2e.js [add] tests/e2e/runtime-trigger-teleport.e2e.js [add] tests/e2e/viewport-quad-layout.e2e.js [add] tests/e2e/viewport-test-helpers.js [add] tests/e2e/whitebox-component-selection.e2e.js [add] tests/e2e/world-environment.e2e.js [add] tests/geometry/box-brush-geometry.test.js [add] tests/geometry/box-face-uvs.test.js [add] tests/geometry/model-instance-collider-generation.test.js [add] tests/helpers/model-collider-fixtures.js [add] tests/serialization/local-draft-storage.test.js [add] tests/serialization/project-asset-storage.test.js [add] tests/serialization/scene-document-json.test.js [add] tests/setup/vitest.setup.js [add] tests/unit/audio-assets.test.js [add] tests/unit/entity-instances.test.js [add] tests/unit/package-scripts.test.js [add] tests/unit/transform-foundation.integration.test.js [add] tests/unit/viewport-canvas.test.js [add] tests/unit/viewport-entity-markers.test.js [add] tests/unit/viewport-focus.test.js [add] tests/unit/viewport-layout.test.js [add] tests/unit/viewport-view-modes.test.js [add] vite.config.js [add] vitest.config.js --- playwright.config.js | 29 + src/app/App.js | 3547 +++++++++++++++++ src/app/editor-store.js | 391 ++ src/app/use-editor-store.js | 4 + src/assets/audio-assets.js | 166 + src/assets/gltf-model-import.js | 588 +++ src/assets/image-assets.js | 306 ++ src/assets/model-instance-labels.js | 27 + src/assets/model-instance-rendering.js | 157 + src/assets/model-instances.js | 144 + src/assets/project-asset-storage.js | 177 + src/assets/project-assets.js | 100 + src/commands/brush-command-helpers.js | 82 + src/commands/command-history.js | 37 + src/commands/command.js | 1 + .../commit-transform-session-command.js | 197 + src/commands/create-box-brush-command.js | 51 + src/commands/delete-box-brush-command.js | 59 + src/commands/delete-entity-command.js | 64 + .../delete-interaction-link-command.js | 40 + src/commands/delete-model-instance-command.js | 64 + src/commands/import-audio-asset-command.js | 33 + .../import-background-image-asset-command.js | 41 + src/commands/import-model-asset-command.js | 70 + src/commands/move-box-brush-command.js | 55 + src/commands/resize-box-brush-command.js | 55 + src/commands/rotate-box-brush-command.js | 53 + .../set-box-brush-face-material-command.js | 50 + .../set-box-brush-face-uv-state-command.js | 48 + src/commands/set-box-brush-name-command.js | 30 + .../set-box-brush-transform-command.js | 88 + src/commands/set-entity-name-command.js | 47 + .../set-model-instance-name-command.js | 47 + src/commands/set-player-start-command.js | 12 + src/commands/set-scene-name-command.js | 29 + src/commands/set-world-settings-command.js | 30 + src/commands/upsert-entity-command.js | 70 + .../upsert-interaction-link-command.js | 40 + src/commands/upsert-model-instance-command.js | 75 + src/core/ids.js | 8 + src/core/selection.js | 134 + src/core/tool-mode.js | 1 + src/core/transform-session.js | 573 +++ src/core/vector.js | 5 + src/core/whitebox-selection-feedback.js | 23 + src/core/whitebox-selection-mode.js | 10 + src/document/brushes.js | 266 ++ src/document/migrate-scene-document.js | 1219 ++++++ src/document/scene-document-validation.js | 628 +++ src/document/scene-document.js | 33 + src/document/world-settings.js | 235 ++ src/entities/entity-instances.js | 501 +++ src/entities/entity-labels.js | 45 + src/geometry/box-brush-components.js | 158 + src/geometry/box-brush-mesh.js | 359 ++ src/geometry/box-brush.js | 43 + src/geometry/box-face-uvs.js | 133 + src/geometry/grid-snapping.js | 36 + .../model-instance-collider-debug-mesh.js | 119 + .../model-instance-collider-generation.js | 419 ++ src/interactions/interaction-links.js | 189 + src/main.js | 23 + src/materials/starter-material-library.js | 46 + src/materials/starter-material-textures.js | 73 + src/rendering/advanced-rendering.js | 88 + src/runner-web/RunnerCanvas.js | 55 + .../first-person-navigation-controller.js | 203 + src/runtime-three/navigation-controller.js | 1 + .../orbit-visitor-navigation-controller.js | 117 + src/runtime-three/player-collision.js | 19 + src/runtime-three/rapier-collision-world.js | 272 ++ src/runtime-three/runtime-audio-system.js | 289 ++ src/runtime-three/runtime-host.js | 564 +++ .../runtime-interaction-system.js | 163 + src/runtime-three/runtime-scene-build.js | 331 ++ src/runtime-three/runtime-scene-validation.js | 42 + src/serialization/local-draft-storage.js | 202 + src/serialization/scene-document-json.js | 19 + src/shared-ui/HierarchicalMenu.js | 36 + src/shared-ui/Panel.js | 7 + src/shared-ui/world-background-style.js | 21 + src/viewport-three/ViewportCanvas.js | 133 + src/viewport-three/ViewportPanel.js | 9 + src/viewport-three/viewport-entity-markers.js | 36 + src/viewport-three/viewport-focus.js | 294 ++ src/viewport-three/viewport-host.js | 2950 ++++++++++++++ src/viewport-three/viewport-layout.js | 126 + .../viewport-transient-state.js | 77 + src/viewport-three/viewport-view-modes.js | 89 + .../box-brush-face-editing.command.test.js | 80 + tests/domain/build-runtime-scene.test.js | 675 ++++ tests/domain/create-box-brush.command.test.js | 234 ++ .../create-empty-scene-document.test.js | 65 + tests/domain/editor-store.test.js | 360 ++ tests/domain/entity.command.test.js | 153 + .../interaction-links.validation.test.js | 242 ++ tests/domain/model-import.test.js | 54 + tests/domain/model-instance.command.test.js | 147 + tests/domain/player-start.command.test.js | 70 + tests/domain/rapier-collision-world.test.js | 352 ++ tests/domain/runtime-audio-system.test.js | 12 + .../domain/runtime-interaction-system.test.js | 373 ++ tests/domain/runtime-scene-validation.test.js | 85 + .../domain/scene-document-validation.test.js | 472 +++ .../domain/transform-session.command.test.js | 581 +++ tests/domain/world-settings.command.test.js | 30 + tests/domain/world-settings.test.js | 58 + tests/e2e/app-smoke.e2e.js | 24 + tests/e2e/box-brush-authoring.e2e.js | 103 + tests/e2e/entities-foundation.e2e.js | 155 + tests/e2e/face-material-authoring.e2e.js | 31 + tests/e2e/first-room-workflow.e2e.js | 54 + tests/e2e/import-draco-model-asset.e2e.js | 59 + tests/e2e/import-external-model-asset.e2e.js | 53 + tests/e2e/import-model-asset.e2e.js | 101 + tests/e2e/local-lights-and-background.e2e.js | 71 + tests/e2e/orthographic-views.e2e.js | 47 + tests/e2e/runner-v1.e2e.js | 40 + tests/e2e/runtime-click-interaction.e2e.js | 59 + tests/e2e/runtime-trigger-teleport.e2e.js | 48 + tests/e2e/viewport-quad-layout.e2e.js | 134 + tests/e2e/viewport-test-helpers.js | 105 + tests/e2e/whitebox-component-selection.e2e.js | 121 + tests/e2e/world-environment.e2e.js | 52 + tests/geometry/box-brush-geometry.test.js | 62 + tests/geometry/box-face-uvs.test.js | 60 + ...model-instance-collider-generation.test.js | 122 + tests/helpers/model-collider-fixtures.js | 84 + .../serialization/local-draft-storage.test.js | 159 + .../project-asset-storage.test.js | 30 + .../serialization/scene-document-json.test.js | 1380 +++++++ tests/setup/vitest.setup.js | 1 + tests/unit/audio-assets.test.js | 66 + tests/unit/entity-instances.test.js | 85 + tests/unit/package-scripts.test.js | 17 + .../transform-foundation.integration.test.js | 494 +++ tests/unit/viewport-canvas.test.js | 92 + tests/unit/viewport-entity-markers.test.js | 23 + tests/unit/viewport-focus.test.js | 232 ++ tests/unit/viewport-layout.test.js | 36 + tests/unit/viewport-view-modes.test.js | 61 + vite.config.js | 9 + vitest.config.js | 10 + 143 files changed, 26849 insertions(+) create mode 100644 playwright.config.js create mode 100644 src/app/App.js create mode 100644 src/app/editor-store.js create mode 100644 src/app/use-editor-store.js create mode 100644 src/assets/audio-assets.js create mode 100644 src/assets/gltf-model-import.js create mode 100644 src/assets/image-assets.js create mode 100644 src/assets/model-instance-labels.js create mode 100644 src/assets/model-instance-rendering.js create mode 100644 src/assets/model-instances.js create mode 100644 src/assets/project-asset-storage.js create mode 100644 src/assets/project-assets.js create mode 100644 src/commands/brush-command-helpers.js create mode 100644 src/commands/command-history.js create mode 100644 src/commands/command.js create mode 100644 src/commands/commit-transform-session-command.js create mode 100644 src/commands/create-box-brush-command.js create mode 100644 src/commands/delete-box-brush-command.js create mode 100644 src/commands/delete-entity-command.js create mode 100644 src/commands/delete-interaction-link-command.js create mode 100644 src/commands/delete-model-instance-command.js create mode 100644 src/commands/import-audio-asset-command.js create mode 100644 src/commands/import-background-image-asset-command.js create mode 100644 src/commands/import-model-asset-command.js create mode 100644 src/commands/move-box-brush-command.js create mode 100644 src/commands/resize-box-brush-command.js create mode 100644 src/commands/rotate-box-brush-command.js create mode 100644 src/commands/set-box-brush-face-material-command.js create mode 100644 src/commands/set-box-brush-face-uv-state-command.js create mode 100644 src/commands/set-box-brush-name-command.js create mode 100644 src/commands/set-box-brush-transform-command.js create mode 100644 src/commands/set-entity-name-command.js create mode 100644 src/commands/set-model-instance-name-command.js create mode 100644 src/commands/set-player-start-command.js create mode 100644 src/commands/set-scene-name-command.js create mode 100644 src/commands/set-world-settings-command.js create mode 100644 src/commands/upsert-entity-command.js create mode 100644 src/commands/upsert-interaction-link-command.js create mode 100644 src/commands/upsert-model-instance-command.js create mode 100644 src/core/ids.js create mode 100644 src/core/selection.js create mode 100644 src/core/tool-mode.js create mode 100644 src/core/transform-session.js create mode 100644 src/core/vector.js create mode 100644 src/core/whitebox-selection-feedback.js create mode 100644 src/core/whitebox-selection-mode.js create mode 100644 src/document/brushes.js create mode 100644 src/document/migrate-scene-document.js create mode 100644 src/document/scene-document-validation.js create mode 100644 src/document/scene-document.js create mode 100644 src/document/world-settings.js create mode 100644 src/entities/entity-instances.js create mode 100644 src/entities/entity-labels.js create mode 100644 src/geometry/box-brush-components.js create mode 100644 src/geometry/box-brush-mesh.js create mode 100644 src/geometry/box-brush.js create mode 100644 src/geometry/box-face-uvs.js create mode 100644 src/geometry/grid-snapping.js create mode 100644 src/geometry/model-instance-collider-debug-mesh.js create mode 100644 src/geometry/model-instance-collider-generation.js create mode 100644 src/interactions/interaction-links.js create mode 100644 src/main.js create mode 100644 src/materials/starter-material-library.js create mode 100644 src/materials/starter-material-textures.js create mode 100644 src/rendering/advanced-rendering.js create mode 100644 src/runner-web/RunnerCanvas.js create mode 100644 src/runtime-three/first-person-navigation-controller.js create mode 100644 src/runtime-three/navigation-controller.js create mode 100644 src/runtime-three/orbit-visitor-navigation-controller.js create mode 100644 src/runtime-three/player-collision.js create mode 100644 src/runtime-three/rapier-collision-world.js create mode 100644 src/runtime-three/runtime-audio-system.js create mode 100644 src/runtime-three/runtime-host.js create mode 100644 src/runtime-three/runtime-interaction-system.js create mode 100644 src/runtime-three/runtime-scene-build.js create mode 100644 src/runtime-three/runtime-scene-validation.js create mode 100644 src/serialization/local-draft-storage.js create mode 100644 src/serialization/scene-document-json.js create mode 100644 src/shared-ui/HierarchicalMenu.js create mode 100644 src/shared-ui/Panel.js create mode 100644 src/shared-ui/world-background-style.js create mode 100644 src/viewport-three/ViewportCanvas.js create mode 100644 src/viewport-three/ViewportPanel.js create mode 100644 src/viewport-three/viewport-entity-markers.js create mode 100644 src/viewport-three/viewport-focus.js create mode 100644 src/viewport-three/viewport-host.js create mode 100644 src/viewport-three/viewport-layout.js create mode 100644 src/viewport-three/viewport-transient-state.js create mode 100644 src/viewport-three/viewport-view-modes.js create mode 100644 tests/domain/box-brush-face-editing.command.test.js create mode 100644 tests/domain/build-runtime-scene.test.js create mode 100644 tests/domain/create-box-brush.command.test.js create mode 100644 tests/domain/create-empty-scene-document.test.js create mode 100644 tests/domain/editor-store.test.js create mode 100644 tests/domain/entity.command.test.js create mode 100644 tests/domain/interaction-links.validation.test.js create mode 100644 tests/domain/model-import.test.js create mode 100644 tests/domain/model-instance.command.test.js create mode 100644 tests/domain/player-start.command.test.js create mode 100644 tests/domain/rapier-collision-world.test.js create mode 100644 tests/domain/runtime-audio-system.test.js create mode 100644 tests/domain/runtime-interaction-system.test.js create mode 100644 tests/domain/runtime-scene-validation.test.js create mode 100644 tests/domain/scene-document-validation.test.js create mode 100644 tests/domain/transform-session.command.test.js create mode 100644 tests/domain/world-settings.command.test.js create mode 100644 tests/domain/world-settings.test.js create mode 100644 tests/e2e/app-smoke.e2e.js create mode 100644 tests/e2e/box-brush-authoring.e2e.js create mode 100644 tests/e2e/entities-foundation.e2e.js create mode 100644 tests/e2e/face-material-authoring.e2e.js create mode 100644 tests/e2e/first-room-workflow.e2e.js create mode 100644 tests/e2e/import-draco-model-asset.e2e.js create mode 100644 tests/e2e/import-external-model-asset.e2e.js create mode 100644 tests/e2e/import-model-asset.e2e.js create mode 100644 tests/e2e/local-lights-and-background.e2e.js create mode 100644 tests/e2e/orthographic-views.e2e.js create mode 100644 tests/e2e/runner-v1.e2e.js create mode 100644 tests/e2e/runtime-click-interaction.e2e.js create mode 100644 tests/e2e/runtime-trigger-teleport.e2e.js create mode 100644 tests/e2e/viewport-quad-layout.e2e.js create mode 100644 tests/e2e/viewport-test-helpers.js create mode 100644 tests/e2e/whitebox-component-selection.e2e.js create mode 100644 tests/e2e/world-environment.e2e.js create mode 100644 tests/geometry/box-brush-geometry.test.js create mode 100644 tests/geometry/box-face-uvs.test.js create mode 100644 tests/geometry/model-instance-collider-generation.test.js create mode 100644 tests/helpers/model-collider-fixtures.js create mode 100644 tests/serialization/local-draft-storage.test.js create mode 100644 tests/serialization/project-asset-storage.test.js create mode 100644 tests/serialization/scene-document-json.test.js create mode 100644 tests/setup/vitest.setup.js create mode 100644 tests/unit/audio-assets.test.js create mode 100644 tests/unit/entity-instances.test.js create mode 100644 tests/unit/package-scripts.test.js create mode 100644 tests/unit/transform-foundation.integration.test.js create mode 100644 tests/unit/viewport-canvas.test.js create mode 100644 tests/unit/viewport-entity-markers.test.js create mode 100644 tests/unit/viewport-focus.test.js create mode 100644 tests/unit/viewport-layout.test.js create mode 100644 tests/unit/viewport-view-modes.test.js create mode 100644 vite.config.js create mode 100644 vitest.config.js diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..10786c6c --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: ["**/*.e2e.ts"], + timeout: 30_000, + expect: { + timeout: 5_000 + }, + use: { + baseURL: "http://127.0.0.1:4173", + trace: "on-first-retry" + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + launchOptions: { + args: ["--enable-webgl", "--use-gl=angle", "--use-angle=swiftshader-webgl"] + } + } + } + ], + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI + } +}); diff --git a/src/app/App.js b/src/app/App.js new file mode 100644 index 00000000..d95d7f14 --- /dev/null +++ b/src/app/App.js @@ -0,0 +1,3547 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useEffect, useRef, useState } from "react"; +import { createCreateBoxBrushCommand } from "../commands/create-box-brush-command"; +import { createDeleteBoxBrushCommand } from "../commands/delete-box-brush-command"; +import { createDeleteEntityCommand } from "../commands/delete-entity-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 { createDeleteModelInstanceCommand } from "../commands/delete-model-instance-command"; +import { createCommitTransformSessionCommand } from "../commands/commit-transform-session-command"; +import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command"; +import { createRotateBoxBrushCommand } from "../commands/rotate-box-brush-command"; +import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command"; +import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command"; +import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command"; +import { createSetEntityNameCommand } from "../commands/set-entity-name-command"; +import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command"; +import { createDeleteInteractionLinkCommand } from "../commands/delete-interaction-link-command"; +import { createSetModelInstanceNameCommand } from "../commands/set-model-instance-name-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 { getSelectedBrushEdgeId, getSelectedBrushFaceId, getSelectedBrushVertexId, getSingleSelectedBrushId, getSingleSelectedEntityId, getSingleSelectedModelInstanceId, isBrushFaceSelected, isBrushSelected } from "../core/selection"; +import { createTransformSession, doesTransformSessionChangeTarget, getTransformOperationLabel, getTransformTargetLabel, resolveTransformTarget, supportsTransformAxisConstraint, supportsTransformOperation } from "../core/transform-session"; +import { MODEL_INSTANCE_COLLISION_MODES, areModelInstancesEqual, createModelInstance, createModelInstancePlacementPosition, DEFAULT_MODEL_INSTANCE_POSITION, DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, DEFAULT_MODEL_INSTANCE_SCALE, normalizeModelInstanceName } from "../assets/model-instances"; +import { getModelInstanceDisplayLabelById, getSortedModelInstanceDisplayLabels } from "../assets/model-instance-labels"; +import { importAudioAssetFromFile, loadAudioAssetFromStorage } from "../assets/audio-assets"; +import { importModelAssetFromFile, importModelAssetFromFiles, loadModelAssetFromStorage, disposeModelTemplate } from "../assets/gltf-model-import"; +import { importBackgroundImageAssetFromFile, loadImageAssetFromStorage, disposeLoadedImageAsset } from "../assets/image-assets"; +import { getProjectAssetKindLabel } from "../assets/project-assets"; +import { getWhiteboxSelectionModeLabel, WHITEBOX_SELECTION_MODES } from "../core/whitebox-selection-mode"; +import { BOX_EDGE_LABELS, BOX_FACE_IDS, BOX_FACE_LABELS, BOX_VERTEX_LABELS, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_ROTATION_DEGREES, DEFAULT_BOX_BRUSH_SIZE, createDefaultFaceUvState, normalizeBrushName } from "../document/brushes"; +import { ADVANCED_RENDERING_SHADOW_MAP_SIZES, ADVANCED_RENDERING_SHADOW_TYPES, ADVANCED_RENDERING_TONE_MAPPING_MODES, areWorldSettingsEqual, changeWorldBackgroundMode, cloneWorldSettings } from "../document/world-settings"; +import { formatSceneDiagnosticSummary, validateSceneDocument } from "../document/scene-document-validation"; +import { getBrowserProjectAssetStorageAccess } 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_PLAYER_START_BOX_SIZE, DEFAULT_PLAYER_START_CAPSULE_HEIGHT, DEFAULT_PLAYER_START_CAPSULE_RADIUS, DEFAULT_PLAYER_START_EYE_HEIGHT, 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, PLAYER_START_COLLIDER_MODES, DEFAULT_SPOT_LIGHT_ANGLE_DEGREES, DEFAULT_SPOT_LIGHT_COLOR_HEX, DEFAULT_SPOT_LIGHT_DISTANCE, DEFAULT_SPOT_LIGHT_DIRECTION, DEFAULT_SPOT_LIGHT_INTENSITY, DEFAULT_TRIGGER_VOLUME_SIZE, areEntityInstancesEqual, createInteractableEntity, createPointLightEntity, createPlayerStartEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity, getEntityInstances, getEntityKindLabel, getPrimaryPlayerStartEntity, normalizeEntityName, normalizeYawDegrees, normalizeInteractablePrompt } from "../entities/entity-instances"; +import { getEntityDisplayLabelById, getSortedEntityDisplayLabels } from "../entities/entity-labels"; +import { areInteractionLinksEqual, createPlayAnimationInteractionLink, createPlaySoundInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, getInteractionLinksForSource } from "../interactions/interaction-links"; +import { STARTER_MATERIAL_LIBRARY } from "../materials/starter-material-library"; +import { RunnerCanvas } from "../runner-web/RunnerCanvas"; +import { buildRuntimeSceneFromDocument } from "../runtime-three/runtime-scene-build"; +import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation"; +import { Panel } from "../shared-ui/Panel"; +import { HierarchicalMenu } from "../shared-ui/HierarchicalMenu"; +import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; +import { ViewportPanel } from "../viewport-three/ViewportPanel"; +import { getViewportViewModeLabel } from "../viewport-three/viewport-view-modes"; +import { VIEWPORT_LAYOUT_MODES, VIEWPORT_PANEL_IDS, getViewportDisplayModeLabel, getViewportLayoutModeLabel, getViewportPanelLabel } from "../viewport-three/viewport-layout"; +import { useEditorStoreState } from "./use-editor-store"; +function getModelInstanceCollisionModeDescription(mode) { + switch (mode) { + case "none": + return "No generated collider is built for this model instance."; + case "terrain": + return "Builds a Rapier heightfield from a regular-grid terrain mesh. Unsupported terrain sources fail with build diagnostics."; + case "static": + return "Builds a fixed Rapier triangle-mesh collider from the imported model geometry."; + case "dynamic": + return "Builds convex compound pieces for Rapier queries. In this slice they participate as fixed world collision, not fully simulated rigid bodies."; + case "simple": + return "Builds one cheap oriented box from the imported model bounds."; + } +} +function getPlayerStartColliderModeDescription(mode) { + switch (mode) { + case "capsule": + return "Uses a capsule player collider for standard grounded first-person traversal."; + case "box": + return "Uses an axis-aligned box player collider for sharper footprint bounds."; + case "none": + return "Disables player collision detection. First-person traversal continues without world clipping."; + } +} +const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index])); +const MIN_VIEWPORT_QUAD_SPLIT = 0.2; +const MAX_VIEWPORT_QUAD_SPLIT = 0.8; +function formatVec3(vector) { + return `${vector.x}, ${vector.y}, ${vector.z}`; +} +function resolveOptionalPositiveNumber(value, fallback) { + const parsedValue = Number(value); + return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : fallback; +} +function getWhiteboxInputStep(enabled, step) { + return enabled ? step : "any"; +} +function formatDiagnosticCount(count, label) { + return `${count} ${label}${count === 1 ? "" : "s"}`; +} +function clampViewportQuadSplitValue(value) { + return Math.min(MAX_VIEWPORT_QUAD_SPLIT, Math.max(MIN_VIEWPORT_QUAD_SPLIT, value)); +} +function createViewportQuadPanelsStyle(viewportQuadSplit) { + return { + "--viewport-quad-split-x": String(viewportQuadSplit.x), + "--viewport-quad-split-y": String(viewportQuadSplit.y) + }; +} +function getViewportQuadResizeCursor(resizeMode) { + switch (resizeMode) { + case "vertical": + return "col-resize"; + case "horizontal": + return "row-resize"; + case "center": + return "move"; + } +} +function createVec2Draft(vector) { + return { + x: String(vector.x), + y: String(vector.y) + }; +} +function createVec3Draft(vector) { + return { + x: String(vector.x), + y: String(vector.y), + z: String(vector.z) + }; +} +function readVec2Draft(draft, label) { + 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, label) { + 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, label) { + 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, label) { + 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) { + 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) { + return normalizeInteractablePrompt(source); +} +function readNonNegativeNumberDraft(source, label) { + 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, label) { + const value = Number(source); + if (!Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + return value; +} +function readPositiveIntegerDraft(source, label) { + 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, label) { + 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, right) { + return left.x === right.x && left.y === right.y; +} +function areVec3Equal(left, right) { + return left.x === right.x && left.y === right.y && left.z === right.z; +} +function maybeSnapVec3(vector, enabled, step) { + if (!enabled) { + return vector; + } + return { + x: Math.round(vector.x / step) * step, + y: Math.round(vector.y / step) * step, + z: Math.round(vector.z / step) * step + }; +} +function maybeSnapPositiveSize(size, enabled, step) { + const clampComponent = (value) => Math.max(0.01, Math.abs(value)); + if (!enabled) { + return { + x: clampComponent(size.x), + y: clampComponent(size.y), + z: clampComponent(size.z) + }; + } + return { + x: Math.max(0.01, Math.round(Math.abs(size.x) / step) * step), + y: Math.max(0.01, Math.round(Math.abs(size.y) / step) * step), + z: Math.max(0.01, Math.round(Math.abs(size.z) / step) * step) + }; +} +function areFaceUvStatesEqual(left, right) { + 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, brushes) { + const selectedBrushId = getSingleSelectedBrushId(selection); + if (selectedBrushId === null) { + return null; + } + return brushes.find((brush) => brush.id === selectedBrushId) ?? null; +} +function getSelectedEntity(selection, entities) { + const selectedEntityId = getSingleSelectedEntityId(selection); + if (selectedEntityId === null) { + return null; + } + return entities.find((entity) => entity.id === selectedEntityId) ?? null; +} +function getSelectedModelInstance(selection, modelInstances) { + const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection); + if (selectedModelInstanceId === null) { + return null; + } + return modelInstances.find((modelInstance) => modelInstance.id === selectedModelInstanceId) ?? null; +} +function isModelAsset(asset) { + return asset.kind === "model"; +} +function isImageAsset(asset) { + return asset.kind === "image"; +} +function isAudioAsset(asset) { + return asset.kind === "audio"; +} +function formatByteLength(byteLength) { + 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) { + 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) { + 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) { + const details = [ + `${asset.metadata.width} x ${asset.metadata.height}`, + asset.metadata.hasAlpha ? "alpha" : "opaque", + formatByteLength(asset.byteLength) + ]; + return details.join(" | "); +} +function formatAudioAssetSummary(asset) { + 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) { + const details = [ + `${getProjectAssetKindLabel(asset.kind)} asset`, + asset.mimeType, + asset.kind === "model" + ? formatModelAssetSummary(asset) + : asset.kind === "image" + ? formatImageAssetSummary(asset) + : formatAudioAssetSummary(asset), + `Storage key: ${asset.storageKey}` + ]; + if (asset.kind === "model") { + details.push(formatModelBoundingBoxLabel(asset)); + } + if (asset.metadata.warnings.length > 0) { + details.push(`Warnings: ${asset.metadata.warnings.join(" | ")}`); + } + return `${asset.sourceName} | ${details.join(" | ")}`; +} +function getBrushLabel(brush, index) { + return brush.name ?? `Whitebox Box ${index + 1}`; +} +function getBrushLabelById(brushId, brushes) { + const brushIndex = brushes.findIndex((brush) => brush.id === brushId); + return brushIndex === -1 ? "Whitebox Box" : getBrushLabel(brushes[brushIndex], brushIndex); +} +function getSelectedBrushLabel(selection, brushes) { + const selectedBrushId = getSingleSelectedBrushId(selection); + if (selectedBrushId === null) { + return "No solid selected"; + } + return getBrushLabelById(selectedBrushId, brushes); +} +function describeSelection(selection, brushes, modelInstances, assets, entities) { + switch (selection.kind) { + case "none": + return "No authored selection"; + case "brushes": + return `${selection.ids.length} solid${selection.ids.length === 1 ? "" : "s"} selected (${getSelectedBrushLabel(selection, brushes)})`; + case "brushFace": + return `1 face selected (${BOX_FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushes)})`; + case "brushEdge": + return `1 edge selected (${BOX_EDGE_LABELS[selection.edgeId]} on ${getBrushLabelById(selection.brushId, brushes)})`; + case "brushVertex": + return `1 vertex selected (${BOX_VERTEX_LABELS[selection.vertexId]} on ${getBrushLabelById(selection.brushId, brushes)})`; + 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 getWhiteboxSelectionModeStatus(mode) { + switch (mode) { + case "object": + return "Whitebox selection mode set to Object. Whole-solid transforms are available."; + case "face": + return "Whitebox selection mode set to Face. Click a face to edit materials and UVs."; + case "edge": + return "Whitebox selection mode set to Edge. Edge transforms land in the next slice."; + case "vertex": + return "Whitebox selection mode set to Vertex. Vertex transforms land in the next slice."; + } +} +function getInteractionTriggerLabel(trigger) { + switch (trigger) { + case "enter": + return "On Enter"; + case "exit": + return "On Exit"; + case "click": + return "On Click"; + } +} +function getInteractionActionLabel(link) { + 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) { + if (visible === true) { + return "show"; + } + if (visible === false) { + return "hide"; + } + return "toggle"; +} +function readVisibilityModeSelectValue(value) { + switch (value) { + case "toggle": + return undefined; + case "show": + return true; + case "hide": + return false; + } +} +function getDefaultTriggerVolumeLinkTrigger(triggerOnEnter, triggerOnExit) { + if (triggerOnEnter) { + return "enter"; + } + if (triggerOnExit) { + return "exit"; + } + return "enter"; +} +function isInteractionSourceEntity(entity) { + return entity !== null && (entity.kind === "triggerVolume" || entity.kind === "interactable"); +} +function isSoundEmitterEntity(entity) { + return entity !== null && entity.kind === "soundEmitter"; +} +function getDefaultInteractionLinkTrigger(sourceEntity) { + return sourceEntity.kind === "triggerVolume" + ? getDefaultTriggerVolumeLinkTrigger(sourceEntity.triggerOnEnter, sourceEntity.triggerOnExit) + : "click"; +} +function getErrorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return "An unexpected error occurred."; +} +function isTextEntryTarget(target) { + if (!(target instanceof HTMLElement)) { + return false; + } + return (target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable); +} +function isCommitIncrementKey(key) { + 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) { + 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) { + 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) { + return ((rotationQuarterTurns + 1) % 4); +} +function getTransformOperationPastTense(operation) { + switch (operation) { + case "translate": + return "Moved"; + case "rotate": + return "Rotated"; + case "scale": + return "Scaled"; + } +} +function getTransformOperationShortcut(operation) { + switch (operation) { + case "translate": + return "G"; + case "rotate": + return "R"; + case "scale": + return "S"; + } +} +function formatRunnerFeetPosition(position) { + if (position === null) { + return "n/a"; + } + return `${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`; +} +function formatWorldBackgroundLabel(world) { + if (world.background.mode === "solid") { + return "Solid"; + } + if (world.background.mode === "verticalGradient") { + return "Vertical Gradient"; + } + return "Image"; +} +function formatAdvancedRenderingShadowTypeLabel(type) { + switch (type) { + case "basic": + return "Basic"; + case "pcf": + return "PCF"; + case "pcfSoft": + return "PCF Soft"; + } +} +function formatAdvancedRenderingToneMappingLabel(mode) { + 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 }) { + const editorState = useEditorStoreState(store); + const brushList = Object.values(editorState.document.brushes); + const layoutMode = editorState.viewportLayoutMode; + const activePanelId = editorState.activeViewportPanelId; + const viewportToolPreview = editorState.viewportTransientState.toolPreview; + const transformSession = editorState.viewportTransientState.transformSession; + 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 whiteboxSelectionMode = editorState.whiteboxSelectionMode; + const selectedFaceId = getSelectedBrushFaceId(editorState.selection); + const selectedEdgeId = getSelectedBrushEdgeId(editorState.selection); + const selectedVertexId = getSelectedBrushVertexId(editorState.selection); + const selectedFace = selectedBrush !== null && selectedFaceId !== null ? selectedBrush.faces[selectedFaceId] : null; + const selectedFaceMaterial = selectedFace !== null && selectedFace.materialId !== null ? editorState.document.materials[selectedFace.materialId] ?? null : null; + const 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"); + 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 [entityNameDraft, setEntityNameDraft] = useState(""); + const [modelInstanceNameDraft, setModelInstanceNameDraft] = useState(""); + const [positionDraft, setPositionDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); + const [rotationDraft, setRotationDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_ROTATION_DEGREES)); + const [sizeDraft, setSizeDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); + const [whiteboxSnapEnabled, setWhiteboxSnapEnabled] = useState(true); + const [whiteboxSnapStepDraft, setWhiteboxSnapStepDraft] = useState(String(DEFAULT_GRID_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 [playerStartColliderModeDraft, setPlayerStartColliderModeDraft] = useState("capsule"); + const [playerStartEyeHeightDraft, setPlayerStartEyeHeightDraft] = useState(String(DEFAULT_PLAYER_START_EYE_HEIGHT)); + const [playerStartCapsuleRadiusDraft, setPlayerStartCapsuleRadiusDraft] = useState(String(DEFAULT_PLAYER_START_CAPSULE_RADIUS)); + const [playerStartCapsuleHeightDraft, setPlayerStartCapsuleHeightDraft] = useState(String(DEFAULT_PLAYER_START_CAPSULE_HEIGHT)); + const [playerStartBoxSizeDraft, setPlayerStartBoxSizeDraft] = useState(createVec3Draft(DEFAULT_PLAYER_START_BOX_SIZE)); + const [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 [hoveredAssetId, setHoveredAssetId] = useState(null); + const [hoveredViewportPanelId, setHoveredViewportPanelId] = useState(null); + const [addMenuPosition, setAddMenuPosition] = 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: 0, + panelId: "topLeft", + selection: { + kind: "none" + } + }); + const importInputRef = useRef(null); + const importModelInputRef = useRef(null); + const importBackgroundImageInputRef = useRef(null); + const importAudioInputRef = useRef(null); + const viewportPanelsRef = useRef(null); + const loadedModelAssetsRef = useRef({}); + const loadedImageAssetsRef = useRef({}); + const loadedAudioAssetsRef = useRef({}); + const viewportQuadSplitRef = useRef(editorState.viewportQuadSplit); + const lastPointerPositionRef = useRef({ + x: Math.round(window.innerWidth * 0.5), + y: Math.round(window.innerHeight * 0.5) + }); + const [viewportQuadResizeMode, setViewportQuadResizeMode] = useState(null); + const documentValidation = validateSceneDocument(editorState.document); + const runValidation = validateRuntimeSceneBuild(editorState.document, { + navigationMode: preferredNavigationMode, + loadedModelAssets + }); + 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; + const hoveredAsset = hoveredAssetId === null ? null : editorState.document.assets[hoveredAssetId] ?? null; + const hoveredAssetStatusMessage = hoveredAsset === null ? null : formatAssetHoverStatus(hoveredAsset); + const selectedTransformTarget = resolveTransformTarget(editorState.document, editorState.selection, whiteboxSelectionMode).target; + const canTranslateSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation(selectedTransformTarget, "translate"); + const canRotateSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation(selectedTransformTarget, "rotate"); + const canScaleSelectedTarget = selectedTransformTarget !== null && supportsTransformOperation(selectedTransformTarget, "scale"); + const whiteboxSnapStep = resolveOptionalPositiveNumber(whiteboxSnapStepDraft, DEFAULT_GRID_SIZE); + const whiteboxVectorInputStep = getWhiteboxInputStep(whiteboxSnapEnabled, whiteboxSnapStep); + useEffect(() => { + setSceneNameDraft(editorState.document.name); + }, [editorState.document.name]); + useEffect(() => { + setBrushNameDraft(selectedBrush?.name ?? ""); + }, [selectedBrush]); + useEffect(() => { + setEntityNameDraft(selectedEntity?.name ?? ""); + }, [selectedEntity]); + useEffect(() => { + setModelInstanceNameDraft(selectedModelInstance?.name ?? ""); + }, [selectedModelInstance]); + useEffect(() => { + if (selectedBrush === null) { + setPositionDraft(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)); + setRotationDraft(createVec3Draft(DEFAULT_BOX_BRUSH_ROTATION_DEGREES)); + setSizeDraft(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)); + return; + } + setPositionDraft(createVec3Draft(selectedBrush.center)); + setRotationDraft(createVec3Draft(selectedBrush.rotationDegrees)); + 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"); + setPlayerStartColliderModeDraft("capsule"); + setPlayerStartEyeHeightDraft(String(DEFAULT_PLAYER_START_EYE_HEIGHT)); + setPlayerStartCapsuleRadiusDraft(String(DEFAULT_PLAYER_START_CAPSULE_RADIUS)); + setPlayerStartCapsuleHeightDraft(String(DEFAULT_PLAYER_START_CAPSULE_HEIGHT)); + setPlayerStartBoxSizeDraft(createVec3Draft(DEFAULT_PLAYER_START_BOX_SIZE)); + 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)); + setPlayerStartColliderModeDraft(selectedEntity.collider.mode); + setPlayerStartEyeHeightDraft(String(selectedEntity.collider.eyeHeight)); + setPlayerStartCapsuleRadiusDraft(String(selectedEntity.collider.capsuleRadius)); + setPlayerStartCapsuleHeightDraft(String(selectedEntity.collider.capsuleHeight)); + setPlayerStartBoxSizeDraft(createVec3Draft(selectedEntity.collider.boxSize)); + 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(() => { + viewportQuadSplitRef.current = editorState.viewportQuadSplit; + }, [editorState.viewportQuadSplit]); + useEffect(() => { + let cancelled = false; + void (async () => { + const access = await getBrowserProjectAssetStorageAccess(); + if (cancelled) { + return; + } + setProjectAssetStorage(access.storage); + setAssetStatusMessage(access.diagnostic); + setProjectAssetStorageReady(true); + })().catch((error) => { + if (cancelled) { + return; + } + setProjectAssetStorage(null); + setProjectAssetStorageReady(true); + setAssetStatusMessage(getErrorMessage(error)); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { + 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 = {}; + const nextLoadedImageAssets = {}; + const nextLoadedAudioAssets = {}; + const syncErrorMessages = []; + 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 handleWindowPointerMove = (event) => { + lastPointerPositionRef.current = { + x: event.clientX, + y: event.clientY + }; + const hoveredViewportPanelElement = event.target instanceof Element ? event.target.closest("[data-viewport-panel-id]") : null; + const hoveredPanelId = hoveredViewportPanelElement?.dataset.viewportPanelId; + setHoveredViewportPanelId(hoveredPanelId === "topLeft" || hoveredPanelId === "topRight" || hoveredPanelId === "bottomLeft" || hoveredPanelId === "bottomRight" + ? hoveredPanelId + : null); + }; + const handleWindowKeyDown = (event) => { + if (isTextEntryTarget(event.target)) { + return; + } + const hasPrimaryModifier = (event.metaKey || event.ctrlKey) && !event.altKey; + if (hasPrimaryModifier && event.code === "KeyR" && !event.shiftKey) { + event.preventDefault(); + handleEnterPlayMode(); + return; + } + if (hasPrimaryModifier && event.code === "KeyS" && !event.shiftKey) { + event.preventDefault(); + handleSaveDraft(); + return; + } + if (hasPrimaryModifier && event.code === "KeyZ") { + event.preventDefault(); + if (event.shiftKey) { + if (store.redo()) { + setStatusMessage("Redid the last action."); + } + else { + setStatusMessage("Nothing to redo."); + } + } + else if (store.undo()) { + setStatusMessage("Undid the last action."); + } + else { + setStatusMessage("Nothing to undo."); + } + return; + } + if (hasPrimaryModifier && event.code === "KeyY") { + event.preventDefault(); + if (store.redo()) { + setStatusMessage("Redid the last action."); + } + else { + setStatusMessage("Nothing to redo."); + } + return; + } + if (event.key === "Escape" && addMenuPosition !== null) { + event.preventDefault(); + setAddMenuPosition(null); + return; + } + if (transformSession.kind === "active") { + if (event.key === "Escape") { + event.preventDefault(); + cancelTransformSession(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + commitTransformSession(transformSession); + return; + } + if (!event.altKey && !event.ctrlKey && !event.metaKey) { + if (event.code === "KeyX") { + event.preventDefault(); + applyTransformAxisConstraint("x"); + return; + } + if (event.code === "KeyY") { + event.preventDefault(); + applyTransformAxisConstraint("y"); + return; + } + if (event.code === "KeyZ") { + event.preventDefault(); + applyTransformAxisConstraint("z"); + return; + } + } + } + if (event.key === "Escape" && editorState.toolMode === "create") { + event.preventDefault(); + store.setToolMode("select"); + setStatusMessage("Cancelled the current creation preview."); + return; + } + if (event.shiftKey && event.code === "KeyA") { + event.preventDefault(); + setAddMenuPosition({ + x: lastPointerPositionRef.current.x, + y: lastPointerPositionRef.current.y + }); + return; + } + if (!event.altKey && !event.ctrlKey && !event.metaKey) { + let transformOperation = null; + if (event.code === "KeyG") { + transformOperation = "translate"; + } + else if (event.code === "KeyR") { + transformOperation = "rotate"; + } + else if (event.code === "KeyS") { + transformOperation = "scale"; + } + if (transformOperation !== null) { + event.preventDefault(); + beginTransformOperation(transformOperation, "keyboard"); + return; + } + } + const isDeletionKey = event.key === "Delete" || event.key === "Backspace"; + const isDeleteShortcut = !event.altKey && !event.ctrlKey && !event.metaKey && (event.code === "KeyX" || isDeletionKey); + if (addMenuPosition !== null) { + if (isDeletionKey) { + event.preventDefault(); + } + return; + } + if (isDeleteShortcut) { + if (editorState.toolMode !== "create") { + const deleted = handleDeleteSelectedSceneItem(); + if (deleted || isDeletionKey) { + event.preventDefault(); + } + } + else if (isDeletionKey) { + event.preventDefault(); + } + return; + } + if (event.code !== "NumpadComma" && + !(event.key === "," && event.location === globalThis.KeyboardEvent.DOM_KEY_LOCATION_NUMPAD)) { + return; + } + event.preventDefault(); + if (editorState.selection.kind === "none" && brushList.length === 0 && entityList.length === 0) { + setStatusMessage("Nothing authored yet to frame in the viewport."); + return; + } + setFocusRequest((current) => ({ + id: current.id + 1, + panelId: activePanelId, + selection: editorState.selection + })); + setStatusMessage(editorState.selection.kind === "none" ? "Framed the authored scene in the viewport." : "Framed the current selection."); + }; + document.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("keydown", handleWindowKeyDown); + return () => { + document.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("keydown", handleWindowKeyDown); + }; + }, [activePanelId, addMenuPosition, brushList.length, editorState.selection, editorState.toolMode, entityList.length, hoveredViewportPanelId, layoutMode, transformSession]); + useEffect(() => { + if (layoutMode === "quad" || viewportQuadResizeMode === null) { + return; + } + setViewportQuadResizeMode(null); + }, [layoutMode, viewportQuadResizeMode]); + useEffect(() => { + if (layoutMode !== "quad" || viewportQuadResizeMode === null) { + return; + } + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = getViewportQuadResizeCursor(viewportQuadResizeMode); + document.body.style.userSelect = "none"; + const handlePointerMove = (event) => { + const viewportPanels = viewportPanelsRef.current; + if (viewportPanels === null) { + return; + } + const rect = viewportPanels.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const nextViewportQuadSplit = { + ...viewportQuadSplitRef.current + }; + if (viewportQuadResizeMode !== "horizontal") { + nextViewportQuadSplit.x = clampViewportQuadSplitValue((event.clientX - rect.left) / rect.width); + } + if (viewportQuadResizeMode !== "vertical") { + nextViewportQuadSplit.y = clampViewportQuadSplitValue((event.clientY - rect.top) / rect.height); + } + store.setViewportQuadSplit(nextViewportQuadSplit); + }; + const stopViewportResize = () => { + setViewportQuadResizeMode(null); + }; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopViewportResize); + window.addEventListener("pointercancel", stopViewportResize); + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopViewportResize); + window.removeEventListener("pointercancel", stopViewportResize); + }; + }, [layoutMode, store, viewportQuadResizeMode]); + useEffect(() => { + if (editorState.toolMode !== "play") { + return; + } + const handleWindowKeyDown = (event) => { + if (isTextEntryTarget(event.target)) { + return; + } + if (event.key !== "Escape") { + return; + } + const pointerCaptured = activeNavigationMode === "firstPerson" && firstPersonTelemetry?.pointerLocked === true; + if (pointerCaptured) { + return; + } + event.preventDefault(); + handleExitPlayMode(); + }; + window.addEventListener("keydown", handleWindowKeyDown); + return () => { + window.removeEventListener("keydown", handleWindowKeyDown); + }; + }, [activeNavigationMode, editorState.toolMode, firstPersonTelemetry]); + const 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, status) => { + setFocusRequest((current) => ({ + id: current.id + 1, + panelId: activePanelId, + selection + })); + if (status !== undefined) { + setStatusMessage(status); + } + }; + const openAddMenuAt = (position) => { + setHoveredAssetId(null); + setAddMenuPosition(position); + }; + const closeAddMenu = () => { + setHoveredAssetId(null); + setAddMenuPosition(null); + }; + const handleOpenAddMenuFromButton = (event) => { + const rect = event.currentTarget.getBoundingClientRect(); + openAddMenuAt({ + x: rect.left, + y: rect.bottom + 8 + }); + }; + const handleSetViewportLayoutMode = (nextLayoutMode) => { + if (editorState.viewportLayoutMode === nextLayoutMode) { + return; + } + blurActiveTextEntry(); + store.setViewportLayoutMode(nextLayoutMode); + setStatusMessage(`Switched the viewport to ${getViewportLayoutModeLabel(nextLayoutMode)}.`); + }; + const handleActivateViewportPanel = (panelId) => { + if (editorState.activeViewportPanelId === panelId) { + return; + } + blurActiveTextEntry(); + store.setActiveViewportPanel(panelId); + setStatusMessage("Activated the viewport panel."); + }; + const handleSetViewportPanelViewMode = (panelId, nextViewMode) => { + if (editorState.viewportPanels[panelId].viewMode === nextViewMode) { + return; + } + blurActiveTextEntry(); + store.setViewportPanelViewMode(panelId, nextViewMode); + setStatusMessage(`Set the viewport panel to ${getViewportViewModeLabel(nextViewMode)} view.`); + }; + const handleSetViewportPanelDisplayMode = (panelId, nextDisplayMode) => { + if (editorState.viewportPanels[panelId].displayMode === nextDisplayMode) { + return; + } + blurActiveTextEntry(); + store.setViewportPanelDisplayMode(panelId, nextDisplayMode); + setStatusMessage(`Set the viewport panel to ${getViewportDisplayModeLabel(nextDisplayMode)} display.`); + }; + const beginTransformOperation = (operation, source) => { + if (editorState.toolMode !== "select") { + return; + } + const transformSourcePanelId = layoutMode === "quad" ? hoveredViewportPanelId ?? activePanelId : activePanelId; + const transformTargetResult = resolveTransformTarget(editorState.document, editorState.selection, whiteboxSelectionMode); + const transformTarget = transformTargetResult.target; + if (transformTarget === null) { + setStatusMessage(transformTargetResult.message ?? "Select a single brush, entity, or model instance before transforming it."); + return; + } + if (!supportsTransformOperation(transformTarget, operation)) { + setStatusMessage(`${getTransformOperationLabel(operation)} is not supported for ${getTransformTargetLabel(transformTarget)}.`); + return; + } + blurActiveTextEntry(); + closeAddMenu(); + if (editorState.activeViewportPanelId !== transformSourcePanelId) { + store.setActiveViewportPanel(transformSourcePanelId); + } + store.setTransformSession(createTransformSession({ + source, + sourcePanelId: transformSourcePanelId, + operation, + target: transformTarget + })); + setStatusMessage(`${getTransformOperationLabel(operation)} ${getTransformTargetLabel(transformTarget).toLowerCase()} in ${getViewportPanelLabel(transformSourcePanelId)}. Move the pointer, press X/Y/Z to constrain, click or press Enter to commit, Escape cancels.`); + }; + const cancelTransformSession = (status = "Cancelled the current transform.") => { + if (transformSession.kind === "none") { + return; + } + store.clearTransformSession(); + setStatusMessage(status); + }; + const commitTransformSession = (activeTransformSession) => { + if (!doesTransformSessionChangeTarget(activeTransformSession)) { + store.clearTransformSession(); + setStatusMessage("No transform change was committed."); + return; + } + try { + store.clearTransformSession(); + store.executeCommand(createCommitTransformSessionCommand(editorState.document, activeTransformSession)); + setStatusMessage(`${getTransformOperationPastTense(activeTransformSession.operation)} ${getTransformTargetLabel(activeTransformSession.target).toLowerCase()}.`); + } + catch (error) { + store.clearTransformSession(); + setStatusMessage(getErrorMessage(error)); + } + }; + const applyTransformAxisConstraint = (axis) => { + if (transformSession.kind !== "active") { + return; + } + if (!supportsTransformAxisConstraint(transformSession, axis)) { + const supportedAxes = ["x", "y", "z"] + .filter((candidateAxis) => supportsTransformAxisConstraint(transformSession, candidateAxis)) + .map((candidateAxis) => candidateAxis.toUpperCase()) + .join("/"); + setStatusMessage(supportedAxes.length === 0 + ? `${getTransformOperationLabel(transformSession.operation)} does not support axis constraints for ${getTransformTargetLabel(transformSession.target)}.` + : `${getTransformOperationLabel(transformSession.operation)} on ${getTransformTargetLabel(transformSession.target)} only supports ${supportedAxes}.`); + return; + } + store.setTransformAxisConstraint(axis); + setStatusMessage(`Constrained ${getTransformOperationLabel(transformSession.operation).toLowerCase()} to ${axis.toUpperCase()}.`); + }; + const handleViewportQuadResizeStart = (resizeMode) => (event) => { + if (layoutMode !== "quad") { + return; + } + const viewportPanels = viewportPanelsRef.current; + if (viewportPanels === null) { + return; + } + event.preventDefault(); + event.stopPropagation(); + blurActiveTextEntry(); + const rect = viewportPanels.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const nextViewportQuadSplit = { + ...viewportQuadSplitRef.current + }; + if (resizeMode !== "horizontal") { + nextViewportQuadSplit.x = clampViewportQuadSplitValue((event.clientX - rect.left) / rect.width); + } + if (resizeMode !== "vertical") { + nextViewportQuadSplit.y = clampViewportQuadSplitValue((event.clientY - rect.top) / rect.height); + } + store.setViewportQuadSplit(nextViewportQuadSplit); + } + setViewportQuadResizeMode(resizeMode); + }; + const beginCreation = (toolPreview, status) => { + blurActiveTextEntry(); + closeAddMenu(); + store.setToolMode("create"); + store.setViewportToolPreview(toolPreview); + setStatusMessage(status); + }; + const completeCreation = (status) => { + store.setToolMode("select"); + store.clearViewportToolPreview(); + setStatusMessage(status); + }; + const beginBoxCreation = () => { + beginCreation({ + kind: "create", + sourcePanelId: activePanelId, + target: { + kind: "box-brush" + }, + center: null + }, `Previewing a whitebox box. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.`); + }; + const handleWhiteboxSnapToggle = () => { + const nextEnabled = !whiteboxSnapEnabled; + setWhiteboxSnapEnabled(nextEnabled); + setStatusMessage(nextEnabled ? `Grid snap enabled at ${whiteboxSnapStep}m.` : "Grid snap disabled for whitebox transforms."); + }; + const handleWhiteboxSnapStepBlur = () => { + const normalizedStep = resolveOptionalPositiveNumber(whiteboxSnapStepDraft, DEFAULT_GRID_SIZE); + setWhiteboxSnapStepDraft(String(normalizedStep)); + }; + const handleWhiteboxSelectionModeChange = (mode) => { + if (whiteboxSelectionMode === mode) { + return; + } + blurActiveTextEntry(); + store.setWhiteboxSelectionMode(mode); + setStatusMessage(getWhiteboxSelectionModeStatus(mode)); + }; + const applySelection = (selection, source, options = {}) => { + 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 ${BOX_FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`); + break; + case "brushEdge": + setStatusMessage(`Selected ${BOX_EDGE_LABELS[selection.edgeId]} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`); + break; + case "brushVertex": + setStatusMessage(`Selected ${BOX_VERTEX_LABELS[selection.vertexId]} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`); + break; + 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 || editorState.selection.kind !== "brushes" || whiteboxSelectionMode !== "object") { + setStatusMessage("Switch to Object mode and select a whitebox box before moving it."); + return; + } + try { + const nextCenter = maybeSnapVec3(readVec3Draft(positionDraft, "Whitebox box position"), whiteboxSnapEnabled, whiteboxSnapStep); + if (areVec3Equal(nextCenter, selectedBrush.center)) { + return; + } + store.executeCommand(createMoveBoxBrushCommand({ + brushId: selectedBrush.id, + center: nextCenter, + snapToGrid: false + })); + setStatusMessage("Moved selected whitebox box."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyRotationChange = () => { + if (selectedBrush === null || editorState.selection.kind !== "brushes" || whiteboxSelectionMode !== "object") { + setStatusMessage("Switch to Object mode and select a whitebox box before rotating it."); + return; + } + try { + const nextRotationDegrees = readVec3Draft(rotationDraft, "Whitebox box rotation"); + if (areVec3Equal(nextRotationDegrees, selectedBrush.rotationDegrees)) { + return; + } + store.executeCommand(createRotateBoxBrushCommand({ + brushId: selectedBrush.id, + rotationDegrees: nextRotationDegrees + })); + setStatusMessage("Rotated selected whitebox box."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applySizeChange = () => { + if (selectedBrush === null || editorState.selection.kind !== "brushes" || whiteboxSelectionMode !== "object") { + setStatusMessage("Switch to Object mode and select a whitebox box before scaling it."); + return; + } + try { + const nextSize = maybeSnapPositiveSize(readVec3Draft(sizeDraft, "Whitebox box size"), whiteboxSnapEnabled, whiteboxSnapStep); + if (areVec3Equal(nextSize, selectedBrush.size)) { + return; + } + store.executeCommand(createResizeBoxBrushCommand({ + brushId: selectedBrush.id, + size: nextSize, + snapToGrid: false + })); + setStatusMessage("Scaled selected whitebox box."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const commitEntityChange = (currentEntity, nextEntity, successMessage) => { + if (areEntityInstancesEqual(currentEntity, nextEntity)) { + return; + } + store.executeCommand(createUpsertEntityCommand({ + entity: nextEntity, + label: `Update ${getEntityKindLabel(nextEntity.kind).toLowerCase()}` + })); + setStatusMessage(successMessage); + }; + const beginEntityCreation = (kind, options = {}) => { + beginCreation({ + kind: "create", + sourcePanelId: activePanelId, + target: { + kind: "entity", + entityKind: kind, + audioAssetId: options.audioAssetId ?? null + }, + center: null + }, `Previewing ${getEntityKindLabel(kind)}. Click in the viewport to place it.`); + }; + const beginModelInstanceCreation = (assetId) => { + const asset = editorState.document.assets[assetId]; + if (asset === undefined || asset.kind !== "model") { + setStatusMessage("Select a model asset before placing a model instance."); + return; + } + beginCreation({ + kind: "create", + sourcePanelId: activePanelId, + target: { + kind: "model-instance", + assetId: asset.id + }, + center: null + }, `Previewing ${asset.sourceName}. Click in the viewport to place it.`); + }; + const handleCommitCreation = (creationPreview) => { + try { + if (creationPreview.target.kind === "box-brush") { + const center = creationPreview.center === null ? undefined : creationPreview.center; + store.executeCommand(createCreateBoxBrushCommand(center === undefined + ? { + snapToGrid: whiteboxSnapEnabled, + gridSize: whiteboxSnapStep + } + : { + center, + snapToGrid: whiteboxSnapEnabled, + gridSize: whiteboxSnapStep + })); + completeCreation(center === undefined + ? whiteboxSnapEnabled + ? `Created a whitebox box on the ${whiteboxSnapStep}m grid.` + : "Created a whitebox box." + : whiteboxSnapEnabled + ? `Created a whitebox box at snapped center ${formatVec3(center)}.` + : `Created a whitebox box at ${formatVec3(center)}.`); + return true; + } + if (creationPreview.target.kind === "model-instance") { + const asset = editorState.document.assets[creationPreview.target.assetId]; + if (asset === undefined || asset.kind !== "model") { + setStatusMessage("Select a model asset before placing a model instance."); + return false; + } + const nextModelInstance = createModelInstance({ + assetId: asset.id, + position: creationPreview.center === null ? createModelInstancePlacementPosition(asset, null) : creationPreview.center, + rotationDegrees: DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, + scale: DEFAULT_MODEL_INSTANCE_SCALE + }); + store.executeCommand(createUpsertModelInstanceCommand({ + modelInstance: nextModelInstance, + label: `Place ${asset.sourceName}` + })); + completeCreation(`Placed ${asset.sourceName}.`); + return true; + } + const position = creationPreview.center ?? DEFAULT_ENTITY_POSITION; + switch (creationPreview.target.entityKind) { + case "pointLight": + store.executeCommand(createUpsertEntityCommand({ + entity: createPointLightEntity({ + position + }), + label: "Place point light" + })); + completeCreation("Placed Point Light."); + return true; + case "spotLight": + store.executeCommand(createUpsertEntityCommand({ + entity: createSpotLightEntity({ + position + }), + label: "Place spot light" + })); + completeCreation("Placed Spot Light."); + return true; + case "playerStart": + store.executeCommand(createUpsertEntityCommand({ + entity: createPlayerStartEntity({ + position + }), + label: "Place player start" + })); + completeCreation("Placed Player Start."); + return true; + case "soundEmitter": { + const placedAudioAssetId = creationPreview.target.audioAssetId ?? audioAssetList[0]?.id ?? null; + store.executeCommand(createUpsertEntityCommand({ + entity: createSoundEmitterEntity({ + position, + audioAssetId: placedAudioAssetId + }), + label: "Place sound emitter" + })); + completeCreation(placedAudioAssetId === null + ? "Placed Sound Emitter." + : `Placed Sound Emitter using ${editorState.document.assets[placedAudioAssetId]?.sourceName ?? "the authored audio asset"}.`); + return true; + } + case "triggerVolume": + store.executeCommand(createUpsertEntityCommand({ + entity: createTriggerVolumeEntity({ + position + }), + label: "Place trigger volume" + })); + completeCreation("Placed Trigger Volume."); + return true; + case "teleportTarget": + store.executeCommand(createUpsertEntityCommand({ + entity: createTeleportTargetEntity({ + position + }), + label: "Place teleport target" + })); + completeCreation("Placed Teleport Target."); + return true; + case "interactable": + store.executeCommand(createUpsertEntityCommand({ + entity: createInteractableEntity({ + position + }), + label: "Place interactable" + })); + completeCreation("Placed Interactable."); + return true; + } + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + return false; + }; + const commitModelInstanceChange = (currentModelInstance, nextModelInstance, successMessage) => { + 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 applyModelInstanceChange = () => { + if (selectedModelInstance === null) { + setStatusMessage("Select a model instance before editing it."); + return; + } + try { + const nextModelInstance = createModelInstance({ + id: selectedModelInstance.id, + assetId: selectedModelInstance.assetId, + name: selectedModelInstance.name, + collision: selectedModelInstance.collision, + position: readVec3Draft(modelPositionDraft, "Model instance position"), + rotationDegrees: readVec3Draft(modelRotationDraft, "Model instance rotation"), + scale: readPositiveVec3Draft(modelScaleDraft, "Model instance scale"), + animationClipName: selectedModelInstance.animationClipName, + animationAutoplay: selectedModelInstance.animationAutoplay + }); + commitModelInstanceChange(selectedModelInstance, nextModelInstance, "Updated model instance."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyPlayerStartChange = (overrides = {}) => { + 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 colliderMode = overrides.colliderMode ?? playerStartColliderModeDraft; + const nextEntity = createPlayerStartEntity({ + id: selectedPlayerStart.id, + name: selectedPlayerStart.name, + position: snappedPosition, + yawDegrees, + collider: { + mode: colliderMode, + eyeHeight: readPositiveNumberDraft(playerStartEyeHeightDraft, "Player Start eye height"), + capsuleRadius: readPositiveNumberDraft(playerStartCapsuleRadiusDraft, "Player Start capsule radius"), + capsuleHeight: readPositiveNumberDraft(playerStartCapsuleHeightDraft, "Player Start capsule height"), + boxSize: readPositiveVec3Draft(playerStartBoxSizeDraft, "Player Start box size") + } + }); + commitEntityChange(selectedPlayerStart, nextEntity, "Updated Player Start."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyPointLightChange = (overrides = {}) => { + if (selectedPointLight === null) { + setStatusMessage("Select a Point Light before editing it."); + return; + } + try { + const nextEntity = createPointLightEntity({ + id: selectedPointLight.id, + name: selectedPointLight.name, + position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Point Light position"), DEFAULT_GRID_SIZE), + colorHex: overrides.colorHex ?? pointLightColorDraft, + intensity: readNonNegativeNumberDraft(pointLightIntensityDraft, "Point Light intensity"), + distance: readPositiveNumberDraft(pointLightDistanceDraft, "Point Light distance") + }); + commitEntityChange(selectedPointLight, nextEntity, "Updated Point Light."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applySpotLightChange = (overrides = {}) => { + if (selectedSpotLight === null) { + setStatusMessage("Select a Spot Light before editing it."); + return; + } + try { + const nextEntity = createSpotLightEntity({ + id: selectedSpotLight.id, + name: selectedSpotLight.name, + position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Spot Light position"), DEFAULT_GRID_SIZE), + direction: readVec3Draft(spotLightDirectionDraft, "Spot Light direction"), + colorHex: overrides.colorHex ?? spotLightColorDraft, + intensity: readNonNegativeNumberDraft(spotLightIntensityDraft, "Spot Light intensity"), + distance: readPositiveNumberDraft(spotLightDistanceDraft, "Spot Light distance"), + angleDegrees: readPositiveNumberDraft(spotLightAngleDraft, "Spot Light angle") + }); + commitEntityChange(selectedSpotLight, nextEntity, "Updated Spot Light."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applySelectedEntityDraftChange = () => { + if (selectedEntity === null) { + return; + } + switch (selectedEntity.kind) { + case "pointLight": + applyPointLightChange(); + break; + case "spotLight": + applySpotLightChange(); + break; + case "playerStart": + applyPlayerStartChange(); + break; + case "soundEmitter": + applySoundEmitterChange(); + break; + case "triggerVolume": + applyTriggerVolumeChange(); + break; + case "teleportTarget": + applyTeleportTargetChange(); + break; + case "interactable": + applyInteractableChange(); + break; + } + }; + const applySoundEmitterChange = (overrides = {}) => { + if (selectedSoundEmitter === null) { + setStatusMessage("Select a Sound Emitter before editing it."); + return; + } + try { + const trimmedAudioAssetId = soundEmitterAudioAssetIdDraft.trim(); + const nextAudioAssetId = overrides.audioAssetId !== undefined + ? overrides.audioAssetId + : trimmedAudioAssetId.length === 0 + ? null + : trimmedAudioAssetId; + const nextEntity = createSoundEmitterEntity({ + id: selectedSoundEmitter.id, + name: selectedSoundEmitter.name, + position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Sound Emitter position"), DEFAULT_GRID_SIZE), + audioAssetId: nextAudioAssetId, + volume: readNonNegativeNumberDraft(soundEmitterVolumeDraft, "Sound Emitter volume"), + refDistance: readPositiveNumberDraft(soundEmitterRefDistanceDraft, "Sound Emitter ref distance"), + maxDistance: readPositiveNumberDraft(soundEmitterMaxDistanceDraft, "Sound Emitter max distance"), + autoplay: overrides.autoplay ?? soundEmitterAutoplayDraft, + loop: overrides.loop ?? soundEmitterLoopDraft + }); + commitEntityChange(selectedSoundEmitter, nextEntity, "Updated Sound Emitter."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyTriggerVolumeChange = () => { + if (selectedTriggerVolume === null) { + setStatusMessage("Select a Trigger Volume before editing it."); + return; + } + try { + // Derive triggerOnEnter/triggerOnExit from the actual links so the flags + // stay in sync automatically — no manual checkbox needed. + const links = getInteractionLinksForSource(editorState.document.interactionLinks, selectedTriggerVolume.id); + const triggerOnEnter = links.some((l) => l.trigger === "enter"); + const triggerOnExit = links.some((l) => l.trigger === "exit"); + const nextEntity = createTriggerVolumeEntity({ + id: selectedTriggerVolume.id, + name: selectedTriggerVolume.name, + position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Trigger Volume position"), DEFAULT_GRID_SIZE), + size: snapPositiveSizeToGrid(readVec3Draft(triggerVolumeSizeDraft, "Trigger Volume size"), DEFAULT_GRID_SIZE), + triggerOnEnter, + triggerOnExit + }); + commitEntityChange(selectedTriggerVolume, nextEntity, "Updated Trigger Volume."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyTeleportTargetChange = () => { + if (selectedTeleportTarget === null) { + setStatusMessage("Select a Teleport Target before editing it."); + return; + } + try { + const nextEntity = createTeleportTargetEntity({ + id: selectedTeleportTarget.id, + name: selectedTeleportTarget.name, + position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Teleport Target position"), DEFAULT_GRID_SIZE), + yawDegrees: readYawDegreesDraft(teleportTargetYawDraft) + }); + commitEntityChange(selectedTeleportTarget, nextEntity, "Updated Teleport Target."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyInteractableChange = (overrides = {}) => { + if (selectedInteractable === null) { + setStatusMessage("Select an Interactable before editing it."); + return; + } + try { + const nextEntity = createInteractableEntity({ + id: selectedInteractable.id, + name: selectedInteractable.name, + position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Interactable position"), DEFAULT_GRID_SIZE), + radius: readPositiveNumberDraft(interactableRadiusDraft, "Interactable radius"), + prompt: readInteractablePromptDraft(interactablePromptDraft), + enabled: overrides.enabled ?? interactableEnabledDraft + }); + commitEntityChange(selectedInteractable, nextEntity, "Updated Interactable."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const commitInteractionLinkChange = (currentLink, nextLink, successMessage, label = "Update interaction link") => { + if (areInteractionLinksEqual(currentLink, nextLink)) { + return; + } + store.executeCommand(createUpsertInteractionLinkCommand({ + link: nextLink, + label + })); + setStatusMessage(successMessage); + }; + const getInteractionSourceEntityForLink = (link) => { + 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 whitebox solid 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) => { + 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) => { + try { + store.executeCommand(createDeleteInteractionLinkCommand(linkId)); + setStatusMessage("Deleted interaction link."); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const confirmDeleteSceneItem = (label) => globalThis.window.confirm(`Delete ${label}?\n\nThis can be undone with Undo.`); + const handleDeleteBrush = (brushId) => { + const label = getBrushLabelById(brushId, brushList); + if (!confirmDeleteSceneItem(label)) { + return false; + } + try { + store.executeCommand(createDeleteBoxBrushCommand(brushId)); + setStatusMessage(`Deleted ${label}.`); + return true; + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + return false; + } + }; + const handleDeleteEntity = (entityId) => { + const label = getEntityDisplayLabelById(entityId, editorState.document.entities, editorState.document.assets); + if (!confirmDeleteSceneItem(label)) { + return false; + } + try { + store.executeCommand(createDeleteEntityCommand(entityId)); + setStatusMessage(`Deleted ${label}.`); + return true; + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + return false; + } + }; + const handleDeleteModelInstance = (modelInstanceId) => { + const label = getModelInstanceDisplayLabelById(modelInstanceId, editorState.document.modelInstances, editorState.document.assets); + if (!confirmDeleteSceneItem(label)) { + return false; + } + try { + store.executeCommand(createDeleteModelInstanceCommand(modelInstanceId)); + setStatusMessage(`Deleted ${label}.`); + return true; + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + return false; + } + }; + const handleDeleteSelectedSceneItem = () => { + const selectedBrushId = getSingleSelectedBrushId(editorState.selection); + if (selectedBrushId !== null) { + return handleDeleteBrush(selectedBrushId); + } + const selectedEntityId = getSingleSelectedEntityId(editorState.selection); + if (selectedEntityId !== null) { + return handleDeleteEntity(selectedEntityId); + } + const selectedModelInstanceId = getSingleSelectedModelInstanceId(editorState.selection); + if (selectedModelInstanceId !== null) { + return handleDeleteModelInstance(selectedModelInstanceId); + } + return false; + }; + const updateInteractionLinkTrigger = (link, trigger) => { + 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; + 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, actionType) => { + 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 whitebox solid before switching this link to visibility."); + return; + } + commitInteractionLinkChange(link, createToggleVisibilityInteractionLink({ + id: link.id, + sourceEntityId: sourceEntity.id, + trigger: link.trigger, + targetBrushId: defaultBrush.id + }), "Switched link action to toggle visibility."); + }; + const updateTeleportInteractionLinkTarget = (link, targetEntityId) => { + 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, targetBrushId) => { + 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, mode) => { + 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, targetSoundEmitterId) => { + 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, targetModelInstanceId) => { + 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, clipName) => { + 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, loop) => { + 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) => { + 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) => { + 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, links, addTeleportTestId, addVisibilityTestId, addPlaySoundTestId, addStopSoundTestId) => (_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Links" }), links.length === 0 ? (_jsx("div", { className: "outliner-empty", children: sourceEntity.kind === "triggerVolume" ? "No trigger links authored yet." : "No click links authored yet." })) : (_jsx("div", { className: "outliner-list", children: links.map((link, index) => (_jsxs("div", { className: "outliner-item", children: [_jsxs("div", { className: "outliner-item__select", children: [_jsx("span", { className: "outliner-item__title", children: `Link ${index + 1}` }), _jsx("span", { className: "outliner-item__meta", children: getInteractionActionLabel(link) })] }), _jsx("div", { className: "form-section", children: _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Trigger" }), sourceEntity.kind === "triggerVolume" ? (_jsxs("select", { "data-testid": `interaction-link-trigger-${link.id}`, className: "text-input", value: link.trigger, onChange: (event) => updateInteractionLinkTrigger(link, event.currentTarget.value), children: [_jsx("option", { value: "enter", children: "On Enter" }), _jsx("option", { value: "exit", children: "On Exit" })] })) : (_jsx("input", { "data-testid": `interaction-link-trigger-${link.id}`, className: "text-input", type: "text", value: getInteractionTriggerLabel(link.trigger), readOnly: true }))] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Action" }), _jsxs("select", { "data-testid": `interaction-link-action-${link.id}`, className: "text-input", value: link.action.type, onChange: (event) => updateInteractionLinkActionType(link, event.currentTarget.value), children: [_jsx("option", { value: "teleportPlayer", children: "Teleport Player" }), _jsx("option", { value: "toggleVisibility", children: "Toggle Visibility" }), _jsx("option", { value: "playAnimation", children: "Play Animation" }), _jsx("option", { value: "stopAnimation", children: "Stop Animation" }), _jsx("option", { value: "playSound", children: "Play Sound" }), _jsx("option", { value: "stopSound", children: "Stop Sound" })] })] })] }) }), link.action.type === "teleportPlayer" ? (_jsx("div", { className: "form-section", children: _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Target" }), _jsx("select", { "data-testid": `interaction-link-teleport-target-${link.id}`, className: "text-input", value: link.action.targetEntityId, onChange: (event) => updateTeleportInteractionLinkTarget(link, event.currentTarget.value), children: teleportTargetOptions.map(({ entity, label }) => (_jsx("option", { value: entity.id, children: label }, entity.id))) })] }) })) : link.action.type === "toggleVisibility" ? (_jsx("div", { className: "form-section", children: _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Solid" }), _jsx("select", { "data-testid": `interaction-link-visibility-target-${link.id}`, className: "text-input", value: link.action.targetBrushId, onChange: (event) => updateVisibilityInteractionLinkTarget(link, event.currentTarget.value), children: visibilityBrushOptions.map(({ brush, label }) => (_jsx("option", { value: brush.id, children: label }, brush.id))) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Mode" }), _jsxs("select", { "data-testid": `interaction-link-visibility-mode-${link.id}`, className: "text-input", value: getVisibilityModeSelectValue(link.action.visible), onChange: (event) => updateVisibilityInteractionMode(link, event.currentTarget.value), children: [_jsx("option", { value: "toggle", children: "Toggle" }), _jsx("option", { value: "show", children: "Show" }), _jsx("option", { value: "hide", children: "Hide" })] })] })] }) })) : link.action.type === "playAnimation" ? (_jsxs("div", { className: "form-section", children: [_jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Instance" }), _jsx("select", { "data-testid": `interaction-link-play-anim-instance-${link.id}`, className: "text-input", value: link.action.targetModelInstanceId, onChange: (event) => updateAnimationInteractionLinkTarget(link, event.currentTarget.value), children: modelInstanceDisplayList.map(({ modelInstance, label }) => (_jsx("option", { value: modelInstance.id, children: label }, modelInstance.id))) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Clip" }), _jsx("select", { "data-testid": `interaction-link-play-anim-clip-${link.id}`, className: "text-input", value: link.action.clipName, onChange: (event) => updatePlayAnimationLinkClip(link, event.currentTarget.value), children: editorState.document.assets[editorState.document.modelInstances[link.action.targetModelInstanceId]?.assetId ?? ""]?.metadata.animationNames.map((name) => (_jsx("option", { value: name, children: name }, name))) ?? _jsx("option", { value: link.action.clipName, children: link.action.clipName }) })] })] }), _jsxs("label", { className: "form-field", children: [_jsx("input", { type: "checkbox", "data-testid": `interaction-link-play-anim-loop-${link.id}`, checked: link.action.loop !== false, onChange: (event) => updatePlayAnimationLinkLoop(link, event.currentTarget.checked) }), _jsx("span", { className: "label", children: "Loop" })] })] })) : link.action.type === "playSound" || link.action.type === "stopSound" ? (_jsx("div", { className: "form-section", children: _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Emitter" }), _jsx("select", { "data-testid": `interaction-link-sound-target-${link.id}`, className: "text-input", value: link.action.targetSoundEmitterId, onChange: (event) => updateSoundInteractionLinkTarget(link, event.currentTarget.value), children: soundEmitterOptions.map(({ entity, label }) => (_jsx("option", { value: entity.id, children: label }, entity.id))) })] }) })) : (_jsx("div", { className: "form-section", children: _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Instance" }), _jsx("select", { "data-testid": `interaction-link-stop-anim-instance-${link.id}`, className: "text-input", value: link.action.targetModelInstanceId, onChange: (event) => updateAnimationInteractionLinkTarget(link, event.currentTarget.value), children: modelInstanceDisplayList.map(({ modelInstance, label }) => (_jsx("option", { value: modelInstance.id, children: label }, modelInstance.id))) })] }) })), _jsx("div", { className: "inline-actions", children: _jsx("button", { className: "toolbar__button", type: "button", "data-testid": `delete-interaction-link-${link.id}`, onClick: () => handleDeleteInteractionLink(link.id), children: "Delete Link" }) })] }, link.id))) })), _jsxs("div", { className: "inline-actions", children: [_jsx("button", { className: "toolbar__button", type: "button", "data-testid": addTeleportTestId, disabled: teleportTargetOptions.length === 0, onClick: handleAddTeleportInteractionLink, children: "Add Teleport Link" }), _jsx("button", { className: "toolbar__button", type: "button", "data-testid": addVisibilityTestId, disabled: visibilityBrushOptions.length === 0, onClick: handleAddVisibilityInteractionLink, children: "Add Visibility Link" }), _jsx("button", { className: "toolbar__button", type: "button", disabled: modelInstanceDisplayList.length === 0, onClick: () => handleAddPlayAnimationLink(sourceEntity), children: "Add Play Anim Link" }), _jsx("button", { className: "toolbar__button", type: "button", disabled: modelInstanceDisplayList.length === 0, onClick: () => handleAddStopAnimationLink(sourceEntity), children: "Add Stop Anim Link" }), _jsx("button", { className: "toolbar__button", type: "button", "data-testid": addPlaySoundTestId, disabled: playableSoundEmitterOptions.length === 0, onClick: () => handleAddSoundInteractionLink("playSound"), children: "Add Play Sound Link" }), _jsx("button", { className: "toolbar__button", type: "button", "data-testid": addStopSoundTestId, disabled: playableSoundEmitterOptions.length === 0, onClick: () => handleAddSoundInteractionLink("stopSound"), children: "Add Stop Sound Link" })] })] })); + const applyWorldSettings = (nextWorld, label, successMessage) => { + if (areWorldSettingsEqual(editorState.document.world, nextWorld)) { + return; + } + try { + store.executeCommand(createSetWorldSettingsCommand({ + label, + world: nextWorld + })); + setStatusMessage(successMessage); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyAdvancedRenderingSettings = (label, successMessage, mutate) => { + const nextWorld = cloneWorldSettings(editorState.document.world); + mutate(nextWorld.advancedRendering); + applyWorldSettings(nextWorld, label, successMessage); + }; + const applyWorldBackgroundMode = (mode, imageAssetId) => { + 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) => { + 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, colorHex) => { + 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) => { + 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) => { + 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) => { + applyAdvancedRenderingSettings("Set advanced rendering", enabled ? "Advanced rendering enabled." : "Advanced rendering disabled.", (advancedRendering) => { + advancedRendering.enabled = enabled; + }); + }; + const applyAdvancedRenderingShadowsEnabled = (enabled) => { + applyAdvancedRenderingSettings("Set advanced rendering shadows", enabled ? "Advanced rendering shadows enabled." : "Advanced rendering shadows disabled.", (advancedRendering) => { + advancedRendering.shadows.enabled = enabled; + }); + }; + const applyAdvancedRenderingShadowMapSize = (shadowMapSize) => { + applyAdvancedRenderingSettings("Set advanced rendering shadow map size", "Updated the shadow map size.", (advancedRendering) => { + advancedRendering.shadows.mapSize = shadowMapSize; + }); + }; + const applyAdvancedRenderingShadowType = (shadowType) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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 whitebox box before renaming it."); + return; + } + const nextName = normalizeBrushName(brushNameDraft); + if (selectedBrush.name === nextName) { + return; + } + try { + store.executeCommand(createSetBoxBrushNameCommand({ + brushId: selectedBrush.id, + name: brushNameDraft + })); + setStatusMessage(nextName === undefined ? "Cleared the authored brush name." : `Renamed brush to ${nextName}.`); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyEntityNameChange = () => { + if (selectedEntity === null) { + setStatusMessage("Select an entity before renaming it."); + return; + } + const nextName = normalizeEntityName(entityNameDraft); + if (selectedEntity.name === nextName) { + return; + } + try { + store.executeCommand(createSetEntityNameCommand({ + entityId: selectedEntity.id, + name: entityNameDraft + })); + setStatusMessage(nextName === undefined ? "Cleared the authored entity name." : `Renamed entity to ${nextName}.`); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const applyModelInstanceNameChange = () => { + if (selectedModelInstance === null) { + setStatusMessage("Select a model instance before renaming it."); + return; + } + const nextName = normalizeModelInstanceName(modelInstanceNameDraft); + if (selectedModelInstance.name === nextName) { + return; + } + try { + store.executeCommand(createSetModelInstanceNameCommand({ + modelInstanceId: selectedModelInstance.id, + name: modelInstanceNameDraft + })); + setStatusMessage(nextName === undefined ? "Cleared the authored model instance name." : `Renamed model instance to ${nextName}.`); + } + catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const handleInlineNameInputKeyDown = (event, resetDraft) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + resetDraft(); + event.currentTarget.blur(); + } + }; + const handleDraftVectorKeyDown = (event, applyChange) => { + if (event.key === "Enter") { + applyChange(); + } + }; + const scheduleDraftCommit = (applyChange) => { + window.setTimeout(() => { + applyChange(); + }, 0); + }; + const handleNumberInputPointerUp = (_event, applyChange) => { + scheduleDraftCommit(applyChange); + }; + const handleNumberInputKeyUp = (event, applyChange) => { + 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) => { + 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) => { + 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 = 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) => { + 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 = 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) => { + 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 = 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) => { + if (selectedBrush === null || selectedFaceId === null || selectedFace === null) { + setStatusMessage("Select a single box face before applying a material."); + return; + } + if (selectedFace.materialId === materialId) { + setStatusMessage(`${BOX_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 ${BOX_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(`${BOX_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 ${BOX_FACE_LABELS[selectedFaceId]}.`); + }; + const applyFaceUvState = (uvState, label, successMessage) => { + 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) => { + 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, + loadedModelAssets + }); + 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) => { + 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."); + } + }; + const createAssetMenuHoverHandler = (assetId) => (hovered) => { + setHoveredAssetId((current) => (hovered ? assetId : current === assetId ? null : current)); + }; + const createDisabledMenuAction = (label, testId) => ({ + kind: "action", + label, + testId, + disabled: true, + onSelect: () => undefined + }); + const addMenuItems = [ + { + kind: "action", + label: "Whitebox Box", + testId: "add-menu-box", + onSelect: beginBoxCreation + }, + { + kind: "group", + label: "Entities", + testId: "add-menu-entities", + children: [ + { + kind: "action", + label: "Player Start", + testId: "add-menu-player-start", + onSelect: () => beginEntityCreation("playerStart") + }, + { + kind: "action", + label: "Sound Emitter", + testId: "add-menu-sound-emitter", + onSelect: () => beginEntityCreation("soundEmitter", { audioAssetId: audioAssetList[0]?.id ?? null }) + }, + { + kind: "action", + label: "Trigger Volume", + testId: "add-menu-trigger-volume", + onSelect: () => beginEntityCreation("triggerVolume") + }, + { + kind: "action", + label: "Teleport Target", + testId: "add-menu-teleport-target", + onSelect: () => beginEntityCreation("teleportTarget") + }, + { + kind: "action", + label: "Interactable", + testId: "add-menu-interactable", + onSelect: () => beginEntityCreation("interactable") + } + ] + }, + { + kind: "group", + label: "Lights", + testId: "add-menu-lights", + children: [ + { + kind: "action", + label: "Point Light", + testId: "add-menu-point-light", + onSelect: () => beginEntityCreation("pointLight") + }, + { + kind: "action", + label: "Spot Light", + testId: "add-menu-spot-light", + onSelect: () => beginEntityCreation("spotLight") + } + ] + }, + { + kind: "group", + label: "Assets", + testId: "add-menu-assets", + children: [ + { + kind: "group", + label: "3D Models", + testId: "add-menu-assets-models", + children: modelAssetList.length === 0 + ? [createDisabledMenuAction("No imported 3D models", "add-menu-assets-models-empty")] + : modelAssetList.map((asset) => ({ + kind: "action", + label: asset.sourceName, + testId: `add-menu-model-asset-${asset.id}`, + onSelect: () => beginModelInstanceCreation(asset.id), + onHoverChange: createAssetMenuHoverHandler(asset.id) + })) + }, + { + kind: "group", + label: "Environments", + testId: "add-menu-assets-environments", + children: imageAssetList.length === 0 + ? [createDisabledMenuAction("No imported environments", "add-menu-assets-environments-empty")] + : imageAssetList.map((asset) => ({ + kind: "action", + label: asset.sourceName, + testId: `add-menu-image-asset-${asset.id}`, + onSelect: () => applyWorldBackgroundMode("image", asset.id), + onHoverChange: createAssetMenuHoverHandler(asset.id) + })) + }, + { + kind: "group", + label: "Audio", + testId: "add-menu-assets-audio", + children: audioAssetList.length === 0 + ? [createDisabledMenuAction("No imported audio", "add-menu-assets-audio-empty")] + : audioAssetList.map((asset) => ({ + kind: "action", + label: asset.sourceName, + testId: `add-menu-audio-asset-${asset.id}`, + onSelect: () => beginEntityCreation("soundEmitter", { audioAssetId: asset.id }), + onHoverChange: createAssetMenuHoverHandler(asset.id) + })) + } + ] + }, + { + kind: "group", + label: "Import", + testId: "add-menu-import", + children: [ + { + kind: "action", + label: "3D Model (GLB/GLTF)", + testId: "import-menu-model", + disabled: !projectAssetStorageReady || projectAssetStorage === null, + onSelect: handleImportModelButtonClick + }, + { + kind: "action", + label: "Environment", + testId: "import-menu-environment", + disabled: !projectAssetStorageReady || projectAssetStorage === null, + onSelect: handleImportBackgroundImageButtonClick + }, + { + kind: "action", + label: "Audio", + testId: "import-menu-audio", + disabled: !projectAssetStorageReady || projectAssetStorage === null, + onSelect: handleImportAudioButtonClick + } + ] + } + ]; + const viewportPanelsStyle = layoutMode === "quad" ? createViewportQuadPanelsStyle(editorState.viewportQuadSplit) : undefined; + if (editorState.toolMode === "play" && runtimeScene !== null) { + return (_jsxs("div", { className: "app-shell app-shell--play", children: [_jsxs("header", { className: "toolbar", children: [_jsxs("div", { className: "toolbar__brand", children: [_jsx("div", { className: "toolbar__title", children: "WebEditor3D" }), _jsx("div", { className: "toolbar__subtitle", children: "Slice 3.1 GLB/GLTF import and unified creation" })] }), _jsxs("div", { className: "toolbar__actions", children: [_jsxs("div", { className: "toolbar__group", children: [_jsx("button", { className: `toolbar__button ${activeNavigationMode === "firstPerson" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "runner-mode-first-person", onClick: () => handleSetPreferredNavigationMode("firstPerson"), children: "First Person" }), _jsx("button", { className: `toolbar__button ${activeNavigationMode === "orbitVisitor" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "runner-mode-orbit-visitor", onClick: () => handleSetPreferredNavigationMode("orbitVisitor"), children: "Orbit Visitor" })] }), _jsx("div", { className: "toolbar__group", children: _jsx("button", { className: "toolbar__button toolbar__button--accent", type: "button", "data-testid": "exit-run-mode", onClick: handleExitPlayMode, children: "Return To Editor" }) })] })] }), _jsxs("div", { className: "runner-workspace", children: [_jsx("main", { className: "runner-region", children: _jsx(RunnerCanvas, { runtimeScene: runtimeScene, projectAssets: editorState.document.assets, loadedModelAssets: loadedModelAssets, loadedImageAssets: loadedImageAssets, loadedAudioAssets: loadedAudioAssets, navigationMode: activeNavigationMode, onRuntimeMessageChange: setRuntimeMessage, onFirstPersonTelemetryChange: setFirstPersonTelemetry, onInteractionPromptChange: setRuntimeInteractionPrompt }) }), _jsx("aside", { className: "side-column", children: _jsxs(Panel, { title: "Runner", children: [_jsxs("div", { className: "stat-grid", children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Navigation" }), _jsx("div", { className: "value", children: activeNavigationMode === "firstPerson" ? "First Person" : "Orbit Visitor" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Spawn Source" }), _jsx("div", { className: "value", children: runtimeScene.spawn.source === "playerStart" ? "Player Start" : "Fallback" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Pointer Lock" }), _jsx("div", { className: "value", children: activeNavigationMode === "firstPerson" ? (firstPersonTelemetry?.pointerLocked ? "active" : "idle") : "not used" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Grounded" }), _jsx("div", { className: "value", children: firstPersonTelemetry?.grounded ? "yes" : activeNavigationMode === "firstPerson" ? "no" : "n/a" })] })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "FPS Feet Position" }), _jsx("div", { className: "value", "data-testid": "runner-player-position", children: formatRunnerFeetPosition(firstPersonTelemetry?.feetPosition ?? runtimeScene.spawn.position) }), _jsxs("div", { className: "material-summary", "data-testid": "runner-spawn-state", children: ["Spawn: ", runtimeScene.spawn.source === "playerStart" ? "Player Start" : "Fallback", " at", " ", formatRunnerFeetPosition(runtimeScene.spawn.position)] })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Interaction" }), _jsx("div", { className: "value", "data-testid": "runner-interaction-state", children: activeNavigationMode === "firstPerson" ? (runtimeInteractionPrompt === null ? "No target" : "Ready") : "Not available" }), _jsx("div", { className: "material-summary", "data-testid": "runner-interaction-summary", children: activeNavigationMode === "firstPerson" + ? runtimeInteractionPrompt === null + ? "Aim at an authored Interactable and click when a prompt appears." + : `Click "${runtimeInteractionPrompt.prompt}" within ${runtimeInteractionPrompt.range.toFixed(1)}m.` + : "Switch to First Person to use click interactions." })] }), runtimeMessage === null ? null : _jsx("div", { className: "info-banner", children: runtimeMessage }), activeNavigationMode === "firstPerson" ? (_jsx("div", { className: "info-banner", "data-testid": "runner-interaction-help", children: "Mouse click activates the current prompt target. Keyboard/controller fallback is not active yet." })) : null] }) })] }), _jsxs("footer", { className: "status-bar", children: [_jsxs("div", { children: [_jsx("span", { className: "status-bar__strong", children: "Status:" }), " ", statusMessage] }), _jsxs("div", { children: [_jsx("span", { className: "status-bar__strong", children: "Spawn:" }), " ", runtimeScene.spawn.source === "playerStart" ? "Authored Player Start" : "Fallback runtime spawn"] })] })] })); + } + return (_jsxs("div", { className: "app-shell", children: [_jsxs("header", { className: "toolbar", children: [_jsxs("label", { className: "toolbar__scene-name", children: [_jsx("span", { className: "visually-hidden", children: "Scene Name" }), _jsx("input", { "data-testid": "toolbar-scene-name", className: "text-input toolbar__scene-name-input", type: "text", value: sceneNameDraft, onChange: (event) => setSceneNameDraft(event.currentTarget.value), onBlur: applySceneName, onKeyDown: (event) => { + if (event.key === "Enter") { + applySceneName(); + } + } })] }), _jsxs("div", { className: "toolbar__actions", children: [_jsxs("div", { className: "toolbar__group", children: [_jsx("button", { className: "toolbar__button toolbar__button--accent", type: "button", "data-testid": "outliner-add-button", "aria-haspopup": "menu", "aria-expanded": addMenuPosition !== null, onClick: handleOpenAddMenuFromButton, children: "Add" }), _jsx("button", { className: "toolbar__button", type: "button", disabled: !editorState.storageAvailable, onClick: handleSaveDraft, children: "Save Draft" }), _jsx("button", { className: "toolbar__button", type: "button", disabled: !editorState.storageAvailable, onClick: handleLoadDraft, children: "Load Draft" }), _jsx("button", { className: "toolbar__button", type: "button", onClick: handleExportJson, children: "Export JSON" }), _jsx("button", { className: "toolbar__button", type: "button", onClick: handleImportJsonButtonClick, children: "Import JSON" })] }), _jsx("div", { className: "toolbar__group", role: "group", "aria-label": "Viewport layout mode", children: VIEWPORT_LAYOUT_MODES.map((mode) => (_jsx("button", { className: `toolbar__button toolbar__button--compact ${editorState.viewportLayoutMode === mode ? "toolbar__button--active" : ""}`, type: "button", "data-testid": `viewport-layout-${mode}`, "aria-pressed": editorState.viewportLayoutMode === mode, onClick: () => handleSetViewportLayoutMode(mode), children: getViewportLayoutModeLabel(mode) }, mode))) }), _jsxs("div", { className: "toolbar__group", role: "group", "aria-label": "Transform operations", children: [_jsxs("button", { className: `toolbar__button ${transformSession.kind === "active" && transformSession.operation === "translate" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "transform-translate-button", "aria-pressed": transformSession.kind === "active" && transformSession.operation === "translate", disabled: editorState.toolMode !== "select" || !canTranslateSelectedTarget, onClick: () => beginTransformOperation("translate", "toolbar"), children: ["Move (", getTransformOperationShortcut("translate"), ")"] }), _jsxs("button", { className: `toolbar__button ${transformSession.kind === "active" && transformSession.operation === "rotate" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "transform-rotate-button", "aria-pressed": transformSession.kind === "active" && transformSession.operation === "rotate", disabled: editorState.toolMode !== "select" || !canRotateSelectedTarget, onClick: () => beginTransformOperation("rotate", "toolbar"), children: ["Rotate (", getTransformOperationShortcut("rotate"), ")"] }), _jsxs("button", { className: `toolbar__button ${transformSession.kind === "active" && transformSession.operation === "scale" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "transform-scale-button", "aria-pressed": transformSession.kind === "active" && transformSession.operation === "scale", disabled: editorState.toolMode !== "select" || !canScaleSelectedTarget, onClick: () => beginTransformOperation("scale", "toolbar"), children: ["Scale (", getTransformOperationShortcut("scale"), ")"] })] }), _jsx("div", { className: "toolbar__group", role: "group", "aria-label": "Whitebox selection mode", children: WHITEBOX_SELECTION_MODES.map((mode) => (_jsx("button", { className: `toolbar__button toolbar__button--compact ${whiteboxSelectionMode === mode ? "toolbar__button--active" : ""}`, type: "button", "data-testid": `whitebox-selection-mode-${mode}`, "aria-pressed": whiteboxSelectionMode === mode, onClick: () => handleWhiteboxSelectionModeChange(mode), children: getWhiteboxSelectionModeLabel(mode) }, mode))) }), _jsxs("div", { className: "toolbar__group", role: "group", "aria-label": "Whitebox snap settings", children: [_jsx("button", { className: `toolbar__button ${whiteboxSnapEnabled ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "whitebox-snap-toggle", "aria-pressed": whiteboxSnapEnabled, onClick: handleWhiteboxSnapToggle, children: whiteboxSnapEnabled ? "Grid Snap On" : "Grid Snap Off" }), _jsxs("label", { className: "toolbar__inline-field", children: [_jsx("span", { className: "label", children: "Step" }), _jsx("input", { "data-testid": "whitebox-snap-step", className: "text-input toolbar__inline-input", type: "number", min: "0.01", step: "0.1", value: whiteboxSnapStepDraft, onChange: (event) => setWhiteboxSnapStepDraft(event.currentTarget.value), onBlur: handleWhiteboxSnapStepBlur, onKeyDown: (event) => { + if (event.key === "Enter") { + handleWhiteboxSnapStepBlur(); + } + } })] })] }), _jsx("div", { className: "toolbar__group", children: _jsx("button", { className: `toolbar__button toolbar__button--accent ${blockingDiagnostics.length > 0 ? "toolbar__button--warn" : ""}`, type: "button", "data-testid": "enter-run-mode", onClick: handleEnterPlayMode, children: "Run Scene" }) }), _jsxs("div", { className: "toolbar__group", children: [_jsx("button", { className: `toolbar__button ${preferredNavigationMode === "firstPerson" ? "toolbar__button--active" : ""}`, type: "button", onClick: () => handleSetPreferredNavigationMode("firstPerson"), children: "First Person" }), _jsx("button", { className: `toolbar__button ${preferredNavigationMode === "orbitVisitor" ? "toolbar__button--active" : ""}`, type: "button", onClick: () => handleSetPreferredNavigationMode("orbitVisitor"), children: "Orbit Visitor" })] }), _jsxs("div", { className: "toolbar__group", children: [_jsx("button", { className: "toolbar__button", type: "button", disabled: !editorState.canUndo, onClick: () => store.undo(), children: "Undo" }), _jsx("button", { className: "toolbar__button", type: "button", disabled: !editorState.canRedo, onClick: () => store.redo(), children: "Redo" })] })] })] }), _jsxs("div", { className: "workspace", children: [_jsx("aside", { className: "side-column", children: _jsxs(Panel, { title: "Outliner", children: [assetStatusMessage === null ? null : (_jsx("div", { className: "info-banner", "data-testid": "asset-status-message", children: assetStatusMessage })), projectAssetStorageReady && projectAssetStorage === null ? (_jsx("div", { className: "outliner-empty", children: "Project asset storage is unavailable. Imported assets cannot be persisted." })) : null, _jsxs("div", { className: "outliner-section", children: [_jsx("div", { className: "label", children: "Whitebox Solids" }), brushList.length === 0 ? (_jsx("div", { className: "outliner-empty", children: "Use Add > Whitebox Box and click in the viewport to create the first solid." })) : (_jsx("div", { className: "outliner-list", "data-testid": "outliner-brush-list", children: brushList.map((brush, brushIndex) => { + const label = getBrushLabel(brush, brushIndex); + const isSelected = selectedBrush?.id === brush.id; + return (_jsx("div", { className: `outliner-item outliner-item--compact ${isBrushSelected(editorState.selection, brush.id) ? "outliner-item--selected" : ""}`, children: _jsxs("div", { className: "outliner-item__row", children: [isSelected ? (_jsx("input", { className: "outliner-item__rename", "data-testid": "selected-brush-name", type: "text", value: brushNameDraft, placeholder: `Whitebox Box ${brushIndex + 1}`, onChange: (event) => setBrushNameDraft(event.currentTarget.value), onBlur: applyBrushNameChange, onFocus: (event) => event.currentTarget.select(), onKeyDown: (event) => handleInlineNameInputKeyDown(event, () => { + setBrushNameDraft(selectedBrush?.name ?? ""); + }) })) : (_jsx("button", { className: "outliner-item__select", type: "button", "data-testid": `outliner-brush-${brush.id}`, onClick: () => applySelection({ + kind: "brushes", + ids: [brush.id] + }, "outliner", { + focusViewport: true + }), children: _jsx("span", { className: "outliner-item__title", children: label }) })), _jsx("button", { className: "outliner-item__delete", type: "button", "data-testid": `outliner-delete-brush-${brush.id}`, "aria-label": `Delete ${label}`, onClick: () => handleDeleteBrush(brush.id), children: "x" })] }) }, brush.id)); + }) }))] }), _jsxs("div", { className: "outliner-section", children: [_jsx("div", { className: "label", children: "Model Instances" }), modelInstanceDisplayList.length === 0 ? (_jsx("div", { className: "outliner-empty", children: "No model instances placed yet." })) : (_jsx("div", { className: "outliner-list", "data-testid": "outliner-model-instance-list", children: modelInstanceDisplayList.map(({ modelInstance, label }) => { + const isSelected = editorState.selection.kind === "modelInstances" && editorState.selection.ids.includes(modelInstance.id); + return (_jsx("div", { className: `outliner-item ${isSelected ? "outliner-item--selected" : ""} outliner-item--compact`, children: _jsxs("div", { className: "outliner-item__row", children: [isSelected ? (_jsx("input", { className: "outliner-item__rename", "data-testid": "selected-model-instance-name", type: "text", value: modelInstanceNameDraft, placeholder: editorState.document.assets[modelInstance.assetId]?.sourceName ?? "Model Instance", onChange: (event) => setModelInstanceNameDraft(event.currentTarget.value), onBlur: applyModelInstanceNameChange, onFocus: (event) => event.currentTarget.select(), onKeyDown: (event) => handleInlineNameInputKeyDown(event, () => { + setModelInstanceNameDraft(selectedModelInstance?.name ?? ""); + }) })) : (_jsx("button", { "data-testid": `outliner-model-instance-${modelInstance.id}`, className: "outliner-item__select", type: "button", onClick: () => applySelection({ + kind: "modelInstances", + ids: [modelInstance.id] + }, "outliner", { + focusViewport: true + }), children: _jsx("span", { className: "outliner-item__title", children: label }) })), _jsx("button", { className: "outliner-item__delete", type: "button", "data-testid": `outliner-delete-model-instance-${modelInstance.id}`, "aria-label": `Delete ${label}`, onClick: () => handleDeleteModelInstance(modelInstance.id), children: "x" })] }) }, modelInstance.id)); + }) }))] }), _jsxs("div", { className: "outliner-section", children: [_jsx("div", { className: "label", children: "Entities" }), entityDisplayList.length === 0 ? _jsx("div", { className: "outliner-empty", children: "No entities authored yet." }) : null, entityDisplayList.length === 0 ? null : (_jsx("div", { className: "outliner-list", children: entityDisplayList.map(({ entity, label }) => { + const isSelected = editorState.selection.kind === "entities" && editorState.selection.ids.includes(entity.id); + return (_jsx("div", { className: `outliner-item ${isSelected ? "outliner-item--selected" : ""} outliner-item--compact`, children: _jsxs("div", { className: "outliner-item__row", children: [isSelected ? (_jsx("input", { className: "outliner-item__rename", "data-testid": "selected-entity-name", type: "text", value: entityNameDraft, placeholder: getEntityKindLabel(entity.kind), onChange: (event) => setEntityNameDraft(event.currentTarget.value), onBlur: applyEntityNameChange, onFocus: (event) => event.currentTarget.select(), onKeyDown: (event) => handleInlineNameInputKeyDown(event, () => { + setEntityNameDraft(selectedEntity?.name ?? ""); + }) })) : (_jsx("button", { "data-testid": `outliner-entity-${entity.id}`, className: "outliner-item__select", type: "button", onClick: () => applySelection({ + kind: "entities", + ids: [entity.id] + }, "outliner", { + focusViewport: true + }), children: _jsx("span", { className: "outliner-item__title", children: label }) })), _jsx("button", { className: "outliner-item__delete", type: "button", "data-testid": `outliner-delete-entity-${entity.id}`, "aria-label": `Delete ${label}`, onClick: () => handleDeleteEntity(entity.id), children: "x" })] }) }, entity.id)); + }) }))] })] }) }), _jsx("main", { className: `viewport-region viewport-region--${layoutMode}`, "data-testid": "viewport-shell", children: _jsxs("div", { ref: viewportPanelsRef, className: `viewport-region__panels viewport-region__panels--${layoutMode} ${viewportQuadResizeMode === null ? "" : "viewport-region__panels--resizing"}`.trim(), style: viewportPanelsStyle, children: [VIEWPORT_PANEL_IDS.map((panelId) => (_jsx(ViewportPanel, { panelId: panelId, className: `viewport-panel--${panelId}`, panelState: editorState.viewportPanels[panelId], layoutMode: layoutMode, isActive: activePanelId === panelId, world: editorState.document.world, sceneDocument: editorState.document, projectAssets: editorState.document.assets, loadedModelAssets: loadedModelAssets, loadedImageAssets: loadedImageAssets, whiteboxSelectionMode: whiteboxSelectionMode, whiteboxSnapEnabled: whiteboxSnapEnabled, whiteboxSnapStep: whiteboxSnapStep, selection: editorState.selection, toolMode: editorState.toolMode, toolPreview: viewportToolPreview, transformSession: transformSession, cameraState: editorState.viewportPanels[panelId].cameraState, focusRequestId: focusRequest.panelId === panelId ? focusRequest.id : 0, focusSelection: focusRequest.selection, onActivatePanel: handleActivateViewportPanel, onSetPanelViewMode: handleSetViewportPanelViewMode, onSetPanelDisplayMode: handleSetViewportPanelDisplayMode, onCommitCreation: handleCommitCreation, onCameraStateChange: (cameraState) => { + store.setViewportPanelCameraState(panelId, cameraState); + }, onToolPreviewChange: (toolPreview) => { + store.setViewportToolPreview(toolPreview); + }, onTransformSessionChange: (nextTransformSession) => { + store.setTransformSession(nextTransformSession); + }, onTransformCommit: commitTransformSession, onTransformCancel: () => cancelTransformSession(), onSelectionChange: (selection) => applySelection(selection, "viewport") }, panelId))), layoutMode !== "quad" ? null : (_jsxs(_Fragment, { children: [_jsx("div", { className: "viewport-region__splitter viewport-region__splitter--vertical", "data-testid": "viewport-quad-splitter-vertical", onPointerDown: handleViewportQuadResizeStart("vertical") }), _jsx("div", { className: "viewport-region__splitter viewport-region__splitter--horizontal", "data-testid": "viewport-quad-splitter-horizontal", onPointerDown: handleViewportQuadResizeStart("horizontal") }), _jsx("div", { className: "viewport-region__splitter viewport-region__splitter--center", "data-testid": "viewport-quad-splitter-center", onPointerDown: handleViewportQuadResizeStart("center") })] }))] }) }), _jsx("aside", { className: "side-column", children: editorState.selection.kind === "none" ? (_jsxs(Panel, { title: "World", children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Background" }), _jsx("div", { className: "value", "data-testid": "world-background-mode-value", children: formatWorldBackgroundLabel(editorState.document.world) }), _jsx("div", { className: "world-background-preview", "data-testid": "world-background-preview", style: createWorldBackgroundStyle(editorState.document.world.background, editorState.document.world.background.mode === "image" + ? loadedImageAssets[editorState.document.world.background.assetId]?.sourceUrl ?? null + : null) }), _jsx("div", { className: "material-summary", children: editorState.document.world.background.mode === "solid" + ? editorState.document.world.background.colorHex + : editorState.document.world.background.mode === "verticalGradient" + ? `${editorState.document.world.background.topColorHex} -> ${editorState.document.world.background.bottomColorHex}` + : editorState.document.assets[editorState.document.world.background.assetId]?.sourceName ?? + editorState.document.world.background.assetId }), editorState.document.world.background.mode !== "image" ? null : (_jsxs("div", { className: "material-summary", "data-testid": "world-background-asset-value", children: ["Background Asset:", " ", editorState.document.assets[editorState.document.world.background.assetId]?.sourceName ?? + editorState.document.world.background.assetId] }))] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Background Mode" }), _jsxs("div", { className: "inline-actions", children: [_jsx("button", { className: `toolbar__button ${editorState.document.world.background.mode === "solid" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "world-background-mode-solid", onClick: () => applyWorldBackgroundMode("solid"), children: "Solid" }), _jsx("button", { className: `toolbar__button ${editorState.document.world.background.mode === "verticalGradient" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "world-background-mode-gradient", onClick: () => applyWorldBackgroundMode("verticalGradient"), children: "Gradient" }), _jsx("button", { className: `toolbar__button ${editorState.document.world.background.mode === "image" ? "toolbar__button--active" : ""}`, type: "button", "data-testid": "world-background-mode-image", onClick: () => applyWorldBackgroundMode("image"), children: "Image" })] })] }), editorState.document.world.background.mode === "image" && (_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Environment Intensity" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { "data-testid": "world-background-environment-intensity", className: "text-input", type: "number", min: "0", step: "0.1", value: backgroundEnvironmentIntensityDraft, onChange: (event) => setBackgroundEnvironmentIntensityDraft(event.currentTarget.value), onBlur: applyBackgroundEnvironmentIntensity, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyBackgroundEnvironmentIntensity), onKeyUp: (event) => handleNumberInputKeyUp(event, applyBackgroundEnvironmentIntensity), onPointerUp: (event) => handleNumberInputPointerUp(event, applyBackgroundEnvironmentIntensity) })] })] })), editorState.document.world.background.mode !== "image" && (_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Background Colors" }), editorState.document.world.background.mode === "solid" ? (_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Color" }), _jsx("input", { "data-testid": "world-background-solid-color", className: "color-input", type: "color", value: editorState.document.world.background.colorHex, onChange: (event) => applyWorldBackgroundColor(event.currentTarget.value) })] })) : (_jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Top" }), _jsx("input", { "data-testid": "world-background-top-color", className: "color-input", type: "color", value: editorState.document.world.background.topColorHex, onChange: (event) => applyWorldGradientColor("top", event.currentTarget.value) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Bottom" }), _jsx("input", { "data-testid": "world-background-bottom-color", className: "color-input", type: "color", value: editorState.document.world.background.bottomColorHex, onChange: (event) => applyWorldGradientColor("bottom", event.currentTarget.value) })] })] }))] })), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Ambient Light" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Color" }), _jsx("input", { "data-testid": "world-ambient-color", className: "color-input", type: "color", value: editorState.document.world.ambientLight.colorHex, onChange: (event) => applyAmbientLightColor(event.currentTarget.value) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { "data-testid": "world-ambient-intensity", className: "text-input", type: "number", min: "0", step: "0.1", value: ambientLightIntensityDraft, onChange: (event) => setAmbientLightIntensityDraft(event.currentTarget.value), onBlur: applyAmbientLightIntensity, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAmbientLightIntensity), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAmbientLightIntensity), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAmbientLightIntensity) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Sun Light" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Color" }), _jsx("input", { "data-testid": "world-sun-color", className: "color-input", type: "color", value: editorState.document.world.sunLight.colorHex, onChange: (event) => applySunLightColor(event.currentTarget.value) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { "data-testid": "world-sun-intensity", className: "text-input", type: "number", min: "0", step: "0.1", value: sunLightIntensityDraft, onChange: (event) => setSunLightIntensityDraft(event.currentTarget.value), onBlur: applySunLightIntensity, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySunLightIntensity), onKeyUp: (event) => handleNumberInputKeyUp(event, applySunLightIntensity), onPointerUp: (event) => handleNumberInputPointerUp(event, applySunLightIntensity) })] })] }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Dir X" }), _jsx("input", { "data-testid": "world-sun-direction-x", className: "text-input", type: "number", step: "0.1", value: sunDirectionDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSunDirectionDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applySunLightDirection, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySunLightDirection), onKeyUp: (event) => handleNumberInputKeyUp(event, applySunLightDirection), onPointerUp: (event) => handleNumberInputPointerUp(event, applySunLightDirection) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Dir Y" }), _jsx("input", { "data-testid": "world-sun-direction-y", className: "text-input", type: "number", step: "0.1", value: sunDirectionDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSunDirectionDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applySunLightDirection, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySunLightDirection), onKeyUp: (event) => handleNumberInputKeyUp(event, applySunLightDirection), onPointerUp: (event) => handleNumberInputPointerUp(event, applySunLightDirection) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Dir Z" }), _jsx("input", { "data-testid": "world-sun-direction-z", className: "text-input", type: "number", step: "0.1", value: sunDirectionDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSunDirectionDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applySunLightDirection, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySunLightDirection), onKeyUp: (event) => handleNumberInputKeyUp(event, applySunLightDirection), onPointerUp: (event) => handleNumberInputPointerUp(event, applySunLightDirection) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Advanced Rendering" }), _jsxs("label", { className: "form-field form-field--toggle", children: [_jsx("span", { className: "label", children: "Advanced Rendering" }), _jsx("input", { type: "checkbox", checked: advancedRendering.enabled, onChange: (event) => applyAdvancedRenderingEnabled(event.currentTarget.checked) })] }), !advancedRendering.enabled ? null : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Shadows" }), _jsxs("label", { className: "form-field form-field--toggle", children: [_jsx("span", { className: "label", children: "Enabled" }), _jsx("input", { type: "checkbox", checked: advancedRendering.shadows.enabled, onChange: (event) => applyAdvancedRenderingShadowsEnabled(event.currentTarget.checked) })] }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Shadow Map Size" }), _jsx("select", { className: "select-input", value: advancedRendering.shadows.mapSize, onChange: (event) => applyAdvancedRenderingShadowMapSize(Number(event.currentTarget.value)), children: ADVANCED_RENDERING_SHADOW_MAP_SIZES.map((size) => (_jsx("option", { value: size, children: size }, size))) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Shadow Type" }), _jsx("select", { className: "select-input", value: advancedRendering.shadows.type, onChange: (event) => applyAdvancedRenderingShadowType(event.currentTarget.value), children: ADVANCED_RENDERING_SHADOW_TYPES.map((shadowType) => (_jsx("option", { value: shadowType, children: formatAdvancedRenderingShadowTypeLabel(shadowType) }, shadowType))) })] })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Bias" }), _jsx("input", { className: "text-input", type: "number", step: "0.0001", value: advancedRenderingShadowBiasDraft, onChange: (event) => setAdvancedRenderingShadowBiasDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingShadowBias, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingShadowBias), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingShadowBias), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingShadowBias) })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Ambient Occlusion" }), _jsxs("label", { className: "form-field form-field--toggle", children: [_jsx("span", { className: "label", children: "Enabled" }), _jsx("input", { type: "checkbox", checked: advancedRendering.ambientOcclusion.enabled, onChange: (event) => applyAdvancedRenderingAmbientOcclusionEnabled(event.currentTarget.checked) })] }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { className: "text-input", type: "number", min: "0", step: "0.1", value: advancedRenderingAmbientOcclusionIntensityDraft, onChange: (event) => setAdvancedRenderingAmbientOcclusionIntensityDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingAmbientOcclusionIntensity, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingAmbientOcclusionIntensity), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingAmbientOcclusionIntensity), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingAmbientOcclusionIntensity) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Radius" }), _jsx("input", { className: "text-input", type: "number", min: "0", step: "0.1", value: advancedRenderingAmbientOcclusionRadiusDraft, onChange: (event) => setAdvancedRenderingAmbientOcclusionRadiusDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingAmbientOcclusionRadius, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingAmbientOcclusionRadius), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingAmbientOcclusionRadius), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingAmbientOcclusionRadius) })] })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Samples" }), _jsx("input", { className: "text-input", type: "number", min: "1", step: "1", value: advancedRenderingAmbientOcclusionSamplesDraft, onChange: (event) => setAdvancedRenderingAmbientOcclusionSamplesDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingAmbientOcclusionSamples, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingAmbientOcclusionSamples), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingAmbientOcclusionSamples), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingAmbientOcclusionSamples) })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Bloom" }), _jsxs("label", { className: "form-field form-field--toggle", children: [_jsx("span", { className: "label", children: "Enabled" }), _jsx("input", { type: "checkbox", checked: advancedRendering.bloom.enabled, onChange: (event) => applyAdvancedRenderingBloomEnabled(event.currentTarget.checked) })] }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { className: "text-input", type: "number", min: "0", step: "0.1", value: advancedRenderingBloomIntensityDraft, onChange: (event) => setAdvancedRenderingBloomIntensityDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingBloomIntensity, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingBloomIntensity), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingBloomIntensity), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingBloomIntensity) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Threshold" }), _jsx("input", { className: "text-input", type: "number", min: "0", step: "0.05", value: advancedRenderingBloomThresholdDraft, onChange: (event) => setAdvancedRenderingBloomThresholdDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingBloomThreshold, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingBloomThreshold), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingBloomThreshold), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingBloomThreshold) })] })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Radius" }), _jsx("input", { className: "text-input", type: "number", min: "0", step: "0.05", value: advancedRenderingBloomRadiusDraft, onChange: (event) => setAdvancedRenderingBloomRadiusDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingBloomRadius, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingBloomRadius), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingBloomRadius), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingBloomRadius) })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Tone Mapping" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Mode" }), _jsx("select", { className: "select-input", value: advancedRendering.toneMapping.mode, onChange: (event) => applyAdvancedRenderingToneMappingMode(event.currentTarget.value), children: ADVANCED_RENDERING_TONE_MAPPING_MODES.map((mode) => (_jsx("option", { value: mode, children: formatAdvancedRenderingToneMappingLabel(mode) }, mode))) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Exposure" }), _jsx("input", { className: "text-input", type: "number", min: "0.001", step: "0.1", value: advancedRenderingToneMappingExposureDraft, onChange: (event) => setAdvancedRenderingToneMappingExposureDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingToneMappingExposure, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingToneMappingExposure), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingToneMappingExposure), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingToneMappingExposure) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Depth of Field" }), _jsxs("label", { className: "form-field form-field--toggle", children: [_jsx("span", { className: "label", children: "Enabled" }), _jsx("input", { type: "checkbox", checked: advancedRendering.depthOfField.enabled, onChange: (event) => applyAdvancedRenderingDepthOfFieldEnabled(event.currentTarget.checked) })] }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Focus Distance" }), _jsx("input", { className: "text-input", type: "number", min: "0", step: "0.1", value: advancedRenderingDepthOfFieldFocusDistanceDraft, onChange: (event) => setAdvancedRenderingDepthOfFieldFocusDistanceDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingDepthOfFieldFocusDistance, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingDepthOfFieldFocusDistance), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingDepthOfFieldFocusDistance), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingDepthOfFieldFocusDistance) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Focal Length" }), _jsx("input", { className: "text-input", type: "number", min: "0.001", step: "0.001", value: advancedRenderingDepthOfFieldFocalLengthDraft, onChange: (event) => setAdvancedRenderingDepthOfFieldFocalLengthDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingDepthOfFieldFocalLength, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingDepthOfFieldFocalLength), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingDepthOfFieldFocalLength), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingDepthOfFieldFocalLength) })] })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Bokeh Scale" }), _jsx("input", { className: "text-input", type: "number", min: "0.001", step: "0.1", value: advancedRenderingDepthOfFieldBokehScaleDraft, onChange: (event) => setAdvancedRenderingDepthOfFieldBokehScaleDraft(event.currentTarget.value), onBlur: applyAdvancedRenderingDepthOfFieldBokehScale, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyAdvancedRenderingDepthOfFieldBokehScale), onKeyUp: (event) => handleNumberInputKeyUp(event, applyAdvancedRenderingDepthOfFieldBokehScale), onPointerUp: (event) => handleNumberInputPointerUp(event, applyAdvancedRenderingDepthOfFieldBokehScale) })] })] })] }))] })] })) : (_jsxs(Panel, { title: "Inspector", children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Selection" }), _jsx("div", { className: "value", children: describeSelection(editorState.selection, brushList, editorState.document.modelInstances, editorState.document.assets, editorState.document.entities) })] }), selectedModelInstance !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Model Asset" }), _jsx("div", { className: "value", children: selectedModelAsset?.sourceName ?? "Missing Asset" }), _jsx("div", { className: "material-summary", children: selectedModelAssetRecord === null + ? "This model instance references an asset that is missing from the registry." + : formatModelAssetSummary(selectedModelAssetRecord) }), selectedModelAssetRecord === null ? null : (_jsx("div", { className: "material-summary", children: formatModelBoundingBoxLabel(selectedModelAssetRecord) }))] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Position" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "model-instance-position-x", className: "text-input", type: "number", step: DEFAULT_GRID_SIZE, value: modelPositionDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelPositionDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "model-instance-position-y", className: "text-input", type: "number", step: DEFAULT_GRID_SIZE, value: modelPositionDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelPositionDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "model-instance-position-z", className: "text-input", type: "number", step: DEFAULT_GRID_SIZE, value: modelPositionDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelPositionDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Rotation" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "model-instance-rotation-x", className: "text-input", type: "number", step: "1", value: modelRotationDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelRotationDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "model-instance-rotation-y", className: "text-input", type: "number", step: "1", value: modelRotationDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelRotationDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "model-instance-rotation-z", className: "text-input", type: "number", step: "1", value: modelRotationDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelRotationDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Scale" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "model-instance-scale-x", className: "text-input", type: "number", min: "0.001", step: "0.1", value: modelScaleDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelScaleDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "model-instance-scale-y", className: "text-input", type: "number", min: "0.001", step: "0.1", value: modelScaleDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelScaleDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "model-instance-scale-z", className: "text-input", type: "number", min: "0.001", step: "0.1", value: modelScaleDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setModelScaleDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applyModelInstanceChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyModelInstanceChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyModelInstanceChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyModelInstanceChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Collision" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Mode" }), _jsx("select", { "data-testid": "model-instance-collision-mode", className: "select-input", value: selectedModelInstance.collision.mode, onChange: (event) => { + store.executeCommand(createUpsertModelInstanceCommand({ + modelInstance: { + ...selectedModelInstance, + collision: { + ...selectedModelInstance.collision, + mode: event.target.value + } + }, + label: "Set model collision mode" + })); + }, children: MODEL_INSTANCE_COLLISION_MODES.map((mode) => (_jsx("option", { value: mode, children: mode }, mode))) })] }), _jsxs("label", { className: "form-field", children: [_jsx("input", { "data-testid": "model-instance-collision-visible", type: "checkbox", checked: selectedModelInstance.collision.visible, onChange: (event) => { + store.executeCommand(createUpsertModelInstanceCommand({ + modelInstance: { + ...selectedModelInstance, + collision: { + ...selectedModelInstance.collision, + visible: event.target.checked + } + }, + label: event.target.checked ? "Show model collision debug" : "Hide model collision debug" + })); + } }), _jsx("span", { className: "label", children: "Show generated collision debug" })] }), _jsx("div", { className: "material-summary", children: getModelInstanceCollisionModeDescription(selectedModelInstance.collision.mode) })] }), selectedModelAssetRecord !== null && selectedModelAssetRecord.metadata.animationNames.length > 0 && (_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Animation" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Clip" }), _jsxs("select", { className: "select-input", value: selectedModelInstance.animationClipName ?? "", onChange: (e) => { + const clipName = e.target.value || undefined; + store.executeCommand(createUpsertModelInstanceCommand({ + modelInstance: { ...selectedModelInstance, animationClipName: clipName }, + label: "Set animation clip" + })); + }, children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), selectedModelAssetRecord.metadata.animationNames.map((name) => (_jsx("option", { value: name, children: name }, name)))] })] }), _jsxs("label", { className: "form-field", children: [_jsx("input", { type: "checkbox", checked: selectedModelInstance.animationAutoplay ?? false, onChange: (e) => { + store.executeCommand(createUpsertModelInstanceCommand({ + modelInstance: { ...selectedModelInstance, animationAutoplay: e.target.checked }, + label: "Set animation autoplay" + })); + } }), _jsx("span", { className: "label", children: "Autoplay on scene load" })] })] })), _jsx("div", { className: "inline-actions", children: _jsx("button", { className: "toolbar__button", type: "button", "data-testid": "apply-model-instance", onClick: applyModelInstanceChange, children: "Apply Transform" }) })] })) : selectedEntity !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Entity Kind" }), _jsx("div", { className: "value", children: getEntityKindLabel(selectedEntity.kind) })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Position" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": selectedEntity.kind === "playerStart" ? "player-start-position-x" : `${selectedEntity.kind}-position-x`, className: "text-input", type: "number", step: DEFAULT_GRID_SIZE, value: entityPositionDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setEntityPositionDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applySelectedEntityDraftChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySelectedEntityDraftChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySelectedEntityDraftChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySelectedEntityDraftChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": selectedEntity.kind === "playerStart" ? "player-start-position-y" : `${selectedEntity.kind}-position-y`, className: "text-input", type: "number", step: DEFAULT_GRID_SIZE, value: entityPositionDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setEntityPositionDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applySelectedEntityDraftChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySelectedEntityDraftChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySelectedEntityDraftChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySelectedEntityDraftChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": selectedEntity.kind === "playerStart" ? "player-start-position-z" : `${selectedEntity.kind}-position-z`, className: "text-input", type: "number", step: DEFAULT_GRID_SIZE, value: entityPositionDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setEntityPositionDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applySelectedEntityDraftChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySelectedEntityDraftChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySelectedEntityDraftChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySelectedEntityDraftChange) })] })] })] }), selectedPointLight !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Light" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Color" }), _jsx("input", { "data-testid": "point-light-color", className: "color-input", type: "color", value: pointLightColorDraft, onChange: (event) => { + const nextColorHex = event.currentTarget.value; + setPointLightColorDraft(nextColorHex); + scheduleDraftCommit(() => applyPointLightChange({ colorHex: nextColorHex })); + } })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { "data-testid": "point-light-intensity", className: "text-input", type: "number", min: "0", step: "0.1", value: pointLightIntensityDraft, onChange: (event) => setPointLightIntensityDraft(event.currentTarget.value), onBlur: () => applyPointLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPointLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPointLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPointLightChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Range" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Distance" }), _jsx("input", { "data-testid": "point-light-distance", className: "text-input", type: "number", min: "0.1", step: "0.1", value: pointLightDistanceDraft, onChange: (event) => setPointLightDistanceDraft(event.currentTarget.value), onBlur: () => applyPointLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPointLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPointLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPointLightChange) })] })] })] })) : null, selectedSpotLight !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Light" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Color" }), _jsx("input", { "data-testid": "spot-light-color", className: "color-input", type: "color", value: spotLightColorDraft, onChange: (event) => { + const nextColorHex = event.currentTarget.value; + setSpotLightColorDraft(nextColorHex); + scheduleDraftCommit(() => applySpotLightChange({ colorHex: nextColorHex })); + } })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Intensity" }), _jsx("input", { "data-testid": "spot-light-intensity", className: "text-input", type: "number", min: "0", step: "0.1", value: spotLightIntensityDraft, onChange: (event) => setSpotLightIntensityDraft(event.currentTarget.value), onBlur: () => applySpotLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySpotLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySpotLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySpotLightChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Range" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Distance" }), _jsx("input", { "data-testid": "spot-light-distance", className: "text-input", type: "number", min: "0.1", step: "0.1", value: spotLightDistanceDraft, onChange: (event) => setSpotLightDistanceDraft(event.currentTarget.value), onBlur: () => applySpotLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySpotLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySpotLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySpotLightChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Angle" }), _jsx("input", { "data-testid": "spot-light-angle", className: "text-input", type: "number", min: "1", max: "179", step: "1", value: spotLightAngleDraft, onChange: (event) => setSpotLightAngleDraft(event.currentTarget.value), onBlur: () => applySpotLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySpotLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySpotLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySpotLightChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Direction" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "spot-light-direction-x", className: "text-input", type: "number", step: "0.1", value: spotLightDirectionDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSpotLightDirectionDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: () => applySpotLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySpotLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySpotLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySpotLightChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "spot-light-direction-y", className: "text-input", type: "number", step: "0.1", value: spotLightDirectionDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSpotLightDirectionDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: () => applySpotLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySpotLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySpotLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySpotLightChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "spot-light-direction-z", className: "text-input", type: "number", step: "0.1", value: spotLightDirectionDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSpotLightDirectionDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: () => applySpotLightChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySpotLightChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySpotLightChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySpotLightChange) })] })] })] })] })) : null, selectedPlayerStart !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Yaw" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Degrees" }), _jsx("input", { "data-testid": "player-start-yaw", className: "text-input", type: "number", step: "1", value: playerStartYawDraft, onChange: (event) => setPlayerStartYawDraft(event.currentTarget.value), onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Player Collider" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Mode" }), _jsx("select", { "data-testid": "player-start-collider-mode", className: "select-input", value: playerStartColliderModeDraft, onChange: (event) => { + const nextMode = event.currentTarget.value; + setPlayerStartColliderModeDraft(nextMode); + scheduleDraftCommit(() => applyPlayerStartChange({ colliderMode: nextMode })); + }, children: PLAYER_START_COLLIDER_MODES.map((mode) => (_jsx("option", { value: mode, children: mode }, mode))) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Eye Height" }), _jsx("input", { "data-testid": "player-start-eye-height", className: "text-input", type: "number", min: "0.01", step: "0.1", value: playerStartEyeHeightDraft, onChange: (event) => setPlayerStartEyeHeightDraft(event.currentTarget.value), onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] }), playerStartColliderModeDraft === "capsule" ? (_jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Radius" }), _jsx("input", { "data-testid": "player-start-capsule-radius", className: "text-input", type: "number", min: "0.01", step: "0.1", value: playerStartCapsuleRadiusDraft, onChange: (event) => setPlayerStartCapsuleRadiusDraft(event.currentTarget.value), onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Height" }), _jsx("input", { "data-testid": "player-start-capsule-height", className: "text-input", type: "number", min: "0.01", step: "0.1", value: playerStartCapsuleHeightDraft, onChange: (event) => setPlayerStartCapsuleHeightDraft(event.currentTarget.value), onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] })] })) : null, playerStartColliderModeDraft === "box" ? (_jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Size X" }), _jsx("input", { "data-testid": "player-start-box-size-x", className: "text-input", type: "number", min: "0.01", step: "0.1", value: playerStartBoxSizeDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setPlayerStartBoxSizeDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Size Y" }), _jsx("input", { "data-testid": "player-start-box-size-y", className: "text-input", type: "number", min: "0.01", step: "0.1", value: playerStartBoxSizeDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setPlayerStartBoxSizeDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Size Z" }), _jsx("input", { "data-testid": "player-start-box-size-z", className: "text-input", type: "number", min: "0.01", step: "0.1", value: playerStartBoxSizeDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setPlayerStartBoxSizeDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: () => applyPlayerStartChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPlayerStartChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPlayerStartChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPlayerStartChange) })] })] })) : null, _jsx("div", { className: "material-summary", children: getPlayerStartColliderModeDescription(playerStartColliderModeDraft) })] })] })) : null, selectedSoundEmitter !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Audio Asset" }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "value", children: selectedSoundEmitter.audioAssetId === null + ? "Unassigned" + : selectedSoundEmitterAudioAssetRecord?.sourceName ?? "Missing Audio Asset" }), _jsx("div", { className: "material-summary", children: selectedSoundEmitter.audioAssetId === null + ? "Choose an audio asset to make this emitter playable." + : selectedSoundEmitterAudioAssetRecord === null + ? `This sound emitter references ${selectedSoundEmitter.audioAssetId}, but the asset is missing or not audio.` + : formatAudioAssetSummary(selectedSoundEmitterAudioAssetRecord) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Audio" }), _jsxs("select", { "data-testid": "sound-emitter-audio-asset", className: "text-input", value: soundEmitterAudioAssetIdDraft, onChange: (event) => { + const nextAudioAssetId = event.currentTarget.value.trim(); + setSoundEmitterAudioAssetIdDraft(nextAudioAssetId); + scheduleDraftCommit(() => applySoundEmitterChange({ + audioAssetId: nextAudioAssetId.length === 0 ? null : nextAudioAssetId + })); + }, children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), audioAssetList.map((asset) => (_jsx("option", { value: asset.id, children: asset.sourceName }, asset.id)))] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Volume" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Amount" }), _jsx("input", { "data-testid": "sound-emitter-volume", className: "text-input", type: "number", min: "0", step: "0.1", value: soundEmitterVolumeDraft, onChange: (event) => setSoundEmitterVolumeDraft(event.currentTarget.value), onBlur: () => applySoundEmitterChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySoundEmitterChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySoundEmitterChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySoundEmitterChange) })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Distance" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Ref Distance" }), _jsx("input", { "data-testid": "sound-emitter-ref-distance", className: "text-input", type: "number", min: "0.1", step: "0.1", value: soundEmitterRefDistanceDraft, onChange: (event) => setSoundEmitterRefDistanceDraft(event.currentTarget.value), onBlur: () => applySoundEmitterChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySoundEmitterChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySoundEmitterChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySoundEmitterChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Max Distance" }), _jsx("input", { "data-testid": "sound-emitter-max-distance", className: "text-input", type: "number", min: "0.1", step: "0.1", value: soundEmitterMaxDistanceDraft, onChange: (event) => setSoundEmitterMaxDistanceDraft(event.currentTarget.value), onBlur: () => applySoundEmitterChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applySoundEmitterChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySoundEmitterChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySoundEmitterChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Playback" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Autoplay" }), _jsx("input", { "data-testid": "sound-emitter-autoplay", type: "checkbox", checked: soundEmitterAutoplayDraft, onChange: (event) => { + const nextAutoplay = event.currentTarget.checked; + setSoundEmitterAutoplayDraft(nextAutoplay); + scheduleDraftCommit(() => applySoundEmitterChange({ autoplay: nextAutoplay })); + } })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Loop" }), _jsx("input", { "data-testid": "sound-emitter-loop", type: "checkbox", checked: soundEmitterLoopDraft, onChange: (event) => { + const nextLoop = event.currentTarget.checked; + setSoundEmitterLoopDraft(nextLoop); + scheduleDraftCommit(() => applySoundEmitterChange({ loop: nextLoop })); + } })] })] })] })] })) : null, selectedTriggerVolume !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Size" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "trigger-volume-size-x", className: "text-input", type: "number", min: DEFAULT_GRID_SIZE, step: DEFAULT_GRID_SIZE, value: triggerVolumeSizeDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setTriggerVolumeSizeDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applyTriggerVolumeChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyTriggerVolumeChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyTriggerVolumeChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyTriggerVolumeChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "trigger-volume-size-y", className: "text-input", type: "number", min: DEFAULT_GRID_SIZE, step: DEFAULT_GRID_SIZE, value: triggerVolumeSizeDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setTriggerVolumeSizeDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applyTriggerVolumeChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyTriggerVolumeChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyTriggerVolumeChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyTriggerVolumeChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "trigger-volume-size-z", className: "text-input", type: "number", min: DEFAULT_GRID_SIZE, step: DEFAULT_GRID_SIZE, value: triggerVolumeSizeDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setTriggerVolumeSizeDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applyTriggerVolumeChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyTriggerVolumeChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyTriggerVolumeChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyTriggerVolumeChange) })] })] })] }), renderInteractionLinksSection(selectedTriggerVolume, selectedTriggerVolumeLinks, "add-trigger-teleport-link", "add-trigger-visibility-link", "add-trigger-play-sound-link", "add-trigger-stop-sound-link")] })) : null, selectedTeleportTarget !== null ? (_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Yaw" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Degrees" }), _jsx("input", { "data-testid": "teleport-target-yaw", className: "text-input", type: "number", step: "1", value: teleportTargetYawDraft, onChange: (event) => setTeleportTargetYawDraft(event.currentTarget.value), onBlur: applyTeleportTargetChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyTeleportTargetChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyTeleportTargetChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyTeleportTargetChange) })] })] })) : null, selectedInteractable !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Interaction" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Range" }), _jsx("input", { "data-testid": "interactable-radius", className: "text-input", type: "number", min: "0.1", step: "0.1", value: interactableRadiusDraft, onChange: (event) => setInteractableRadiusDraft(event.currentTarget.value), onBlur: () => applyInteractableChange(), onKeyDown: (event) => handleDraftVectorKeyDown(event, applyInteractableChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyInteractableChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyInteractableChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Enabled" }), _jsx("input", { "data-testid": "interactable-enabled", type: "checkbox", checked: interactableEnabledDraft, onChange: (event) => { + const nextEnabled = event.currentTarget.checked; + setInteractableEnabledDraft(nextEnabled); + scheduleDraftCommit(() => applyInteractableChange({ enabled: nextEnabled })); + } })] })] }), _jsx("div", { className: "material-summary", children: "Range defines how close the player must be before the click prompt can activate." })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Prompt" }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Text" }), _jsx("input", { "data-testid": "interactable-prompt", className: "text-input", type: "text", value: interactablePromptDraft, onChange: (event) => setInteractablePromptDraft(event.currentTarget.value), onBlur: () => applyInteractableChange(), onKeyDown: (event) => { + if (event.key === "Enter") { + applyInteractableChange(); + } + } })] })] }), renderInteractionLinksSection(selectedInteractable, selectedInteractableLinks, "add-interactable-teleport-link", "add-interactable-visibility-link", "add-interactable-play-sound-link", "add-interactable-stop-sound-link")] })) : null] })) : selectedBrush === null ? (_jsx("div", { className: "outliner-empty", children: "Select a whitebox solid or entity to edit authored properties." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Whitebox Solid Type" }), _jsx("div", { className: "value", children: "box" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Selection Mode" }), _jsx("div", { className: "value", children: getWhiteboxSelectionModeLabel(whiteboxSelectionMode) })] }), whiteboxSelectionMode !== "object" ? (_jsx("div", { className: "outliner-empty", children: whiteboxSelectionMode === "face" + ? "Face mode keeps whole-solid transforms out of the way. Select a face to edit its material or UV transform." + : whiteboxSelectionMode === "edge" + ? "Edge mode is selection-only in this slice. Edge transforms land next." + : "Vertex mode is selection-only in this slice. Vertex transforms land next." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Center" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "brush-center-x", className: "text-input", type: "number", step: whiteboxVectorInputStep, value: positionDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setPositionDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applyPositionChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPositionChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPositionChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPositionChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "brush-center-y", className: "text-input", type: "number", step: whiteboxVectorInputStep, value: positionDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setPositionDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applyPositionChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPositionChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPositionChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPositionChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "brush-center-z", className: "text-input", type: "number", step: whiteboxVectorInputStep, value: positionDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setPositionDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applyPositionChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyPositionChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyPositionChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyPositionChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Rotation" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "brush-rotation-x", className: "text-input", type: "number", step: "0.1", value: rotationDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setRotationDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applyRotationChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyRotationChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyRotationChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyRotationChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "brush-rotation-y", className: "text-input", type: "number", step: "0.1", value: rotationDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setRotationDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applyRotationChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyRotationChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyRotationChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyRotationChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "brush-rotation-z", className: "text-input", type: "number", step: "0.1", value: rotationDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setRotationDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applyRotationChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applyRotationChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applyRotationChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applyRotationChange) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Size" }), _jsxs("div", { className: "vector-inputs", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "X" }), _jsx("input", { "data-testid": "brush-size-x", className: "text-input", type: "number", min: "0.01", step: whiteboxVectorInputStep, value: sizeDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSizeDraft((draft) => ({ ...draft, x: nextValue })); + }, onBlur: applySizeChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySizeChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySizeChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySizeChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Y" }), _jsx("input", { "data-testid": "brush-size-y", className: "text-input", type: "number", min: "0.01", step: whiteboxVectorInputStep, value: sizeDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSizeDraft((draft) => ({ ...draft, y: nextValue })); + }, onBlur: applySizeChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySizeChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySizeChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySizeChange) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "Z" }), _jsx("input", { "data-testid": "brush-size-z", className: "text-input", type: "number", min: "0.01", step: whiteboxVectorInputStep, value: sizeDraft.z, onChange: (event) => { + const nextValue = event.currentTarget.value; + setSizeDraft((draft) => ({ ...draft, z: nextValue })); + }, onBlur: applySizeChange, onKeyDown: (event) => handleDraftVectorKeyDown(event, applySizeChange), onKeyUp: (event) => handleNumberInputKeyUp(event, applySizeChange), onPointerUp: (event) => handleNumberInputPointerUp(event, applySizeChange) })] })] })] })] })), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Faces" }), _jsx("div", { className: "face-grid", children: BOX_FACE_IDS.map((faceId) => (_jsxs("button", { type: "button", "data-testid": `face-button-${faceId}`, className: `face-chip ${isBrushFaceSelected(editorState.selection, selectedBrush.id, faceId) ? "face-chip--active" : ""}`, onClick: () => { + store.setWhiteboxSelectionMode("face"); + applySelection({ + kind: "brushFace", + brushId: selectedBrush.id, + faceId + }, "inspector"); + }, children: [_jsx("span", { className: "face-chip__title", children: BOX_FACE_LABELS[faceId] }), _jsx("span", { className: "face-chip__meta", children: faceId })] }, faceId))) })] }), whiteboxSelectionMode === "edge" ? (selectedEdgeId === null ? (_jsx("div", { className: "outliner-empty", children: "Select an edge in the viewport to inspect it. Edge transforms land in the next slice." })) : (_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Active Edge" }), _jsx("div", { className: "value", children: BOX_EDGE_LABELS[selectedEdgeId] }), _jsx("div", { className: "material-summary", children: "Edge selection is visible in the viewport. Persistent edge transforms are still deferred." })] }))) : whiteboxSelectionMode === "vertex" ? (selectedVertexId === null ? (_jsx("div", { className: "outliner-empty", children: "Select a vertex in the viewport to inspect it. Vertex transforms land in the next slice." })) : (_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Active Vertex" }), _jsx("div", { className: "value", children: BOX_VERTEX_LABELS[selectedVertexId] }), _jsx("div", { className: "material-summary", children: "Vertex selection is visible in the viewport. Persistent vertex transforms are still deferred." })] }))) : whiteboxSelectionMode !== "face" ? (_jsx("div", { className: "outliner-empty", children: "Switch to Face mode or choose a face chip to edit materials and UVs." })) : selectedFace === null || selectedFaceId === null ? (_jsx("div", { className: "outliner-empty", children: "Select a face to edit its material and UV transform." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Active Face" }), _jsx("div", { className: "value", children: BOX_FACE_LABELS[selectedFaceId] }), _jsxs("div", { className: "material-summary", "data-testid": "selected-face-material-name", children: ["Material: ", selectedFaceMaterial?.name ?? "Fallback face color"] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "Material" }), _jsx("div", { className: "material-browser", children: materialList.map((material) => (_jsxs("button", { type: "button", "data-testid": `material-button-${material.id}`, className: `material-item ${selectedFace.materialId === material.id ? "material-item--active" : ""}`, onClick: () => applyFaceMaterial(material.id), children: [_jsx("span", { className: "material-item__preview", style: getMaterialPreviewStyle(material), "aria-hidden": "true" }), _jsxs("span", { className: "material-item__text", children: [_jsx("span", { className: "material-item__title", children: material.name }), _jsx("span", { className: "material-item__meta", children: material.tags.join(" | ") })] })] }, material.id))) }), _jsx("div", { className: "inline-actions", children: _jsx("button", { className: "toolbar__button", type: "button", onClick: clearFaceMaterial, children: "Clear Material" }) })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "UV Offset" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "U" }), _jsx("input", { "data-testid": "face-uv-offset-x", className: "text-input", type: "number", step: "0.125", value: uvOffsetDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setUvOffsetDraft((draft) => ({ ...draft, x: nextValue })); + }, onKeyDown: (event) => handleDraftVectorKeyDown(event, handleApplyUvDraft) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "V" }), _jsx("input", { "data-testid": "face-uv-offset-y", className: "text-input", type: "number", step: "0.125", value: uvOffsetDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setUvOffsetDraft((draft) => ({ ...draft, y: nextValue })); + }, onKeyDown: (event) => handleDraftVectorKeyDown(event, handleApplyUvDraft) })] })] })] }), _jsxs("div", { className: "form-section", children: [_jsx("div", { className: "label", children: "UV Scale" }), _jsxs("div", { className: "vector-inputs vector-inputs--two", children: [_jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "U" }), _jsx("input", { "data-testid": "face-uv-scale-x", className: "text-input", type: "number", min: "0.001", step: "0.125", value: uvScaleDraft.x, onChange: (event) => { + const nextValue = event.currentTarget.value; + setUvScaleDraft((draft) => ({ ...draft, x: nextValue })); + }, onKeyDown: (event) => handleDraftVectorKeyDown(event, handleApplyUvDraft) })] }), _jsxs("label", { className: "form-field", children: [_jsx("span", { className: "label", children: "V" }), _jsx("input", { "data-testid": "face-uv-scale-y", className: "text-input", type: "number", min: "0.001", step: "0.125", value: uvScaleDraft.y, onChange: (event) => { + const nextValue = event.currentTarget.value; + setUvScaleDraft((draft) => ({ ...draft, y: nextValue })); + }, onKeyDown: (event) => handleDraftVectorKeyDown(event, handleApplyUvDraft) })] })] })] }), _jsxs("div", { className: "inline-actions", children: [_jsx("button", { className: "toolbar__button", type: "button", "data-testid": "apply-face-uv", onClick: handleApplyUvDraft, children: "Apply UV Offset/Scale" }), _jsx("button", { className: "toolbar__button", type: "button", onClick: handleRotateUv, children: "Rotate 90" }), _jsx("button", { className: "toolbar__button", type: "button", onClick: () => handleFlipUv("u"), children: "Flip U" }), _jsx("button", { className: "toolbar__button", type: "button", onClick: () => handleFlipUv("v"), children: "Flip V" }), _jsx("button", { className: "toolbar__button", type: "button", onClick: handleFitUvToFace, children: "Fit To Face" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "UV Flags" }), _jsxs("div", { className: "value", children: ["Rotation ", selectedFace.uv.rotationQuarterTurns * 90, "\u00B0"] }), _jsxs("div", { className: "material-summary", children: ["U ", selectedFace.uv.flipU ? "flipped" : "normal", " \u00B7 V ", selectedFace.uv.flipV ? "flipped" : "normal"] })] })] }))] }))] })) })] }), addMenuPosition === null ? null : (_jsx(HierarchicalMenu, { title: "Add", position: addMenuPosition, items: addMenuItems, onClose: closeAddMenu })), _jsxs("footer", { className: "status-bar", children: [_jsxs("div", { className: "status-bar__item", "data-testid": "status-message", children: [_jsx("span", { className: "status-bar__strong", children: "Status:" }), " ", statusMessage] }), _jsxs("div", { className: "status-bar__item", "data-testid": "status-whitebox-selection-mode", children: [_jsx("span", { className: "status-bar__strong", children: "Whitebox:" }), " ", getWhiteboxSelectionModeLabel(whiteboxSelectionMode)] }), _jsxs("div", { className: "status-bar__item", "data-testid": "status-document", children: [_jsx("span", { className: "status-bar__strong", children: "Document:" }), " ", documentStatusLabel] }), _jsxs("div", { className: "status-bar__item", "data-testid": "status-run-preflight", children: [_jsx("span", { className: "status-bar__strong", children: "Run:" }), " ", runReadyLabel] }), _jsxs("div", { className: "status-bar__item", "data-testid": "status-warnings", children: [_jsx("span", { className: "status-bar__strong", children: "Warnings:" }), " ", warningDiagnostics.length] }), hoveredAssetStatusMessage === null ? null : (_jsxs("div", { className: "status-bar__item status-bar__item--asset", "data-testid": "status-asset-hover", children: [_jsx("span", { className: "status-bar__strong", children: "Asset:" }), " ", hoveredAssetStatusMessage] })), _jsxs("div", { className: "status-bar__item", "data-testid": "status-last-command", children: [_jsx("span", { className: "status-bar__strong", children: "Last:" }), " ", lastCommandLabel] })] }), _jsx("input", { ref: importInputRef, className: "visually-hidden", type: "file", accept: ".json,application/json", onChange: handleImportJsonChange }), _jsx("input", { ref: importModelInputRef, className: "visually-hidden", type: "file", multiple: true, accept: ".glb,.gltf,model/gltf-binary,model/gltf+json,application/octet-stream", onChange: handleImportModelChange }), _jsx("input", { ref: importBackgroundImageInputRef, className: "visually-hidden", type: "file", accept: ".avif,.exr,.gif,.hdr,.jpg,.jpeg,.png,.svg,.webp,image/*", onChange: handleImportBackgroundImageChange }), _jsx("input", { ref: importAudioInputRef, className: "visually-hidden", type: "file", accept: ".aac,.flac,.m4a,.mp3,.oga,.ogg,.wav,.webm,audio/*", onChange: handleImportAudioChange })] })); +} diff --git a/src/app/editor-store.js b/src/app/editor-store.js new file mode 100644 index 00000000..100229d9 --- /dev/null +++ b/src/app/editor-store.js @@ -0,0 +1,391 @@ +import { CommandHistory } from "../commands/command-history"; +import { areEditorSelectionsEqual, normalizeSelectionForWhiteboxSelectionMode } from "../core/selection"; +import {} from "../core/whitebox-selection-mode"; +import { areTransformSessionsEqual, cloneTransformSession, createInactiveTransformSession } from "../core/transform-session"; +import { createEmptySceneDocument } from "../document/scene-document"; +import { DEFAULT_SCENE_DRAFT_STORAGE_KEY, loadSceneDocumentDraft, saveSceneDocumentDraft } from "../serialization/local-draft-storage"; +import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json"; +import { areViewportToolPreviewsEqual, cloneViewportToolPreview, createDefaultViewportTransientState, isViewportToolPreviewCompatible } from "../viewport-three/viewport-transient-state"; +import { areViewportPanelCameraStatesEqual, cloneViewportLayoutState, cloneViewportPanelCameraState, createDefaultViewportLayoutState } from "../viewport-three/viewport-layout"; +export class EditorStore { + document; + selection = { kind: "none" }; + whiteboxSelectionMode = "object"; + toolMode = "select"; + viewportLayoutMode; + activeViewportPanelId; + viewportPanels; + viewportQuadSplit; + viewportTransientState = createDefaultViewportTransientState(); + previousEditingToolMode = "select"; + history = new CommandHistory(); + listeners = new Set(); + storage; + storageKey; + lastCommandLabel = null; + snapshot; + commandContext = { + getDocument: () => this.document, + setDocument: (document) => { + this.document = document; + }, + getSelection: () => this.selection, + setSelection: (selection) => { + this.selection = selection; + }, + getToolMode: () => this.toolMode, + setToolMode: (toolMode) => { + this.toolMode = toolMode; + } + }; + constructor(options = {}) { + const initialViewportLayoutState = cloneViewportLayoutState(options.initialViewportLayoutState ?? createDefaultViewportLayoutState()); + this.document = options.initialDocument ?? createEmptySceneDocument(); + this.viewportLayoutMode = initialViewportLayoutState.layoutMode; + this.activeViewportPanelId = initialViewportLayoutState.activePanelId; + this.viewportPanels = initialViewportLayoutState.panels; + this.viewportQuadSplit = initialViewportLayoutState.viewportQuadSplit; + this.storage = options.storage ?? null; + this.storageKey = options.storageKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY; + this.snapshot = this.createSnapshot(); + } + subscribe = (listener) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + getState = () => this.snapshot; + setToolMode(toolMode) { + if (this.toolMode === toolMode) { + return; + } + if (toolMode !== "play") { + this.previousEditingToolMode = toolMode; + } + this.toolMode = toolMode; + if (!isViewportToolPreviewCompatible(toolMode, this.viewportTransientState.toolPreview)) { + this.viewportTransientState = { + ...this.viewportTransientState, + toolPreview: createDefaultViewportTransientState().toolPreview + }; + } + if (toolMode !== "select" && this.viewportTransientState.transformSession.kind !== "none") { + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + } + this.emit(); + } + setViewportLayoutMode(viewportLayoutMode) { + if (this.viewportLayoutMode === viewportLayoutMode) { + return; + } + this.viewportLayoutMode = viewportLayoutMode; + this.emit(); + } + setActiveViewportPanel(panelId) { + if (this.activeViewportPanelId === panelId) { + return; + } + this.activeViewportPanelId = panelId; + this.emit(); + } + setViewportPanelViewMode(panelId, viewMode) { + if (this.viewportPanels[panelId].viewMode === viewMode) { + return; + } + this.viewportPanels = { + ...this.viewportPanels, + [panelId]: { + ...this.viewportPanels[panelId], + viewMode + } + }; + this.emit(); + } + setViewportPanelDisplayMode(panelId, displayMode) { + if (this.viewportPanels[panelId].displayMode === displayMode) { + return; + } + this.viewportPanels = { + ...this.viewportPanels, + [panelId]: { + ...this.viewportPanels[panelId], + displayMode + } + }; + this.emit(); + } + setViewportPanelCameraState(panelId, cameraState) { + if (areViewportPanelCameraStatesEqual(this.viewportPanels[panelId].cameraState, cameraState)) { + return; + } + this.viewportPanels = { + ...this.viewportPanels, + [panelId]: { + ...this.viewportPanels[panelId], + cameraState: cloneViewportPanelCameraState(cameraState) + } + }; + this.emit(); + } + setViewportQuadSplit(viewportQuadSplit) { + if (this.viewportQuadSplit.x === viewportQuadSplit.x && this.viewportQuadSplit.y === viewportQuadSplit.y) { + return; + } + this.viewportQuadSplit = { + x: viewportQuadSplit.x, + y: viewportQuadSplit.y + }; + this.emit(); + } + setViewportToolPreview(toolPreview) { + const nextToolPreview = cloneViewportToolPreview(toolPreview); + if (areViewportToolPreviewsEqual(this.viewportTransientState.toolPreview, nextToolPreview)) { + return; + } + this.viewportTransientState = { + ...this.viewportTransientState, + toolPreview: nextToolPreview + }; + this.emit(); + } + clearViewportToolPreview(sourcePanelId) { + const currentToolPreview = this.viewportTransientState.toolPreview; + if (currentToolPreview.kind === "none") { + return; + } + if (sourcePanelId !== undefined && currentToolPreview.sourcePanelId !== sourcePanelId) { + return; + } + this.viewportTransientState = { + ...this.viewportTransientState, + toolPreview: createDefaultViewportTransientState().toolPreview + }; + this.emit(); + } + setTransformSession(transformSession) { + const nextTransformSession = cloneTransformSession(transformSession); + if (areTransformSessionsEqual(this.viewportTransientState.transformSession, nextTransformSession)) { + return; + } + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: nextTransformSession + }; + this.emit(); + } + clearTransformSession() { + if (this.viewportTransientState.transformSession.kind === "none") { + return; + } + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + this.emit(); + } + setTransformAxisConstraint(axisConstraint) { + if (this.viewportTransientState.transformSession.kind !== "active") { + return; + } + if (this.viewportTransientState.transformSession.axisConstraint === axisConstraint) { + return; + } + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: { + ...cloneTransformSession(this.viewportTransientState.transformSession), + axisConstraint + } + }; + this.emit(); + } + setViewportViewMode(viewportViewMode) { + this.setViewportPanelViewMode(this.activeViewportPanelId, viewportViewMode); + } + enterPlayMode() { + if (this.toolMode === "play") { + return; + } + this.previousEditingToolMode = this.toolMode; + this.toolMode = "play"; + if (this.viewportTransientState.toolPreview.kind !== "none") { + this.viewportTransientState = createDefaultViewportTransientState(); + } + this.emit(); + } + exitPlayMode() { + if (this.toolMode !== "play") { + return; + } + this.toolMode = this.previousEditingToolMode; + this.emit(); + } + setSelection(selection) { + if (this.viewportTransientState.transformSession.kind === "active" && !areEditorSelectionsEqual(this.selection, selection)) { + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + } + this.selection = selection; + this.emit(); + } + setWhiteboxSelectionMode(mode) { + if (this.whiteboxSelectionMode === mode) { + return; + } + if (this.viewportTransientState.transformSession.kind !== "none") { + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + } + this.whiteboxSelectionMode = mode; + this.selection = normalizeSelectionForWhiteboxSelectionMode(this.selection, mode); + this.emit(); + } + executeCommand(command) { + if (this.viewportTransientState.transformSession.kind !== "none") { + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + } + this.history.execute(command, this.commandContext); + this.lastCommandLabel = command.label; + this.emit(); + } + undo() { + let clearedTransformSession = false; + if (this.viewportTransientState.transformSession.kind !== "none") { + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + clearedTransformSession = true; + } + const command = this.history.undo(this.commandContext); + if (command === null) { + if (clearedTransformSession) { + this.emit(); + } + return false; + } + this.lastCommandLabel = `Undid ${command.label}`; + this.emit(); + return true; + } + redo() { + let clearedTransformSession = false; + if (this.viewportTransientState.transformSession.kind !== "none") { + this.viewportTransientState = { + ...this.viewportTransientState, + transformSession: createInactiveTransformSession() + }; + clearedTransformSession = true; + } + const command = this.history.redo(this.commandContext); + if (command === null) { + if (clearedTransformSession) { + this.emit(); + } + return false; + } + this.lastCommandLabel = `Redid ${command.label}`; + this.emit(); + return true; + } + replaceDocument(document, resetHistory = true) { + this.document = document; + this.selection = { kind: "none" }; + this.whiteboxSelectionMode = "object"; + this.toolMode = "select"; + this.previousEditingToolMode = "select"; + this.viewportTransientState = createDefaultViewportTransientState(); + if (resetHistory) { + this.history.clear(); + this.lastCommandLabel = null; + } + this.emit(); + } + saveDraft() { + if (this.storage === null) { + return { + status: "error", + message: "Browser local storage is unavailable." + }; + } + return saveSceneDocumentDraft(this.storage, this.document, this.createViewportLayoutState(), this.storageKey); + } + loadDraft() { + if (this.storage === null) { + return { + status: "error", + message: "Browser local storage is unavailable." + }; + } + const draftResult = loadSceneDocumentDraft(this.storage, this.storageKey); + if (draftResult.status !== "loaded") { + return draftResult; + } + this.replaceDocument(draftResult.document); + if (draftResult.viewportLayoutState !== null) { + this.applyViewportLayoutState(draftResult.viewportLayoutState); + this.emit(); + } + return draftResult; + } + exportDocumentJson() { + return serializeSceneDocument(this.document); + } + importDocumentJson(source) { + const document = parseSceneDocumentJson(source); + this.replaceDocument(document); + return document; + } + emit() { + this.snapshot = this.createSnapshot(); + for (const listener of this.listeners) { + listener(); + } + } + createViewportLayoutState() { + return cloneViewportLayoutState({ + layoutMode: this.viewportLayoutMode, + activePanelId: this.activeViewportPanelId, + panels: this.viewportPanels, + viewportQuadSplit: this.viewportQuadSplit + }); + } + applyViewportLayoutState(viewportLayoutState) { + const nextViewportLayoutState = cloneViewportLayoutState(viewportLayoutState); + this.viewportLayoutMode = nextViewportLayoutState.layoutMode; + this.activeViewportPanelId = nextViewportLayoutState.activePanelId; + this.viewportPanels = nextViewportLayoutState.panels; + this.viewportQuadSplit = nextViewportLayoutState.viewportQuadSplit; + } + createSnapshot() { + return { + document: this.document, + selection: this.selection, + whiteboxSelectionMode: this.whiteboxSelectionMode, + toolMode: this.toolMode, + viewportLayoutMode: this.viewportLayoutMode, + activeViewportPanelId: this.activeViewportPanelId, + viewportPanels: this.viewportPanels, + viewportQuadSplit: this.viewportQuadSplit, + viewportTransientState: this.viewportTransientState, + canUndo: this.history.canUndo(), + canRedo: this.history.canRedo(), + lastCommandLabel: this.lastCommandLabel, + storageAvailable: this.storage !== null + }; + } +} +export function createEditorStore(options) { + return new EditorStore(options); +} diff --git a/src/app/use-editor-store.js b/src/app/use-editor-store.js new file mode 100644 index 00000000..a1fadac6 --- /dev/null +++ b/src/app/use-editor-store.js @@ -0,0 +1,4 @@ +import { useSyncExternalStore } from "react"; +export function useEditorStoreState(store) { + return useSyncExternalStore(store.subscribe, store.getState, store.getState); +} diff --git a/src/assets/audio-assets.js b/src/assets/audio-assets.js new file mode 100644 index 00000000..b99aab9f --- /dev/null +++ b/src/assets/audio-assets.js @@ -0,0 +1,166 @@ +import { createOpaqueId } from "../core/ids"; +import { createProjectAssetStorageKey } from "./project-assets"; +function getErrorDetail(error) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return "Unknown error."; +} +function getFileExtension(sourceName) { + const match = /\.([^.]+)$/u.exec(sourceName.trim()); + return match === null ? "" : match[1].toLowerCase(); +} +function inferAudioMimeType(sourceName, fallbackMimeType) { + if (fallbackMimeType.trim().startsWith("audio/")) { + return fallbackMimeType.trim(); + } + switch (getFileExtension(sourceName)) { + case "aac": + return "audio/aac"; + case "flac": + return "audio/flac"; + case "m4a": + case "mp4": + return "audio/mp4"; + case "mp3": + return "audio/mpeg"; + case "oga": + case "ogg": + return "audio/ogg"; + case "wav": + case "wave": + return "audio/wav"; + case "webm": + return "audio/webm"; + default: + throw new Error(`Unsupported audio asset format for ${sourceName}. Use a browser-supported audio file.`); + } +} +function getImportedFilePath(file) { + const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : ""; + const sourcePath = relativePath.length > 0 ? relativePath : file.name.trim(); + return sourcePath.replace(/\\/gu, "/"); +} +function createAudioContext() { + const AudioContextConstructor = globalThis.AudioContext ?? + globalThis.webkitAudioContext; + if (AudioContextConstructor === undefined) { + throw new Error("Audio context is unavailable in this browser environment."); + } + return new AudioContextConstructor(); +} +async function decodeAudioBuffer(bytes) { + const audioContext = createAudioContext(); + try { + return await audioContext.decodeAudioData(bytes.slice(0)); + } + catch (error) { + throw new Error(getErrorDetail(error)); + } + finally { + await audioContext.close().catch(() => undefined); + } +} +function extractAudioAssetMetadata(buffer) { + if (!Number.isFinite(buffer.duration) || buffer.duration <= 0) { + throw new Error("Imported audio assets must have measurable duration."); + } + return { + kind: "audio", + durationSeconds: buffer.duration, + channelCount: buffer.numberOfChannels, + sampleRateHz: buffer.sampleRate, + warnings: [] + }; +} +function createLoadedAudioAsset(asset, buffer) { + return { + assetId: asset.id, + storageKey: asset.storageKey, + metadata: asset.metadata, + buffer + }; +} +function createAudioAssetRecord(sourceName, mimeType, byteLength, metadata) { + const assetId = createOpaqueId("asset-audio"); + return { + id: assetId, + kind: "audio", + sourceName, + mimeType, + storageKey: createProjectAssetStorageKey(assetId), + byteLength, + metadata + }; +} +async function loadAudioAssetFromFileRecord(asset, fileRecord) { + try { + const buffer = await decodeAudioBuffer(fileRecord.bytes); + return createLoadedAudioAsset(asset, buffer); + } + catch (error) { + throw new Error(`Audio asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } +} +function getStoredAudioAssetFile(asset, storedAsset) { + const directFile = storedAsset.files[asset.sourceName]; + if (directFile !== undefined) { + return directFile; + } + const storedFiles = Object.values(storedAsset.files); + if (storedFiles.length === 1) { + return storedFiles[0]; + } + return null; +} +export async function importAudioAssetFromFile(file, storage) { + const sourceName = getImportedFilePath(file); + const mimeType = inferAudioMimeType(sourceName, file.type); + const bytes = await file.arrayBuffer(); + let buffer; + try { + buffer = await decodeAudioBuffer(bytes); + } + catch (error) { + throw new Error(`Audio import failed for ${sourceName}: ${getErrorDetail(error)}`); + } + const metadata = extractAudioAssetMetadata(buffer); + const asset = createAudioAssetRecord(sourceName, mimeType, bytes.byteLength, metadata); + const loadedAsset = createLoadedAudioAsset(asset, buffer); + const packageRecord = { + files: { + [sourceName]: { + bytes, + mimeType + } + } + }; + try { + await storage.putAsset(asset.storageKey, packageRecord); + return { + asset, + loadedAsset + }; + } + catch (error) { + await storage.deleteAsset(asset.storageKey).catch(() => undefined); + throw error; + } +} +export async function loadAudioAssetFromStorage(storage, asset) { + let storedAsset; + try { + storedAsset = await storage.getAsset(asset.storageKey); + } + catch (error) { + throw new Error(`Audio asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } + if (storedAsset === null) { + throw new Error(`Missing stored binary data for imported audio asset ${asset.sourceName}.`); + } + const storedAudioFile = getStoredAudioAssetFile(asset, storedAsset); + if (storedAudioFile === null) { + throw new Error(`Missing stored audio file for imported audio asset ${asset.sourceName}.`); + } + return loadAudioAssetFromFileRecord(asset, storedAudioFile); +} diff --git a/src/assets/gltf-model-import.js b/src/assets/gltf-model-import.js new file mode 100644 index 00000000..67af01da --- /dev/null +++ b/src/assets/gltf-model-import.js @@ -0,0 +1,588 @@ +import { Box3, Group, Mesh } from "three"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; +import { createModelInstance } from "./model-instances"; +import { createProjectAssetStorageKey } from "./project-assets"; +import { createOpaqueId } from "../core/ids"; +const DRACO_DECODER_PATH = "/draco/gltf/"; +let sharedDracoLoader = null; +function getErrorDetail(error) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return "Unknown error."; +} +function getFileExtension(sourceName) { + const match = /\.([^.]+)$/u.exec(sourceName.trim()); + return match === null ? "" : match[1].toLowerCase(); +} +function inferFileMimeType(sourceName, fallbackMimeType) { + if (fallbackMimeType.trim().length > 0 && fallbackMimeType !== "application/octet-stream") { + return fallbackMimeType; + } + switch (getFileExtension(sourceName)) { + case "bin": + return "application/octet-stream"; + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "gif": + return "image/gif"; + case "webp": + return "image/webp"; + case "avif": + return "image/avif"; + case "ktx2": + return "image/ktx2"; + case "wav": + return "audio/wav"; + case "mp3": + return "audio/mpeg"; + case "ogg": + return "audio/ogg"; + case "glb": + return "model/gltf-binary"; + case "gltf": + return "model/gltf+json"; + default: + return fallbackMimeType.trim().length > 0 ? fallbackMimeType : "application/octet-stream"; + } +} +function inferModelAssetFormat(sourceName, mimeType) { + const extension = getFileExtension(sourceName); + if (mimeType === "model/gltf-binary" || extension === "glb") { + return "glb"; + } + if (mimeType === "model/gltf+json" || mimeType === "application/json" || extension === "gltf") { + return "gltf"; + } + throw new Error(`Unsupported model asset format for ${sourceName}. Use .glb or .gltf.`); +} +function inferModelMimeType(format) { + return format === "glb" ? "model/gltf-binary" : "model/gltf+json"; +} +function stripUrlQueryAndHash(path) { + const queryIndex = path.search(/[?#]/u); + return queryIndex === -1 ? path : path.slice(0, queryIndex); +} +function normalizeRelativePath(path) { + const normalizedPath = stripUrlQueryAndHash(path.trim()).replace(/\\/gu, "/"); + const segments = normalizedPath.split("/"); + const resolvedSegments = []; + for (const segment of segments) { + if (segment === "" || segment === ".") { + continue; + } + if (segment === "..") { + const previousSegment = resolvedSegments.at(-1); + if (previousSegment !== undefined && previousSegment !== "..") { + resolvedSegments.pop(); + } + else { + resolvedSegments.push(".."); + } + continue; + } + resolvedSegments.push(segment); + } + return resolvedSegments.join("/"); +} +function getPathDirectory(path) { + const normalizedPath = normalizeRelativePath(path); + const lastSlashIndex = normalizedPath.lastIndexOf("/"); + return lastSlashIndex === -1 ? "" : normalizedPath.slice(0, lastSlashIndex); +} +function getRelativePath(fromDirectory, targetPath) { + const normalizedFromSegments = normalizeRelativePath(fromDirectory).split("/").filter((segment) => segment.length > 0); + const normalizedTargetSegments = normalizeRelativePath(targetPath).split("/").filter((segment) => segment.length > 0); + while (normalizedFromSegments.length > 0 && + normalizedTargetSegments.length > 0 && + normalizedFromSegments[0] === normalizedTargetSegments[0]) { + normalizedFromSegments.shift(); + normalizedTargetSegments.shift(); + } + return [...new Array(normalizedFromSegments.length).fill(".."), ...normalizedTargetSegments].join("/"); +} +function getImportedFilePath(file) { + const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : ""; + return normalizeRelativePath(relativePath.length > 0 ? relativePath : file.name.trim()); +} +function createBoundingBoxFromObject(object) { + const box = new Box3().setFromObject(object); + if (box.isEmpty()) { + return null; + } + const min = { + x: box.min.x, + y: box.min.y, + z: box.min.z + }; + const max = { + x: box.max.x, + y: box.max.y, + z: box.max.z + }; + return { + min, + max, + size: { + x: max.x - min.x, + y: max.y - min.y, + z: max.z - min.z + } + }; +} +function collectMaterialNames(scene) { + const names = new Set(); + scene.traverse((object) => { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return; + } + const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : [maybeMesh.material]; + for (const material of materials) { + if (material.name.trim().length > 0) { + names.add(material.name); + } + } + }); + return [...names].sort((left, right) => left.localeCompare(right)); +} +function collectTextureNames(parserJson) { + const textures = parserJson.textures ?? []; + const names = new Set(); + for (const texture of textures) { + if (texture.name !== undefined && texture.name.trim().length > 0) { + names.add(texture.name); + } + } + return [...names].sort((left, right) => left.localeCompare(right)); +} +function collectAnimationNames(gltf) { + return gltf.animations + .map((animation, index) => (animation.name.trim().length > 0 ? animation.name : `Animation ${index + 1}`)) + .sort((left, right) => left.localeCompare(right)); +} +function countNodes(scene) { + let count = 0; + scene.traverse(() => { + count += 1; + }); + return count; +} +export function extractModelAssetMetadata(gltf, format) { + gltf.scene.updateMatrixWorld(true); + const boundingBox = createBoundingBoxFromObject(gltf.scene); + let actualMeshCount = 0; + gltf.scene.traverse((object) => { + if (object.isMesh === true) { + actualMeshCount += 1; + } + }); + const parserJson = gltf.parser.json; + const materialNames = collectMaterialNames(gltf.scene); + const textureNames = collectTextureNames(parserJson); + const animationNames = collectAnimationNames(gltf); + const warnings = []; + if (boundingBox === null) { + warnings.push("The imported model does not contain measurable geometry."); + } + if (actualMeshCount === 0) { + warnings.push("The imported model does not contain any meshes."); + } + if (materialNames.length === 0 && (parserJson.materials?.length ?? 0) > 0) { + for (const material of parserJson.materials ?? []) { + if (material.name !== undefined && material.name.trim().length > 0) { + materialNames.push(material.name); + } + } + } + return { + kind: "model", + format, + sceneName: gltf.scene.name.trim().length > 0 ? gltf.scene.name : null, + nodeCount: countNodes(gltf.scene), + meshCount: actualMeshCount, + materialNames: [...new Set(materialNames)].sort((left, right) => left.localeCompare(right)), + textureNames, + animationNames, + boundingBox, + warnings + }; +} +function createLoadedModelAsset(asset, template, animations) { + return { + assetId: asset.id, + storageKey: asset.storageKey, + metadata: asset.metadata, + template, + animations + }; +} +function createModelAssetRecord(sourceName, mimeType, byteLength, metadata) { + const assetId = createOpaqueId("asset-model"); + return { + id: assetId, + kind: "model", + sourceName, + mimeType, + storageKey: createProjectAssetStorageKey(assetId), + byteLength, + metadata + }; +} +async function loadGltfFromArrayBuffer(arrayBuffer) { + const loader = createConfiguredGltfLoader(); + return loader.parseAsync(arrayBuffer, ""); +} +function createConfiguredGltfLoader() { + const loader = new GLTFLoader(); + loader.setDRACOLoader(getSharedDracoLoader()); + return loader; +} +function getSharedDracoLoader() { + if (sharedDracoLoader === null) { + sharedDracoLoader = new DRACOLoader(); + sharedDracoLoader.setDecoderPath(DRACO_DECODER_PATH); + } + return sharedDracoLoader; +} +function createDataUrlForStoredFile(file) { + const byteArray = new Uint8Array(file.bytes); + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < byteArray.length; index += chunkSize) { + binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize)); + } + const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64"); + return `data:${file.mimeType};base64,${base64}`; +} +function createTransientResourceUrl(file) { + if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") { + const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType })); + return { + url: objectUrl, + revoke: () => { + if (typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(objectUrl); + } + } + }; + } + return { + url: createDataUrlForStoredFile(file), + revoke: () => undefined + }; +} +function rewriteGltfResourceUris(gltfJson, files) { + const dataUrlsByPath = new Map(); + const revokeUrls = []; + const missingUris = new Set(); + const resolveUri = (uri) => { + if (uri.startsWith("data:") || uri.startsWith("blob:")) { + return uri; + } + const normalizedUri = normalizeRelativePath(uri); + const storedFile = files[normalizedUri]; + if (storedFile === undefined) { + return null; + } + const cachedDataUrl = dataUrlsByPath.get(normalizedUri); + if (cachedDataUrl !== undefined) { + return cachedDataUrl; + } + const transientResourceUrl = createTransientResourceUrl(storedFile); + dataUrlsByPath.set(normalizedUri, transientResourceUrl.url); + revokeUrls.push(transientResourceUrl.revoke); + return transientResourceUrl.url; + }; + const rewriteUri = (value) => { + if (typeof value !== "string") { + return value; + } + const resolvedUri = resolveUri(stripUrlQueryAndHash(value)); + if (resolvedUri === null) { + missingUris.add(normalizeRelativePath(value)); + return value; + } + return resolvedUri; + }; + const buffers = Array.isArray(gltfJson.buffers) ? gltfJson.buffers : []; + for (const buffer of buffers) { + if (typeof buffer.uri === "string") { + buffer.uri = rewriteUri(buffer.uri); + } + } + const images = Array.isArray(gltfJson.images) ? gltfJson.images : []; + for (const image of images) { + if (typeof image.uri === "string") { + image.uri = rewriteUri(image.uri); + } + } + return { + missingUris: [...missingUris], + revokeUrls + }; +} +function cloneTemplateScene(scene) { + return scene.clone(true); +} +function cloneMaterial(material) { + return material.clone(); +} +function cloneMeshResources(object) { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return; + } + maybeMesh.geometry = maybeMesh.geometry.clone(); + maybeMesh.material = Array.isArray(maybeMesh.material) + ? maybeMesh.material.map((material) => cloneMaterial(material)) + : cloneMaterial(maybeMesh.material); +} +function disposeTexture(texture, seenTextures) { + if (seenTextures.has(texture)) { + return; + } + seenTextures.add(texture); + texture.dispose(); +} +function disposeMaterialResources(material, disposeTextures, seenTextures) { + if (disposeTextures) { + for (const value of Object.values(material)) { + if (value === null || value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const entry of value) { + if (entry !== null && typeof entry === "object" && "isTexture" in entry) { + disposeTexture(entry, seenTextures); + } + } + continue; + } + if (typeof value === "object" && "isTexture" in value) { + disposeTexture(value, seenTextures); + } + } + } + material.dispose(); +} +function disposeMeshResources(object, disposeTextures, seenTextures) { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return; + } + maybeMesh.geometry.dispose(); + if (Array.isArray(maybeMesh.material)) { + for (const material of maybeMesh.material) { + disposeMaterialResources(material, disposeTextures, seenTextures); + } + } + else { + disposeMaterialResources(maybeMesh.material, disposeTextures, seenTextures); + } +} +export function instantiateModelTemplate(template) { + const clone = cloneSkeleton(template); + clone.traverse(cloneMeshResources); + return clone; +} +export function disposeModelTemplate(template) { + const seenTextures = new Set(); + template.traverse((object) => { + disposeMeshResources(object, true, seenTextures); + }); +} +export function disposeModelInstance(instance) { + const seenTextures = new Set(); + instance.traverse((object) => { + disposeMeshResources(object, false, seenTextures); + }); +} +async function loadModelFileSet(files) { + if (files.length === 0) { + throw new Error("Select a .glb or .gltf file to import."); + } + const modelFiles = files.filter((file) => { + try { + inferModelAssetFormat(file.name, file.type); + return true; + } + catch { + return false; + } + }); + if (modelFiles.length === 0) { + throw new Error("Select a .glb or .gltf file to import."); + } + if (modelFiles.length > 1) { + throw new Error("Select exactly one .glb or .gltf file and any matching sidecar resources."); + } + const rootFile = modelFiles[0]; + const rootSourcePath = getImportedFilePath(rootFile); + const rootDirectory = getPathDirectory(rootSourcePath); + const importedFiles = await Promise.all(files.map(async (file) => ({ + file, + bytes: await file.arrayBuffer() + }))); + const fileEntries = []; + const packageFiles = {}; + for (const { file, bytes } of importedFiles) { + const sourcePath = file === rootFile ? normalizeRelativePath(rootFile.name.trim()) : getRelativePath(rootDirectory, getImportedFilePath(file)); + const mimeType = inferFileMimeType(file.name, file.type); + if (packageFiles[sourcePath] !== undefined) { + throw new Error(`Duplicate imported file path ${sourcePath}.`); + } + const entry = { + bytes, + mimeType, + path: sourcePath + }; + fileEntries.push(entry); + packageFiles[sourcePath] = { + bytes, + mimeType + }; + } + const rootEntry = fileEntries.find((entry) => entry.path === normalizeRelativePath(rootFile.name.trim())); + if (rootEntry === undefined) { + throw new Error(`Unable to locate the root model file ${rootFile.name}.`); + } + // Keep the root file's canonical storage path equal to its source name so reloads can find it directly. + const packageRecord = { + files: packageFiles + }; + return { + fileEntries, + packageRecord, + rootFile: rootEntry, + totalByteLength: fileEntries.reduce((total, entry) => total + entry.bytes.byteLength, 0) + }; +} +async function loadGltfFromImportedModelFileSet(fileSet) { + const rootFormat = inferModelAssetFormat(fileSet.rootFile.path, fileSet.rootFile.mimeType); + if (rootFormat === "glb") { + return loadGltfFromArrayBuffer(fileSet.rootFile.bytes); + } + const text = new TextDecoder().decode(fileSet.rootFile.bytes); + const gltfJson = JSON.parse(text); + const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileSet.packageRecord.files); + if (missingUris.length > 0) { + for (const revokeUrl of revokeUrls) { + revokeUrl(); + } + throw new Error(`Missing external model resource(s): ${missingUris.join(", ")}.`); + } + const loader = createConfiguredGltfLoader(); + try { + return await loader.parseAsync(JSON.stringify(gltfJson), ""); + } + finally { + for (const revokeUrl of revokeUrls) { + revokeUrl(); + } + } +} +function createModelAssetRecordFromFileSet(sourceName, mimeType, byteLength, metadata) { + return createModelAssetRecord(sourceName, mimeType, byteLength, metadata); +} +export async function importModelAssetFromFiles(files, storage) { + let fileSet; + try { + fileSet = await loadModelFileSet(files); + } + catch (error) { + throw new Error(`Model import failed: ${getErrorDetail(error)}`); + } + const sourceName = fileSet.rootFile.path; + const format = inferModelAssetFormat(sourceName, fileSet.rootFile.mimeType); + const mimeType = inferModelMimeType(format); + let gltf; + try { + gltf = await loadGltfFromImportedModelFileSet(fileSet); + } + catch (error) { + throw new Error(`Model import failed for ${sourceName}: ${getErrorDetail(error)}`); + } + const metadata = extractModelAssetMetadata(gltf, format); + const asset = createModelAssetRecordFromFileSet(sourceName, mimeType, fileSet.totalByteLength, metadata); + try { + await storage.putAsset(asset.storageKey, fileSet.packageRecord); + const modelInstance = createModelInstance({ + assetId: asset.id, + name: undefined + }); + return { + asset, + modelInstance, + loadedAsset: createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations) + }; + } + catch (error) { + await storage.deleteAsset(asset.storageKey).catch(() => undefined); + throw error; + } +} +export async function importModelAssetFromFile(file, storage) { + return importModelAssetFromFiles([file], storage); +} +function getStoredModelAssetFile(asset, storedAsset) { + const directFile = storedAsset.files[asset.sourceName]; + if (directFile !== undefined) { + return directFile; + } + const storedFiles = Object.values(storedAsset.files); + if (storedFiles.length === 1) { + return storedFiles[0]; + } + return null; +} +export async function loadModelAssetFromStorage(storage, asset) { + let storedAsset; + try { + storedAsset = await storage.getAsset(asset.storageKey); + } + catch (error) { + throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } + if (storedAsset === null) { + throw new Error(`Missing stored binary data for imported model asset ${asset.sourceName}.`); + } + const storedModelFile = getStoredModelAssetFile(asset, storedAsset); + if (storedModelFile === null) { + throw new Error(`Missing stored root file for imported model asset ${asset.sourceName}.`); + } + if (asset.metadata.format === "glb") { + try { + const gltf = await loadGltfFromArrayBuffer(storedModelFile.bytes); + return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations); + } + catch (error) { + throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } + } + const fileEntries = storedAsset.files; + const rootFileBytes = storedModelFile.bytes; + const gltfJson = JSON.parse(new TextDecoder().decode(rootFileBytes)); + const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileEntries); + if (missingUris.length > 0) { + for (const revokeUrl of revokeUrls) { + revokeUrl(); + } + throw new Error(`Missing stored external model resource(s): ${missingUris.join(", ")}.`); + } + const loader = createConfiguredGltfLoader(); + try { + const gltf = await loader.parseAsync(JSON.stringify(gltfJson), ""); + return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations); + } + finally { + for (const revokeUrl of revokeUrls) { + revokeUrl(); + } + } +} diff --git a/src/assets/image-assets.js b/src/assets/image-assets.js new file mode 100644 index 00000000..0fb5aa21 --- /dev/null +++ b/src/assets/image-assets.js @@ -0,0 +1,306 @@ +import { DataTexture, EquirectangularReflectionMapping, LinearSRGBColorSpace, SRGBColorSpace, Texture } from "three"; +import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js"; +import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js"; +import { createOpaqueId } from "../core/ids"; +import { createProjectAssetStorageKey } from "./project-assets"; +import {} from "./project-asset-storage"; +function getErrorDetail(error) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return "Unknown error."; +} +function getFileExtension(sourceName) { + const match = /\.([^.]+)$/u.exec(sourceName.trim()); + return match === null ? "" : match[1].toLowerCase(); +} +function inferImageMimeType(sourceName, fallbackMimeType) { + if (fallbackMimeType.trim().startsWith("image/")) { + return fallbackMimeType.trim(); + } + switch (getFileExtension(sourceName)) { + case "avif": + return "image/avif"; + case "exr": + return "image/x-exr"; + case "gif": + return "image/gif"; + case "hdr": + return "image/vnd.radiance"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "png": + return "image/png"; + case "svg": + return "image/svg+xml"; + case "webp": + return "image/webp"; + default: + throw new Error(`Unsupported image asset format for ${sourceName}. Use a browser-supported image file or .exr/.hdr.`); + } +} +function isHdrFormat(sourceName) { + const ext = getFileExtension(sourceName); + return ext === "exr" || ext === "hdr"; +} +function getImportedFilePath(file) { + const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : ""; + const sourcePath = relativePath.length > 0 ? relativePath : file.name.trim(); + return sourcePath.replace(/\\/gu, "/"); +} +function createDataUrlForStoredFile(file) { + const byteArray = new Uint8Array(file.bytes); + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < byteArray.length; index += chunkSize) { + binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize)); + } + const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64"); + return `data:${file.mimeType};base64,${base64}`; +} +function createTransientResourceUrl(file) { + if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") { + const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType })); + return { + url: objectUrl, + revoke: () => { + if (typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(objectUrl); + } + } + }; + } + return { + url: createDataUrlForStoredFile(file), + revoke: () => undefined + }; +} +function loadImageElement(sourceUrl) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.decoding = "async"; + image.addEventListener("load", () => { + resolve(image); + }); + image.addEventListener("error", () => { + reject(new Error(`Image could not be loaded from ${sourceUrl}.`)); + }); + image.src = sourceUrl; + }); +} +function detectImageHasAlpha(image) { + const canvas = document.createElement("canvas"); + const sampleWidth = Math.max(1, Math.min(64, image.naturalWidth || image.width)); + const sampleHeight = Math.max(1, Math.min(64, image.naturalHeight || image.height)); + const context = canvas.getContext("2d", { + willReadFrequently: true + }); + if (context === null) { + return false; + } + canvas.width = sampleWidth; + canvas.height = sampleHeight; + context.drawImage(image, 0, 0, sampleWidth, sampleHeight); + try { + const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data; + for (let index = 3; index < pixels.length; index += 4) { + if (pixels[index] !== 255) { + return true; + } + } + } + catch { + return false; + } + return false; +} +function extractImageAssetMetadata(image) { + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + throw new Error("Imported image assets must have measurable dimensions."); + } + const warnings = []; + const aspectRatio = width / height; + if (Math.abs(aspectRatio - 2) > 0.15) { + warnings.push("Background images work best as a 2:1 equirectangular panorama."); + } + return { + kind: "image", + width, + height, + hasAlpha: detectImageHasAlpha(image), + warnings + }; +} +function extractHdrTextureMetadata(texture) { + const width = texture.image.width; + const height = texture.image.height; + const warnings = []; + if (Math.abs(width / height - 2) > 0.15) { + warnings.push("Background images work best as a 2:1 equirectangular panorama."); + } + return { kind: "image", width, height, hasAlpha: false, warnings }; +} +function createImageTexture(image) { + const texture = new Texture(image); + texture.colorSpace = SRGBColorSpace; + texture.mapping = EquirectangularReflectionMapping; + texture.needsUpdate = true; + return texture; +} +function configureHdrTexture(texture) { + // HDR/EXR data is linear — do not apply sRGB color space + texture.colorSpace = LinearSRGBColorSpace; + texture.mapping = EquirectangularReflectionMapping; + texture.needsUpdate = true; + return texture; +} +function loadHdrTexture(url, sourceName) { + return new Promise((resolve, reject) => { + if (getFileExtension(sourceName) === "exr") { + new EXRLoader().load(url, resolve, undefined, () => { + reject(new Error(`EXR file could not be loaded: ${sourceName}.`)); + }); + } + else { + new RGBELoader().load(url, resolve, undefined, () => { + reject(new Error(`HDR file could not be loaded: ${sourceName}.`)); + }); + } + }); +} +function createLoadedImageAsset(asset, image, sourceUrl, revokeSourceUrl) { + return { + assetId: asset.id, + storageKey: asset.storageKey, + metadata: asset.metadata, + texture: createImageTexture(image), + sourceUrl, + revokeSourceUrl + }; +} +function createLoadedHdrImageAsset(asset, texture, sourceUrl, revokeSourceUrl) { + return { + assetId: asset.id, + storageKey: asset.storageKey, + metadata: asset.metadata, + texture: configureHdrTexture(texture), + sourceUrl, + revokeSourceUrl + }; +} +function createImageAssetRecord(sourceName, mimeType, byteLength, metadata) { + const assetId = createOpaqueId("asset-image"); + return { + id: assetId, + kind: "image", + sourceName, + mimeType, + storageKey: createProjectAssetStorageKey(assetId), + byteLength, + metadata + }; +} +function getStoredImageAssetFile(asset, storedAsset) { + const directFile = storedAsset.files[asset.sourceName]; + if (directFile !== undefined) { + return directFile; + } + const storedFiles = Object.values(storedAsset.files); + if (storedFiles.length === 1) { + return storedFiles[0]; + } + return null; +} +async function loadImageAssetFromFileRecord(asset, fileRecord) { + const transientResourceUrl = createTransientResourceUrl(fileRecord); + if (isHdrFormat(asset.sourceName)) { + try { + const texture = await loadHdrTexture(transientResourceUrl.url, asset.sourceName); + return createLoadedHdrImageAsset(asset, texture, transientResourceUrl.url, transientResourceUrl.revoke); + } + catch (error) { + transientResourceUrl.revoke(); + throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } + } + try { + const image = await loadImageElement(transientResourceUrl.url); + return createLoadedImageAsset(asset, image, transientResourceUrl.url, transientResourceUrl.revoke); + } + catch (error) { + transientResourceUrl.revoke(); + throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } +} +export async function importBackgroundImageAssetFromFile(file, storage) { + const sourceName = getImportedFilePath(file); + const mimeType = inferImageMimeType(sourceName, file.type); + const bytes = await file.arrayBuffer(); + const fileRecord = { bytes, mimeType }; + let asset; + let loadedAsset; + if (isHdrFormat(sourceName)) { + const transientResourceUrl = createTransientResourceUrl(fileRecord); + let texture; + try { + texture = await loadHdrTexture(transientResourceUrl.url, sourceName); + } + catch (error) { + transientResourceUrl.revoke(); + throw new Error(`Image import failed for ${sourceName}: ${getErrorDetail(error)}`); + } + const metadata = extractHdrTextureMetadata(texture); + asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata); + loadedAsset = createLoadedHdrImageAsset(asset, texture, transientResourceUrl.url, transientResourceUrl.revoke); + } + else { + const transientResourceUrl = createTransientResourceUrl(fileRecord); + let image; + try { + image = await loadImageElement(transientResourceUrl.url); + } + catch (error) { + transientResourceUrl.revoke(); + throw new Error(`Image import failed for ${sourceName}: ${getErrorDetail(error)}`); + } + const metadata = extractImageAssetMetadata(image); + asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata); + loadedAsset = createLoadedImageAsset(asset, image, transientResourceUrl.url, transientResourceUrl.revoke); + } + const packageRecord = { + files: { [sourceName]: fileRecord } + }; + try { + await storage.putAsset(asset.storageKey, packageRecord); + return { asset, loadedAsset }; + } + catch (error) { + disposeLoadedImageAsset(loadedAsset); + await storage.deleteAsset(asset.storageKey).catch(() => undefined); + throw error; + } +} +export async function loadImageAssetFromStorage(storage, asset) { + let storedAsset; + try { + storedAsset = await storage.getAsset(asset.storageKey); + } + catch (error) { + throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); + } + if (storedAsset === null) { + throw new Error(`Missing stored binary data for imported image asset ${asset.sourceName}.`); + } + const storedImageFile = getStoredImageAssetFile(asset, storedAsset); + if (storedImageFile === null) { + throw new Error(`Missing stored image file for imported image asset ${asset.sourceName}.`); + } + return loadImageAssetFromFileRecord(asset, storedImageFile); +} +export function disposeLoadedImageAsset(asset) { + asset.texture.dispose(); + asset.revokeSourceUrl(); +} diff --git a/src/assets/model-instance-labels.js b/src/assets/model-instance-labels.js new file mode 100644 index 00000000..8eab72e2 --- /dev/null +++ b/src/assets/model-instance-labels.js @@ -0,0 +1,27 @@ +import { getModelInstances } from "./model-instances"; +function getModelInstanceBaseLabel(modelInstance, assets) { + if (modelInstance.name !== undefined) { + return modelInstance.name; + } + const asset = assets[modelInstance.assetId]; + if (asset === undefined) { + return "Model Instance"; + } + return asset.sourceName; +} +export function getModelInstanceDisplayLabel(modelInstance, assets) { + return getModelInstanceBaseLabel(modelInstance, assets); +} +export function getModelInstanceDisplayLabelById(modelInstanceId, modelInstances, assets) { + const modelInstance = modelInstances[modelInstanceId]; + if (modelInstance === undefined) { + return "Model Instance"; + } + return getModelInstanceDisplayLabel(modelInstance, assets); +} +export function getSortedModelInstanceDisplayLabels(modelInstances, assets) { + return getModelInstances(modelInstances).map((modelInstance) => ({ + modelInstance, + label: getModelInstanceDisplayLabel(modelInstance, assets) + })); +} diff --git a/src/assets/model-instance-rendering.js b/src/assets/model-instance-rendering.js new file mode 100644 index 00000000..7b264ce0 --- /dev/null +++ b/src/assets/model-instance-rendering.js @@ -0,0 +1,157 @@ +import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from "three"; +import { instantiateModelTemplate } from "./gltf-model-import"; +const MODEL_PLACEHOLDER_COLOR = 0x89b6ff; +const MODEL_SELECTION_COLOR = 0xf7d2aa; +const MODEL_PREVIEW_SHELL_OPACITY = 0.5; +function getLocalModelBounds(asset) { + if (asset?.kind === "model" && asset.metadata.boundingBox !== null) { + const boundingBox = asset.metadata.boundingBox; + return { + center: { + x: (boundingBox.min.x + boundingBox.max.x) * 0.5, + y: (boundingBox.min.y + boundingBox.max.y) * 0.5, + z: (boundingBox.min.z + boundingBox.max.z) * 0.5 + }, + size: { + x: Math.max(0.1, Math.abs(boundingBox.max.x - boundingBox.min.x)), + y: Math.max(0.1, Math.abs(boundingBox.max.y - boundingBox.min.y)), + z: Math.max(0.1, Math.abs(boundingBox.max.z - boundingBox.min.z)) + } + }; + } + return { + center: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 1, + y: 1, + z: 1 + } + }; +} +function createWireframeBox(size, color, opacity) { + return new Mesh(new BoxGeometry(size.x, size.y, size.z), new MeshBasicMaterial({ + color, + wireframe: true, + transparent: true, + opacity, + depthWrite: false + })); +} +function createWireframeMaterial(material) { + const source = material; + const opacity = typeof source.opacity === "number" ? source.opacity : 1; + return new MeshBasicMaterial({ + color: source.color?.getHex() ?? MODEL_PLACEHOLDER_COLOR, + wireframe: true, + transparent: source.transparent === true || opacity < 1, + opacity, + depthWrite: false + }); +} +function applyWireframeMaterialPresentation(group) { + group.traverse((object) => { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return; + } + if (Array.isArray(maybeMesh.material)) { + const originalMaterials = maybeMesh.material; + maybeMesh.material = originalMaterials.map((material) => createWireframeMaterial(material)); + for (const material of originalMaterials) { + material.dispose(); + } + return; + } + const originalMaterial = maybeMesh.material; + maybeMesh.material = createWireframeMaterial(originalMaterial); + originalMaterial.dispose(); + }); +} +function disposeTexture(texture, seenTextures) { + if (seenTextures.has(texture)) { + return; + } + seenTextures.add(texture); + texture.dispose(); +} +function disposeMaterialResources(material, disposeTextures, seenTextures) { + if (disposeTextures) { + for (const value of Object.values(material)) { + if (value === null || value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const entry of value) { + if (entry !== null && typeof entry === "object" && "isTexture" in entry) { + disposeTexture(entry, seenTextures); + } + } + continue; + } + if (typeof value === "object" && "isTexture" in value) { + disposeTexture(value, seenTextures); + } + } + } + material.dispose(); +} +function disposeMeshResources(object, disposeTextures, seenTextures) { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return; + } + maybeMesh.geometry.dispose(); + if (Array.isArray(maybeMesh.material)) { + for (const material of maybeMesh.material) { + disposeMaterialResources(material, disposeTextures, seenTextures); + } + } + else { + disposeMaterialResources(maybeMesh.material, disposeTextures, seenTextures); + } +} +export function createModelInstanceRenderGroup(modelInstance, asset, loadedAsset, selected = false, previewShellColor, renderMode = "normal") { + const bounds = getLocalModelBounds(asset); + const group = new Group(); + group.position.set(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z); + group.rotation.set((modelInstance.rotationDegrees.x * Math.PI) / 180, (modelInstance.rotationDegrees.y * Math.PI) / 180, (modelInstance.rotationDegrees.z * Math.PI) / 180); + group.scale.set(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z); + group.userData.modelInstanceId = modelInstance.id; + group.userData.assetId = modelInstance.assetId; + if (loadedAsset !== undefined) { + const instantiatedModel = instantiateModelTemplate(loadedAsset.template); + if (renderMode === "wireframe") { + applyWireframeMaterialPresentation(instantiatedModel); + } + group.add(instantiatedModel); + } + else { + const placeholder = createWireframeBox(bounds.size, previewShellColor ?? MODEL_PLACEHOLDER_COLOR, previewShellColor === undefined ? 0.28 : MODEL_PREVIEW_SHELL_OPACITY); + placeholder.position.set(bounds.center.x, bounds.center.y, bounds.center.z); + placeholder.userData.shadowIgnored = true; + group.add(placeholder); + } + if (loadedAsset !== undefined && previewShellColor !== undefined) { + const previewShell = createWireframeBox(bounds.size, previewShellColor, MODEL_PREVIEW_SHELL_OPACITY); + previewShell.position.set(bounds.center.x, bounds.center.y, bounds.center.z); + previewShell.userData.shadowIgnored = true; + group.add(previewShell); + } + if (selected) { + const selectionShell = createWireframeBox(bounds.size, MODEL_SELECTION_COLOR, 0.8); + selectionShell.position.set(bounds.center.x, bounds.center.y, bounds.center.z); + selectionShell.userData.shadowIgnored = true; + group.add(selectionShell); + } + return group; +} +export function disposeModelInstance(instance) { + const seenTextures = new Set(); + instance.traverse((object) => { + disposeMeshResources(object, false, seenTextures); + }); +} diff --git a/src/assets/model-instances.js b/src/assets/model-instances.js new file mode 100644 index 00000000..eeed2006 --- /dev/null +++ b/src/assets/model-instances.js @@ -0,0 +1,144 @@ +import { createOpaqueId } from "../core/ids"; +export const MODEL_INSTANCE_COLLISION_MODES = ["none", "terrain", "static", "dynamic", "simple"]; +export const DEFAULT_MODEL_INSTANCE_POSITION = { + x: 0, + y: 0, + z: 0 +}; +export const DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES = { + x: 0, + y: 0, + z: 0 +}; +export const DEFAULT_MODEL_INSTANCE_SCALE = { + x: 1, + y: 1, + z: 1 +}; +export const DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS = { + mode: "none", + visible: false +}; +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function areVec3Equal(left, right) { + return left.x === right.x && left.y === right.y && left.z === right.z; +} +export function isModelInstanceCollisionMode(value) { + return MODEL_INSTANCE_COLLISION_MODES.includes(value); +} +export function createModelInstanceCollisionSettings(overrides = {}) { + const mode = overrides.mode ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.mode; + if (!isModelInstanceCollisionMode(mode)) { + throw new Error("Model instance collision mode must be a supported value."); + } + const visible = overrides.visible ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.visible; + if (typeof visible !== "boolean") { + throw new Error("Model instance collision visibility must be a boolean."); + } + return { + mode, + visible + }; +} +export function cloneModelInstanceCollisionSettings(settings) { + return createModelInstanceCollisionSettings(settings); +} +export function areModelInstanceCollisionSettingsEqual(left, right) { + return left.mode === right.mode && left.visible === right.visible; +} +export function normalizeModelInstanceName(name) { + if (name === undefined || name === null) { + return undefined; + } + const trimmedName = name.trim(); + return trimmedName.length === 0 ? undefined : trimmedName; +} +function assertFiniteVec3(vector, label) { + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + throw new Error(`${label} must be finite on every axis.`); + } +} +function assertPositiveFiniteVec3(vector, label) { + assertFiniteVec3(vector, label); + if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) { + throw new Error(`${label} must remain positive on every axis.`); + } +} +export function createModelInstance(overrides) { + const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION); + const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES); + const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE); + const collision = cloneModelInstanceCollisionSettings(overrides.collision ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS); + if (overrides.assetId.trim().length === 0) { + throw new Error("Model instance assetId must be a non-empty string."); + } + assertFiniteVec3(position, "Model instance position"); + assertFiniteVec3(rotationDegrees, "Model instance rotation"); + assertPositiveFiniteVec3(scale, "Model instance scale"); + return { + id: overrides.id ?? createOpaqueId("model-instance"), + kind: "modelInstance", + assetId: overrides.assetId, + name: normalizeModelInstanceName(overrides.name), + position, + rotationDegrees, + scale, + collision, + animationClipName: overrides.animationClipName, + animationAutoplay: overrides.animationAutoplay + }; +} +export function createModelInstancePlacementPosition(asset, anchor) { + const boundingBox = asset?.metadata.boundingBox; + if (anchor !== null) { + const floorOffset = boundingBox === null || boundingBox === undefined ? 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 || boundingBox === undefined ? DEFAULT_MODEL_INSTANCE_POSITION.y : Math.max(DEFAULT_MODEL_INSTANCE_POSITION.y, -boundingBox.min.y), + z: DEFAULT_MODEL_INSTANCE_POSITION.z + }; +} +export function cloneModelInstance(instance) { + return createModelInstance(instance); +} +export function areModelInstancesEqual(left, right) { + return (left.id === right.id && + left.kind === right.kind && + left.assetId === right.assetId && + left.name === right.name && + areVec3Equal(left.position, right.position) && + areVec3Equal(left.rotationDegrees, right.rotationDegrees) && + areVec3Equal(left.scale, right.scale) && + areModelInstanceCollisionSettingsEqual(left.collision, right.collision) && + left.animationClipName === right.animationClipName && + left.animationAutoplay === right.animationAutoplay); +} +export function compareModelInstances(left, right) { + if (left.assetId !== right.assetId) { + return left.assetId.localeCompare(right.assetId); + } + const leftName = left.name ?? ""; + const rightName = right.name ?? ""; + if (leftName !== rightName) { + return leftName.localeCompare(rightName); + } + return left.id.localeCompare(right.id); +} +export function getModelInstances(modelInstances) { + return Object.values(modelInstances).sort(compareModelInstances); +} +export function getModelInstanceKindLabel() { + return "Model Instance"; +} diff --git a/src/assets/project-asset-storage.js b/src/assets/project-asset-storage.js new file mode 100644 index 00000000..52d50c41 --- /dev/null +++ b/src/assets/project-asset-storage.js @@ -0,0 +1,177 @@ +const PROJECT_ASSET_DATABASE_NAME = "webeditor3d-project-assets"; +const PROJECT_ASSET_DATABASE_VERSION = 1; +const PROJECT_ASSET_OBJECT_STORE_NAME = "assets"; +function cloneArrayBuffer(bytes) { + return bytes.slice(0); +} +function cloneFileRecord(file) { + return { + bytes: cloneArrayBuffer(file.bytes), + mimeType: file.mimeType + }; +} +export function cloneProjectAssetStorageRecord(record) { + const files = {}; + for (const [path, file] of Object.entries(record.files)) { + files[path] = cloneFileRecord(file); + } + return { + files + }; +} +function isObject(value) { + return value !== null && typeof value === "object"; +} +function isLegacyProjectAssetStorageRecord(value) { + return (isObject(value) && + value.bytes instanceof ArrayBuffer && + typeof value.mimeType === "string"); +} +function isProjectAssetStoragePackageRecord(value) { + if (!isObject(value) || !isObject(value.files)) { + return false; + } + return Object.values(value.files).every((entry) => { + return (isObject(entry) && + entry.bytes instanceof ArrayBuffer && + typeof entry.mimeType === "string"); + }); +} +function normalizeStoredAssetRecord(storageKey, value) { + if (isProjectAssetStoragePackageRecord(value)) { + return cloneProjectAssetStorageRecord(value); + } + if (isLegacyProjectAssetStorageRecord(value)) { + return { + files: { + [storageKey]: cloneFileRecord(value) + } + }; + } + return null; +} +function getErrorDetail(error) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return "Unknown error."; +} +function formatDiagnostic(prefix, error) { + return `${prefix} ${getErrorDetail(error)}`; +} +function promisifyRequest(request) { + return new Promise((resolve, reject) => { + request.addEventListener("success", () => { + resolve(request.result); + }); + request.addEventListener("error", () => { + reject(request.error ?? new Error("IndexedDB request failed.")); + }); + }); +} +function openIndexedDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(PROJECT_ASSET_DATABASE_NAME, PROJECT_ASSET_DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + const database = request.result; + if (!database.objectStoreNames.contains(PROJECT_ASSET_OBJECT_STORE_NAME)) { + database.createObjectStore(PROJECT_ASSET_OBJECT_STORE_NAME); + } + }); + request.addEventListener("success", () => { + resolve(request.result); + }); + request.addEventListener("error", () => { + reject(request.error ?? new Error("IndexedDB open failed.")); + }); + }); +} +class IndexedDbProjectAssetStorage { + databasePromise; + constructor(databasePromise) { + this.databasePromise = databasePromise; + } + async withStore(mode, callback) { + const database = await this.databasePromise; + const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, mode); + const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME); + const result = await promisifyRequest(callback(store)); + await new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed."))); + transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction aborted."))); + }); + return result; + } + async getAsset(storageKey) { + const database = await this.databasePromise; + const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, "readonly"); + const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME); + const result = await promisifyRequest(store.get(storageKey)); + return normalizeStoredAssetRecord(storageKey, result); + } + async putAsset(storageKey, asset) { + await this.withStore("readwrite", (store) => store.put(cloneProjectAssetStorageRecord(asset), storageKey)); + } + async deleteAsset(storageKey) { + await this.withStore("readwrite", (store) => store.delete(storageKey)); + } +} +class InMemoryProjectAssetStorage { + values = new Map(); + constructor(initialValues = {}) { + for (const [storageKey, asset] of Object.entries(initialValues)) { + this.values.set(storageKey, cloneStoredAsset(asset)); + } + } + async getAsset(storageKey) { + const asset = this.values.get(storageKey); + if (asset === undefined) { + return null; + } + return normalizeStoredAssetRecord(storageKey, asset); + } + async putAsset(storageKey, asset) { + this.values.set(storageKey, cloneProjectAssetStorageRecord(asset)); + } + async deleteAsset(storageKey) { + this.values.delete(storageKey); + } +} +function cloneStoredAsset(asset) { + if (isLegacyProjectAssetStorageRecord(asset)) { + return cloneFileRecord(asset); + } + return cloneProjectAssetStorageRecord(asset); +} +export function createInMemoryProjectAssetStorage(initialValues = {}) { + return new InMemoryProjectAssetStorage(initialValues); +} +export async function getBrowserProjectAssetStorageAccess() { + if (typeof window === "undefined") { + return { + storage: null, + diagnostic: null + }; + } + if (typeof indexedDB === "undefined") { + return { + storage: null, + diagnostic: "IndexedDB is unavailable in this browser environment." + }; + } + try { + const databasePromise = openIndexedDb(); + await databasePromise; + return { + storage: new IndexedDbProjectAssetStorage(databasePromise), + diagnostic: null + }; + } + catch (error) { + return { + storage: null, + diagnostic: formatDiagnostic("Project asset storage could not be opened.", error) + }; + } +} diff --git a/src/assets/project-assets.js b/src/assets/project-assets.js new file mode 100644 index 00000000..9e17a0dd --- /dev/null +++ b/src/assets/project-assets.js @@ -0,0 +1,100 @@ +export const PROJECT_ASSET_KINDS = ["model", "image", "audio"]; +export function createProjectAssetStorageKey(assetId) { + return `project-asset:${assetId}`; +} +export function isProjectAssetKind(value) { + return value === "model" || value === "image" || value === "audio"; +} +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function cloneBoundingBox(boundingBox) { + if (boundingBox === null) { + return null; + } + return { + min: cloneVec3(boundingBox.min), + max: cloneVec3(boundingBox.max), + size: cloneVec3(boundingBox.size) + }; +} +function cloneModelAssetMetadata(metadata) { + return { + kind: "model", + format: metadata.format, + sceneName: metadata.sceneName, + nodeCount: metadata.nodeCount, + meshCount: metadata.meshCount, + materialNames: [...metadata.materialNames], + textureNames: [...metadata.textureNames], + animationNames: [...metadata.animationNames], + boundingBox: cloneBoundingBox(metadata.boundingBox), + warnings: [...metadata.warnings] + }; +} +function cloneImageAssetMetadata(metadata) { + return { + kind: "image", + width: metadata.width, + height: metadata.height, + hasAlpha: metadata.hasAlpha, + warnings: [...metadata.warnings] + }; +} +function cloneAudioAssetMetadata(metadata) { + return { + kind: "audio", + durationSeconds: metadata.durationSeconds, + channelCount: metadata.channelCount, + sampleRateHz: metadata.sampleRateHz, + warnings: [...metadata.warnings] + }; +} +export function cloneProjectAssetRecord(asset) { + switch (asset.kind) { + case "model": + return { + id: asset.id, + kind: "model", + sourceName: asset.sourceName, + mimeType: asset.mimeType, + storageKey: asset.storageKey, + byteLength: asset.byteLength, + metadata: cloneModelAssetMetadata(asset.metadata) + }; + case "image": + return { + id: asset.id, + kind: "image", + sourceName: asset.sourceName, + mimeType: asset.mimeType, + storageKey: asset.storageKey, + byteLength: asset.byteLength, + metadata: cloneImageAssetMetadata(asset.metadata) + }; + case "audio": + return { + id: asset.id, + kind: "audio", + sourceName: asset.sourceName, + mimeType: asset.mimeType, + storageKey: asset.storageKey, + byteLength: asset.byteLength, + metadata: cloneAudioAssetMetadata(asset.metadata) + }; + } +} +export function getProjectAssetKindLabel(kind) { + switch (kind) { + case "model": + return "Model"; + case "image": + return "Image"; + case "audio": + return "Audio"; + } +} diff --git a/src/commands/brush-command-helpers.js b/src/commands/brush-command-helpers.js new file mode 100644 index 00000000..42d90a0e --- /dev/null +++ b/src/commands/brush-command-helpers.js @@ -0,0 +1,82 @@ +import { cloneEditorSelection } from "../core/selection"; +import { cloneFaceUvState } from "../document/brushes"; +export function getBoxBrushOrThrow(document, brushId) { + const brush = document.brushes[brushId]; + if (brush === undefined) { + throw new Error(`Box brush ${brushId} does not exist.`); + } + if (brush.kind !== "box") { + throw new Error(`Brush ${brushId} is not a supported box brush.`); + } + return brush; +} +export function setSingleBrushSelection(brushId) { + return { + kind: "brushes", + ids: [brushId] + }; +} +export function setSingleBrushFaceSelection(brushId, faceId) { + return { + kind: "brushFace", + brushId, + faceId + }; +} +export function setSingleBrushEdgeSelection(brushId, edgeId) { + return { + kind: "brushEdge", + brushId, + edgeId + }; +} +export function setSingleBrushVertexSelection(brushId, vertexId) { + return { + kind: "brushVertex", + brushId, + vertexId + }; +} +export function cloneSelectionForCommand(selection) { + return cloneEditorSelection(selection); +} +export function replaceBrush(document, brush) { + return { + ...document, + brushes: { + ...document.brushes, + [brush.id]: brush + } + }; +} +export function removeBrush(document, brushId) { + const remainingBrushes = { + ...document.brushes + }; + delete remainingBrushes[brushId]; + return { + ...document, + brushes: remainingBrushes + }; +} +export function getBoxBrushFaceOrThrow(document, brushId, faceId) { + const brush = getBoxBrushOrThrow(document, brushId); + const face = brush.faces[faceId]; + if (face === undefined) { + throw new Error(`Box brush ${brushId} does not contain face ${faceId}.`); + } + return face; +} +export function replaceBoxBrushFace(document, brushId, faceId, face) { + const brush = getBoxBrushOrThrow(document, brushId); + return replaceBrush(document, { + ...brush, + faces: { + ...brush.faces, + [faceId]: { + materialId: face.materialId, + uv: cloneFaceUvState(face.uv) + } + } + }); +} diff --git a/src/commands/command-history.js b/src/commands/command-history.js new file mode 100644 index 00000000..46d981aa --- /dev/null +++ b/src/commands/command-history.js @@ -0,0 +1,37 @@ +export class CommandHistory { + undoStack = []; + redoStack = []; + execute(command, context) { + command.execute(context); + this.undoStack.push(command); + this.redoStack.length = 0; + } + undo(context) { + const command = this.undoStack.pop(); + if (command === undefined) { + return null; + } + command.undo(context); + this.redoStack.push(command); + return command; + } + redo(context) { + const command = this.redoStack.pop(); + if (command === undefined) { + return null; + } + command.execute(context); + this.undoStack.push(command); + return command; + } + clear() { + this.undoStack.length = 0; + this.redoStack.length = 0; + } + canUndo() { + return this.undoStack.length > 0; + } + canRedo() { + return this.redoStack.length > 0; + } +} diff --git a/src/commands/command.js b/src/commands/command.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/commands/command.js @@ -0,0 +1 @@ +export {}; diff --git a/src/commands/commit-transform-session-command.js b/src/commands/commit-transform-session-command.js new file mode 100644 index 00000000..325d8620 --- /dev/null +++ b/src/commands/commit-transform-session-command.js @@ -0,0 +1,197 @@ +import { createMoveBoxBrushCommand } from "./move-box-brush-command"; +import { createResizeBoxBrushCommand } from "./resize-box-brush-command"; +import { createRotateBoxBrushCommand } from "./rotate-box-brush-command"; +import { createSetBoxBrushTransformCommand } from "./set-box-brush-transform-command"; +import { createUpsertEntityCommand } from "./upsert-entity-command"; +import { createUpsertModelInstanceCommand } from "./upsert-model-instance-command"; +import { createModelInstance } from "../assets/model-instances"; +import { createInteractableEntity, createPlayerStartEntity, createPointLightEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../entities/entity-instances"; +import { getTransformOperationLabel } from "../core/transform-session"; +function createTransformCommandLabel(session) { + return `${getTransformOperationLabel(session.operation)} ${session.target.kind === "brush" + ? "whitebox box" + : session.target.kind === "brushFace" + ? "whitebox face" + : session.target.kind === "brushEdge" + ? "whitebox edge" + : session.target.kind === "brushVertex" + ? "whitebox vertex" + : session.target.kind === "entity" + ? session.target.entityKind === "playerStart" + ? "player start" + : session.target.entityKind === "pointLight" + ? "point light" + : session.target.entityKind === "spotLight" + ? "spot light" + : session.target.entityKind === "soundEmitter" + ? "sound emitter" + : session.target.entityKind === "triggerVolume" + ? "trigger volume" + : session.target.entityKind === "teleportTarget" + ? "teleport target" + : "interactable" + : "model instance"}`; +} +export function createCommitTransformSessionCommand(document, session) { + switch (session.target.kind) { + case "brush": + if (session.preview.kind !== "brush") { + throw new Error("Brush transform preview is invalid."); + } + switch (session.operation) { + case "translate": + return createMoveBoxBrushCommand({ + brushId: session.target.brushId, + center: session.preview.center, + snapToGrid: false, + label: createTransformCommandLabel(session) + }); + case "rotate": + return createRotateBoxBrushCommand({ + brushId: session.target.brushId, + rotationDegrees: session.preview.rotationDegrees, + label: createTransformCommandLabel(session) + }); + case "scale": + return createResizeBoxBrushCommand({ + brushId: session.target.brushId, + size: session.preview.size, + snapToGrid: false, + label: createTransformCommandLabel(session) + }); + } + case "brushFace": + if (session.preview.kind !== "brush") { + throw new Error("Whitebox face transform preview is invalid."); + } + return createSetBoxBrushTransformCommand({ + selection: { + kind: "brushFace", + brushId: session.target.brushId, + faceId: session.target.faceId + }, + center: session.preview.center, + rotationDegrees: session.preview.rotationDegrees, + size: session.preview.size, + label: createTransformCommandLabel(session) + }); + case "brushEdge": + if (session.preview.kind !== "brush") { + throw new Error("Whitebox edge transform preview is invalid."); + } + return createSetBoxBrushTransformCommand({ + selection: { + kind: "brushEdge", + brushId: session.target.brushId, + edgeId: session.target.edgeId + }, + center: session.preview.center, + rotationDegrees: session.preview.rotationDegrees, + size: session.preview.size, + label: createTransformCommandLabel(session) + }); + case "brushVertex": + if (session.preview.kind !== "brush") { + throw new Error("Whitebox vertex transform preview is invalid."); + } + return createSetBoxBrushTransformCommand({ + selection: { + kind: "brushVertex", + brushId: session.target.brushId, + vertexId: session.target.vertexId + }, + center: session.preview.center, + rotationDegrees: session.preview.rotationDegrees, + size: session.preview.size, + label: createTransformCommandLabel(session) + }); + case "modelInstance": { + if (session.preview.kind !== "modelInstance") { + throw new Error("Model instance transform preview is invalid."); + } + const modelInstance = document.modelInstances[session.target.modelInstanceId]; + if (modelInstance === undefined) { + throw new Error(`Model instance ${session.target.modelInstanceId} does not exist.`); + } + return createUpsertModelInstanceCommand({ + modelInstance: createModelInstance({ + ...modelInstance, + position: session.preview.position, + rotationDegrees: session.preview.rotationDegrees, + scale: session.preview.scale + }), + label: createTransformCommandLabel(session) + }); + } + case "entity": { + if (session.preview.kind !== "entity") { + throw new Error("Entity transform preview is invalid."); + } + const entity = document.entities[session.target.entityId]; + if (entity === undefined) { + throw new Error(`Entity ${session.target.entityId} does not exist.`); + } + switch (entity.kind) { + case "pointLight": + return createUpsertEntityCommand({ + entity: createPointLightEntity({ + ...entity, + position: session.preview.position + }), + label: createTransformCommandLabel(session) + }); + case "spotLight": + return createUpsertEntityCommand({ + entity: createSpotLightEntity({ + ...entity, + position: session.preview.position, + direction: session.preview.rotation.kind === "direction" ? session.preview.rotation.direction : entity.direction + }), + label: createTransformCommandLabel(session) + }); + case "playerStart": + return createUpsertEntityCommand({ + entity: createPlayerStartEntity({ + ...entity, + position: session.preview.position, + yawDegrees: session.preview.rotation.kind === "yaw" ? session.preview.rotation.yawDegrees : entity.yawDegrees + }), + label: createTransformCommandLabel(session) + }); + case "soundEmitter": + return createUpsertEntityCommand({ + entity: createSoundEmitterEntity({ + ...entity, + position: session.preview.position + }), + label: createTransformCommandLabel(session) + }); + case "triggerVolume": + return createUpsertEntityCommand({ + entity: createTriggerVolumeEntity({ + ...entity, + position: session.preview.position + }), + label: createTransformCommandLabel(session) + }); + case "teleportTarget": + return createUpsertEntityCommand({ + entity: createTeleportTargetEntity({ + ...entity, + position: session.preview.position, + yawDegrees: session.preview.rotation.kind === "yaw" ? session.preview.rotation.yawDegrees : entity.yawDegrees + }), + label: createTransformCommandLabel(session) + }); + case "interactable": + return createUpsertEntityCommand({ + entity: createInteractableEntity({ + ...entity, + position: session.preview.position + }), + label: createTransformCommandLabel(session) + }); + } + } + } +} diff --git a/src/commands/create-box-brush-command.js b/src/commands/create-box-brush-command.js new file mode 100644 index 00000000..4fc09259 --- /dev/null +++ b/src/commands/create-box-brush-command.js @@ -0,0 +1,51 @@ +import { createBoxBrush, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes"; +import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; +import { createOpaqueId } from "../core/ids"; +import { cloneSelectionForCommand, removeBrush, setSingleBrushSelection } from "./brush-command-helpers"; +export function createCreateBoxBrushCommand(options = {}) { + const snapToGrid = options.snapToGrid ?? true; + const brush = createBoxBrush({ + center: snapToGrid === false + ? options.center ?? DEFAULT_BOX_BRUSH_CENTER + : snapVec3ToGrid(options.center ?? DEFAULT_BOX_BRUSH_CENTER, options.gridSize ?? DEFAULT_GRID_SIZE), + size: snapToGrid === false + ? options.size ?? DEFAULT_BOX_BRUSH_SIZE + : snapPositiveSizeToGrid(options.size ?? DEFAULT_BOX_BRUSH_SIZE, options.gridSize ?? DEFAULT_GRID_SIZE) + }); + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: "Create box brush", + execute(context) { + const currentDocument = context.getDocument(); + if (currentDocument.brushes[brush.id] !== undefined) { + throw new Error(`Box brush ${brush.id} already exists.`); + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument({ + ...currentDocument, + brushes: { + ...currentDocument.brushes, + [brush.id]: brush + } + }); + context.setSelection(setSingleBrushSelection(brush.id)); + context.setToolMode("select"); + }, + undo(context) { + context.setDocument(removeBrush(context.getDocument(), brush.id)); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/delete-box-brush-command.js b/src/commands/delete-box-brush-command.js new file mode 100644 index 00000000..87b6c48d --- /dev/null +++ b/src/commands/delete-box-brush-command.js @@ -0,0 +1,59 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneBoxBrush } from "../document/brushes"; +import { cloneSelectionForCommand, removeBrush } from "./brush-command-helpers"; +function selectionIncludesBrush(selection, brushId) { + return ((selection.kind === "brushes" && selection.ids.includes(brushId)) || + ((selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") && + selection.brushId === brushId)); +} +export function createDeleteBoxBrushCommand(brushId) { + let previousBrush = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: "Delete box brush", + execute(context) { + const currentDocument = context.getDocument(); + const currentBrush = currentDocument.brushes[brushId]; + if (currentBrush === undefined) { + throw new Error(`Box brush ${brushId} does not exist.`); + } + if (previousBrush === null) { + previousBrush = cloneBoxBrush(currentBrush); + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(removeBrush(currentDocument, brushId)); + if (selectionIncludesBrush(context.getSelection(), brushId)) { + context.setSelection({ + kind: "none" + }); + } + context.setToolMode("select"); + }, + undo(context) { + if (previousBrush === null) { + return; + } + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + brushes: { + ...currentDocument.brushes, + [previousBrush.id]: cloneBoxBrush(previousBrush) + } + }); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/delete-entity-command.js b/src/commands/delete-entity-command.js new file mode 100644 index 00000000..fcc5fbc5 --- /dev/null +++ b/src/commands/delete-entity-command.js @@ -0,0 +1,64 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection } from "../core/selection"; +import { cloneEntityInstance } from "../entities/entity-instances"; +function selectionIncludesEntity(selection, entityId) { + return selection.kind === "entities" && selection.ids.includes(entityId); +} +export function createDeleteEntityCommand(entityId) { + let previousEntity = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: "Delete entity", + execute(context) { + const currentDocument = context.getDocument(); + const currentEntity = currentDocument.entities[entityId]; + if (currentEntity === undefined) { + throw new Error(`Entity ${entityId} does not exist.`); + } + if (previousEntity === null) { + previousEntity = cloneEntityInstance(currentEntity); + } + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + const nextEntities = { + ...currentDocument.entities + }; + delete nextEntities[entityId]; + context.setDocument({ + ...currentDocument, + entities: nextEntities + }); + if (selectionIncludesEntity(context.getSelection(), entityId)) { + context.setSelection({ + kind: "none" + }); + } + context.setToolMode("select"); + }, + undo(context) { + if (previousEntity === null) { + return; + } + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + entities: { + ...currentDocument.entities, + [previousEntity.id]: cloneEntityInstance(previousEntity) + } + }); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/delete-interaction-link-command.js b/src/commands/delete-interaction-link-command.js new file mode 100644 index 00000000..481935bd --- /dev/null +++ b/src/commands/delete-interaction-link-command.js @@ -0,0 +1,40 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneInteractionLink } from "../interactions/interaction-links"; +export function createDeleteInteractionLinkCommand(linkId) { + let previousLink = null; + return { + id: createOpaqueId("command"), + label: "Delete interaction link", + execute(context) { + const currentDocument = context.getDocument(); + const currentLink = currentDocument.interactionLinks[linkId]; + if (currentLink === undefined) { + throw new Error(`Interaction link ${linkId} does not exist.`); + } + if (previousLink === null) { + previousLink = cloneInteractionLink(currentLink); + } + const nextInteractionLinks = { + ...currentDocument.interactionLinks + }; + delete nextInteractionLinks[linkId]; + context.setDocument({ + ...currentDocument, + interactionLinks: nextInteractionLinks + }); + }, + undo(context) { + if (previousLink === null) { + return; + } + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + interactionLinks: { + ...currentDocument.interactionLinks, + [previousLink.id]: cloneInteractionLink(previousLink) + } + }); + } + }; +} diff --git a/src/commands/delete-model-instance-command.js b/src/commands/delete-model-instance-command.js new file mode 100644 index 00000000..8c46143c --- /dev/null +++ b/src/commands/delete-model-instance-command.js @@ -0,0 +1,64 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection } from "../core/selection"; +import { cloneModelInstance } from "../assets/model-instances"; +function selectionIncludesModelInstance(selection, modelInstanceId) { + return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId); +} +export function createDeleteModelInstanceCommand(modelInstanceId) { + let previousModelInstance = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: "Delete model instance", + execute(context) { + const currentDocument = context.getDocument(); + const currentModelInstance = currentDocument.modelInstances[modelInstanceId]; + if (currentModelInstance === undefined) { + throw new Error(`Model instance ${modelInstanceId} does not exist.`); + } + if (previousModelInstance === null) { + previousModelInstance = cloneModelInstance(currentModelInstance); + } + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + const nextModelInstances = { + ...currentDocument.modelInstances + }; + delete nextModelInstances[modelInstanceId]; + context.setDocument({ + ...currentDocument, + modelInstances: nextModelInstances + }); + if (selectionIncludesModelInstance(context.getSelection(), modelInstanceId)) { + context.setSelection({ + kind: "none" + }); + } + context.setToolMode("select"); + }, + undo(context) { + if (previousModelInstance === null) { + return; + } + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + modelInstances: { + ...currentDocument.modelInstances, + [previousModelInstance.id]: cloneModelInstance(previousModelInstance) + } + }); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/import-audio-asset-command.js b/src/commands/import-audio-asset-command.js new file mode 100644 index 00000000..8689c051 --- /dev/null +++ b/src/commands/import-audio-asset-command.js @@ -0,0 +1,33 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneProjectAssetRecord } from "../assets/project-assets"; +export function createImportAudioAssetCommand(options) { + const nextAsset = cloneProjectAssetRecord(options.asset); + return { + id: createOpaqueId("command"), + label: options.label ?? `Import ${nextAsset.sourceName}`, + execute(context) { + const currentDocument = context.getDocument(); + if (currentDocument.assets[nextAsset.id] !== undefined) { + throw new Error(`Asset ${nextAsset.id} already exists.`); + } + context.setDocument({ + ...currentDocument, + assets: { + ...currentDocument.assets, + [nextAsset.id]: cloneProjectAssetRecord(nextAsset) + } + }); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextAssets = { + ...currentDocument.assets + }; + delete nextAssets[nextAsset.id]; + context.setDocument({ + ...currentDocument, + assets: nextAssets + }); + } + }; +} diff --git a/src/commands/import-background-image-asset-command.js b/src/commands/import-background-image-asset-command.js new file mode 100644 index 00000000..fa2112cb --- /dev/null +++ b/src/commands/import-background-image-asset-command.js @@ -0,0 +1,41 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneWorldSettings } from "../document/world-settings"; +import { cloneProjectAssetRecord } from "../assets/project-assets"; +export function createImportBackgroundImageAssetCommand(options) { + const nextAsset = cloneProjectAssetRecord(options.asset); + const nextWorld = cloneWorldSettings(options.world); + let previousWorld = null; + return { + id: createOpaqueId("command"), + label: options.label ?? `Import ${nextAsset.sourceName} as background`, + execute(context) { + const currentDocument = context.getDocument(); + if (currentDocument.assets[nextAsset.id] !== undefined) { + throw new Error(`Asset ${nextAsset.id} already exists.`); + } + if (previousWorld === null) { + previousWorld = cloneWorldSettings(currentDocument.world); + } + context.setDocument({ + ...currentDocument, + assets: { + ...currentDocument.assets, + [nextAsset.id]: cloneProjectAssetRecord(nextAsset) + }, + world: cloneWorldSettings(nextWorld) + }); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextAssets = { + ...currentDocument.assets + }; + delete nextAssets[nextAsset.id]; + context.setDocument({ + ...currentDocument, + assets: nextAssets, + world: previousWorld === null ? cloneWorldSettings(currentDocument.world) : cloneWorldSettings(previousWorld) + }); + } + }; +} diff --git a/src/commands/import-model-asset-command.js b/src/commands/import-model-asset-command.js new file mode 100644 index 00000000..47393058 --- /dev/null +++ b/src/commands/import-model-asset-command.js @@ -0,0 +1,70 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection } from "../core/selection"; +import { cloneModelInstance } from "../assets/model-instances"; +import { cloneProjectAssetRecord } from "../assets/project-assets"; +function setSingleModelInstanceSelection(modelInstanceId) { + return { + kind: "modelInstances", + ids: [modelInstanceId] + }; +} +export function createImportModelAssetCommand(options) { + const nextAsset = cloneProjectAssetRecord(options.asset); + const nextModelInstance = cloneModelInstance(options.modelInstance); + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? `Import ${nextAsset.sourceName}`, + execute(context) { + const currentDocument = context.getDocument(); + if (currentDocument.assets[nextAsset.id] !== undefined) { + throw new Error(`Asset ${nextAsset.id} already exists.`); + } + if (currentDocument.modelInstances[nextModelInstance.id] !== undefined) { + throw new Error(`Model instance ${nextModelInstance.id} already exists.`); + } + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument({ + ...currentDocument, + assets: { + ...currentDocument.assets, + [nextAsset.id]: cloneProjectAssetRecord(nextAsset) + }, + modelInstances: { + ...currentDocument.modelInstances, + [nextModelInstance.id]: cloneModelInstance(nextModelInstance) + } + }); + context.setSelection(setSingleModelInstanceSelection(nextModelInstance.id)); + context.setToolMode("select"); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextAssets = { + ...currentDocument.assets + }; + const nextModelInstances = { + ...currentDocument.modelInstances + }; + delete nextAssets[nextAsset.id]; + delete nextModelInstances[nextModelInstance.id]; + context.setDocument({ + ...currentDocument, + assets: nextAssets, + modelInstances: nextModelInstances + }); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/move-box-brush-command.js b/src/commands/move-box-brush-command.js new file mode 100644 index 00000000..93fce2f3 --- /dev/null +++ b/src/commands/move-box-brush-command.js @@ -0,0 +1,55 @@ +import { DEFAULT_GRID_SIZE, snapVec3ToGrid } from "../geometry/grid-snapping"; +import { createOpaqueId } from "../core/ids"; +import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers"; +export function createMoveBoxBrushCommand(options) { + const resolvedCenter = options.snapToGrid === false ? options.center : snapVec3ToGrid(options.center, options.gridSize ?? DEFAULT_GRID_SIZE); + let previousCenter = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? "Move box brush", + execute(context) { + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + if (previousCenter === null) { + previousCenter = { + ...brush.center + }; + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(replaceBrush(currentDocument, { + ...brush, + center: { + ...resolvedCenter + } + })); + context.setSelection(setSingleBrushSelection(options.brushId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousCenter === null) { + return; + } + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + context.setDocument(replaceBrush(currentDocument, { + ...brush, + center: { + ...previousCenter + } + })); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/resize-box-brush-command.js b/src/commands/resize-box-brush-command.js new file mode 100644 index 00000000..73e4dd64 --- /dev/null +++ b/src/commands/resize-box-brush-command.js @@ -0,0 +1,55 @@ +import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid } from "../geometry/grid-snapping"; +import { createOpaqueId } from "../core/ids"; +import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers"; +export function createResizeBoxBrushCommand(options) { + const resolvedSize = options.snapToGrid === false ? options.size : snapPositiveSizeToGrid(options.size, options.gridSize ?? DEFAULT_GRID_SIZE); + let previousSize = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? "Resize box brush", + execute(context) { + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + if (previousSize === null) { + previousSize = { + ...brush.size + }; + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(replaceBrush(currentDocument, { + ...brush, + size: { + ...resolvedSize + } + })); + context.setSelection(setSingleBrushSelection(options.brushId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousSize === null) { + return; + } + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + context.setDocument(replaceBrush(currentDocument, { + ...brush, + size: { + ...previousSize + } + })); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/rotate-box-brush-command.js b/src/commands/rotate-box-brush-command.js new file mode 100644 index 00000000..f155266e --- /dev/null +++ b/src/commands/rotate-box-brush-command.js @@ -0,0 +1,53 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers"; +export function createRotateBoxBrushCommand(options) { + let previousRotationDegrees = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? "Rotate box brush", + execute(context) { + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + if (previousRotationDegrees === null) { + previousRotationDegrees = { + ...brush.rotationDegrees + }; + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(replaceBrush(currentDocument, { + ...brush, + rotationDegrees: { + ...options.rotationDegrees + } + })); + context.setSelection(setSingleBrushSelection(options.brushId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousRotationDegrees === null) { + return; + } + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + context.setDocument(replaceBrush(currentDocument, { + ...brush, + rotationDegrees: { + ...previousRotationDegrees + } + })); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/set-box-brush-face-material-command.js b/src/commands/set-box-brush-face-material-command.js new file mode 100644 index 00000000..1952caf8 --- /dev/null +++ b/src/commands/set-box-brush-face-material-command.js @@ -0,0 +1,50 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneSelectionForCommand, getBoxBrushFaceOrThrow, replaceBoxBrushFace, setSingleBrushFaceSelection } from "./brush-command-helpers"; +export function createSetBoxBrushFaceMaterialCommand(options) { + let previousMaterialId; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.materialId === null ? `Clear ${options.faceId} face material` : `Apply material to ${options.faceId} face`, + execute(context) { + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + if (options.materialId !== null && currentDocument.materials[options.materialId] === undefined) { + throw new Error(`Material ${options.materialId} does not exist in the document registry.`); + } + if (previousMaterialId === undefined) { + previousMaterialId = currentFace.materialId; + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + materialId: options.materialId + })); + context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousMaterialId === undefined) { + return; + } + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + materialId: previousMaterialId + })); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/set-box-brush-face-uv-state-command.js b/src/commands/set-box-brush-face-uv-state-command.js new file mode 100644 index 00000000..d3341e64 --- /dev/null +++ b/src/commands/set-box-brush-face-uv-state-command.js @@ -0,0 +1,48 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneFaceUvState } from "../document/brushes"; +import { cloneSelectionForCommand, getBoxBrushFaceOrThrow, replaceBoxBrushFace, setSingleBrushFaceSelection } from "./brush-command-helpers"; +export function createSetBoxBrushFaceUvStateCommand(options) { + let previousUvState = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? `Update ${options.faceId} face UVs`, + execute(context) { + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + if (previousUvState === null) { + previousUvState = cloneFaceUvState(currentFace.uv); + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + uv: cloneFaceUvState(options.uvState) + })); + context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId)); + context.setToolMode("select"); + }, + undo(context) { + if (previousUvState === null) { + return; + } + const currentDocument = context.getDocument(); + const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId); + context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, { + ...currentFace, + uv: cloneFaceUvState(previousUvState) + })); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/set-box-brush-name-command.js b/src/commands/set-box-brush-name-command.js new file mode 100644 index 00000000..530713e6 --- /dev/null +++ b/src/commands/set-box-brush-name-command.js @@ -0,0 +1,30 @@ +import { createOpaqueId } from "../core/ids"; +import { normalizeBrushName } from "../document/brushes"; +import { getBoxBrushOrThrow, replaceBrush } from "./brush-command-helpers"; +export function createSetBoxBrushNameCommand(options) { + const normalizedName = normalizeBrushName(options.name); + let previousName; + return { + id: createOpaqueId("command"), + label: normalizedName === undefined ? "Clear box brush name" : `Rename box brush to ${normalizedName}`, + execute(context) { + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + if (previousName === undefined) { + previousName = brush.name; + } + context.setDocument(replaceBrush(currentDocument, { + ...brush, + name: normalizedName + })); + }, + undo(context) { + const currentDocument = context.getDocument(); + const brush = getBoxBrushOrThrow(currentDocument, options.brushId); + context.setDocument(replaceBrush(currentDocument, { + ...brush, + name: previousName + })); + } + }; +} diff --git a/src/commands/set-box-brush-transform-command.js b/src/commands/set-box-brush-transform-command.js new file mode 100644 index 00000000..05318197 --- /dev/null +++ b/src/commands/set-box-brush-transform-command.js @@ -0,0 +1,88 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushEdgeSelection, setSingleBrushFaceSelection, setSingleBrushSelection, setSingleBrushVertexSelection } from "./brush-command-helpers"; +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function selectionToEditorSelection(selection) { + switch (selection.kind) { + case "brush": + return setSingleBrushSelection(selection.brushId); + case "brushFace": + return setSingleBrushFaceSelection(selection.brushId, selection.faceId); + case "brushEdge": + return setSingleBrushEdgeSelection(selection.brushId, selection.edgeId); + case "brushVertex": + return setSingleBrushVertexSelection(selection.brushId, selection.vertexId); + } +} +function getBrushId(selection) { + return selection.brushId; +} +function assertPositiveSize(size) { + if (!(size.x > 0 && size.y > 0 && size.z > 0)) { + throw new Error("Whitebox box size must remain positive on every axis."); + } + if (!Number.isFinite(size.x) || !Number.isFinite(size.y) || !Number.isFinite(size.z)) { + throw new Error("Whitebox box size values must be finite numbers."); + } +} +export function createSetBoxBrushTransformCommand(options) { + assertPositiveSize(options.size); + let previousSnapshot = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? "Set box brush transform", + execute(context) { + const currentDocument = context.getDocument(); + const brushId = getBrushId(options.selection); + const brush = getBoxBrushOrThrow(currentDocument, brushId); + if (previousSnapshot === null) { + previousSnapshot = { + center: cloneVec3(brush.center), + rotationDegrees: cloneVec3(brush.rotationDegrees), + size: cloneVec3(brush.size) + }; + } + if (previousSelection === null) { + previousSelection = cloneSelectionForCommand(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + context.setDocument(replaceBrush(currentDocument, { + ...brush, + center: cloneVec3(options.center), + rotationDegrees: cloneVec3(options.rotationDegrees), + size: cloneVec3(options.size) + })); + context.setSelection(selectionToEditorSelection(options.selection)); + context.setToolMode("select"); + }, + undo(context) { + if (previousSnapshot === null) { + return; + } + const currentDocument = context.getDocument(); + const brushId = getBrushId(options.selection); + const brush = getBoxBrushOrThrow(currentDocument, brushId); + context.setDocument(replaceBrush(currentDocument, { + ...brush, + center: cloneVec3(previousSnapshot.center), + rotationDegrees: cloneVec3(previousSnapshot.rotationDegrees), + size: cloneVec3(previousSnapshot.size) + })); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/set-entity-name-command.js b/src/commands/set-entity-name-command.js new file mode 100644 index 00000000..3b0d278c --- /dev/null +++ b/src/commands/set-entity-name-command.js @@ -0,0 +1,47 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEntityInstance, normalizeEntityName } from "../entities/entity-instances"; +export function createSetEntityNameCommand(options) { + const normalizedName = normalizeEntityName(options.name); + let previousName; + return { + id: createOpaqueId("command"), + label: normalizedName === undefined ? "Clear entity name" : `Rename entity to ${normalizedName}`, + execute(context) { + const currentDocument = context.getDocument(); + const entity = currentDocument.entities[options.entityId]; + if (entity === undefined) { + throw new Error(`Entity ${options.entityId} does not exist.`); + } + if (previousName === undefined) { + previousName = entity.name; + } + context.setDocument({ + ...currentDocument, + entities: { + ...currentDocument.entities, + [entity.id]: cloneEntityInstance({ + ...entity, + name: normalizedName + }) + } + }); + }, + undo(context) { + const currentDocument = context.getDocument(); + const entity = currentDocument.entities[options.entityId]; + if (entity === undefined) { + throw new Error(`Entity ${options.entityId} does not exist.`); + } + context.setDocument({ + ...currentDocument, + entities: { + ...currentDocument.entities, + [entity.id]: cloneEntityInstance({ + ...entity, + name: previousName + }) + } + }); + } + }; +} diff --git a/src/commands/set-model-instance-name-command.js b/src/commands/set-model-instance-name-command.js new file mode 100644 index 00000000..ef31611e --- /dev/null +++ b/src/commands/set-model-instance-name-command.js @@ -0,0 +1,47 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneModelInstance, normalizeModelInstanceName } from "../assets/model-instances"; +export function createSetModelInstanceNameCommand(options) { + const normalizedName = normalizeModelInstanceName(options.name); + let previousName; + return { + id: createOpaqueId("command"), + label: normalizedName === undefined ? "Clear model instance name" : `Rename model instance to ${normalizedName}`, + execute(context) { + const currentDocument = context.getDocument(); + const modelInstance = currentDocument.modelInstances[options.modelInstanceId]; + if (modelInstance === undefined) { + throw new Error(`Model instance ${options.modelInstanceId} does not exist.`); + } + if (previousName === undefined) { + previousName = modelInstance.name; + } + context.setDocument({ + ...currentDocument, + modelInstances: { + ...currentDocument.modelInstances, + [modelInstance.id]: cloneModelInstance({ + ...modelInstance, + name: normalizedName + }) + } + }); + }, + undo(context) { + const currentDocument = context.getDocument(); + const modelInstance = currentDocument.modelInstances[options.modelInstanceId]; + if (modelInstance === undefined) { + throw new Error(`Model instance ${options.modelInstanceId} does not exist.`); + } + context.setDocument({ + ...currentDocument, + modelInstances: { + ...currentDocument.modelInstances, + [modelInstance.id]: cloneModelInstance({ + ...modelInstance, + name: previousName + }) + } + }); + } + }; +} diff --git a/src/commands/set-player-start-command.js b/src/commands/set-player-start-command.js new file mode 100644 index 00000000..008efafc --- /dev/null +++ b/src/commands/set-player-start-command.js @@ -0,0 +1,12 @@ +import { createPlayerStartEntity } from "../entities/entity-instances"; +import { createUpsertEntityCommand } from "./upsert-entity-command"; +export function createSetPlayerStartCommand(options) { + return createUpsertEntityCommand({ + entity: createPlayerStartEntity({ + id: options.entityId, + position: options.position, + yawDegrees: options.yawDegrees + }), + label: options.entityId === undefined ? "Place player start" : "Move player start" + }); +} diff --git a/src/commands/set-scene-name-command.js b/src/commands/set-scene-name-command.js new file mode 100644 index 00000000..5f293558 --- /dev/null +++ b/src/commands/set-scene-name-command.js @@ -0,0 +1,29 @@ +import { createOpaqueId } from "../core/ids"; +export function createSetSceneNameCommand(nextName) { + const normalizedName = nextName.trim() || "Untitled Scene"; + let previousName = null; + return { + id: createOpaqueId("command"), + label: `Rename scene to ${normalizedName}`, + execute(context) { + const currentDocument = context.getDocument(); + if (previousName === null) { + previousName = currentDocument.name; + } + context.setDocument({ + ...currentDocument, + name: normalizedName + }); + }, + undo(context) { + if (previousName === null) { + return; + } + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + name: previousName + }); + } + }; +} diff --git a/src/commands/set-world-settings-command.js b/src/commands/set-world-settings-command.js new file mode 100644 index 00000000..0518be99 --- /dev/null +++ b/src/commands/set-world-settings-command.js @@ -0,0 +1,30 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneWorldSettings } from "../document/world-settings"; +export function createSetWorldSettingsCommand(options) { + const nextWorld = cloneWorldSettings(options.world); + let previousWorld = null; + return { + id: createOpaqueId("command"), + label: options.label, + execute(context) { + const currentDocument = context.getDocument(); + if (previousWorld === null) { + previousWorld = cloneWorldSettings(currentDocument.world); + } + context.setDocument({ + ...currentDocument, + world: cloneWorldSettings(nextWorld) + }); + }, + undo(context) { + if (previousWorld === null) { + return; + } + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + world: cloneWorldSettings(previousWorld) + }); + } + }; +} diff --git a/src/commands/upsert-entity-command.js b/src/commands/upsert-entity-command.js new file mode 100644 index 00000000..7603c02d --- /dev/null +++ b/src/commands/upsert-entity-command.js @@ -0,0 +1,70 @@ +import { cloneEditorSelection } from "../core/selection"; +import { createOpaqueId } from "../core/ids"; +import { cloneEntityInstance, getEntityKindLabel } from "../entities/entity-instances"; +function setSingleEntitySelection(entityId) { + return { + kind: "entities", + ids: [entityId] + }; +} +function createDefaultEntityCommandLabel(entity, isNewEntity) { + const action = isNewEntity ? "Place" : "Update"; + return `${action} ${getEntityKindLabel(entity.kind).toLowerCase()}`; +} +export function createUpsertEntityCommand(options) { + const nextEntity = cloneEntityInstance(options.entity); + let previousEntity = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? createDefaultEntityCommandLabel(nextEntity, true), + execute(context) { + const currentDocument = context.getDocument(); + const currentEntity = currentDocument.entities[nextEntity.id]; + if (currentEntity !== undefined && currentEntity.kind !== nextEntity.kind) { + throw new Error(`Entity ${nextEntity.id} is a ${currentEntity.kind}, not a ${nextEntity.kind}.`); + } + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + if (previousEntity === null && currentEntity !== undefined) { + previousEntity = cloneEntityInstance(currentEntity); + } + context.setDocument({ + ...currentDocument, + entities: { + ...currentDocument.entities, + [nextEntity.id]: cloneEntityInstance(nextEntity) + } + }); + context.setSelection(setSingleEntitySelection(nextEntity.id)); + context.setToolMode("select"); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextEntities = { + ...currentDocument.entities + }; + if (previousEntity === null) { + delete nextEntities[nextEntity.id]; + } + else { + nextEntities[nextEntity.id] = cloneEntityInstance(previousEntity); + } + context.setDocument({ + ...currentDocument, + entities: nextEntities + }); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/upsert-interaction-link-command.js b/src/commands/upsert-interaction-link-command.js new file mode 100644 index 00000000..914ad55f --- /dev/null +++ b/src/commands/upsert-interaction-link-command.js @@ -0,0 +1,40 @@ +import { cloneInteractionLink } from "../interactions/interaction-links"; +import { createOpaqueId } from "../core/ids"; +export function createUpsertInteractionLinkCommand(options) { + const nextLink = cloneInteractionLink(options.link); + let previousLink = null; + return { + id: createOpaqueId("command"), + label: options.label ?? "Update interaction link", + execute(context) { + const currentDocument = context.getDocument(); + const currentLink = currentDocument.interactionLinks[nextLink.id]; + if (previousLink === null && currentLink !== undefined) { + previousLink = cloneInteractionLink(currentLink); + } + context.setDocument({ + ...currentDocument, + interactionLinks: { + ...currentDocument.interactionLinks, + [nextLink.id]: cloneInteractionLink(nextLink) + } + }); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextInteractionLinks = { + ...currentDocument.interactionLinks + }; + if (previousLink === null) { + delete nextInteractionLinks[nextLink.id]; + } + else { + nextInteractionLinks[nextLink.id] = cloneInteractionLink(previousLink); + } + context.setDocument({ + ...currentDocument, + interactionLinks: nextInteractionLinks + }); + } + }; +} diff --git a/src/commands/upsert-model-instance-command.js b/src/commands/upsert-model-instance-command.js new file mode 100644 index 00000000..5e51dd80 --- /dev/null +++ b/src/commands/upsert-model-instance-command.js @@ -0,0 +1,75 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection } from "../core/selection"; +import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances"; +import { getProjectAssetKindLabel } from "../assets/project-assets"; +function setSingleModelInstanceSelection(modelInstanceId) { + return { + kind: "modelInstances", + ids: [modelInstanceId] + }; +} +function createDefaultModelInstanceCommandLabel(isNewModelInstance) { + const action = isNewModelInstance ? "Place" : "Update"; + return `${action} ${getModelInstanceKindLabel().toLowerCase()}`; +} +export function createUpsertModelInstanceCommand(options) { + const nextModelInstance = cloneModelInstance(options.modelInstance); + let previousModelInstance = null; + let previousSelection = null; + let previousToolMode = null; + return { + id: createOpaqueId("command"), + label: options.label ?? createDefaultModelInstanceCommandLabel(true), + execute(context) { + const currentDocument = context.getDocument(); + const currentAsset = currentDocument.assets[nextModelInstance.assetId]; + if (currentAsset === undefined) { + throw new Error(`Model instance ${nextModelInstance.id} cannot reference missing asset ${nextModelInstance.assetId}.`); + } + if (currentAsset.kind !== "model") { + throw new Error(`Model instance ${nextModelInstance.id} must reference a model asset, not ${getProjectAssetKindLabel(currentAsset.kind).toLowerCase()}.`); + } + const currentModelInstance = currentDocument.modelInstances[nextModelInstance.id]; + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + if (previousModelInstance === null && currentModelInstance !== undefined) { + previousModelInstance = cloneModelInstance(currentModelInstance); + } + context.setDocument({ + ...currentDocument, + modelInstances: { + ...currentDocument.modelInstances, + [nextModelInstance.id]: cloneModelInstance(nextModelInstance) + } + }); + context.setSelection(setSingleModelInstanceSelection(nextModelInstance.id)); + context.setToolMode("select"); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextModelInstances = { + ...currentDocument.modelInstances + }; + if (previousModelInstance === null) { + delete nextModelInstances[nextModelInstance.id]; + } + else { + nextModelInstances[nextModelInstance.id] = cloneModelInstance(previousModelInstance); + } + context.setDocument({ + ...currentDocument, + modelInstances: nextModelInstances + }); + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/core/ids.js b/src/core/ids.js new file mode 100644 index 00000000..dba09fe3 --- /dev/null +++ b/src/core/ids.js @@ -0,0 +1,8 @@ +let fallbackCounter = 0; +export function createOpaqueId(prefix) { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + fallbackCounter += 1; + return `${prefix}-${Date.now()}-${fallbackCounter}`; +} diff --git a/src/core/selection.js b/src/core/selection.js new file mode 100644 index 00000000..a1d53a71 --- /dev/null +++ b/src/core/selection.js @@ -0,0 +1,134 @@ +export function cloneEditorSelection(selection) { + if (selection.kind === "none") { + return { + kind: "none" + }; + } + if (selection.kind === "brushFace") { + return { + kind: "brushFace", + brushId: selection.brushId, + faceId: selection.faceId + }; + } + if (selection.kind === "brushEdge") { + return { + kind: "brushEdge", + brushId: selection.brushId, + edgeId: selection.edgeId + }; + } + if (selection.kind === "brushVertex") { + return { + kind: "brushVertex", + brushId: selection.brushId, + vertexId: selection.vertexId + }; + } + return { + kind: selection.kind, + ids: [...selection.ids] + }; +} +export function areEditorSelectionsEqual(left, right) { + if (left.kind !== right.kind) { + return false; + } + switch (left.kind) { + case "none": + return true; + case "brushFace": + return right.kind === "brushFace" && left.brushId === right.brushId && left.faceId === right.faceId; + case "brushEdge": + return right.kind === "brushEdge" && left.brushId === right.brushId && left.edgeId === right.edgeId; + case "brushVertex": + return right.kind === "brushVertex" && left.brushId === right.brushId && left.vertexId === right.vertexId; + case "brushes": + case "entities": + case "modelInstances": + return right.kind === left.kind && left.ids.length === right.ids.length && left.ids.every((id, index) => id === right.ids[index]); + } +} +export function getSingleSelectedBrushId(selection) { + if (selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") { + return selection.brushId; + } + if (selection.kind !== "brushes" || selection.ids.length !== 1) { + return null; + } + return selection.ids[0]; +} +export function getSelectedBrushFaceId(selection) { + if (selection.kind !== "brushFace") { + return null; + } + return selection.faceId; +} +export function getSelectedBrushEdgeId(selection) { + if (selection.kind !== "brushEdge") { + return null; + } + return selection.edgeId; +} +export function getSelectedBrushVertexId(selection) { + if (selection.kind !== "brushVertex") { + return null; + } + return selection.vertexId; +} +export function getSingleSelectedEntityId(selection) { + if (selection.kind !== "entities" || selection.ids.length !== 1) { + return null; + } + return selection.ids[0]; +} +export function getSingleSelectedModelInstanceId(selection) { + if (selection.kind !== "modelInstances" || selection.ids.length !== 1) { + return null; + } + return selection.ids[0]; +} +export function isBrushSelected(selection, brushId) { + return ((selection.kind === "brushes" && selection.ids.includes(brushId)) || + ((selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") && + selection.brushId === brushId)); +} +export function isBrushFaceSelected(selection, brushId, faceId) { + return selection.kind === "brushFace" && selection.brushId === brushId && selection.faceId === faceId; +} +export function isBrushEdgeSelected(selection, brushId, edgeId) { + return selection.kind === "brushEdge" && selection.brushId === brushId && selection.edgeId === edgeId; +} +export function isBrushVertexSelected(selection, brushId, vertexId) { + return selection.kind === "brushVertex" && selection.brushId === brushId && selection.vertexId === vertexId; +} +export function isModelInstanceSelected(selection, modelInstanceId) { + return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId); +} +export function normalizeSelectionForWhiteboxSelectionMode(selection, mode) { + switch (selection.kind) { + case "brushFace": + return mode === "face" + ? selection + : { + kind: "brushes", + ids: [selection.brushId] + }; + case "brushEdge": + return mode === "edge" + ? selection + : { + kind: "brushes", + ids: [selection.brushId] + }; + case "brushVertex": + return mode === "vertex" + ? selection + : { + kind: "brushes", + ids: [selection.brushId] + }; + default: + return selection; + } +} diff --git a/src/core/tool-mode.js b/src/core/tool-mode.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/core/tool-mode.js @@ -0,0 +1 @@ +export {}; diff --git a/src/core/transform-session.js b/src/core/transform-session.js new file mode 100644 index 00000000..7a2264db --- /dev/null +++ b/src/core/transform-session.js @@ -0,0 +1,573 @@ +import { createOpaqueId } from "./ids"; +import { BOX_EDGE_LABELS, BOX_FACE_LABELS, BOX_VERTEX_LABELS } from "../document/brushes"; +import { cloneEntityInstance, getEntityKindLabel } from "../entities/entity-instances"; +import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances"; +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function areVec3Equal(left, right) { + return left.x === right.x && left.y === right.y && left.z === right.z; +} +function cloneEntityTransformRotationState(rotation) { + switch (rotation.kind) { + case "none": + return { + kind: "none" + }; + case "yaw": + return { + kind: "yaw", + yawDegrees: rotation.yawDegrees + }; + case "direction": + return { + kind: "direction", + direction: cloneVec3(rotation.direction) + }; + } +} +function areEntityTransformRotationsEqual(left, right) { + if (left.kind !== right.kind) { + return false; + } + switch (left.kind) { + case "none": + return true; + case "yaw": + return right.kind === "yaw" && left.yawDegrees === right.yawDegrees; + case "direction": + return right.kind === "direction" && areVec3Equal(left.direction, right.direction); + } +} +export function createInactiveTransformSession() { + return { + kind: "none" + }; +} +export function cloneTransformTarget(target) { + switch (target.kind) { + case "brush": + return { + kind: "brush", + brushId: target.brushId, + initialCenter: cloneVec3(target.initialCenter), + initialRotationDegrees: cloneVec3(target.initialRotationDegrees), + initialSize: cloneVec3(target.initialSize) + }; + case "brushFace": + return { + kind: "brushFace", + brushId: target.brushId, + faceId: target.faceId, + initialCenter: cloneVec3(target.initialCenter), + initialRotationDegrees: cloneVec3(target.initialRotationDegrees), + initialSize: cloneVec3(target.initialSize) + }; + case "brushEdge": + return { + kind: "brushEdge", + brushId: target.brushId, + edgeId: target.edgeId, + initialCenter: cloneVec3(target.initialCenter), + initialRotationDegrees: cloneVec3(target.initialRotationDegrees), + initialSize: cloneVec3(target.initialSize) + }; + case "brushVertex": + return { + kind: "brushVertex", + brushId: target.brushId, + vertexId: target.vertexId, + initialCenter: cloneVec3(target.initialCenter), + initialRotationDegrees: cloneVec3(target.initialRotationDegrees), + initialSize: cloneVec3(target.initialSize) + }; + case "modelInstance": + return { + kind: "modelInstance", + modelInstanceId: target.modelInstanceId, + assetId: target.assetId, + initialPosition: cloneVec3(target.initialPosition), + initialRotationDegrees: cloneVec3(target.initialRotationDegrees), + initialScale: cloneVec3(target.initialScale) + }; + case "entity": + return { + kind: "entity", + entityId: target.entityId, + entityKind: target.entityKind, + initialPosition: cloneVec3(target.initialPosition), + initialRotation: cloneEntityTransformRotationState(target.initialRotation) + }; + } +} +export function cloneTransformPreview(preview) { + switch (preview.kind) { + case "brush": + return { + kind: "brush", + center: cloneVec3(preview.center), + rotationDegrees: cloneVec3(preview.rotationDegrees), + size: cloneVec3(preview.size) + }; + case "modelInstance": + return { + kind: "modelInstance", + position: cloneVec3(preview.position), + rotationDegrees: cloneVec3(preview.rotationDegrees), + scale: cloneVec3(preview.scale) + }; + case "entity": + return { + kind: "entity", + position: cloneVec3(preview.position), + rotation: cloneEntityTransformRotationState(preview.rotation) + }; + } +} +export function cloneTransformSession(session) { + if (session.kind === "none") { + return session; + } + return { + kind: "active", + id: session.id, + source: session.source, + sourcePanelId: session.sourcePanelId, + operation: session.operation, + axisConstraint: session.axisConstraint, + target: cloneTransformTarget(session.target), + preview: cloneTransformPreview(session.preview) + }; +} +export function areTransformSessionsEqual(left, right) { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "none" || right.kind === "none") { + return true; + } + return (left.id === right.id && + left.source === right.source && + left.sourcePanelId === right.sourcePanelId && + left.operation === right.operation && + left.axisConstraint === right.axisConstraint && + areTransformTargetsEqual(left.target, right.target) && + areTransformPreviewsEqual(left.preview, right.preview)); +} +function areTransformTargetsEqual(left, right) { + if (left.kind !== right.kind) { + return false; + } + switch (left.kind) { + case "brush": + return (right.kind === "brush" && + left.brushId === right.brushId && + areVec3Equal(left.initialCenter, right.initialCenter) && + areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && + areVec3Equal(left.initialSize, right.initialSize)); + case "brushFace": + return (right.kind === "brushFace" && + left.brushId === right.brushId && + left.faceId === right.faceId && + areVec3Equal(left.initialCenter, right.initialCenter) && + areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && + areVec3Equal(left.initialSize, right.initialSize)); + case "brushEdge": + return (right.kind === "brushEdge" && + left.brushId === right.brushId && + left.edgeId === right.edgeId && + areVec3Equal(left.initialCenter, right.initialCenter) && + areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && + areVec3Equal(left.initialSize, right.initialSize)); + case "brushVertex": + return (right.kind === "brushVertex" && + left.brushId === right.brushId && + left.vertexId === right.vertexId && + areVec3Equal(left.initialCenter, right.initialCenter) && + areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && + areVec3Equal(left.initialSize, right.initialSize)); + case "modelInstance": + return (right.kind === "modelInstance" && + left.modelInstanceId === right.modelInstanceId && + left.assetId === right.assetId && + areVec3Equal(left.initialPosition, right.initialPosition) && + areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && + areVec3Equal(left.initialScale, right.initialScale)); + case "entity": + return (right.kind === "entity" && + left.entityId === right.entityId && + left.entityKind === right.entityKind && + areVec3Equal(left.initialPosition, right.initialPosition) && + areEntityTransformRotationsEqual(left.initialRotation, right.initialRotation)); + } +} +function areTransformPreviewsEqual(left, right) { + if (left.kind !== right.kind) { + return false; + } + switch (left.kind) { + case "brush": + return (right.kind === "brush" && + areVec3Equal(left.center, right.center) && + areVec3Equal(left.rotationDegrees, right.rotationDegrees) && + areVec3Equal(left.size, right.size)); + case "modelInstance": + return (right.kind === "modelInstance" && + areVec3Equal(left.position, right.position) && + areVec3Equal(left.rotationDegrees, right.rotationDegrees) && + areVec3Equal(left.scale, right.scale)); + case "entity": + return right.kind === "entity" && areVec3Equal(left.position, right.position) && areEntityTransformRotationsEqual(left.rotation, right.rotation); + } +} +export function createTransformSession(options) { + return { + kind: "active", + id: createOpaqueId("transform-session"), + source: options.source, + sourcePanelId: options.sourcePanelId, + operation: options.operation, + axisConstraint: options.axisConstraint ?? null, + target: cloneTransformTarget(options.target), + preview: createTransformPreviewFromTarget(options.target) + }; +} +export function createTransformPreviewFromTarget(target) { + switch (target.kind) { + case "brush": + case "brushFace": + case "brushEdge": + case "brushVertex": + return { + kind: "brush", + center: cloneVec3(target.initialCenter), + rotationDegrees: cloneVec3(target.initialRotationDegrees), + size: cloneVec3(target.initialSize) + }; + case "modelInstance": + return { + kind: "modelInstance", + position: cloneVec3(target.initialPosition), + rotationDegrees: cloneVec3(target.initialRotationDegrees), + scale: cloneVec3(target.initialScale) + }; + case "entity": + return { + kind: "entity", + position: cloneVec3(target.initialPosition), + rotation: cloneEntityTransformRotationState(target.initialRotation) + }; + } +} +export function doesTransformSessionChangeTarget(session) { + switch (session.target.kind) { + case "brush": + case "brushFace": + case "brushEdge": + case "brushVertex": + return (session.preview.kind === "brush" && + (!areVec3Equal(session.preview.center, session.target.initialCenter) || + !areVec3Equal(session.preview.rotationDegrees, session.target.initialRotationDegrees) || + !areVec3Equal(session.preview.size, session.target.initialSize))); + case "modelInstance": + return (session.preview.kind === "modelInstance" && + (!areVec3Equal(session.preview.position, session.target.initialPosition) || + !areVec3Equal(session.preview.rotationDegrees, session.target.initialRotationDegrees) || + !areVec3Equal(session.preview.scale, session.target.initialScale))); + case "entity": + return (session.preview.kind === "entity" && + (!areVec3Equal(session.preview.position, session.target.initialPosition) || + !areEntityTransformRotationsEqual(session.preview.rotation, session.target.initialRotation))); + } +} +export function getTransformOperationLabel(operation) { + switch (operation) { + case "translate": + return "Move"; + case "rotate": + return "Rotate"; + case "scale": + return "Scale"; + } +} +export function getTransformAxisLabel(axis) { + return axis.toUpperCase(); +} +export function getTransformTargetLabel(target) { + switch (target.kind) { + case "brush": + return "Whitebox Box"; + case "brushFace": + return `Whitebox Face (${BOX_FACE_LABELS[target.faceId]})`; + case "brushEdge": + return `Whitebox Edge (${BOX_EDGE_LABELS[target.edgeId]})`; + case "brushVertex": + return `Whitebox Vertex (${BOX_VERTEX_LABELS[target.vertexId]})`; + case "modelInstance": + return getModelInstanceKindLabel(); + case "entity": + return getEntityKindLabel(target.entityKind); + } +} +export function getSupportedTransformOperations(target) { + switch (target.kind) { + case "brush": + case "brushFace": + case "brushEdge": + return ["translate", "rotate", "scale"]; + case "brushVertex": + return ["translate"]; + case "modelInstance": + return ["translate", "rotate", "scale"]; + case "entity": + return target.initialRotation.kind === "none" ? ["translate"] : ["translate", "rotate"]; + } +} +export function supportsTransformOperation(target, operation) { + return getSupportedTransformOperations(target).includes(operation); +} +export function supportsTransformAxisConstraint(session, axis) { + switch (session.operation) { + case "translate": + return true; + case "scale": + if (session.target.kind === "modelInstance" || session.target.kind === "brush" || session.target.kind === "brushVertex") { + return session.target.kind !== "brushVertex"; + } + if (session.target.kind === "brushFace") { + const normalAxis = session.target.faceId === "posX" || session.target.faceId === "negX" ? "x" : session.target.faceId === "posY" || session.target.faceId === "negY" ? "y" : "z"; + return axis === normalAxis; + } + if (session.target.kind === "brushEdge") { + if (session.target.edgeId.startsWith("edgeX_")) { + return axis !== "x"; + } + if (session.target.edgeId.startsWith("edgeY_")) { + return axis !== "y"; + } + return axis !== "z"; + } + return false; + case "rotate": + if (session.target.kind === "entity" && session.target.initialRotation.kind === "yaw") { + return axis === "y"; + } + if (session.target.kind === "brushFace") { + const normalAxis = session.target.faceId === "posX" || session.target.faceId === "negX" ? "x" : session.target.faceId === "posY" || session.target.faceId === "negY" ? "y" : "z"; + return axis === normalAxis; + } + if (session.target.kind === "brushEdge") { + if (session.target.edgeId.startsWith("edgeX_")) { + return axis === "x"; + } + if (session.target.edgeId.startsWith("edgeY_")) { + return axis === "y"; + } + return axis === "z"; + } + if (session.target.kind === "brushVertex") { + return false; + } + return true; + } +} +function resolveEntityRotation(entity) { + switch (entity.kind) { + case "playerStart": + case "teleportTarget": + return { + kind: "yaw", + yawDegrees: entity.yawDegrees + }; + case "spotLight": + return { + kind: "direction", + direction: cloneVec3(entity.direction) + }; + case "pointLight": + case "soundEmitter": + case "triggerVolume": + case "interactable": + return { + kind: "none" + }; + } +} +function createBrushTransformTarget(document, brushId) { + const brush = document.brushes[brushId]; + if (brush === undefined || brush.kind !== "box") { + return { + target: null, + message: "Select a supported whitebox box before transforming it." + }; + } + return { + target: { + kind: "brush", + brushId: brush.id, + initialCenter: cloneVec3(brush.center), + initialRotationDegrees: cloneVec3(brush.rotationDegrees), + initialSize: cloneVec3(brush.size) + }, + message: null + }; +} +function createBrushFaceTransformTarget(document, brushId, faceId) { + const brushResolution = createBrushTransformTarget(document, brushId); + if (brushResolution.target === null || brushResolution.target.kind !== "brush") { + return brushResolution; + } + return { + target: { + kind: "brushFace", + brushId, + faceId, + initialCenter: cloneVec3(brushResolution.target.initialCenter), + initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees), + initialSize: cloneVec3(brushResolution.target.initialSize) + }, + message: null + }; +} +function createBrushEdgeTransformTarget(document, brushId, edgeId) { + const brushResolution = createBrushTransformTarget(document, brushId); + if (brushResolution.target === null || brushResolution.target.kind !== "brush") { + return brushResolution; + } + return { + target: { + kind: "brushEdge", + brushId, + edgeId, + initialCenter: cloneVec3(brushResolution.target.initialCenter), + initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees), + initialSize: cloneVec3(brushResolution.target.initialSize) + }, + message: null + }; +} +function createBrushVertexTransformTarget(document, brushId, vertexId) { + const brushResolution = createBrushTransformTarget(document, brushId); + if (brushResolution.target === null || brushResolution.target.kind !== "brush") { + return brushResolution; + } + return { + target: { + kind: "brushVertex", + brushId, + vertexId, + initialCenter: cloneVec3(brushResolution.target.initialCenter), + initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees), + initialSize: cloneVec3(brushResolution.target.initialSize) + }, + message: null + }; +} +function createEntityTransformTarget(document, entityId) { + const entity = document.entities[entityId]; + if (entity === undefined) { + return { + target: null, + message: "Select an authored entity before transforming it." + }; + } + const clonedEntity = cloneEntityInstance(entity); + return { + target: { + kind: "entity", + entityId: clonedEntity.id, + entityKind: clonedEntity.kind, + initialPosition: cloneVec3(clonedEntity.position), + initialRotation: resolveEntityRotation(clonedEntity) + }, + message: null + }; +} +function createModelInstanceTransformTarget(document, modelInstanceId) { + const modelInstance = document.modelInstances[modelInstanceId]; + if (modelInstance === undefined) { + return { + target: null, + message: "Select a model instance before transforming it." + }; + } + const clonedModelInstance = cloneModelInstance(modelInstance); + return { + target: { + kind: "modelInstance", + modelInstanceId: clonedModelInstance.id, + assetId: clonedModelInstance.assetId, + initialPosition: cloneVec3(clonedModelInstance.position), + initialRotationDegrees: cloneVec3(clonedModelInstance.rotationDegrees), + initialScale: cloneVec3(clonedModelInstance.scale) + }, + message: null + }; +} +export function resolveTransformTarget(document, selection, whiteboxSelectionMode = "object") { + switch (selection.kind) { + case "none": + return { + target: null, + message: "Select a single brush, entity, or model instance before transforming it." + }; + case "brushFace": + if (whiteboxSelectionMode !== "face") { + return { + target: null, + message: "Switch to Face mode to transform a selected whitebox face." + }; + } + return createBrushFaceTransformTarget(document, selection.brushId, selection.faceId); + case "brushEdge": + if (whiteboxSelectionMode !== "edge") { + return { + target: null, + message: "Switch to Edge mode to transform a selected whitebox edge." + }; + } + return createBrushEdgeTransformTarget(document, selection.brushId, selection.edgeId); + case "brushVertex": + if (whiteboxSelectionMode !== "vertex") { + return { + target: null, + message: "Switch to Vertex mode to transform a selected whitebox vertex." + }; + } + return createBrushVertexTransformTarget(document, selection.brushId, selection.vertexId); + case "brushes": + if (whiteboxSelectionMode !== "object") { + return { + target: null, + message: "Switch to Object mode to transform the whole whitebox box." + }; + } + if (selection.ids.length !== 1) { + return { + target: null, + message: "Select a single brush before transforming it." + }; + } + return createBrushTransformTarget(document, selection.ids[0]); + case "entities": + if (selection.ids.length !== 1) { + return { + target: null, + message: "Select a single entity before transforming it." + }; + } + return createEntityTransformTarget(document, selection.ids[0]); + case "modelInstances": + if (selection.ids.length !== 1) { + return { + target: null, + message: "Select a single model instance before transforming it." + }; + } + return createModelInstanceTransformTarget(document, selection.ids[0]); + } +} diff --git a/src/core/vector.js b/src/core/vector.js new file mode 100644 index 00000000..50ee7a26 --- /dev/null +++ b/src/core/vector.js @@ -0,0 +1,5 @@ +export const DEFAULT_SUN_DIRECTION = { + x: -0.6, + y: 1, + z: 0.35 +}; diff --git a/src/core/whitebox-selection-feedback.js b/src/core/whitebox-selection-feedback.js new file mode 100644 index 00000000..7205ff8c --- /dev/null +++ b/src/core/whitebox-selection-feedback.js @@ -0,0 +1,23 @@ +import { BOX_EDGE_LABELS, BOX_FACE_LABELS, BOX_VERTEX_LABELS } from "../document/brushes"; +function getBrushDisplayLabel(document, brushId) { + const brushes = Object.values(document.brushes); + const brushIndex = brushes.findIndex((brush) => brush.id === brushId); + if (brushIndex === -1) { + return "Whitebox Box"; + } + return brushes[brushIndex].name ?? `Whitebox Box ${brushIndex + 1}`; +} +export function getWhiteboxSelectionFeedbackLabel(document, selection) { + switch (selection.kind) { + case "brushes": + return selection.ids.length === 1 ? `Solid · ${getBrushDisplayLabel(document, selection.ids[0])}` : null; + case "brushFace": + return `Face · ${BOX_FACE_LABELS[selection.faceId]} · ${getBrushDisplayLabel(document, selection.brushId)}`; + case "brushEdge": + return `Edge · ${BOX_EDGE_LABELS[selection.edgeId]} · ${getBrushDisplayLabel(document, selection.brushId)}`; + case "brushVertex": + return `Vertex · ${BOX_VERTEX_LABELS[selection.vertexId]} · ${getBrushDisplayLabel(document, selection.brushId)}`; + default: + return null; + } +} diff --git a/src/core/whitebox-selection-mode.js b/src/core/whitebox-selection-mode.js new file mode 100644 index 00000000..2b103100 --- /dev/null +++ b/src/core/whitebox-selection-mode.js @@ -0,0 +1,10 @@ +export const WHITEBOX_SELECTION_MODES = ["object", "face", "edge", "vertex"]; +export const WHITEBOX_SELECTION_MODE_LABELS = { + object: "Object", + face: "Face", + edge: "Edge", + vertex: "Vertex" +}; +export function getWhiteboxSelectionModeLabel(mode) { + return WHITEBOX_SELECTION_MODE_LABELS[mode]; +} diff --git a/src/document/brushes.js b/src/document/brushes.js new file mode 100644 index 00000000..cc60d5be --- /dev/null +++ b/src/document/brushes.js @@ -0,0 +1,266 @@ +import { createOpaqueId } from "../core/ids"; +export const BOX_FACE_IDS = ["posX", "negX", "posY", "negY", "posZ", "negZ"]; +export const BOX_EDGE_IDS = [ + "edgeX_negY_negZ", + "edgeX_posY_negZ", + "edgeX_negY_posZ", + "edgeX_posY_posZ", + "edgeY_negX_negZ", + "edgeY_posX_negZ", + "edgeY_negX_posZ", + "edgeY_posX_posZ", + "edgeZ_negX_negY", + "edgeZ_posX_negY", + "edgeZ_negX_posY", + "edgeZ_posX_posY" +]; +export const BOX_VERTEX_IDS = [ + "negX_negY_negZ", + "posX_negY_negZ", + "negX_posY_negZ", + "posX_posY_negZ", + "negX_negY_posZ", + "posX_negY_posZ", + "negX_posY_posZ", + "posX_posY_posZ" +]; +export const FACE_UV_ROTATION_QUARTER_TURNS = [0, 1, 2, 3]; +export const BOX_FACE_LABELS = { + posX: "Right", + negX: "Left", + posY: "Top", + negY: "Bottom", + posZ: "Front", + negZ: "Back" +}; +export const BOX_EDGE_LABELS = { + edgeX_negY_negZ: "X Edge (-Y, -Z)", + edgeX_posY_negZ: "X Edge (+Y, -Z)", + edgeX_negY_posZ: "X Edge (-Y, +Z)", + edgeX_posY_posZ: "X Edge (+Y, +Z)", + edgeY_negX_negZ: "Y Edge (-X, -Z)", + edgeY_posX_negZ: "Y Edge (+X, -Z)", + edgeY_negX_posZ: "Y Edge (-X, +Z)", + edgeY_posX_posZ: "Y Edge (+X, +Z)", + edgeZ_negX_negY: "Z Edge (-X, -Y)", + edgeZ_posX_negY: "Z Edge (+X, -Y)", + edgeZ_negX_posY: "Z Edge (-X, +Y)", + edgeZ_posX_posY: "Z Edge (+X, +Y)" +}; +export const BOX_VERTEX_LABELS = { + negX_negY_negZ: "Vertex (-X, -Y, -Z)", + posX_negY_negZ: "Vertex (+X, -Y, -Z)", + negX_posY_negZ: "Vertex (-X, +Y, -Z)", + posX_posY_negZ: "Vertex (+X, +Y, -Z)", + negX_negY_posZ: "Vertex (-X, -Y, +Z)", + posX_negY_posZ: "Vertex (+X, -Y, +Z)", + negX_posY_posZ: "Vertex (-X, +Y, +Z)", + posX_posY_posZ: "Vertex (+X, +Y, +Z)" +}; +export const DEFAULT_BOX_BRUSH_CENTER = { + x: 0, + y: 1, + z: 0 +}; +export const DEFAULT_BOX_BRUSH_SIZE = { + x: 2, + y: 2, + z: 2 +}; +export const DEFAULT_BOX_BRUSH_ROTATION_DEGREES = { + x: 0, + y: 0, + z: 0 +}; +export function normalizeBrushName(name) { + if (name === undefined || name === null) { + return undefined; + } + const trimmedName = name.trim(); + return trimmedName.length === 0 ? undefined : trimmedName; +} +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function cloneBrushFace(face) { + return { + materialId: face.materialId, + uv: cloneFaceUvState(face.uv) + }; +} +function cloneBoxBrushGeometryVertex(vertex) { + return { + x: vertex.x, + y: vertex.y, + z: vertex.z + }; +} +export function cloneBoxBrushGeometry(geometry) { + return { + vertices: { + negX_negY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_negY_negZ), + posX_negY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_negY_negZ), + negX_posY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_posY_negZ), + posX_posY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_posY_negZ), + negX_negY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_negY_posZ), + posX_negY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_negY_posZ), + negX_posY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_posY_posZ), + posX_posY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_posY_posZ) + } + }; +} +export function getBoxBrushGeometryLocalBounds(geometry) { + const vertices = Object.values(geometry.vertices); + const firstVertex = vertices[0]; + const min = { ...firstVertex }; + const max = { ...firstVertex }; + for (const vertex of vertices.slice(1)) { + min.x = Math.min(min.x, vertex.x); + min.y = Math.min(min.y, vertex.y); + min.z = Math.min(min.z, vertex.z); + max.x = Math.max(max.x, vertex.x); + max.y = Math.max(max.y, vertex.y); + max.z = Math.max(max.z, vertex.z); + } + return { + min, + max + }; +} +export function deriveBoxBrushSizeFromGeometry(geometry) { + const bounds = getBoxBrushGeometryLocalBounds(geometry); + return { + x: bounds.max.x - bounds.min.x, + y: bounds.max.y - bounds.min.y, + z: bounds.max.z - bounds.min.z + }; +} +export function createDefaultBoxBrushGeometry(size = DEFAULT_BOX_BRUSH_SIZE) { + const halfSize = { + x: size.x * 0.5, + y: size.y * 0.5, + z: size.z * 0.5 + }; + return { + vertices: { + negX_negY_negZ: { x: -halfSize.x, y: -halfSize.y, z: -halfSize.z }, + posX_negY_negZ: { x: halfSize.x, y: -halfSize.y, z: -halfSize.z }, + negX_posY_negZ: { x: -halfSize.x, y: halfSize.y, z: -halfSize.z }, + posX_posY_negZ: { x: halfSize.x, y: halfSize.y, z: -halfSize.z }, + negX_negY_posZ: { x: -halfSize.x, y: -halfSize.y, z: halfSize.z }, + posX_negY_posZ: { x: halfSize.x, y: -halfSize.y, z: halfSize.z }, + negX_posY_posZ: { x: -halfSize.x, y: halfSize.y, z: halfSize.z }, + posX_posY_posZ: { x: halfSize.x, y: halfSize.y, z: halfSize.z } + } + }; +} +export function isBoxFaceId(value) { + return typeof value === "string" && BOX_FACE_IDS.some((faceId) => faceId === value); +} +export function isBoxEdgeId(value) { + return typeof value === "string" && BOX_EDGE_IDS.some((edgeId) => edgeId === value); +} +export function isBoxVertexId(value) { + return typeof value === "string" && BOX_VERTEX_IDS.some((vertexId) => vertexId === value); +} +export function isFaceUvRotationQuarterTurns(value) { + return typeof value === "number" && FACE_UV_ROTATION_QUARTER_TURNS.includes(value); +} +export function hasPositiveBoxSize(size) { + return size.x > 0 && size.y > 0 && size.z > 0; +} +export function createDefaultFaceUvState() { + return { + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + }; +} +export function cloneFaceUvState(uv) { + return { + offset: { + ...uv.offset + }, + scale: { + ...uv.scale + }, + rotationQuarterTurns: uv.rotationQuarterTurns, + flipU: uv.flipU, + flipV: uv.flipV + }; +} +export function cloneBoxBrushFaces(faces) { + return { + posX: cloneBrushFace(faces.posX), + negX: cloneBrushFace(faces.negX), + posY: cloneBrushFace(faces.posY), + negY: cloneBrushFace(faces.negY), + posZ: cloneBrushFace(faces.posZ), + negZ: cloneBrushFace(faces.negZ) + }; +} +export function createDefaultBoxBrushFaces() { + return { + posX: { + materialId: null, + uv: createDefaultFaceUvState() + }, + negX: { + materialId: null, + uv: createDefaultFaceUvState() + }, + posY: { + materialId: null, + uv: createDefaultFaceUvState() + }, + negY: { + materialId: null, + uv: createDefaultFaceUvState() + }, + posZ: { + materialId: null, + uv: createDefaultFaceUvState() + }, + negZ: { + materialId: null, + uv: createDefaultFaceUvState() + } + }; +} +export function createBoxBrush(overrides = {}) { + const center = cloneVec3(overrides.center ?? DEFAULT_BOX_BRUSH_CENTER); + const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_BOX_BRUSH_ROTATION_DEGREES); + const fallbackSize = cloneVec3(overrides.size ?? DEFAULT_BOX_BRUSH_SIZE); + const geometry = overrides.geometry === undefined ? createDefaultBoxBrushGeometry(fallbackSize) : cloneBoxBrushGeometry(overrides.geometry); + const size = deriveBoxBrushSizeFromGeometry(geometry); + if (!hasPositiveBoxSize(size)) { + throw new Error("Box brush size must remain positive on every axis."); + } + return { + id: overrides.id ?? createOpaqueId("brush"), + kind: "box", + name: normalizeBrushName(overrides.name), + center, + rotationDegrees, + size, + geometry, + faces: overrides.faces === undefined ? createDefaultBoxBrushFaces() : cloneBoxBrushFaces(overrides.faces), + layerId: overrides.layerId, + groupId: overrides.groupId + }; +} +export function cloneBoxBrush(brush) { + return createBoxBrush(brush); +} diff --git a/src/document/migrate-scene-document.js b/src/document/migrate-scene-document.js new file mode 100644 index 00000000..bfd13186 --- /dev/null +++ b/src/document/migrate-scene-document.js @@ -0,0 +1,1219 @@ +import { createStarterMaterialRegistry } from "../materials/starter-material-library"; +import { createModelInstanceCollisionSettings, createModelInstance, isModelInstanceCollisionMode, normalizeModelInstanceName } from "../assets/model-instances"; +import { isProjectAssetKind } from "../assets/project-assets"; +import { createPlayerStartColliderSettings, createInteractableEntity, normalizeEntityName, createPointLightEntity, createPlayerStartEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity, isPlayerStartColliderMode } from "../entities/entity-instances"; +import { createPlayAnimationInteractionLink, createPlaySoundInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, isInteractionTriggerKind } from "../interactions/interaction-links"; +import { createBoxBrush, createDefaultFaceUvState, DEFAULT_BOX_BRUSH_ROTATION_DEGREES, isBoxFaceId, isFaceUvRotationQuarterTurns, normalizeBrushName } from "./brushes"; +import { BOX_BRUSH_SCENE_DOCUMENT_VERSION, ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION, ENTITY_NAMES_SCENE_DOCUMENT_VERSION, ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION, FACE_MATERIALS_SCENE_DOCUMENT_VERSION, FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION, FOUNDATION_SCENE_DOCUMENT_VERSION, IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION, MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION, PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION, RUNNER_V1_SCENE_DOCUMENT_VERSION, SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_VERSION, TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION, WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION, WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION } from "./scene-document"; +import { createDefaultAdvancedRenderingSettings, isAdvancedRenderingShadowMapSize, isAdvancedRenderingShadowType, isAdvancedRenderingToneMappingMode, isWorldBackgroundMode } from "./world-settings"; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function expectFiniteNumber(value, label) { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + return value; +} +function expectNonNegativeFiniteNumber(value, label) { + const numberValue = expectFiniteNumber(value, label); + if (numberValue < 0) { + throw new Error(`${label} must be zero or greater.`); + } + return numberValue; +} +function expectPositiveFiniteNumber(value, label) { + const numberValue = expectFiniteNumber(value, label); + if (numberValue <= 0) { + throw new Error(`${label} must be greater than zero.`); + } + return numberValue; +} +function expectString(value, label) { + if (typeof value !== "string") { + throw new Error(`${label} must be a string.`); + } + return value; +} +function expectBoolean(value, label) { + if (typeof value !== "boolean") { + throw new Error(`${label} must be a boolean.`); + } + return value; +} +function expectStringArray(value, label) { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new Error(`${label} must be a string array.`); + } + return [...value]; +} +function expectHexColor(value, label) { + const normalizedValue = expectString(value, label); + if (!/^#[0-9a-f]{6}$/i.test(normalizedValue)) { + throw new Error(`${label} must use #RRGGBB format.`); + } + return normalizedValue; +} +function expectLiteralString(value, expectedValue, label) { + if (value !== expectedValue) { + throw new Error(`${label} must be ${expectedValue}.`); + } + return expectedValue; +} +function readOptionalBoolean(value, label, fallback) { + if (value === undefined) { + return fallback; + } + return expectBoolean(value, label); +} +function readOptionalFiniteNumber(value, label, fallback) { + if (value === undefined) { + return fallback; + } + return expectFiniteNumber(value, label); +} +function readOptionalNonNegativeFiniteNumber(value, label, fallback) { + if (value === undefined) { + return fallback; + } + return expectNonNegativeFiniteNumber(value, label); +} +function readOptionalPositiveInteger(value, label, fallback) { + if (value === undefined) { + return fallback; + } + const integerValue = expectFiniteNumber(value, label); + if (!Number.isInteger(integerValue) || integerValue <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return integerValue; +} +function readOptionalAllowedValue(value, label, fallback, guard) { + if (value === undefined) { + return fallback; + } + if (!guard(value)) { + throw new Error(`${label} must be a supported value.`); + } + return value; +} +function readAdvancedRenderingSettings(value) { + const defaults = createDefaultAdvancedRenderingSettings(); + if (value === undefined) { + return defaults; + } + if (!isRecord(value)) { + throw new Error("world.advancedRendering must be an object."); + } + if (value.shadows !== undefined && !isRecord(value.shadows)) { + throw new Error("world.advancedRendering.shadows must be an object."); + } + if (value.ambientOcclusion !== undefined && !isRecord(value.ambientOcclusion)) { + throw new Error("world.advancedRendering.ambientOcclusion must be an object."); + } + if (value.bloom !== undefined && !isRecord(value.bloom)) { + throw new Error("world.advancedRendering.bloom must be an object."); + } + if (value.toneMapping !== undefined && !isRecord(value.toneMapping)) { + throw new Error("world.advancedRendering.toneMapping must be an object."); + } + if (value.depthOfField !== undefined && !isRecord(value.depthOfField)) { + throw new Error("world.advancedRendering.depthOfField must be an object."); + } + const shadows = value.shadows; + const ambientOcclusion = value.ambientOcclusion; + const bloom = value.bloom; + const toneMapping = value.toneMapping; + const depthOfField = value.depthOfField; + const shadowsMapSize = readOptionalAllowedValue(shadows?.mapSize, "world.advancedRendering.shadows.mapSize", defaults.shadows.mapSize, isAdvancedRenderingShadowMapSize); + const shadowsType = readOptionalAllowedValue(shadows?.type, "world.advancedRendering.shadows.type", defaults.shadows.type, isAdvancedRenderingShadowType); + const toneMappingMode = readOptionalAllowedValue(toneMapping?.mode, "world.advancedRendering.toneMapping.mode", defaults.toneMapping.mode, isAdvancedRenderingToneMappingMode); + return { + enabled: readOptionalBoolean(value.enabled, "world.advancedRendering.enabled", defaults.enabled), + shadows: { + enabled: readOptionalBoolean(shadows?.enabled, "world.advancedRendering.shadows.enabled", defaults.shadows.enabled), + mapSize: shadowsMapSize, + type: shadowsType, + bias: readOptionalFiniteNumber(shadows?.bias, "world.advancedRendering.shadows.bias", defaults.shadows.bias) + }, + ambientOcclusion: { + enabled: readOptionalBoolean(ambientOcclusion?.enabled, "world.advancedRendering.ambientOcclusion.enabled", defaults.ambientOcclusion.enabled), + intensity: readOptionalNonNegativeFiniteNumber(ambientOcclusion?.intensity, "world.advancedRendering.ambientOcclusion.intensity", defaults.ambientOcclusion.intensity), + radius: readOptionalNonNegativeFiniteNumber(ambientOcclusion?.radius, "world.advancedRendering.ambientOcclusion.radius", defaults.ambientOcclusion.radius), + samples: readOptionalPositiveInteger(ambientOcclusion?.samples, "world.advancedRendering.ambientOcclusion.samples", defaults.ambientOcclusion.samples) + }, + bloom: { + enabled: readOptionalBoolean(bloom?.enabled, "world.advancedRendering.bloom.enabled", defaults.bloom.enabled), + intensity: readOptionalNonNegativeFiniteNumber(bloom?.intensity, "world.advancedRendering.bloom.intensity", defaults.bloom.intensity), + threshold: readOptionalNonNegativeFiniteNumber(bloom?.threshold, "world.advancedRendering.bloom.threshold", defaults.bloom.threshold), + radius: readOptionalNonNegativeFiniteNumber(bloom?.radius, "world.advancedRendering.bloom.radius", defaults.bloom.radius) + }, + toneMapping: { + mode: toneMappingMode, + exposure: readOptionalFiniteNumber(toneMapping?.exposure, "world.advancedRendering.toneMapping.exposure", defaults.toneMapping.exposure) + }, + depthOfField: { + enabled: readOptionalBoolean(depthOfField?.enabled, "world.advancedRendering.depthOfField.enabled", defaults.depthOfField.enabled), + focusDistance: readOptionalNonNegativeFiniteNumber(depthOfField?.focusDistance, "world.advancedRendering.depthOfField.focusDistance", defaults.depthOfField.focusDistance), + focalLength: readOptionalNonNegativeFiniteNumber(depthOfField?.focalLength, "world.advancedRendering.depthOfField.focalLength", defaults.depthOfField.focalLength), + bokehScale: readOptionalNonNegativeFiniteNumber(depthOfField?.bokehScale, "world.advancedRendering.depthOfField.bokehScale", defaults.depthOfField.bokehScale) + } + }; +} +function expectOptionalString(value, label) { + if (value === undefined) { + return undefined; + } + return expectString(value, label); +} +function readOptionalBrushName(value, label) { + return normalizeBrushName(expectOptionalString(value, label)); +} +function readOptionalEntityName(value, label) { + return normalizeEntityName(expectOptionalString(value, label)); +} +function expectEmptyCollection(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be a record.`); + } + if (Object.keys(value).length > 0) { + throw new Error(`${label} must be empty in the current schema.`); + } + return {}; +} +function readProjectAssetBoundingBox(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const min = readVec3(value.min, `${label}.min`); + const max = readVec3(value.max, `${label}.max`); + const size = readVec3(value.size, `${label}.size`); + if (size.x < 0 || size.y < 0 || size.z < 0) { + throw new Error(`${label}.size values must remain zero or greater.`); + } + return { + min, + max, + size + }; +} +function readModelAssetMetadata(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const format = expectString(value.format, `${label}.format`); + if (format !== "glb" && format !== "gltf") { + throw new Error(`${label}.format must be glb or gltf.`); + } + const sceneName = value.sceneName === null ? null : expectOptionalString(value.sceneName, `${label}.sceneName`) ?? null; + return { + kind: "model", + format, + sceneName, + nodeCount: expectNonNegativeFiniteNumber(value.nodeCount, `${label}.nodeCount`), + meshCount: expectNonNegativeFiniteNumber(value.meshCount, `${label}.meshCount`), + materialNames: expectStringArray(value.materialNames, `${label}.materialNames`), + textureNames: expectStringArray(value.textureNames, `${label}.textureNames`), + animationNames: expectStringArray(value.animationNames, `${label}.animationNames`), + boundingBox: value.boundingBox === null ? null : readProjectAssetBoundingBox(value.boundingBox, `${label}.boundingBox`), + warnings: expectStringArray(value.warnings, `${label}.warnings`) + }; +} +function readImageAssetMetadata(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + return { + kind: "image", + width: expectPositiveFiniteNumber(value.width, `${label}.width`), + height: expectPositiveFiniteNumber(value.height, `${label}.height`), + hasAlpha: expectBoolean(value.hasAlpha, `${label}.hasAlpha`), + warnings: expectStringArray(value.warnings, `${label}.warnings`) + }; +} +function readAudioAssetMetadata(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + return { + kind: "audio", + durationSeconds: value.durationSeconds === null + ? null + : expectNonNegativeFiniteNumber(value.durationSeconds, `${label}.durationSeconds`), + channelCount: value.channelCount === null ? null : expectPositiveFiniteNumber(value.channelCount, `${label}.channelCount`), + sampleRateHz: value.sampleRateHz === null ? null : expectPositiveFiniteNumber(value.sampleRateHz, `${label}.sampleRateHz`), + warnings: expectStringArray(value.warnings, `${label}.warnings`) + }; +} +function readProjectAsset(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = value.kind; + if (!isProjectAssetKind(kind)) { + throw new Error(`${label}.kind must be model, image, or audio.`); + } + const id = expectString(value.id, `${label}.id`); + const sourceName = expectString(value.sourceName, `${label}.sourceName`); + const mimeType = expectString(value.mimeType, `${label}.mimeType`); + const storageKey = expectString(value.storageKey, `${label}.storageKey`); + const byteLength = expectPositiveFiniteNumber(value.byteLength, `${label}.byteLength`); + switch (kind) { + case "model": + return { + id, + kind, + sourceName, + mimeType, + storageKey, + byteLength, + metadata: readModelAssetMetadata(value.metadata, `${label}.metadata`) + }; + case "image": + return { + id, + kind, + sourceName, + mimeType, + storageKey, + byteLength, + metadata: readImageAssetMetadata(value.metadata, `${label}.metadata`) + }; + case "audio": + return { + id, + kind, + sourceName, + mimeType, + storageKey, + byteLength, + metadata: readAudioAssetMetadata(value.metadata, `${label}.metadata`) + }; + } +} +function readAssets(value) { + if (!isRecord(value)) { + throw new Error("assets must be a record."); + } + const assets = {}; + for (const [assetId, assetValue] of Object.entries(value)) { + const asset = readProjectAsset(assetValue, `assets.${assetId}`); + if (asset.id !== assetId) { + throw new Error(`assets.${assetId}.id must match the registry key.`); + } + assets[assetId] = asset; + } + return assets; +} +function readModelInstanceCollisionSettings(value, label) { + if (value === undefined) { + return createModelInstanceCollisionSettings(); + } + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const mode = readOptionalAllowedValue(value.mode, `${label}.mode`, "none", isModelInstanceCollisionMode); + return createModelInstanceCollisionSettings({ + mode, + visible: readOptionalBoolean(value.visible, `${label}.visible`, false) + }); +} +function readPlayerStartColliderSettings(value, label) { + if (value === undefined) { + return createPlayerStartColliderSettings(); + } + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const mode = readOptionalAllowedValue(value.mode, `${label}.mode`, "capsule", (candidate) => typeof candidate === "string" && isPlayerStartColliderMode(candidate)); + return createPlayerStartColliderSettings({ + mode, + eyeHeight: value.eyeHeight === undefined ? undefined : expectPositiveFiniteNumber(value.eyeHeight, `${label}.eyeHeight`), + capsuleRadius: value.capsuleRadius === undefined ? undefined : expectPositiveFiniteNumber(value.capsuleRadius, `${label}.capsuleRadius`), + capsuleHeight: value.capsuleHeight === undefined ? undefined : expectPositiveFiniteNumber(value.capsuleHeight, `${label}.capsuleHeight`), + boxSize: value.boxSize === undefined ? undefined : readVec3(value.boxSize, `${label}.boxSize`) + }); +} +function readModelInstance(value, label, assets) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const assetId = expectString(value.assetId, `${label}.assetId`); + const asset = assets[assetId]; + if (asset === undefined) { + throw new Error(`${label}.assetId references missing asset ${assetId}.`); + } + if (asset.kind !== "model") { + throw new Error(`${label}.assetId must reference a model asset.`); + } + return createModelInstance({ + id: expectString(value.id, `${label}.id`), + assetId, + name: normalizeModelInstanceName(expectOptionalString(value.name, `${label}.name`)), + position: readVec3(value.position, `${label}.position`), + rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`), + scale: readVec3(value.scale, `${label}.scale`), + collision: readModelInstanceCollisionSettings(value.collision, `${label}.collision`), + animationClipName: (() => { + const raw = expectOptionalString(value.animationClipName, `${label}.animationClipName`); + if (raw === undefined) + return undefined; + const trimmed = raw.trim(); + return trimmed.length === 0 ? undefined : trimmed; + })(), + animationAutoplay: value.animationAutoplay === undefined + ? undefined + : expectBoolean(value.animationAutoplay, `${label}.animationAutoplay`) + }); +} +function readModelInstances(value, assets) { + if (!isRecord(value)) { + throw new Error("modelInstances must be a record."); + } + const modelInstances = {}; + for (const [modelInstanceId, modelInstanceValue] of Object.entries(value)) { + const modelInstance = readModelInstance(modelInstanceValue, `modelInstances.${modelInstanceId}`, assets); + if (modelInstance.id !== modelInstanceId) { + throw new Error(`modelInstances.${modelInstanceId}.id must match the registry key.`); + } + modelInstances[modelInstanceId] = modelInstance; + } + return modelInstances; +} +function readVec2(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + return { + x: expectFiniteNumber(value.x, `${label}.x`), + y: expectFiniteNumber(value.y, `${label}.y`) + }; +} +function readVec3(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + return { + x: expectFiniteNumber(value.x, `${label}.x`), + y: expectFiniteNumber(value.y, `${label}.y`), + z: expectFiniteNumber(value.z, `${label}.z`) + }; +} +function readOptionalVec3(value, label, fallback) { + if (value === undefined) { + return { + x: fallback.x, + y: fallback.y, + z: fallback.z + }; + } + return readVec3(value, label); +} +function assertNonZeroVec3(vector, label) { + if (vector.x === 0 && vector.y === 0 && vector.z === 0) { + throw new Error(`${label} must not be the zero vector.`); + } +} +function expectMaterialPattern(value, label) { + if (value !== "grid" && value !== "checker" && value !== "stripes" && value !== "diamond") { + throw new Error(`${label} must be a supported starter material pattern.`); + } + return value; +} +function readMaterialRegistry(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be a record.`); + } + const materials = {}; + for (const [materialId, materialValue] of Object.entries(value)) { + if (!isRecord(materialValue)) { + throw new Error(`${label}.${materialId} must be an object.`); + } + const material = { + id: expectString(materialValue.id, `${label}.${materialId}.id`), + name: expectString(materialValue.name, `${label}.${materialId}.name`), + baseColorHex: expectHexColor(materialValue.baseColorHex, `${label}.${materialId}.baseColorHex`), + accentColorHex: expectHexColor(materialValue.accentColorHex, `${label}.${materialId}.accentColorHex`), + pattern: expectMaterialPattern(materialValue.pattern, `${label}.${materialId}.pattern`), + tags: expectStringArray(materialValue.tags, `${label}.${materialId}.tags`) + }; + if (material.id !== materialId) { + throw new Error(`${label}.${materialId}.id must match the registry key.`); + } + materials[materialId] = material; + } + return materials; +} +function readFaceUvState(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const rotationQuarterTurns = expectFiniteNumber(value.rotationQuarterTurns, `${label}.rotationQuarterTurns`); + if (!isFaceUvRotationQuarterTurns(rotationQuarterTurns)) { + throw new Error(`${label}.rotationQuarterTurns must be 0, 1, 2, or 3.`); + } + const scale = readVec2(value.scale, `${label}.scale`); + if (scale.x <= 0 || scale.y <= 0) { + throw new Error(`${label}.scale values must remain positive.`); + } + return { + offset: readVec2(value.offset, `${label}.offset`), + scale, + rotationQuarterTurns, + flipU: expectBoolean(value.flipU, `${label}.flipU`), + flipV: expectBoolean(value.flipV, `${label}.flipV`) + }; +} +function readBrushFace(value, label, materials, allowMissingUvState) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const materialId = value.materialId; + if (materialId !== null && materialId !== undefined && typeof materialId !== "string") { + throw new Error(`${label}.materialId must be a string or null.`); + } + if (materialId !== null && materialId !== undefined && materials[materialId] === undefined) { + throw new Error(`${label}.materialId references missing material ${materialId}.`); + } + return { + materialId: materialId ?? null, + uv: value.uv === undefined && allowMissingUvState + ? createDefaultFaceUvState() + : readFaceUvState(value.uv, `${label}.uv`) + }; +} +function readBoxBrushFaces(value, label, materials, allowMissingUvState) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const extraFaceKeys = Object.keys(value).filter((faceId) => !isBoxFaceId(faceId)); + if (extraFaceKeys.length > 0) { + throw new Error(`${label} contains unsupported face ids: ${extraFaceKeys.join(", ")}.`); + } + return { + posX: readBrushFace(value.posX, `${label}.posX`, materials, allowMissingUvState), + negX: readBrushFace(value.negX, `${label}.negX`, materials, allowMissingUvState), + posY: readBrushFace(value.posY, `${label}.posY`, materials, allowMissingUvState), + negY: readBrushFace(value.negY, `${label}.negY`, materials, allowMissingUvState), + posZ: readBrushFace(value.posZ, `${label}.posZ`, materials, allowMissingUvState), + negZ: readBrushFace(value.negZ, `${label}.negZ`, materials, allowMissingUvState) + }; +} +function readBrushes(value, materials, allowMissingUvState) { + if (!isRecord(value)) { + throw new Error("brushes must be a record."); + } + const brushes = {}; + for (const [brushId, brushValue] of Object.entries(value)) { + if (!isRecord(brushValue)) { + throw new Error(`brushes.${brushId} must be an object.`); + } + if (brushValue.kind !== "box") { + throw new Error(`brushes.${brushId}.kind must be box.`); + } + const center = readVec3(brushValue.center, `brushes.${brushId}.center`); + const size = readVec3(brushValue.size, `brushes.${brushId}.size`); + if (size.x <= 0 || size.y <= 0 || size.z <= 0) { + throw new Error(`brushes.${brushId}.size values must be positive.`); + } + brushes[brushId] = createBoxBrush({ + id: expectString(brushValue.id, `brushes.${brushId}.id`), + name: readOptionalBrushName(brushValue.name, `brushes.${brushId}.name`), + center, + rotationDegrees: readOptionalVec3(brushValue.rotationDegrees, `brushes.${brushId}.rotationDegrees`, DEFAULT_BOX_BRUSH_ROTATION_DEGREES), + size, + faces: readBoxBrushFaces(brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState), + layerId: expectOptionalString(brushValue.layerId, `brushes.${brushId}.layerId`), + groupId: expectOptionalString(brushValue.groupId, `brushes.${brushId}.groupId`) + }); + } + return brushes; +} +function readWorldSettings(value) { + if (!isRecord(value)) { + throw new Error("world must be an object."); + } + const background = value.background; + const ambientLight = value.ambientLight; + const sunLight = value.sunLight; + if (!isRecord(background)) { + throw new Error("world.background must be an object."); + } + if (!isRecord(ambientLight)) { + throw new Error("world.ambientLight must be an object."); + } + if (!isRecord(sunLight)) { + throw new Error("world.sunLight must be an object."); + } + const direction = readVec3(sunLight.direction, "world.sunLight.direction"); + assertNonZeroVec3(direction, "world.sunLight.direction"); + const backgroundMode = expectString(background.mode, "world.background.mode"); + let resolvedBackground; + if (!isWorldBackgroundMode(backgroundMode)) { + throw new Error("world.background.mode must be a supported background mode."); + } + if (backgroundMode === "solid") { + resolvedBackground = { + mode: "solid", + colorHex: expectHexColor(background.colorHex, "world.background.colorHex") + }; + } + else if (backgroundMode === "verticalGradient") { + resolvedBackground = { + mode: "verticalGradient", + topColorHex: expectHexColor(background.topColorHex, "world.background.topColorHex"), + bottomColorHex: expectHexColor(background.bottomColorHex, "world.background.bottomColorHex") + }; + } + else { + resolvedBackground = { + mode: "image", + assetId: expectString(background.assetId, "world.background.assetId"), + // Default to 0.5 for documents saved before environmentIntensity was added + environmentIntensity: typeof background.environmentIntensity === "number" && isFinite(background.environmentIntensity) && background.environmentIntensity >= 0 + ? background.environmentIntensity + : 0.5 + }; + } + return { + background: resolvedBackground, + ambientLight: { + colorHex: expectHexColor(ambientLight.colorHex, "world.ambientLight.colorHex"), + intensity: expectNonNegativeFiniteNumber(ambientLight.intensity, "world.ambientLight.intensity") + }, + sunLight: { + colorHex: expectHexColor(sunLight.colorHex, "world.sunLight.colorHex"), + intensity: expectNonNegativeFiniteNumber(sunLight.intensity, "world.sunLight.intensity"), + direction + }, + advancedRendering: readAdvancedRenderingSettings(value.advancedRendering) + }; +} +function readPointLightEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "pointLight", `${label}.kind`); + const entity = createPointLightEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + colorHex: expectHexColor(value.colorHex, `${label}.colorHex`), + intensity: expectNonNegativeFiniteNumber(value.intensity, `${label}.intensity`), + distance: expectPositiveFiniteNumber(value.distance, `${label}.distance`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be pointLight.`); + } + return entity; +} +function readSpotLightEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "spotLight", `${label}.kind`); + const entity = createSpotLightEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + direction: readVec3(value.direction, `${label}.direction`), + colorHex: expectHexColor(value.colorHex, `${label}.colorHex`), + intensity: expectNonNegativeFiniteNumber(value.intensity, `${label}.intensity`), + distance: expectPositiveFiniteNumber(value.distance, `${label}.distance`), + angleDegrees: expectFiniteNumber(value.angleDegrees, `${label}.angleDegrees`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be spotLight.`); + } + return entity; +} +function readPlayerStartEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "playerStart", `${label}.kind`); + const entity = createPlayerStartEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`), + collider: readPlayerStartColliderSettings(value.collider, `${label}.collider`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be playerStart.`); + } + return entity; +} +function readSoundEmitterEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "soundEmitter", `${label}.kind`); + const entity = createSoundEmitterEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + audioAssetId: value.audioAssetId === undefined || value.audioAssetId === null + ? undefined + : expectString(value.audioAssetId, `${label}.audioAssetId`), + volume: expectNonNegativeFiniteNumber(value.volume, `${label}.volume`), + refDistance: expectPositiveFiniteNumber(value.refDistance, `${label}.refDistance`), + maxDistance: expectPositiveFiniteNumber(value.maxDistance, `${label}.maxDistance`), + autoplay: expectBoolean(value.autoplay, `${label}.autoplay`), + loop: expectBoolean(value.loop, `${label}.loop`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be soundEmitter.`); + } + return entity; +} +function readLegacySoundEmitterEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "soundEmitter", `${label}.kind`); + const radius = expectPositiveFiniteNumber(value.radius, `${label}.radius`); + const entity = createSoundEmitterEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + refDistance: radius, + maxDistance: radius, + volume: expectNonNegativeFiniteNumber(value.gain, `${label}.gain`), + autoplay: expectBoolean(value.autoplay, `${label}.autoplay`), + loop: expectBoolean(value.loop, `${label}.loop`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be soundEmitter.`); + } + return entity; +} +function readTriggerVolumeEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "triggerVolume", `${label}.kind`); + const size = readVec3(value.size, `${label}.size`); + if (size.x <= 0 || size.y <= 0 || size.z <= 0) { + throw new Error(`${label}.size values must be positive.`); + } + const entity = createTriggerVolumeEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + size, + triggerOnEnter: expectBoolean(value.triggerOnEnter, `${label}.triggerOnEnter`), + triggerOnExit: expectBoolean(value.triggerOnExit, `${label}.triggerOnExit`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be triggerVolume.`); + } + return entity; +} +function readTeleportTargetEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "teleportTarget", `${label}.kind`); + const entity = createTeleportTargetEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be teleportTarget.`); + } + return entity; +} +function readInteractableEntity(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const kind = expectLiteralString(value.kind, "interactable", `${label}.kind`); + const entity = createInteractableEntity({ + id: expectString(value.id, `${label}.id`), + name: readOptionalEntityName(value.name, `${label}.name`), + position: readVec3(value.position, `${label}.position`), + radius: expectPositiveFiniteNumber(value.radius, `${label}.radius`), + prompt: expectString(value.prompt, `${label}.prompt`), + enabled: expectBoolean(value.enabled, `${label}.enabled`) + }); + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be interactable.`); + } + return entity; +} +function readEntityInstance(value, label, options) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + switch (value.kind) { + case "pointLight": + return readPointLightEntity(value, label); + case "spotLight": + return readSpotLightEntity(value, label); + case "playerStart": + return readPlayerStartEntity(value, label); + case "soundEmitter": + return options.legacySoundEmitter ? readLegacySoundEmitterEntity(value, label) : readSoundEmitterEntity(value, label); + case "triggerVolume": + return readTriggerVolumeEntity(value, label); + case "teleportTarget": + return readTeleportTargetEntity(value, label); + case "interactable": + return readInteractableEntity(value, label); + default: + throw new Error(`${label}.kind must be a supported entity type.`); + } +} +function readEntities(value, options) { + if (!isRecord(value)) { + throw new Error("entities must be a record."); + } + const entities = {}; + for (const [entityId, entityValue] of Object.entries(value)) { + if (!isRecord(entityValue)) { + throw new Error(`entities.${entityId} must be an object.`); + } + const entity = readEntityInstance(entityValue, `entities.${entityId}`, options); + if (entity.id !== entityId) { + throw new Error(`entities.${entityId}.id must match the registry key.`); + } + entities[entityId] = entity; + } + return entities; +} +function readInteractionAction(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + switch (value.type) { + case "teleportPlayer": + return createTeleportPlayerInteractionLink({ + sourceEntityId: "interaction-source-placeholder", + targetEntityId: expectString(value.targetEntityId, `${label}.targetEntityId`) + }).action; + case "toggleVisibility": + return createToggleVisibilityInteractionLink({ + sourceEntityId: "interaction-source-placeholder", + targetBrushId: expectString(value.targetBrushId, `${label}.targetBrushId`), + visible: value.visible === undefined + ? undefined + : expectBoolean(value.visible, `${label}.visible`) + }).action; + case "playAnimation": { + const targetModelInstanceId = expectString(value.targetModelInstanceId, `${label}.targetModelInstanceId`); + if (targetModelInstanceId.trim().length === 0) { + throw new Error(`${label}.targetModelInstanceId must be non-empty.`); + } + const clipName = expectString(value.clipName, `${label}.clipName`); + if (clipName.trim().length === 0) { + throw new Error(`${label}.clipName must be non-empty.`); + } + return createPlayAnimationInteractionLink({ + sourceEntityId: "interaction-source-placeholder", + targetModelInstanceId, + clipName, + loop: value.loop === undefined ? undefined : expectBoolean(value.loop, `${label}.loop`) + }).action; + } + case "stopAnimation": { + const targetModelInstanceId = expectString(value.targetModelInstanceId, `${label}.targetModelInstanceId`); + if (targetModelInstanceId.trim().length === 0) { + throw new Error(`${label}.targetModelInstanceId must be non-empty.`); + } + return createStopAnimationInteractionLink({ + sourceEntityId: "interaction-source-placeholder", + targetModelInstanceId + }).action; + } + case "playSound": { + const targetSoundEmitterId = expectString(value.targetSoundEmitterId, `${label}.targetSoundEmitterId`); + if (targetSoundEmitterId.trim().length === 0) { + throw new Error(`${label}.targetSoundEmitterId must be non-empty.`); + } + return createPlaySoundInteractionLink({ + sourceEntityId: "interaction-source-placeholder", + targetSoundEmitterId + }).action; + } + case "stopSound": { + const targetSoundEmitterId = expectString(value.targetSoundEmitterId, `${label}.targetSoundEmitterId`); + if (targetSoundEmitterId.trim().length === 0) { + throw new Error(`${label}.targetSoundEmitterId must be non-empty.`); + } + return createStopSoundInteractionLink({ + sourceEntityId: "interaction-source-placeholder", + targetSoundEmitterId + }).action; + } + default: + throw new Error(`${label}.type must be a supported interaction action.`); + } +} +function readInteractionLink(value, label) { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + const trigger = expectString(value.trigger, `${label}.trigger`); + if (!isInteractionTriggerKind(trigger)) { + throw new Error(`${label}.trigger must be a supported interaction trigger.`); + } + const action = readInteractionAction(value.action, `${label}.action`); + switch (action.type) { + case "teleportPlayer": + return createTeleportPlayerInteractionLink({ + id: expectString(value.id, `${label}.id`), + sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + trigger, + targetEntityId: action.targetEntityId + }); + case "toggleVisibility": + return createToggleVisibilityInteractionLink({ + id: expectString(value.id, `${label}.id`), + sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + trigger, + targetBrushId: action.targetBrushId, + visible: action.visible + }); + case "playAnimation": + return createPlayAnimationInteractionLink({ + id: expectString(value.id, `${label}.id`), + sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + trigger, + targetModelInstanceId: action.targetModelInstanceId, + clipName: action.clipName, + loop: action.loop + }); + case "stopAnimation": + return createStopAnimationInteractionLink({ + id: expectString(value.id, `${label}.id`), + sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + trigger, + targetModelInstanceId: action.targetModelInstanceId + }); + case "playSound": + return createPlaySoundInteractionLink({ + id: expectString(value.id, `${label}.id`), + sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + trigger, + targetSoundEmitterId: action.targetSoundEmitterId + }); + case "stopSound": + return createStopSoundInteractionLink({ + id: expectString(value.id, `${label}.id`), + sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + trigger, + targetSoundEmitterId: action.targetSoundEmitterId + }); + } +} +function readInteractionLinks(value) { + if (!isRecord(value)) { + throw new Error("interactionLinks must be a record."); + } + const interactionLinks = {}; + for (const [linkId, linkValue] of Object.entries(value)) { + const interactionLink = readInteractionLink(linkValue, `interactionLinks.${linkId}`); + if (interactionLink.id !== linkId) { + throw new Error(`interactionLinks.${linkId}.id must match the registry key.`); + } + interactionLinks[linkId] = interactionLink; + } + return interactionLinks; +} +export function migrateSceneDocument(source) { + if (!isRecord(source)) { + throw new Error("Scene document must be a JSON object."); + } + if (source.version === FOUNDATION_SCENE_DOCUMENT_VERSION) { + expectEmptyCollection(source.materials, "materials"); + expectEmptyCollection(source.brushes, "brushes"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials: createStarterMaterialRegistry(), + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: {}, + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: expectEmptyCollection(source.entities, "entities"), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === BOX_BRUSH_SCENE_DOCUMENT_VERSION) { + expectEmptyCollection(source.materials, "materials"); + const materials = createStarterMaterialRegistry(); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, true), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: expectEmptyCollection(source.entities, "entities"), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === FACE_MATERIALS_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: expectEmptyCollection(source.entities, "entities"), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === RUNNER_V1_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version === TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + if (source.version === MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + if (source.version === LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + if (source.version === ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: true }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + // v11 → v12: animation fields added to model instances and interaction links + // readModelInstance now reads animationClipName/animationAutoplay as optional (defaulting to undefined) + // so no special handling is needed beyond routing through the same readers + if (source.version === 11) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + if (source.version === SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + // v16 -> v18: Player Start collider settings landed before whitebox box rotation. + if (source.version === IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + // v17 -> v18: box-based whitebox solids gained authored object rotation. + if (source.version === PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + // v15 -> v16: model instances gained authored collider settings. + if (source.version === ENTITY_NAMES_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + // v14 -> v15: entities gained an optional authored name field. + if (source.version === 14) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + if (source.version !== WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION) { + throw new Error(`Unsupported scene document version: ${String(source.version)}.`); + } + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; +} diff --git a/src/document/scene-document-validation.js b/src/document/scene-document-validation.js new file mode 100644 index 00000000..d485393e --- /dev/null +++ b/src/document/scene-document-validation.js @@ -0,0 +1,628 @@ +import {} from "../assets/project-assets"; +import { isModelInstanceCollisionMode } from "../assets/model-instances"; +import { isPlayerStartColliderMode, getPlayerStartColliderHeight } from "../entities/entity-instances"; +import {} from "../interactions/interaction-links"; +import { BOX_FACE_IDS, hasPositiveBoxSize } from "./brushes"; +import { isAdvancedRenderingShadowMapSize, isAdvancedRenderingShadowType, isAdvancedRenderingToneMappingMode, isHexColorString } from "./world-settings"; +export function createDiagnostic(severity, code, message, path, scope = "document") { + return { + code, + severity, + scope, + message, + path + }; +} +function isFiniteNumber(value) { + return typeof value === "number" && Number.isFinite(value); +} +function isFiniteVec3(vector) { + return isFiniteNumber(vector.x) && isFiniteNumber(vector.y) && isFiniteNumber(vector.z); +} +function hasPositiveFiniteVec3(vector) { + return isFiniteVec3(vector) && vector.x > 0 && vector.y > 0 && vector.z > 0; +} +function isNonNegativeFiniteNumber(value) { + return isFiniteNumber(value) && value >= 0; +} +function isPositiveFiniteNumber(value) { + return isFiniteNumber(value) && value > 0; +} +function isPositiveInteger(value) { + return isFiniteNumber(value) && Number.isInteger(value) && value > 0; +} +function isBoolean(value) { + return typeof value === "boolean"; +} +function hasNonZeroVectorLength(vector) { + return vector.x !== 0 || vector.y !== 0 || vector.z !== 0; +} +function validateWorldSettings(world, document, diagnostics) { + if (world.background.mode === "solid") { + if (!isHexColorString(world.background.colorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-world-background-color", "Solid world backgrounds must use #RRGGBB colors.", "world.background.colorHex")); + } + } + else if (world.background.mode === "verticalGradient") { + if (!isHexColorString(world.background.topColorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-world-background-top-color", "Gradient world backgrounds must use #RRGGBB colors for the top color.", "world.background.topColorHex")); + } + if (!isHexColorString(world.background.bottomColorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-world-background-bottom-color", "Gradient world backgrounds must use #RRGGBB colors for the bottom color.", "world.background.bottomColorHex")); + } + } + else { + if (typeof world.background.assetId !== "string" || world.background.assetId.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-world-background-asset-id", "World background images must reference a non-empty asset id.", "world.background.assetId")); + } + else { + const backgroundAsset = document.assets[world.background.assetId]; + if (backgroundAsset === undefined) { + diagnostics.push(createDiagnostic("error", "missing-world-background-asset", `World background image asset ${world.background.assetId} does not exist.`, "world.background.assetId")); + } + else if (backgroundAsset.kind !== "image") { + diagnostics.push(createDiagnostic("error", "invalid-world-background-asset-kind", "World background images must reference image assets.", "world.background.assetId")); + } + } + if (!isNonNegativeFiniteNumber(world.background.environmentIntensity)) { + diagnostics.push(createDiagnostic("error", "invalid-world-background-environment-intensity", "World background environment intensity must be a non-negative finite number.", "world.background.environmentIntensity")); + } + } + if (!isHexColorString(world.ambientLight.colorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-world-ambient-color", "World ambient light must use a #RRGGBB color.", "world.ambientLight.colorHex")); + } + if (!isNonNegativeFiniteNumber(world.ambientLight.intensity)) { + diagnostics.push(createDiagnostic("error", "invalid-world-ambient-intensity", "World ambient light intensity must remain finite and zero or greater.", "world.ambientLight.intensity")); + } + if (!isHexColorString(world.sunLight.colorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-world-sun-color", "World sun color must use a #RRGGBB color.", "world.sunLight.colorHex")); + } + if (!isNonNegativeFiniteNumber(world.sunLight.intensity)) { + diagnostics.push(createDiagnostic("error", "invalid-world-sun-intensity", "World sun intensity must remain finite and zero or greater.", "world.sunLight.intensity")); + } + if (!isFiniteVec3(world.sunLight.direction) || !hasNonZeroVectorLength(world.sunLight.direction)) { + diagnostics.push(createDiagnostic("error", "invalid-world-sun-direction", "World sun direction must remain finite and must not be the zero vector.", "world.sunLight.direction")); + } + const advancedRendering = world.advancedRendering; + if (!isBoolean(advancedRendering.enabled)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-enabled", "Advanced rendering enabled must be a boolean.", "world.advancedRendering.enabled")); + } + if (!isBoolean(advancedRendering.shadows.enabled)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadows-enabled", "Advanced rendering shadow enabled must be a boolean.", "world.advancedRendering.shadows.enabled")); + } + if (!isAdvancedRenderingShadowMapSize(advancedRendering.shadows.mapSize)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadow-map-size", "Advanced rendering shadow map size must be one of 512, 1024, 2048, or 4096.", "world.advancedRendering.shadows.mapSize")); + } + if (!isAdvancedRenderingShadowType(advancedRendering.shadows.type)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadow-type", "Advanced rendering shadow type must be basic, pcf, or pcfSoft.", "world.advancedRendering.shadows.type")); + } + if (!isFiniteNumber(advancedRendering.shadows.bias)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadow-bias", "Advanced rendering shadow bias must be a finite number.", "world.advancedRendering.shadows.bias")); + } + if (!isBoolean(advancedRendering.ambientOcclusion.enabled)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-enabled", "Advanced rendering ambient occlusion enabled must be a boolean.", "world.advancedRendering.ambientOcclusion.enabled")); + } + if (!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.intensity)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-intensity", "Advanced rendering ambient occlusion intensity must be a non-negative finite number.", "world.advancedRendering.ambientOcclusion.intensity")); + } + if (!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.radius)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-radius", "Advanced rendering ambient occlusion radius must be a non-negative finite number.", "world.advancedRendering.ambientOcclusion.radius")); + } + if (!isPositiveInteger(advancedRendering.ambientOcclusion.samples)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-samples", "Advanced rendering ambient occlusion samples must be a positive integer.", "world.advancedRendering.ambientOcclusion.samples")); + } + if (!isBoolean(advancedRendering.bloom.enabled)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-enabled", "Advanced rendering bloom enabled must be a boolean.", "world.advancedRendering.bloom.enabled")); + } + if (!isNonNegativeFiniteNumber(advancedRendering.bloom.intensity)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-intensity", "Advanced rendering bloom intensity must be a non-negative finite number.", "world.advancedRendering.bloom.intensity")); + } + if (!isNonNegativeFiniteNumber(advancedRendering.bloom.threshold)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-threshold", "Advanced rendering bloom threshold must be a non-negative finite number.", "world.advancedRendering.bloom.threshold")); + } + if (!isNonNegativeFiniteNumber(advancedRendering.bloom.radius)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-radius", "Advanced rendering bloom radius must be a non-negative finite number.", "world.advancedRendering.bloom.radius")); + } + if (!isAdvancedRenderingToneMappingMode(advancedRendering.toneMapping.mode)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-tone-mapping-mode", "Advanced rendering tone mapping mode must be none, linear, reinhard, cineon, or acesFilmic.", "world.advancedRendering.toneMapping.mode")); + } + if (!isPositiveFiniteNumber(advancedRendering.toneMapping.exposure)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-tone-mapping-exposure", "Advanced rendering tone mapping exposure must be a positive finite number.", "world.advancedRendering.toneMapping.exposure")); + } + if (!isBoolean(advancedRendering.depthOfField.enabled)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-enabled", "Advanced rendering depth of field enabled must be a boolean.", "world.advancedRendering.depthOfField.enabled")); + } + if (!isNonNegativeFiniteNumber(advancedRendering.depthOfField.focusDistance)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-focus-distance", "Advanced rendering depth of field focus distance must be a non-negative finite number.", "world.advancedRendering.depthOfField.focusDistance")); + } + if (!isPositiveFiniteNumber(advancedRendering.depthOfField.focalLength)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-focal-length", "Advanced rendering depth of field focal length must be a positive finite number.", "world.advancedRendering.depthOfField.focalLength")); + } + if (!isPositiveFiniteNumber(advancedRendering.depthOfField.bokehScale)) { + diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-bokeh-scale", "Advanced rendering depth of field bokeh scale must be a positive finite number.", "world.advancedRendering.depthOfField.bokehScale")); + } +} +function validatePointLightEntity(entity, path, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-point-light-position", "Point Light position must remain finite on every axis.", `${path}.position`)); + } + if (!isHexColorString(entity.colorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-point-light-color", "Point Light color must use a #RRGGBB color.", `${path}.colorHex`)); + } + if (!isNonNegativeFiniteNumber(entity.intensity)) { + diagnostics.push(createDiagnostic("error", "invalid-point-light-intensity", "Point Light intensity must remain finite and zero or greater.", `${path}.intensity`)); + } + if (!isPositiveFiniteNumber(entity.distance)) { + diagnostics.push(createDiagnostic("error", "invalid-point-light-distance", "Point Light distance must remain finite and greater than zero.", `${path}.distance`)); + } +} +function validateSpotLightEntity(entity, path, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-spot-light-position", "Spot Light position must remain finite on every axis.", `${path}.position`)); + } + if (!isFiniteVec3(entity.direction) || !hasNonZeroVectorLength(entity.direction)) { + diagnostics.push(createDiagnostic("error", "invalid-spot-light-direction", "Spot Light direction must remain finite and must not be the zero vector.", `${path}.direction`)); + } + if (!isHexColorString(entity.colorHex)) { + diagnostics.push(createDiagnostic("error", "invalid-spot-light-color", "Spot Light color must use a #RRGGBB color.", `${path}.colorHex`)); + } + if (!isNonNegativeFiniteNumber(entity.intensity)) { + diagnostics.push(createDiagnostic("error", "invalid-spot-light-intensity", "Spot Light intensity must remain finite and zero or greater.", `${path}.intensity`)); + } + if (!isPositiveFiniteNumber(entity.distance)) { + diagnostics.push(createDiagnostic("error", "invalid-spot-light-distance", "Spot Light distance must remain finite and greater than zero.", `${path}.distance`)); + } + if (!isFiniteNumber(entity.angleDegrees) || entity.angleDegrees <= 0 || entity.angleDegrees >= 180) { + diagnostics.push(createDiagnostic("error", "invalid-spot-light-angle", "Spot Light angle must remain a finite degree value between 0 and 180.", `${path}.angleDegrees`)); + } +} +function validateProjectAssetBoundingBox(boundingBox, path, diagnostics) { + if (boundingBox === null) { + return; + } + if (!isFiniteVec3(boundingBox.min)) { + diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-min", "Model asset bounding boxes must have finite minimum coordinates.", `${path}.min`)); + } + if (!isFiniteVec3(boundingBox.max)) { + diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-max", "Model asset bounding boxes must have finite maximum coordinates.", `${path}.max`)); + } + if (!isFiniteVec3(boundingBox.size) || boundingBox.size.x < 0 || boundingBox.size.y < 0 || boundingBox.size.z < 0) { + diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-size", "Model asset bounding boxes must have finite, zero-or-greater size values.", `${path}.size`)); + } +} +function validateModelAssetMetadata(metadata, path, diagnostics) { + if (metadata.format !== "glb" && metadata.format !== "gltf") { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-format", "Model asset format must be glb or gltf.", `${path}.format`)); + } + if (metadata.sceneName !== null && metadata.sceneName.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-scene-name", "Model asset scene names must be non-empty when authored.", `${path}.sceneName`)); + } + if (!isNonNegativeFiniteNumber(metadata.nodeCount)) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-node-count", "Model asset node counts must be finite and zero or greater.", `${path}.nodeCount`)); + } + if (!isNonNegativeFiniteNumber(metadata.meshCount)) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-mesh-count", "Model asset mesh counts must be finite and zero or greater.", `${path}.meshCount`)); + } + if (!Array.isArray(metadata.materialNames) || metadata.materialNames.some((name) => typeof name !== "string")) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-material-names", "Model asset material names must be string arrays.", `${path}.materialNames`)); + } + if (!Array.isArray(metadata.textureNames) || metadata.textureNames.some((name) => typeof name !== "string")) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-texture-names", "Model asset texture names must be string arrays.", `${path}.textureNames`)); + } + if (!Array.isArray(metadata.animationNames) || metadata.animationNames.some((name) => typeof name !== "string")) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-animation-names", "Model asset animation names must be string arrays.", `${path}.animationNames`)); + } + validateProjectAssetBoundingBox(metadata.boundingBox, `${path}.boundingBox`, diagnostics); + if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) { + diagnostics.push(createDiagnostic("error", "invalid-model-asset-warnings", "Model asset warnings must be string arrays.", `${path}.warnings`)); + } +} +function validateImageAssetMetadata(metadata, path, diagnostics) { + if (!isPositiveFiniteNumber(metadata.width)) { + diagnostics.push(createDiagnostic("error", "invalid-image-asset-width", "Image asset width must be finite and greater than zero.", `${path}.width`)); + } + if (!isPositiveFiniteNumber(metadata.height)) { + diagnostics.push(createDiagnostic("error", "invalid-image-asset-height", "Image asset height must be finite and greater than zero.", `${path}.height`)); + } + if (!isBoolean(metadata.hasAlpha)) { + diagnostics.push(createDiagnostic("error", "invalid-image-asset-alpha", "Image asset alpha flags must be booleans.", `${path}.hasAlpha`)); + } + if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) { + diagnostics.push(createDiagnostic("error", "invalid-image-asset-warnings", "Image asset warnings must be string arrays.", `${path}.warnings`)); + } +} +function validateAudioAssetMetadata(metadata, path, diagnostics) { + if (metadata.durationSeconds !== null && !isNonNegativeFiniteNumber(metadata.durationSeconds)) { + diagnostics.push(createDiagnostic("error", "invalid-audio-asset-duration", "Audio asset durations must be finite and zero or greater when authored.", `${path}.durationSeconds`)); + } + if (metadata.channelCount !== null && !isPositiveFiniteNumber(metadata.channelCount)) { + diagnostics.push(createDiagnostic("error", "invalid-audio-asset-channel-count", "Audio asset channel counts must be finite and greater than zero when authored.", `${path}.channelCount`)); + } + if (metadata.sampleRateHz !== null && !isPositiveFiniteNumber(metadata.sampleRateHz)) { + diagnostics.push(createDiagnostic("error", "invalid-audio-asset-sample-rate", "Audio asset sample rates must be finite and greater than zero when authored.", `${path}.sampleRateHz`)); + } + if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) { + diagnostics.push(createDiagnostic("error", "invalid-audio-asset-warnings", "Audio asset warnings must be string arrays.", `${path}.warnings`)); + } +} +function validateProjectAsset(asset, path, diagnostics) { + if (asset.sourceName.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-asset-source-name", "Asset source names must be non-empty strings.", `${path}.sourceName`)); + } + if (asset.mimeType.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-asset-mime-type", "Asset mime types must be non-empty strings.", `${path}.mimeType`)); + } + if (asset.storageKey.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-asset-storage-key", "Asset storage keys must be non-empty strings.", `${path}.storageKey`)); + } + if (!isPositiveFiniteNumber(asset.byteLength)) { + diagnostics.push(createDiagnostic("error", "invalid-asset-byte-length", "Asset byte lengths must be finite and greater than zero.", `${path}.byteLength`)); + } + switch (asset.kind) { + case "model": + validateModelAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics); + break; + case "image": + validateImageAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics); + break; + case "audio": + validateAudioAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics); + break; + } +} +function validateModelInstance(modelInstance, path, document, diagnostics) { + if (modelInstance.name !== undefined && modelInstance.name.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-name", "Model instance names must be non-empty when authored.", `${path}.name`)); + } + if (!isFiniteVec3(modelInstance.position)) { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-position", "Model instance positions must remain finite on every axis.", `${path}.position`)); + } + if (!isFiniteVec3(modelInstance.rotationDegrees)) { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-rotation", "Model instance rotations must remain finite on every axis.", `${path}.rotationDegrees`)); + } + if (!hasPositiveFiniteVec3(modelInstance.scale)) { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-scale", "Model instance scales must remain finite and positive on every axis.", `${path}.scale`)); + } + if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-collision-mode", "Model instance collision mode must be one of none, terrain, static, dynamic, or simple.", `${path}.collision.mode`)); + } + if (!isBoolean(modelInstance.collision.visible)) { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-collision-visibility", "Model instance collision visibility must be a boolean.", `${path}.collision.visible`)); + } + const asset = document.assets[modelInstance.assetId]; + if (asset === undefined) { + diagnostics.push(createDiagnostic("error", "missing-model-instance-asset", `Model instance asset ${modelInstance.assetId} does not exist.`, `${path}.assetId`)); + return; + } + if (asset.kind !== "model") { + diagnostics.push(createDiagnostic("error", "invalid-model-instance-asset-kind", "Model instances may only reference model assets.", `${path}.assetId`)); + } +} +function validateEntityName(name, path, diagnostics) { + if (name !== undefined && name.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-entity-name", "Entity names must be non-empty when authored.", `${path}.name`)); + } +} +function validatePlayerStartEntity(entity, path, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-position", "Player Start position must remain finite on every axis.", `${path}.position`)); + } + if (!isFiniteNumber(entity.yawDegrees)) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-yaw", "Player Start yaw must remain a finite number.", `${path}.yawDegrees`)); + } + if (!isPlayerStartColliderMode(entity.collider.mode)) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-collider-mode", "Player Start collider mode must be capsule, box, or none.", `${path}.collider.mode`)); + } + if (!isFiniteNumber(entity.collider.eyeHeight) || entity.collider.eyeHeight <= 0) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-eye-height", "Player Start eye height must remain a finite number greater than zero.", `${path}.collider.eyeHeight`)); + } + if (!isFiniteNumber(entity.collider.capsuleRadius) || entity.collider.capsuleRadius <= 0) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-capsule-radius", "Player Start capsule radius must remain a finite number greater than zero.", `${path}.collider.capsuleRadius`)); + } + if (!isFiniteNumber(entity.collider.capsuleHeight) || entity.collider.capsuleHeight <= 0) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-capsule-height", "Player Start capsule height must remain a finite number greater than zero.", `${path}.collider.capsuleHeight`)); + } + if (!isFiniteVec3(entity.collider.boxSize) || + entity.collider.boxSize.x <= 0 || + entity.collider.boxSize.y <= 0 || + entity.collider.boxSize.z <= 0) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-box-size", "Player Start box size must remain finite and positive on every axis.", `${path}.collider.boxSize`)); + } + if (entity.collider.capsuleHeight < entity.collider.capsuleRadius * 2) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-capsule-proportions", "Player Start capsule height must be at least twice the capsule radius.", `${path}.collider.capsuleHeight`)); + } + const colliderHeight = getPlayerStartColliderHeight(entity.collider); + if (colliderHeight !== null && entity.collider.eyeHeight > colliderHeight) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-eye-height", "Player Start eye height must fit within the authored collider height.", `${path}.collider.eyeHeight`)); + } +} +function validateSoundEmitterAudioAsset(entity, path, document, diagnostics, missingSeverity) { + if (entity.audioAssetId === null) { + diagnostics.push(createDiagnostic(missingSeverity, "missing-sound-emitter-audio-asset", entity.autoplay + ? "Sound Emitter autoplay requires an assigned audio asset." + : "Sound Emitter has no audio asset assigned yet.", `${path}.audioAssetId`)); + return null; + } + const asset = document.assets[entity.audioAssetId]; + if (asset === undefined) { + diagnostics.push(createDiagnostic("error", "missing-sound-emitter-audio-asset", `Sound Emitter audio asset ${entity.audioAssetId} does not exist.`, `${path}.audioAssetId`)); + return null; + } + if (asset.kind !== "audio") { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-audio-asset-kind", "Sound Emitter audioAssetId must reference an audio asset.", `${path}.audioAssetId`)); + return null; + } + return asset; +} +function validateSoundEmitterEntity(entity, path, document, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-position", "Sound Emitter position must remain finite on every axis.", `${path}.position`)); + } + if (!isNonNegativeFiniteNumber(entity.volume)) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-volume", "Sound Emitter volume must remain finite and zero or greater.", `${path}.volume`)); + } + if (!isPositiveFiniteNumber(entity.refDistance)) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-ref-distance", "Sound Emitter ref distance must remain finite and greater than zero.", `${path}.refDistance`)); + } + if (!isPositiveFiniteNumber(entity.maxDistance)) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-max-distance", "Sound Emitter max distance must remain finite and greater than zero.", `${path}.maxDistance`)); + } + if (isPositiveFiniteNumber(entity.refDistance) && isPositiveFiniteNumber(entity.maxDistance) && entity.maxDistance < entity.refDistance) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-distance-order", "Sound Emitter max distance must be greater than or equal to ref distance.", `${path}.maxDistance`)); + } + if (!isBoolean(entity.autoplay)) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-autoplay", "Sound Emitter autoplay must remain a boolean.", `${path}.autoplay`)); + } + if (!isBoolean(entity.loop)) { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-loop", "Sound Emitter loop must remain a boolean.", `${path}.loop`)); + } + validateSoundEmitterAudioAsset(entity, path, document, diagnostics, entity.autoplay ? "error" : "warning"); +} +function validateTriggerVolumeEntity(entity, path, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-position", "Trigger Volume position must remain finite on every axis.", `${path}.position`)); + } + if (!hasPositiveFiniteVec3(entity.size)) { + diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-size", "Trigger Volume size must remain finite and positive on every axis.", `${path}.size`)); + } + if (!isBoolean(entity.triggerOnEnter)) { + diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-enter-flag", "Trigger Volume triggerOnEnter must remain a boolean.", `${path}.triggerOnEnter`)); + } + if (!isBoolean(entity.triggerOnExit)) { + diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-exit-flag", "Trigger Volume triggerOnExit must remain a boolean.", `${path}.triggerOnExit`)); + } +} +function validateTeleportTargetEntity(entity, path, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-teleport-target-position", "Teleport Target position must remain finite on every axis.", `${path}.position`)); + } + if (!isFiniteNumber(entity.yawDegrees)) { + diagnostics.push(createDiagnostic("error", "invalid-teleport-target-yaw", "Teleport Target yaw must remain a finite number.", `${path}.yawDegrees`)); + } +} +function validateInteractableEntity(entity, path, diagnostics) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push(createDiagnostic("error", "invalid-interactable-position", "Interactable position must remain finite on every axis.", `${path}.position`)); + } + if (!isPositiveFiniteNumber(entity.radius)) { + diagnostics.push(createDiagnostic("error", "invalid-interactable-radius", "Interactable radius must remain finite and greater than zero.", `${path}.radius`)); + } + if (typeof entity.prompt !== "string" || entity.prompt.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-interactable-prompt", "Interactable prompt must remain a non-empty string.", `${path}.prompt`)); + } + if (!isBoolean(entity.enabled)) { + diagnostics.push(createDiagnostic("error", "invalid-interactable-enabled", "Interactable enabled must remain a boolean.", `${path}.enabled`)); + } +} +function validateInteractionLink(link, path, document, diagnostics) { + const sourceEntity = document.entities[link.sourceEntityId]; + if (sourceEntity === undefined) { + diagnostics.push(createDiagnostic("error", "missing-interaction-source-entity", `Interaction source entity ${link.sourceEntityId} does not exist.`, `${path}.sourceEntityId`)); + return; + } + if (sourceEntity.kind !== "triggerVolume" && sourceEntity.kind !== "interactable") { + diagnostics.push(createDiagnostic("error", "invalid-interaction-source-kind", "Interaction links may only source from Trigger Volume or Interactable entities in the current slice.", `${path}.sourceEntityId`)); + } + if (sourceEntity.kind === "triggerVolume") { + if (link.trigger !== "enter" && link.trigger !== "exit") { + diagnostics.push(createDiagnostic("error", "unsupported-interaction-trigger", "Trigger Volume links may only use enter or exit triggers.", `${path}.trigger`)); + } + } + else if (sourceEntity.kind === "interactable") { + if (link.trigger !== "click") { + diagnostics.push(createDiagnostic("error", "unsupported-interaction-trigger", "Interactable links may only use the click trigger.", `${path}.trigger`)); + } + } + switch (link.action.type) { + case "teleportPlayer": { + const targetEntity = document.entities[link.action.targetEntityId]; + if (targetEntity === undefined) { + diagnostics.push(createDiagnostic("error", "missing-teleport-target-entity", `Teleport target entity ${link.action.targetEntityId} does not exist.`, `${path}.action.targetEntityId`)); + return; + } + if (targetEntity.kind !== "teleportTarget") { + diagnostics.push(createDiagnostic("error", "invalid-teleport-target-kind", "Teleport player actions must target a Teleport Target entity.", `${path}.action.targetEntityId`)); + } + break; + } + case "toggleVisibility": + if (document.brushes[link.action.targetBrushId] === undefined) { + diagnostics.push(createDiagnostic("error", "missing-visibility-target-brush", `Visibility target brush ${link.action.targetBrushId} does not exist.`, `${path}.action.targetBrushId`)); + } + if (link.action.visible !== undefined && typeof link.action.visible !== "boolean") { + diagnostics.push(createDiagnostic("error", "invalid-visibility-action-visible", "Visibility actions must use a boolean visible value when authored.", `${path}.action.visible`)); + } + break; + case "playAnimation": + { + const targetModelInstance = document.modelInstances[link.action.targetModelInstanceId]; + if (targetModelInstance === undefined) { + diagnostics.push(createDiagnostic("error", "missing-play-animation-target-instance", `Play animation target model instance ${link.action.targetModelInstanceId} does not exist.`, `${path}.action.targetModelInstanceId`)); + return; + } + if (link.action.clipName.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-play-animation-clip-name", "Play animation clip name must be non-empty.", `${path}.action.clipName`)); + return; + } + const targetAsset = document.assets[targetModelInstance.assetId]; + if (targetAsset === undefined || targetAsset.kind !== "model") { + return; + } + if (!targetAsset.metadata.animationNames.includes(link.action.clipName)) { + diagnostics.push(createDiagnostic("error", "missing-play-animation-clip", `Play animation clip ${link.action.clipName} does not exist on model asset ${targetAsset.id}.`, `${path}.action.clipName`)); + } + break; + } + case "stopAnimation": + // Validate that the target model instance exists in the document + if (document.modelInstances[link.action.targetModelInstanceId] === undefined) { + diagnostics.push(createDiagnostic("error", "missing-stop-animation-target-instance", `Stop animation target model instance ${link.action.targetModelInstanceId} does not exist.`, `${path}.action.targetModelInstanceId`)); + } + break; + case "playSound": + case "stopSound": { + const targetEntity = document.entities[link.action.targetSoundEmitterId]; + if (targetEntity === undefined) { + diagnostics.push(createDiagnostic("error", "missing-sound-emitter-entity", `Sound emitter entity ${link.action.targetSoundEmitterId} does not exist.`, `${path}.action.targetSoundEmitterId`)); + break; + } + if (targetEntity.kind !== "soundEmitter") { + diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-kind", "Sound playback actions must target a Sound Emitter entity.", `${path}.action.targetSoundEmitterId`)); + break; + } + if (targetEntity.audioAssetId === null) { + diagnostics.push(createDiagnostic("error", "missing-sound-emitter-audio-asset", "Sound playback actions require a Sound Emitter that references an audio asset.", `${path}.action.targetSoundEmitterId`)); + } + break; + } + default: + diagnostics.push(createDiagnostic("error", "unsupported-interaction-action", `Unsupported interaction action ${link.action.type}.`, `${path}.action.type`)); + break; + } +} +function registerAuthoredId(id, path, seenIds, diagnostics) { + const previousPath = seenIds.get(id); + if (previousPath !== undefined) { + diagnostics.push(createDiagnostic("error", "duplicate-authored-id", `Duplicate authored id ${id} is already used at ${previousPath}.`, path)); + return; + } + seenIds.set(id, path); +} +export function formatSceneDiagnostic(diagnostic) { + return diagnostic.path === undefined ? diagnostic.message : `${diagnostic.path}: ${diagnostic.message}`; +} +export function formatSceneDiagnosticSummary(diagnostics, limit = 3) { + if (diagnostics.length === 0) { + return "No diagnostics."; + } + const visibleDiagnostics = diagnostics.slice(0, Math.max(1, limit)); + const summary = visibleDiagnostics.map((diagnostic) => formatSceneDiagnostic(diagnostic)).join("; "); + const remainingCount = diagnostics.length - visibleDiagnostics.length; + return remainingCount > 0 ? `${summary}; +${remainingCount} more` : summary; +} +export function validateSceneDocument(document) { + const diagnostics = []; + const seenIds = new Map(); + validateWorldSettings(document.world, document, diagnostics); + for (const [materialKey, material] of Object.entries(document.materials)) { + const path = `materials.${materialKey}`; + if (material.id !== materialKey) { + diagnostics.push(createDiagnostic("error", "material-id-mismatch", "Material ids must match their registry key.", `${path}.id`)); + } + registerAuthoredId(material.id, path, seenIds, diagnostics); + } + for (const [assetKey, asset] of Object.entries(document.assets)) { + const path = `assets.${assetKey}`; + if (asset.id !== assetKey) { + diagnostics.push(createDiagnostic("error", "asset-id-mismatch", "Asset ids must match their registry key.", `${path}.id`)); + } + registerAuthoredId(asset.id, path, seenIds, diagnostics); + validateProjectAsset(asset, path, diagnostics); + } + for (const [brushKey, brush] of Object.entries(document.brushes)) { + const path = `brushes.${brushKey}`; + if (brush.id !== brushKey) { + diagnostics.push(createDiagnostic("error", "brush-id-mismatch", "Brush ids must match their registry key.", `${path}.id`)); + } + registerAuthoredId(brush.id, path, seenIds, diagnostics); + if (brush.name !== undefined && brush.name.trim().length === 0) { + diagnostics.push(createDiagnostic("error", "invalid-box-name", "Box brush names must be non-empty when authored.", `${path}.name`)); + } + if (!isFiniteVec3(brush.center)) { + diagnostics.push(createDiagnostic("error", "invalid-box-center", "Box brush centers must remain finite on every axis.", `${path}.center`)); + } + if (!isFiniteVec3(brush.rotationDegrees)) { + diagnostics.push(createDiagnostic("error", "invalid-box-rotation", "Box brush rotations must remain finite on every axis.", `${path}.rotationDegrees`)); + } + if (!isFiniteVec3(brush.size) || !hasPositiveBoxSize(brush.size)) { + diagnostics.push(createDiagnostic("error", "invalid-box-size", "Box brush sizes must remain finite and positive on every axis.", `${path}.size`)); + } + for (const faceId of BOX_FACE_IDS) { + const materialId = brush.faces[faceId].materialId; + if (materialId !== null && document.materials[materialId] === undefined) { + diagnostics.push(createDiagnostic("error", "missing-material-ref", `Face material reference ${materialId} does not exist in the document material registry.`, `${path}.faces.${faceId}.materialId`)); + } + } + } + for (const [modelInstanceKey, modelInstance] of Object.entries(document.modelInstances)) { + const path = `modelInstances.${modelInstanceKey}`; + if (modelInstance.id !== modelInstanceKey) { + diagnostics.push(createDiagnostic("error", "model-instance-id-mismatch", "Model instance ids must match their registry key.", `${path}.id`)); + } + registerAuthoredId(modelInstance.id, path, seenIds, diagnostics); + validateModelInstance(modelInstance, path, document, diagnostics); + } + for (const [entityKey, entity] of Object.entries(document.entities)) { + const path = `entities.${entityKey}`; + if (entity.id !== entityKey) { + diagnostics.push(createDiagnostic("error", "entity-id-mismatch", "Entity ids must match their registry key.", `${path}.id`)); + } + registerAuthoredId(entity.id, path, seenIds, diagnostics); + validateEntityName(entity.name, path, diagnostics); + switch (entity.kind) { + case "pointLight": + validatePointLightEntity(entity, path, diagnostics); + break; + case "spotLight": + validateSpotLightEntity(entity, path, diagnostics); + break; + case "playerStart": + validatePlayerStartEntity(entity, path, diagnostics); + break; + case "soundEmitter": + validateSoundEmitterEntity(entity, path, document, diagnostics); + break; + case "triggerVolume": + validateTriggerVolumeEntity(entity, path, diagnostics); + break; + case "teleportTarget": + validateTeleportTargetEntity(entity, path, diagnostics); + break; + case "interactable": + validateInteractableEntity(entity, path, diagnostics); + break; + default: + diagnostics.push(createDiagnostic("error", "unsupported-entity-kind", `Unsupported entity kind ${entity.kind}.`, `${path}.kind`)); + break; + } + } + for (const [linkKey, link] of Object.entries(document.interactionLinks)) { + const path = `interactionLinks.${linkKey}`; + if (link.id !== linkKey) { + diagnostics.push(createDiagnostic("error", "interaction-link-id-mismatch", "Interaction link ids must match their registry key.", `${path}.id`)); + } + registerAuthoredId(link.id, path, seenIds, diagnostics); + validateInteractionLink(link, path, document, diagnostics); + } + return { + diagnostics, + errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), + warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning") + }; +} +export function assertSceneDocumentIsValid(document) { + const validation = validateSceneDocument(document); + if (validation.errors.length > 0) { + throw new Error(`Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}`); + } +} diff --git a/src/document/scene-document.js b/src/document/scene-document.js new file mode 100644 index 00000000..981ba0bc --- /dev/null +++ b/src/document/scene-document.js @@ -0,0 +1,33 @@ +import { cloneMaterialRegistry, createStarterMaterialRegistry } from "../materials/starter-material-library"; +import { createDefaultWorldSettings } from "./world-settings"; +export const SCENE_DOCUMENT_VERSION = 18; +export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18; +export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION = 17; +export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16; +export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15; +export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13; +export const ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION = 12; +export const LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION = 10; +export const MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION = 9; +export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1; +export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2; +export const FACE_MATERIALS_SCENE_DOCUMENT_VERSION = 3; +export const RUNNER_V1_SCENE_DOCUMENT_VERSION = 4; +export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5; +export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6; +export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7; +export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION = 8; +export function createEmptySceneDocument(overrides = {}) { + return { + version: SCENE_DOCUMENT_VERSION, + name: overrides.name ?? "Untitled Scene", + world: overrides.world ?? createDefaultWorldSettings(), + materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()), + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }; +} diff --git a/src/document/world-settings.js b/src/document/world-settings.js new file mode 100644 index 00000000..e748c1b6 --- /dev/null +++ b/src/document/world-settings.js @@ -0,0 +1,235 @@ +import { DEFAULT_SUN_DIRECTION } from "../core/vector"; +export const ADVANCED_RENDERING_SHADOW_MAP_SIZES = [512, 1024, 2048, 4096]; +export const ADVANCED_RENDERING_SHADOW_TYPES = ["basic", "pcf", "pcfSoft"]; +export const ADVANCED_RENDERING_TONE_MAPPING_MODES = ["none", "linear", "reinhard", "cineon", "acesFilmic"]; +const DEFAULT_SOLID_BACKGROUND_COLOR = "#2f3947"; +const DEFAULT_GRADIENT_TOP_COLOR = DEFAULT_SOLID_BACKGROUND_COLOR; +const DEFAULT_GRADIENT_BOTTOM_COLOR = "#141a22"; +const DEFAULT_ADVANCED_RENDERING_SHADOW_MAP_SIZE = 2048; +const DEFAULT_ADVANCED_RENDERING_SHADOW_TYPE = "pcfSoft"; +const DEFAULT_ADVANCED_RENDERING_SHADOW_BIAS = -0.0005; +const DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_INTENSITY = 1; +const DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_RADIUS = 0.5; +const DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_SAMPLES = 8; +const DEFAULT_ADVANCED_RENDERING_BLOOM_INTENSITY = 0.75; +const DEFAULT_ADVANCED_RENDERING_BLOOM_THRESHOLD = 0.85; +const DEFAULT_ADVANCED_RENDERING_BLOOM_RADIUS = 0.35; +const DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_MODE = "acesFilmic"; +const DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_EXPOSURE = 1; +const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCUS_DISTANCE = 10; +const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCAL_LENGTH = 0.03; +const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_BOKEH_SCALE = 1.5; +export function isAdvancedRenderingShadowMapSize(value) { + return ADVANCED_RENDERING_SHADOW_MAP_SIZES.includes(value); +} +export function isAdvancedRenderingShadowType(value) { + return ADVANCED_RENDERING_SHADOW_TYPES.includes(value); +} +export function isAdvancedRenderingToneMappingMode(value) { + return ADVANCED_RENDERING_TONE_MAPPING_MODES.includes(value); +} +export function createDefaultAdvancedRenderingSettings() { + return { + enabled: false, + shadows: { + enabled: false, + mapSize: DEFAULT_ADVANCED_RENDERING_SHADOW_MAP_SIZE, + type: DEFAULT_ADVANCED_RENDERING_SHADOW_TYPE, + bias: DEFAULT_ADVANCED_RENDERING_SHADOW_BIAS + }, + ambientOcclusion: { + enabled: false, + intensity: DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_INTENSITY, + radius: DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_RADIUS, + samples: DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_SAMPLES + }, + bloom: { + enabled: false, + intensity: DEFAULT_ADVANCED_RENDERING_BLOOM_INTENSITY, + threshold: DEFAULT_ADVANCED_RENDERING_BLOOM_THRESHOLD, + radius: DEFAULT_ADVANCED_RENDERING_BLOOM_RADIUS + }, + toneMapping: { + mode: DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_MODE, + exposure: DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_EXPOSURE + }, + depthOfField: { + enabled: false, + focusDistance: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCUS_DISTANCE, + focalLength: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCAL_LENGTH, + bokehScale: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_BOKEH_SCALE + } + }; +} +export function createDefaultWorldSettings() { + return { + background: { + mode: "solid", + colorHex: DEFAULT_SOLID_BACKGROUND_COLOR + }, + ambientLight: { + colorHex: "#f7f1e8", + intensity: 1 + }, + sunLight: { + colorHex: "#fff1d5", + intensity: 1.75, + direction: { + ...DEFAULT_SUN_DIRECTION + } + }, + advancedRendering: createDefaultAdvancedRenderingSettings() + }; +} +export function isHexColorString(value) { + return /^#[0-9a-f]{6}$/i.test(value); +} +export function isWorldBackgroundMode(value) { + return value === "solid" || value === "verticalGradient" || value === "image"; +} +export function cloneWorldBackgroundSettings(background) { + if (background.mode === "solid") { + return { + mode: "solid", + colorHex: background.colorHex + }; + } + if (background.mode === "verticalGradient") { + return { + mode: "verticalGradient", + topColorHex: background.topColorHex, + bottomColorHex: background.bottomColorHex + }; + } + return { + mode: "image", + assetId: background.assetId, + environmentIntensity: background.environmentIntensity + }; +} +export function cloneWorldSettings(world) { + return { + background: cloneWorldBackgroundSettings(world.background), + ambientLight: { + ...world.ambientLight + }, + sunLight: { + ...world.sunLight, + direction: { + ...world.sunLight.direction + } + }, + advancedRendering: cloneAdvancedRenderingSettings(world.advancedRendering) + }; +} +export function cloneAdvancedRenderingSettings(settings) { + return { + enabled: settings.enabled, + shadows: { + ...settings.shadows + }, + ambientOcclusion: { + ...settings.ambientOcclusion + }, + bloom: { + ...settings.bloom + }, + toneMapping: { + ...settings.toneMapping + }, + depthOfField: { + ...settings.depthOfField + } + }; +} +export function areWorldBackgroundSettingsEqual(left, right) { + if (left.mode !== right.mode) { + return false; + } + if (left.mode === "solid" && right.mode === "solid") { + return left.colorHex === right.colorHex; + } + if (left.mode === "verticalGradient" && right.mode === "verticalGradient") { + return left.topColorHex === right.topColorHex && left.bottomColorHex === right.bottomColorHex; + } + return left.mode === "image" && right.mode === "image" && left.assetId === right.assetId && left.environmentIntensity === right.environmentIntensity; +} +export function areWorldSettingsEqual(left, right) { + return (areWorldBackgroundSettingsEqual(left.background, right.background) && + left.ambientLight.colorHex === right.ambientLight.colorHex && + left.ambientLight.intensity === right.ambientLight.intensity && + left.sunLight.colorHex === right.sunLight.colorHex && + left.sunLight.intensity === right.sunLight.intensity && + left.sunLight.direction.x === right.sunLight.direction.x && + left.sunLight.direction.y === right.sunLight.direction.y && + left.sunLight.direction.z === right.sunLight.direction.z && + areAdvancedRenderingSettingsEqual(left.advancedRendering, right.advancedRendering)); +} +export function areAdvancedRenderingSettingsEqual(left, right) { + return (left.enabled === right.enabled && + left.shadows.enabled === right.shadows.enabled && + left.shadows.mapSize === right.shadows.mapSize && + left.shadows.type === right.shadows.type && + left.shadows.bias === right.shadows.bias && + left.ambientOcclusion.enabled === right.ambientOcclusion.enabled && + left.ambientOcclusion.intensity === right.ambientOcclusion.intensity && + left.ambientOcclusion.radius === right.ambientOcclusion.radius && + left.ambientOcclusion.samples === right.ambientOcclusion.samples && + left.bloom.enabled === right.bloom.enabled && + left.bloom.intensity === right.bloom.intensity && + left.bloom.threshold === right.bloom.threshold && + left.bloom.radius === right.bloom.radius && + left.toneMapping.mode === right.toneMapping.mode && + left.toneMapping.exposure === right.toneMapping.exposure && + left.depthOfField.enabled === right.depthOfField.enabled && + left.depthOfField.focusDistance === right.depthOfField.focusDistance && + left.depthOfField.focalLength === right.depthOfField.focalLength && + left.depthOfField.bokehScale === right.depthOfField.bokehScale); +} +export function changeWorldBackgroundMode(background, mode, imageAssetId) { + if (mode === "image") { + if (imageAssetId === undefined || imageAssetId.trim().length === 0) { + if (background.mode === "image") { + return cloneWorldBackgroundSettings(background); + } + throw new Error("An image asset must be selected to use an image background."); + } + return { + mode: "image", + assetId: imageAssetId, + environmentIntensity: background.mode === "image" ? background.environmentIntensity : 0.5 + }; + } + if (background.mode === mode) { + return cloneWorldBackgroundSettings(background); + } + if (mode === "solid") { + return { + mode: "solid", + colorHex: background.mode === "solid" + ? background.colorHex + : background.mode === "verticalGradient" + ? background.topColorHex + : DEFAULT_SOLID_BACKGROUND_COLOR + }; + } + if (background.mode === "solid") { + return { + mode: "verticalGradient", + topColorHex: background.colorHex, + bottomColorHex: DEFAULT_GRADIENT_BOTTOM_COLOR + }; + } + if (background.mode === "verticalGradient") { + return { + mode: "verticalGradient", + topColorHex: background.topColorHex, + bottomColorHex: background.bottomColorHex + }; + } + return { + mode: "verticalGradient", + topColorHex: DEFAULT_GRADIENT_TOP_COLOR, + bottomColorHex: DEFAULT_GRADIENT_BOTTOM_COLOR + }; +} diff --git a/src/entities/entity-instances.js b/src/entities/entity-instances.js new file mode 100644 index 00000000..b73c1921 --- /dev/null +++ b/src/entities/entity-instances.js @@ -0,0 +1,501 @@ +import { createOpaqueId } from "../core/ids"; +import { isHexColorString } from "../document/world-settings"; +export const PLAYER_START_COLLIDER_MODES = ["capsule", "box", "none"]; +export const ENTITY_KIND_ORDER = [ + "pointLight", + "spotLight", + "playerStart", + "soundEmitter", + "triggerVolume", + "teleportTarget", + "interactable" +]; +export const DEFAULT_POINT_LIGHT_POSITION = { + x: 0, + y: 0, + z: 0 +}; +export const DEFAULT_POINT_LIGHT_COLOR_HEX = "#ffffff"; +export const DEFAULT_POINT_LIGHT_INTENSITY = 1.25; +export const DEFAULT_POINT_LIGHT_DISTANCE = 8; +export const DEFAULT_SPOT_LIGHT_POSITION = { + x: 0, + y: 0, + z: 0 +}; +export const DEFAULT_SPOT_LIGHT_DIRECTION = { + x: 0, + y: -1, + z: 0 +}; +export const DEFAULT_SPOT_LIGHT_COLOR_HEX = "#ffffff"; +export const DEFAULT_SPOT_LIGHT_INTENSITY = 1.5; +export const DEFAULT_SPOT_LIGHT_DISTANCE = 12; +export const DEFAULT_SPOT_LIGHT_ANGLE_DEGREES = 35; +export const DEFAULT_ENTITY_POSITION = { + x: 0, + y: 0, + z: 0 +}; +export const DEFAULT_PLAYER_START_POSITION = DEFAULT_ENTITY_POSITION; +export const DEFAULT_PLAYER_START_YAW_DEGREES = 0; +export const DEFAULT_PLAYER_START_COLLIDER_MODE = "capsule"; +export const DEFAULT_PLAYER_START_EYE_HEIGHT = 1.6; +export const DEFAULT_PLAYER_START_CAPSULE_RADIUS = 0.3; +export const DEFAULT_PLAYER_START_CAPSULE_HEIGHT = 1.8; +export const DEFAULT_PLAYER_START_BOX_SIZE = { + x: 0.6, + y: 1.8, + z: 0.6 +}; +export const DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID = null; +export const DEFAULT_SOUND_EMITTER_VOLUME = 1; +export const DEFAULT_SOUND_EMITTER_GAIN = DEFAULT_SOUND_EMITTER_VOLUME; +export const DEFAULT_SOUND_EMITTER_REF_DISTANCE = 6; +export const DEFAULT_SOUND_EMITTER_RADIUS = DEFAULT_SOUND_EMITTER_REF_DISTANCE; +export const DEFAULT_SOUND_EMITTER_MAX_DISTANCE = 24; +export const DEFAULT_TRIGGER_VOLUME_SIZE = { + x: 2, + y: 2, + z: 2 +}; +export const DEFAULT_TELEPORT_TARGET_YAW_DEGREES = 0; +export const DEFAULT_INTERACTABLE_RADIUS = 1.5; +export const DEFAULT_INTERACTABLE_PROMPT = "Use"; +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function areVec3Equal(left, right) { + return left.x === right.x && left.y === right.y && left.z === right.z; +} +function assertFiniteVec3(vector, label) { + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + throw new Error(`${label} must be finite on every axis.`); + } +} +function assertPositiveFiniteNumber(value, label) { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a finite number greater than zero.`); + } +} +function assertPositiveFiniteVec3(vector, label) { + assertFiniteVec3(vector, label); + if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) { + throw new Error(`${label} must remain positive on every axis.`); + } +} +function assertNonNegativeFiniteNumber(value, label) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a finite number greater than or equal to zero.`); + } +} +function assertHexColorString(value, label) { + if (!isHexColorString(value)) { + throw new Error(`${label} must use #RRGGBB format.`); + } +} +function assertNonZeroVec3(vector, label) { + if (vector.x === 0 && vector.y === 0 && vector.z === 0) { + throw new Error(`${label} must not be the zero vector.`); + } +} +function assertBoolean(value, label) { + if (typeof value !== "boolean") { + throw new Error(`${label} must be a boolean.`); + } +} +export function isPlayerStartColliderMode(value) { + return PLAYER_START_COLLIDER_MODES.includes(value); +} +export function clonePlayerStartColliderSettings(settings) { + return { + mode: settings.mode, + eyeHeight: settings.eyeHeight, + capsuleRadius: settings.capsuleRadius, + capsuleHeight: settings.capsuleHeight, + boxSize: cloneVec3(settings.boxSize) + }; +} +export function getPlayerStartColliderHeight(settings) { + switch (settings.mode) { + case "capsule": + return settings.capsuleHeight; + case "box": + return settings.boxSize.y; + case "none": + return null; + } +} +export function createPlayerStartColliderSettings(overrides = {}) { + const mode = overrides.mode ?? DEFAULT_PLAYER_START_COLLIDER_MODE; + const eyeHeight = overrides.eyeHeight ?? DEFAULT_PLAYER_START_EYE_HEIGHT; + const capsuleRadius = overrides.capsuleRadius ?? DEFAULT_PLAYER_START_CAPSULE_RADIUS; + const capsuleHeight = overrides.capsuleHeight ?? DEFAULT_PLAYER_START_CAPSULE_HEIGHT; + const boxSize = cloneVec3(overrides.boxSize ?? DEFAULT_PLAYER_START_BOX_SIZE); + if (!isPlayerStartColliderMode(mode)) { + throw new Error("Player Start collider mode must be capsule, box, or none."); + } + assertPositiveFiniteNumber(eyeHeight, "Player Start eye height"); + assertPositiveFiniteNumber(capsuleRadius, "Player Start capsule radius"); + assertPositiveFiniteNumber(capsuleHeight, "Player Start capsule height"); + assertPositiveFiniteVec3(boxSize, "Player Start box size"); + if (capsuleHeight < capsuleRadius * 2) { + throw new Error("Player Start capsule height must be at least twice the capsule radius."); + } + if (mode === "capsule" && eyeHeight > capsuleHeight) { + throw new Error("Player Start eye height must be less than or equal to the capsule height."); + } + if (mode === "box" && eyeHeight > boxSize.y) { + throw new Error("Player Start eye height must be less than or equal to the box height."); + } + return { + mode, + eyeHeight, + capsuleRadius, + capsuleHeight, + boxSize + }; +} +function normalizeSoundEmitterAudioAssetId(audioAssetId) { + if (audioAssetId === undefined || audioAssetId === null) { + return null; + } + const trimmedAudioAssetId = audioAssetId.trim(); + if (trimmedAudioAssetId.length === 0) { + throw new Error("Sound Emitter audio asset id must be non-empty when authored."); + } + return trimmedAudioAssetId; +} +export function normalizeEntityName(name) { + if (name === undefined || name === null) { + return undefined; + } + const trimmedName = name.trim(); + return trimmedName.length === 0 ? undefined : trimmedName; +} +export function normalizeYawDegrees(yawDegrees) { + const normalizedYaw = yawDegrees % 360; + return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw; +} +export function normalizeInteractablePrompt(prompt) { + const normalizedPrompt = prompt.trim(); + if (normalizedPrompt.length === 0) { + throw new Error("Interactable prompt must be non-empty."); + } + return normalizedPrompt; +} +export function createPointLightEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_POINT_LIGHT_POSITION); + const colorHex = overrides.colorHex ?? DEFAULT_POINT_LIGHT_COLOR_HEX; + const intensity = overrides.intensity ?? DEFAULT_POINT_LIGHT_INTENSITY; + const distance = overrides.distance ?? DEFAULT_POINT_LIGHT_DISTANCE; + assertFiniteVec3(position, "Point Light position"); + assertHexColorString(colorHex, "Point Light color"); + assertNonNegativeFiniteNumber(intensity, "Point Light intensity"); + assertPositiveFiniteNumber(distance, "Point Light distance"); + return { + id: overrides.id ?? createOpaqueId("entity-point-light"), + kind: "pointLight", + name: normalizeEntityName(overrides.name), + position, + colorHex, + intensity, + distance + }; +} +export function createSpotLightEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_SPOT_LIGHT_POSITION); + const direction = cloneVec3(overrides.direction ?? DEFAULT_SPOT_LIGHT_DIRECTION); + const colorHex = overrides.colorHex ?? DEFAULT_SPOT_LIGHT_COLOR_HEX; + const intensity = overrides.intensity ?? DEFAULT_SPOT_LIGHT_INTENSITY; + const distance = overrides.distance ?? DEFAULT_SPOT_LIGHT_DISTANCE; + const angleDegrees = overrides.angleDegrees ?? DEFAULT_SPOT_LIGHT_ANGLE_DEGREES; + assertFiniteVec3(position, "Spot Light position"); + assertFiniteVec3(direction, "Spot Light direction"); + assertNonZeroVec3(direction, "Spot Light direction"); + assertHexColorString(colorHex, "Spot Light color"); + assertNonNegativeFiniteNumber(intensity, "Spot Light intensity"); + assertPositiveFiniteNumber(distance, "Spot Light distance"); + if (!Number.isFinite(angleDegrees) || angleDegrees <= 0 || angleDegrees >= 180) { + throw new Error("Spot Light angle must be a finite degree value between 0 and 180."); + } + return { + id: overrides.id ?? createOpaqueId("entity-spot-light"), + kind: "spotLight", + name: normalizeEntityName(overrides.name), + position, + direction, + colorHex, + intensity, + distance, + angleDegrees + }; +} +export function createPlayerStartEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_PLAYER_START_POSITION); + const yawDegrees = overrides.yawDegrees ?? DEFAULT_PLAYER_START_YAW_DEGREES; + const collider = createPlayerStartColliderSettings(overrides.collider); + assertFiniteVec3(position, "Player Start position"); + if (!Number.isFinite(yawDegrees)) { + throw new Error("Player Start yaw must be a finite number."); + } + return { + id: overrides.id ?? createOpaqueId("entity-player-start"), + kind: "playerStart", + name: normalizeEntityName(overrides.name), + position, + yawDegrees: normalizeYawDegrees(yawDegrees), + collider + }; +} +export function createSoundEmitterEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const audioAssetId = normalizeSoundEmitterAudioAssetId(overrides.audioAssetId ?? DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID); + const volume = overrides.volume ?? DEFAULT_SOUND_EMITTER_VOLUME; + const refDistance = overrides.refDistance ?? DEFAULT_SOUND_EMITTER_REF_DISTANCE; + const maxDistance = overrides.maxDistance ?? DEFAULT_SOUND_EMITTER_MAX_DISTANCE; + const autoplay = overrides.autoplay ?? false; + const loop = overrides.loop ?? false; + assertFiniteVec3(position, "Sound Emitter position"); + assertNonNegativeFiniteNumber(volume, "Sound Emitter volume"); + assertPositiveFiniteNumber(refDistance, "Sound Emitter ref distance"); + assertPositiveFiniteNumber(maxDistance, "Sound Emitter max distance"); + if (maxDistance < refDistance) { + throw new Error("Sound Emitter max distance must be greater than or equal to ref distance."); + } + assertBoolean(autoplay, "Sound Emitter autoplay"); + assertBoolean(loop, "Sound Emitter loop"); + return { + id: overrides.id ?? createOpaqueId("entity-sound-emitter"), + kind: "soundEmitter", + name: normalizeEntityName(overrides.name), + position, + audioAssetId, + volume, + refDistance, + maxDistance, + autoplay, + loop + }; +} +export function createTriggerVolumeEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const size = cloneVec3(overrides.size ?? DEFAULT_TRIGGER_VOLUME_SIZE); + const triggerOnEnter = overrides.triggerOnEnter ?? true; + const triggerOnExit = overrides.triggerOnExit ?? false; + assertFiniteVec3(position, "Trigger Volume position"); + assertPositiveFiniteVec3(size, "Trigger Volume size"); + assertBoolean(triggerOnEnter, "Trigger Volume triggerOnEnter"); + assertBoolean(triggerOnExit, "Trigger Volume triggerOnExit"); + return { + id: overrides.id ?? createOpaqueId("entity-trigger-volume"), + kind: "triggerVolume", + name: normalizeEntityName(overrides.name), + position, + size, + triggerOnEnter, + triggerOnExit + }; +} +export function createTeleportTargetEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const yawDegrees = overrides.yawDegrees ?? DEFAULT_TELEPORT_TARGET_YAW_DEGREES; + assertFiniteVec3(position, "Teleport Target position"); + if (!Number.isFinite(yawDegrees)) { + throw new Error("Teleport Target yaw must be a finite number."); + } + return { + id: overrides.id ?? createOpaqueId("entity-teleport-target"), + kind: "teleportTarget", + name: normalizeEntityName(overrides.name), + position, + yawDegrees: normalizeYawDegrees(yawDegrees) + }; +} +export function createInteractableEntity(overrides = {}) { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const radius = overrides.radius ?? DEFAULT_INTERACTABLE_RADIUS; + const prompt = normalizeInteractablePrompt(overrides.prompt ?? DEFAULT_INTERACTABLE_PROMPT); + const enabled = overrides.enabled ?? true; + assertFiniteVec3(position, "Interactable position"); + assertPositiveFiniteNumber(radius, "Interactable radius"); + assertBoolean(enabled, "Interactable enabled"); + return { + id: overrides.id ?? createOpaqueId("entity-interactable"), + kind: "interactable", + name: normalizeEntityName(overrides.name), + position, + radius, + prompt, + enabled + }; +} +export const ENTITY_REGISTRY = { + pointLight: { + kind: "pointLight", + label: "Point Light", + description: "Authored local point light that illuminates nearby geometry in a spherical radius.", + createDefaultEntity: createPointLightEntity + }, + spotLight: { + kind: "spotLight", + label: "Spot Light", + description: "Authored local spotlight with an explicit direction and cone angle.", + createDefaultEntity: createSpotLightEntity + }, + playerStart: { + kind: "playerStart", + label: "Player Start", + description: "Primary authored spawn point for first-person runtime navigation.", + createDefaultEntity: createPlayerStartEntity + }, + soundEmitter: { + kind: "soundEmitter", + label: "Sound Emitter", + description: "Authored positional audio source wired to an audio asset and configurable for looping, volume, and distance falloff.", + createDefaultEntity: createSoundEmitterEntity + }, + triggerVolume: { + kind: "triggerVolume", + label: "Trigger Volume", + description: "Axis-aligned authored trigger volume for enter and exit events.", + createDefaultEntity: createTriggerVolumeEntity + }, + teleportTarget: { + kind: "teleportTarget", + label: "Teleport Target", + description: "Explicit authored teleport destination with a facing direction.", + createDefaultEntity: createTeleportTargetEntity + }, + interactable: { + kind: "interactable", + label: "Interactable", + description: "Explicit authored interaction point for later click and use behavior.", + createDefaultEntity: createInteractableEntity + } +}; +export function isEntityKind(value) { + return typeof value === "string" && Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, value); +} +export function getEntityRegistryEntry(kind) { + return ENTITY_REGISTRY[kind]; +} +export function createDefaultEntityInstance(kind, overrides = {}) { + switch (kind) { + case "pointLight": + return createPointLightEntity(overrides); + case "spotLight": + return createSpotLightEntity(overrides); + case "playerStart": + return createPlayerStartEntity(overrides); + case "soundEmitter": + return createSoundEmitterEntity(overrides); + case "triggerVolume": + return createTriggerVolumeEntity(overrides); + case "teleportTarget": + return createTeleportTargetEntity(overrides); + case "interactable": + return createInteractableEntity(overrides); + } +} +export function cloneEntityInstance(entity) { + switch (entity.kind) { + case "pointLight": + return createPointLightEntity(entity); + case "spotLight": + return createSpotLightEntity(entity); + case "playerStart": + return createPlayerStartEntity(entity); + case "soundEmitter": + return createSoundEmitterEntity(entity); + case "triggerVolume": + return createTriggerVolumeEntity(entity); + case "teleportTarget": + return createTeleportTargetEntity(entity); + case "interactable": + return createInteractableEntity(entity); + } +} +export function cloneEntityRegistry(entities) { + return Object.fromEntries(Object.entries(entities).map(([entityId, entity]) => [entityId, cloneEntityInstance(entity)])); +} +export function areEntityInstancesEqual(left, right) { + if (left.kind !== right.kind || left.id !== right.id || left.name !== right.name || !areVec3Equal(left.position, right.position)) { + return false; + } + switch (left.kind) { + case "pointLight": { + const typedRight = right; + return (left.colorHex === typedRight.colorHex && + left.intensity === typedRight.intensity && + left.distance === typedRight.distance); + } + case "spotLight": { + const typedRight = right; + return (areVec3Equal(left.direction, typedRight.direction) && + left.colorHex === typedRight.colorHex && + left.intensity === typedRight.intensity && + left.distance === typedRight.distance && + left.angleDegrees === typedRight.angleDegrees); + } + case "playerStart": { + const typedRight = right; + return (left.yawDegrees === typedRight.yawDegrees && + left.collider.mode === typedRight.collider.mode && + left.collider.eyeHeight === typedRight.collider.eyeHeight && + left.collider.capsuleRadius === typedRight.collider.capsuleRadius && + left.collider.capsuleHeight === typedRight.collider.capsuleHeight && + areVec3Equal(left.collider.boxSize, typedRight.collider.boxSize)); + } + case "soundEmitter": { + const typedRight = right; + return (left.audioAssetId === typedRight.audioAssetId && + left.volume === typedRight.volume && + left.refDistance === typedRight.refDistance && + left.maxDistance === typedRight.maxDistance && + left.autoplay === typedRight.autoplay && + left.loop === typedRight.loop); + } + case "triggerVolume": { + const typedRight = right; + return (areVec3Equal(left.size, typedRight.size) && + left.triggerOnEnter === typedRight.triggerOnEnter && + left.triggerOnExit === typedRight.triggerOnExit); + } + case "teleportTarget": { + const typedRight = right; + return left.yawDegrees === typedRight.yawDegrees; + } + case "interactable": { + const typedRight = right; + return left.radius === typedRight.radius && left.prompt === typedRight.prompt && left.enabled === typedRight.enabled; + } + } +} +export function compareEntityInstances(left, right) { + const leftOrder = ENTITY_KIND_ORDER.indexOf(left.kind); + const rightOrder = ENTITY_KIND_ORDER.indexOf(right.kind); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.id.localeCompare(right.id); +} +export function getEntityInstances(entities) { + return Object.values(entities).sort(compareEntityInstances); +} +export function getEntitiesOfKind(entities, kind) { + return getEntityInstances(entities).filter((entity) => entity.kind === kind); +} +export function getPlayerStartEntities(entities) { + return getEntitiesOfKind(entities, "playerStart"); +} +export function getPrimaryPlayerStartEntity(entities) { + return getPlayerStartEntities(entities)[0] ?? null; +} +export function getEntityKindLabel(kind) { + return getEntityRegistryEntry(kind).label; +} diff --git a/src/entities/entity-labels.js b/src/entities/entity-labels.js new file mode 100644 index 00000000..0b679732 --- /dev/null +++ b/src/entities/entity-labels.js @@ -0,0 +1,45 @@ +import { compareEntityInstances, getEntityKindLabel, getEntityInstances } from "./entity-instances"; +function getSortedEntitiesByKind(entities, kind) { + return Object.values(entities) + .filter((entity) => entity.kind === kind) + .sort(compareEntityInstances); +} +function getSoundEmitterLabelSuffix(entity, assets) { + if (entity.audioAssetId === null) { + return "No Audio Asset"; + } + const asset = assets?.[entity.audioAssetId]; + if (asset === undefined) { + return `Missing Audio Asset (${entity.audioAssetId})`; + } + if (asset.kind !== "audio") { + return `Invalid Audio Asset (${asset.sourceName})`; + } + return asset.sourceName; +} +export function getEntityDisplayLabel(entity, entities, assets) { + if (entity.name !== undefined) { + return entity.name; + } + const typedEntities = getSortedEntitiesByKind(entities, entity.kind); + const entityIndex = typedEntities.findIndex((candidate) => candidate.id === entity.id); + const baseLabel = getEntityKindLabel(entity.kind); + const numberedLabel = entityIndex <= 0 ? baseLabel : `${baseLabel} ${entityIndex + 1}`; + if (entity.kind !== "soundEmitter" || assets === undefined) { + return numberedLabel; + } + return `${numberedLabel} · ${getSoundEmitterLabelSuffix(entity, assets)}`; +} +export function getEntityDisplayLabelById(entityId, entities, assets) { + const entity = entities[entityId]; + if (entity === undefined) { + return "Entity"; + } + return getEntityDisplayLabel(entity, entities, assets); +} +export function getSortedEntityDisplayLabels(entities, assets) { + return getEntityInstances(entities).map((entity) => ({ + entity, + label: getEntityDisplayLabel(entity, entities, assets) + })); +} diff --git a/src/geometry/box-brush-components.js b/src/geometry/box-brush-components.js new file mode 100644 index 00000000..51c39bb4 --- /dev/null +++ b/src/geometry/box-brush-components.js @@ -0,0 +1,158 @@ +import { Euler, MathUtils, Quaternion, Vector3 } from "three"; +import { BOX_FACE_IDS } from "../document/brushes"; +import { getBoxBrushFaceVertexIds, getBoxBrushLocalVertexPosition } from "./box-brush-mesh"; +const BOX_VERTEX_SIGNS = { + negX_negY_negZ: { x: -1, y: -1, z: -1 }, + posX_negY_negZ: { x: 1, y: -1, z: -1 }, + negX_posY_negZ: { x: -1, y: 1, z: -1 }, + posX_posY_negZ: { x: 1, y: 1, z: -1 }, + negX_negY_posZ: { x: -1, y: -1, z: 1 }, + posX_negY_posZ: { x: 1, y: -1, z: 1 }, + negX_posY_posZ: { x: -1, y: 1, z: 1 }, + posX_posY_posZ: { x: 1, y: 1, z: 1 } +}; +const BOX_FACE_TRANSFORM_META = { + posX: { axis: "x", sign: 1 }, + negX: { axis: "x", sign: -1 }, + posY: { axis: "y", sign: 1 }, + negY: { axis: "y", sign: -1 }, + posZ: { axis: "z", sign: 1 }, + negZ: { axis: "z", sign: -1 } +}; +const BOX_EDGE_TRANSFORM_META = { + edgeX_negY_negZ: { + axis: "x", + signs: { x: null, y: -1, z: -1 } + }, + edgeX_posY_negZ: { + axis: "x", + signs: { x: null, y: 1, z: -1 } + }, + edgeX_negY_posZ: { + axis: "x", + signs: { x: null, y: -1, z: 1 } + }, + edgeX_posY_posZ: { + axis: "x", + signs: { x: null, y: 1, z: 1 } + }, + edgeY_negX_negZ: { + axis: "y", + signs: { x: -1, y: null, z: -1 } + }, + edgeY_posX_negZ: { + axis: "y", + signs: { x: 1, y: null, z: -1 } + }, + edgeY_negX_posZ: { + axis: "y", + signs: { x: -1, y: null, z: 1 } + }, + edgeY_posX_posZ: { + axis: "y", + signs: { x: 1, y: null, z: 1 } + }, + edgeZ_negX_negY: { + axis: "z", + signs: { x: -1, y: -1, z: null } + }, + edgeZ_posX_negY: { + axis: "z", + signs: { x: 1, y: -1, z: null } + }, + edgeZ_negX_posY: { + axis: "z", + signs: { x: -1, y: 1, z: null } + }, + edgeZ_posX_posY: { + axis: "z", + signs: { x: 1, y: 1, z: null } + } +}; +const BOX_EDGE_VERTEX_IDS = { + edgeX_negY_negZ: { start: "negX_negY_negZ", end: "posX_negY_negZ" }, + edgeX_posY_negZ: { start: "negX_posY_negZ", end: "posX_posY_negZ" }, + edgeX_negY_posZ: { start: "negX_negY_posZ", end: "posX_negY_posZ" }, + edgeX_posY_posZ: { start: "negX_posY_posZ", end: "posX_posY_posZ" }, + edgeY_negX_negZ: { start: "negX_negY_negZ", end: "negX_posY_negZ" }, + edgeY_posX_negZ: { start: "posX_negY_negZ", end: "posX_posY_negZ" }, + edgeY_negX_posZ: { start: "negX_negY_posZ", end: "negX_posY_posZ" }, + edgeY_posX_posZ: { start: "posX_negY_posZ", end: "posX_posY_posZ" }, + edgeZ_negX_negY: { start: "negX_negY_negZ", end: "negX_negY_posZ" }, + edgeZ_posX_negY: { start: "posX_negY_negZ", end: "posX_negY_posZ" }, + edgeZ_negX_posY: { start: "negX_posY_negZ", end: "negX_posY_posZ" }, + edgeZ_posX_posY: { start: "posX_posY_negZ", end: "posX_posY_posZ" } +}; +function createBrushRotationEuler(brush) { + return new Euler(MathUtils.degToRad(brush.rotationDegrees.x), MathUtils.degToRad(brush.rotationDegrees.y), MathUtils.degToRad(brush.rotationDegrees.z), "XYZ"); +} +export function transformBoxBrushWorldVectorToLocal(brush, worldVector) { + const rotation = createBrushRotationEuler(brush); + const inverseRotation = new Quaternion().setFromEuler(rotation).invert(); + const localVector = new Vector3(worldVector.x, worldVector.y, worldVector.z).applyQuaternion(inverseRotation); + return { + x: localVector.x, + y: localVector.y, + z: localVector.z + }; +} +export function transformBoxBrushLocalPointToWorld(brush, localPoint) { + const rotation = createBrushRotationEuler(brush); + const rotatedOffset = new Vector3(localPoint.x, localPoint.y, localPoint.z).applyEuler(rotation); + return { + x: brush.center.x + rotatedOffset.x, + y: brush.center.y + rotatedOffset.y, + z: brush.center.z + rotatedOffset.z + }; +} +export function getBoxBrushFaceTransformMeta(faceId) { + return BOX_FACE_TRANSFORM_META[faceId]; +} +export function getBoxBrushEdgeTransformMeta(edgeId) { + return BOX_EDGE_TRANSFORM_META[edgeId]; +} +export function getBoxBrushVertexSigns(vertexId) { + return BOX_VERTEX_SIGNS[vertexId]; +} +export function getBoxBrushFaceWorldCenter(brush, faceId) { + const faceVertexIds = getBoxBrushFaceVertexIds(faceId); + const localCenter = faceVertexIds.reduce((accumulator, vertexId) => { + const vertex = getBoxBrushLocalVertexPosition(brush, vertexId); + return { + x: accumulator.x + vertex.x * 0.25, + y: accumulator.y + vertex.y * 0.25, + z: accumulator.z + vertex.z * 0.25 + }; + }, { x: 0, y: 0, z: 0 }); + return transformBoxBrushLocalPointToWorld(brush, localCenter); +} +export function getBoxBrushFaceAxis(faceId) { + return BOX_FACE_TRANSFORM_META[faceId].axis; +} +export function getBoxBrushEdgeAxis(edgeId) { + return BOX_EDGE_TRANSFORM_META[edgeId].axis; +} +export function getBoxBrushFaceIdsForAxis(axis) { + return BOX_FACE_IDS.filter((faceId) => BOX_FACE_TRANSFORM_META[faceId].axis === axis); +} +export function getBoxBrushVertexLocalPosition(brush, vertexId) { + return getBoxBrushLocalVertexPosition(brush, vertexId); +} +export function getBoxBrushVertexWorldPosition(brush, vertexId) { + return transformBoxBrushLocalPointToWorld(brush, getBoxBrushVertexLocalPosition(brush, vertexId)); +} +export function getBoxBrushEdgeWorldSegment(brush, edgeId) { + const vertexIds = BOX_EDGE_VERTEX_IDS[edgeId]; + const start = getBoxBrushVertexWorldPosition(brush, vertexIds.start); + const end = getBoxBrushVertexWorldPosition(brush, vertexIds.end); + return { + id: edgeId, + start, + end, + center: { + x: (start.x + end.x) * 0.5, + y: (start.y + end.y) * 0.5, + z: (start.z + end.z) * 0.5 + } + }; +} diff --git a/src/geometry/box-brush-mesh.js b/src/geometry/box-brush-mesh.js new file mode 100644 index 00000000..a0ac78f3 --- /dev/null +++ b/src/geometry/box-brush-mesh.js @@ -0,0 +1,359 @@ +import { BufferAttribute, BufferGeometry } from "three"; +import { BOX_EDGE_IDS, BOX_FACE_IDS } from "../document/brushes"; +import { transformProjectedFaceUv } from "./box-face-uvs"; +const FACE_VERTEX_IDS = { + posX: ["posX_negY_posZ", "posX_negY_negZ", "posX_posY_negZ", "posX_posY_posZ"], + negX: ["negX_negY_negZ", "negX_negY_posZ", "negX_posY_posZ", "negX_posY_negZ"], + posY: ["negX_posY_posZ", "posX_posY_posZ", "posX_posY_negZ", "negX_posY_negZ"], + negY: ["negX_negY_negZ", "posX_negY_negZ", "posX_negY_posZ", "negX_negY_posZ"], + posZ: ["negX_negY_posZ", "posX_negY_posZ", "posX_posY_posZ", "negX_posY_posZ"], + negZ: ["posX_negY_negZ", "negX_negY_negZ", "negX_posY_negZ", "posX_posY_negZ"] +}; +const EDGE_VERTEX_IDS = { + edgeX_negY_negZ: ["negX_negY_negZ", "posX_negY_negZ"], + edgeX_posY_negZ: ["negX_posY_negZ", "posX_posY_negZ"], + edgeX_negY_posZ: ["negX_negY_posZ", "posX_negY_posZ"], + edgeX_posY_posZ: ["negX_posY_posZ", "posX_posY_posZ"], + edgeY_negX_negZ: ["negX_negY_negZ", "negX_posY_negZ"], + edgeY_posX_negZ: ["posX_negY_negZ", "posX_posY_negZ"], + edgeY_negX_posZ: ["negX_negY_posZ", "negX_posY_posZ"], + edgeY_posX_posZ: ["posX_negY_posZ", "posX_posY_posZ"], + edgeZ_negX_negY: ["negX_negY_negZ", "negX_negY_posZ"], + edgeZ_posX_negY: ["posX_negY_negZ", "posX_negY_posZ"], + edgeZ_negX_posY: ["negX_posY_negZ", "negX_posY_posZ"], + edgeZ_posX_posY: ["posX_posY_negZ", "posX_posY_posZ"] +}; +function cloneVec3(vector) { + return { x: vector.x, y: vector.y, z: vector.z }; +} +function subtractVec3(left, right) { + return { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z + }; +} +function crossVec3(left, right) { + return { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x + }; +} +function dotVec3(left, right) { + return left.x * right.x + left.y * right.y + left.z * right.z; +} +function getVectorLength(vector) { + return Math.sqrt(dotVec3(vector, vector)); +} +function normalizeVec3(vector) { + const length = getVectorLength(vector); + if (length <= 1e-8) { + return { x: 0, y: 0, z: 0 }; + } + return { + x: vector.x / length, + y: vector.y / length, + z: vector.z / length + }; +} +function computeNewellNormal(vertices) { + let normal = { x: 0, y: 0, z: 0 }; + for (let index = 0; index < vertices.length; index += 1) { + const current = vertices[index]; + const next = vertices[(index + 1) % vertices.length]; + normal.x += (current.y - next.y) * (current.z + next.z); + normal.y += (current.z - next.z) * (current.x + next.x); + normal.z += (current.x - next.x) * (current.y + next.y); + } + return normalizeVec3(normal); +} +function chooseProjectionAxes(normal) { + const absoluteNormal = { + x: Math.abs(normal.x), + y: Math.abs(normal.y), + z: Math.abs(normal.z) + }; + if (absoluteNormal.x >= absoluteNormal.y && absoluteNormal.x >= absoluteNormal.z) { + return ["y", "z"]; + } + if (absoluteNormal.y >= absoluteNormal.z) { + return ["x", "z"]; + } + return ["x", "y"]; +} +function projectVerticesTo2d(vertices, normal) { + const [uAxis, vAxis] = chooseProjectionAxes(normal); + return vertices.map((vertex) => ({ + x: vertex[uAxis], + y: vertex[vAxis] + })); +} +function computeSignedArea(points) { + let area = 0; + for (let index = 0; index < points.length; index += 1) { + const current = points[index]; + const next = points[(index + 1) % points.length]; + area += current.x * next.y - next.x * current.y; + } + return area * 0.5; +} +function isPointInTriangle(point, triangle, orientation) { + const [a, b, c] = triangle; + const edges = [ + (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x), + (c.x - b.x) * (point.y - b.y) - (c.y - b.y) * (point.x - b.x), + (a.x - c.x) * (point.y - c.y) - (a.y - c.y) * (point.x - c.x) + ]; + return orientation > 0 ? edges.every((value) => value >= -1e-8) : edges.every((value) => value <= 1e-8); +} +function triangulateQuad(vertices) { + const normal = computeNewellNormal(vertices); + const projected = projectVerticesTo2d(vertices, normal); + const orientation = computeSignedArea(projected); + if (Math.abs(orientation) <= 1e-8) { + throw new Error("Face projection is degenerate."); + } + const remaining = [0, 1, 2, 3]; + const triangles = []; + while (remaining.length > 3) { + let earFound = false; + for (let offset = 0; offset < remaining.length; offset += 1) { + const previousIndex = remaining[(offset + remaining.length - 1) % remaining.length]; + const currentIndex = remaining[offset]; + const nextIndex = remaining[(offset + 1) % remaining.length]; + const previousPoint = projected[previousIndex]; + const currentPoint = projected[currentIndex]; + const nextPoint = projected[nextIndex]; + const cross = (currentPoint.x - previousPoint.x) * (nextPoint.y - previousPoint.y) - + (currentPoint.y - previousPoint.y) * (nextPoint.x - previousPoint.x); + if ((orientation > 0 && cross <= 1e-8) || (orientation < 0 && cross >= -1e-8)) { + continue; + } + const candidateTriangle = [previousPoint, currentPoint, nextPoint]; + const containsOtherPoint = remaining.some((candidateIndex) => { + if (candidateIndex === previousIndex || candidateIndex === currentIndex || candidateIndex === nextIndex) { + return false; + } + return isPointInTriangle(projected[candidateIndex], candidateTriangle, orientation); + }); + if (containsOtherPoint) { + continue; + } + triangles.push([previousIndex, currentIndex, nextIndex]); + remaining.splice(offset, 1); + earFound = true; + break; + } + if (!earFound) { + throw new Error("Face triangulation could not find a stable ear."); + } + } + triangles.push([remaining[0], remaining[1], remaining[2]]); + return triangles; +} +function projectLocalVertexToFaceUv(vertexPosition, faceId, faceBounds) { + switch (faceId) { + case "posX": + return { + x: faceBounds.max.z - vertexPosition.z, + y: vertexPosition.y - faceBounds.min.y + }; + case "negX": + return { + x: vertexPosition.z - faceBounds.min.z, + y: vertexPosition.y - faceBounds.min.y + }; + case "posY": + return { + x: vertexPosition.x - faceBounds.min.x, + y: faceBounds.max.z - vertexPosition.z + }; + case "negY": + return { + x: vertexPosition.x - faceBounds.min.x, + y: vertexPosition.z - faceBounds.min.z + }; + case "posZ": + return { + x: vertexPosition.x - faceBounds.min.x, + y: vertexPosition.y - faceBounds.min.y + }; + case "negZ": + return { + x: faceBounds.max.x - vertexPosition.x, + y: vertexPosition.y - faceBounds.min.y + }; + } +} +function getFaceUvSize(faceId, faceBounds) { + switch (faceId) { + case "posX": + case "negX": + return { + x: faceBounds.max.z - faceBounds.min.z, + y: faceBounds.max.y - faceBounds.min.y + }; + case "posY": + case "negY": + return { + x: faceBounds.max.x - faceBounds.min.x, + y: faceBounds.max.z - faceBounds.min.z + }; + case "posZ": + case "negZ": + return { + x: faceBounds.max.x - faceBounds.min.x, + y: faceBounds.max.y - faceBounds.min.y + }; + } +} +function computeFaceBounds(vertices) { + const firstVertex = vertices[0]; + const min = { ...firstVertex }; + const max = { ...firstVertex }; + for (const vertex of vertices.slice(1)) { + min.x = Math.min(min.x, vertex.x); + min.y = Math.min(min.y, vertex.y); + min.z = Math.min(min.z, vertex.z); + max.x = Math.max(max.x, vertex.x); + max.y = Math.max(max.y, vertex.y); + max.z = Math.max(max.z, vertex.z); + } + return { min, max }; +} +export function getBoxBrushFaceVertexIds(faceId) { + return FACE_VERTEX_IDS[faceId]; +} +export function getBoxBrushEdgeVertexIds(edgeId) { + return EDGE_VERTEX_IDS[edgeId]; +} +export function getBoxBrushLocalVertexPosition(brush, vertexId) { + return cloneVec3(brush.geometry.vertices[vertexId]); +} +export function buildBoxBrushDerivedMeshData(brush) { + const diagnostics = validateBoxBrushGeometry(brush); + if (diagnostics.length > 0) { + throw new Error(diagnostics[0].message); + } + const positions = []; + const normals = []; + const uvs = []; + const indices = []; + const colliderVertices = []; + const colliderIndices = []; + const faceSurfaces = []; + const groups = []; + const vertexIndexMap = new Map(); + for (const vertexId of Object.keys(brush.geometry.vertices)) { + const vertex = brush.geometry.vertices[vertexId]; + vertexIndexMap.set(vertexId, colliderVertices.length / 3); + colliderVertices.push(vertex.x, vertex.y, vertex.z); + } + for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) { + const faceVertexIds = FACE_VERTEX_IDS[faceId]; + const faceVertices = faceVertexIds.map((vertexId) => getBoxBrushLocalVertexPosition(brush, vertexId)); + const triangles = triangulateQuad(faceVertices); + const normal = computeNewellNormal(faceVertices); + const faceBounds = computeFaceBounds(faceVertices); + const uvSize = getFaceUvSize(faceId, faceBounds); + const indexStart = indices.length; + faceSurfaces.push({ + faceId, + vertexIds: faceVertexIds, + triangles, + normal + }); + for (const triangle of triangles) { + for (const vertexOffset of triangle) { + const vertex = faceVertices[vertexOffset]; + const projectedUv = projectLocalVertexToFaceUv(vertex, faceId, faceBounds); + const transformedUv = transformProjectedFaceUv(projectedUv, uvSize, brush.faces[faceId].uv); + positions.push(vertex.x, vertex.y, vertex.z); + normals.push(normal.x, normal.y, normal.z); + uvs.push(transformedUv.x, transformedUv.y); + indices.push(indices.length); + } + } + groups.push({ + start: indexStart, + count: indices.length - indexStart, + materialIndex + }); + for (const triangle of triangles) { + colliderIndices.push(vertexIndexMap.get(faceVertexIds[triangle[0]]) ?? 0, vertexIndexMap.get(faceVertexIds[triangle[1]]) ?? 0, vertexIndexMap.get(faceVertexIds[triangle[2]]) ?? 0); + } + } + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3)); + geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3)); + geometry.setAttribute("uv", new BufferAttribute(new Float32Array(uvs), 2)); + geometry.setIndex(indices); + for (const group of groups) { + geometry.addGroup(group.start, group.count, group.materialIndex); + } + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + const firstVertex = brush.geometry.vertices.negX_negY_negZ; + const localBounds = { + min: cloneVec3(firstVertex), + max: cloneVec3(firstVertex) + }; + for (const vertex of Object.values(brush.geometry.vertices)) { + localBounds.min.x = Math.min(localBounds.min.x, vertex.x); + localBounds.min.y = Math.min(localBounds.min.y, vertex.y); + localBounds.min.z = Math.min(localBounds.min.z, vertex.z); + localBounds.max.x = Math.max(localBounds.max.x, vertex.x); + localBounds.max.y = Math.max(localBounds.max.y, vertex.y); + localBounds.max.z = Math.max(localBounds.max.z, vertex.z); + } + return { + geometry, + faceSurfaces, + edgeSegments: BOX_EDGE_IDS.map((edgeId) => { + const [startId, endId] = EDGE_VERTEX_IDS[edgeId]; + return { + edgeId, + start: getBoxBrushLocalVertexPosition(brush, startId), + end: getBoxBrushLocalVertexPosition(brush, endId) + }; + }), + colliderVertices: new Float32Array(colliderVertices), + colliderIndices: new Uint32Array(colliderIndices), + localBounds + }; +} +export function validateBoxBrushGeometry(brush) { + const diagnostics = []; + for (const [vertexId, vertex] of Object.entries(brush.geometry.vertices)) { + if (!Number.isFinite(vertex.x) || !Number.isFinite(vertex.y) || !Number.isFinite(vertex.z)) { + diagnostics.push({ + code: "invalid-box-geometry-vertex", + message: `Whitebox vertex ${vertexId} must remain finite.` + }); + } + } + for (const faceId of BOX_FACE_IDS) { + const faceVertices = FACE_VERTEX_IDS[faceId].map((vertexId) => brush.geometry.vertices[vertexId]); + const normal = computeNewellNormal(faceVertices); + if (getVectorLength(normal) <= 1e-8) { + diagnostics.push({ + code: "degenerate-box-face", + message: `Whitebox face ${faceId} is degenerate and cannot be triangulated.`, + faceId + }); + continue; + } + try { + triangulateQuad(faceVertices); + } + catch (error) { + diagnostics.push({ + code: "invalid-box-face-triangulation", + message: error instanceof Error ? `Whitebox face ${faceId} could not be triangulated: ${error.message}` : `Whitebox face ${faceId} could not be triangulated.`, + faceId + }); + } + } + return diagnostics; +} diff --git a/src/geometry/box-brush.js b/src/geometry/box-brush.js new file mode 100644 index 00000000..789ed702 --- /dev/null +++ b/src/geometry/box-brush.js @@ -0,0 +1,43 @@ +import { Euler, MathUtils, Vector3 } from "three"; +import { BOX_VERTEX_IDS } from "../document/brushes"; +import { getBoxBrushLocalVertexPosition } from "./box-brush-mesh"; +export function getBoxBrushHalfSize(brush) { + return { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + }; +} +export function getBoxBrushBounds(brush) { + const corners = getBoxBrushCornerPositions(brush); + const firstCorner = corners[0]; + const min = { ...firstCorner }; + const max = { ...firstCorner }; + for (const corner of corners.slice(1)) { + min.x = Math.min(min.x, corner.x); + min.y = Math.min(min.y, corner.y); + min.z = Math.min(min.z, corner.z); + max.x = Math.max(max.x, corner.x); + max.y = Math.max(max.y, corner.y); + max.z = Math.max(max.z, corner.z); + } + return { + min, + max + }; +} +export function getBoxBrushCornerPositions(brush) { + const rotation = new Euler(MathUtils.degToRad(brush.rotationDegrees.x), MathUtils.degToRad(brush.rotationDegrees.y), MathUtils.degToRad(brush.rotationDegrees.z), "XYZ"); + const offsets = BOX_VERTEX_IDS.map((vertexId) => { + const localVertex = getBoxBrushLocalVertexPosition(brush, vertexId); + return new Vector3(localVertex.x, localVertex.y, localVertex.z); + }); + return offsets.map((offset) => { + const rotatedOffset = offset.clone().applyEuler(rotation); + return { + x: brush.center.x + rotatedOffset.x, + y: brush.center.y + rotatedOffset.y, + z: brush.center.z + rotatedOffset.z + }; + }); +} diff --git a/src/geometry/box-face-uvs.js b/src/geometry/box-face-uvs.js new file mode 100644 index 00000000..784ce6b7 --- /dev/null +++ b/src/geometry/box-face-uvs.js @@ -0,0 +1,133 @@ +import { BoxGeometry } from "three"; +import { BOX_FACE_IDS, createDefaultFaceUvState } from "../document/brushes"; +import { getBoxBrushHalfSize } from "./box-brush"; +export function getBoxBrushFaceSize(brush, faceId) { + switch (faceId) { + case "posX": + case "negX": + return { + x: brush.size.z, + y: brush.size.y + }; + case "posY": + case "negY": + return { + x: brush.size.x, + y: brush.size.z + }; + case "posZ": + case "negZ": + return { + x: brush.size.x, + y: brush.size.y + }; + } +} +export function createFitToFaceBoxBrushFaceUvState(brush, faceId) { + const faceSize = getBoxBrushFaceSize(brush, faceId); + return { + ...createDefaultFaceUvState(), + scale: { + x: 1 / faceSize.x, + y: 1 / faceSize.y + } + }; +} +export function projectBoxFaceVertexToUv(vertexPosition, brush, faceId) { + const halfSize = getBoxBrushHalfSize(brush); + switch (faceId) { + case "posX": + return { + x: halfSize.z - vertexPosition.z, + y: vertexPosition.y + halfSize.y + }; + case "negX": + return { + x: vertexPosition.z + halfSize.z, + y: vertexPosition.y + halfSize.y + }; + case "posY": + return { + x: vertexPosition.x + halfSize.x, + y: halfSize.z - vertexPosition.z + }; + case "negY": + return { + x: vertexPosition.x + halfSize.x, + y: vertexPosition.z + halfSize.z + }; + case "posZ": + return { + x: vertexPosition.x + halfSize.x, + y: vertexPosition.y + halfSize.y + }; + case "negZ": + return { + x: halfSize.x - vertexPosition.x, + y: vertexPosition.y + halfSize.y + }; + } +} +export function transformProjectedFaceUv(baseUv, faceSize, uvState) { + let u = (baseUv.x - faceSize.x * 0.5) * uvState.scale.x; + let v = (baseUv.y - faceSize.y * 0.5) * uvState.scale.y; + if (uvState.flipU) { + u *= -1; + } + if (uvState.flipV) { + v *= -1; + } + switch (uvState.rotationQuarterTurns) { + case 1: { + const nextU = -v; + v = u; + u = nextU; + break; + } + case 2: + u *= -1; + v *= -1; + break; + case 3: { + const nextU = v; + v = -u; + u = nextU; + break; + } + } + return { + x: u + faceSize.x * 0.5 * uvState.scale.x + uvState.offset.x, + y: v + faceSize.y * 0.5 * uvState.scale.y + uvState.offset.y + }; +} +export function applyBoxBrushFaceUvsToGeometry(geometry, brush) { + const positionAttribute = geometry.getAttribute("position"); + const uvAttribute = geometry.getAttribute("uv"); + const indexAttribute = geometry.getIndex(); + if (indexAttribute === null) { + throw new Error("BoxGeometry is expected to be indexed for face UV projection."); + } + // BoxGeometry groups follow the same px, nx, py, ny, pz, nz order as the canonical face ids. + for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) { + const group = geometry.groups.find((candidate) => candidate.materialIndex === materialIndex); + if (group === undefined) { + continue; + } + const faceSize = getBoxBrushFaceSize(brush, faceId); + const vertexIndices = new Set(); + for (let indexOffset = group.start; indexOffset < group.start + group.count; indexOffset += 1) { + vertexIndices.add(indexAttribute.getX(indexOffset)); + } + for (const vertexIndex of vertexIndices) { + const localVertexPosition = { + x: positionAttribute.getX(vertexIndex), + y: positionAttribute.getY(vertexIndex), + z: positionAttribute.getZ(vertexIndex) + }; + const projectedUv = projectBoxFaceVertexToUv(localVertexPosition, brush, faceId); + const transformedUv = transformProjectedFaceUv(projectedUv, faceSize, brush.faces[faceId].uv); + uvAttribute.setXY(vertexIndex, transformedUv.x, transformedUv.y); + } + } + uvAttribute.needsUpdate = true; +} diff --git a/src/geometry/grid-snapping.js b/src/geometry/grid-snapping.js new file mode 100644 index 00000000..d76b5056 --- /dev/null +++ b/src/geometry/grid-snapping.js @@ -0,0 +1,36 @@ +export const DEFAULT_GRID_SIZE = 1; +function assertGridSize(gridSize) { + if (!Number.isFinite(gridSize) || gridSize <= 0) { + throw new Error("Grid size must be a positive finite number."); + } + return gridSize; +} +export function snapValueToGrid(value, gridSize = DEFAULT_GRID_SIZE) { + const step = assertGridSize(gridSize); + if (!Number.isFinite(value)) { + throw new Error("Grid-snapped values must be finite numbers."); + } + return Math.round(value / step) * step; +} +function snapPositiveSizeValue(value, gridSize) { + if (!Number.isFinite(value)) { + throw new Error("Box brush size values must be finite numbers."); + } + const snappedSize = Math.round(Math.abs(value) / gridSize) * gridSize; + return snappedSize > 0 ? snappedSize : gridSize; +} +export function snapVec3ToGrid(vector, gridSize = DEFAULT_GRID_SIZE) { + return { + x: snapValueToGrid(vector.x, gridSize), + y: snapValueToGrid(vector.y, gridSize), + z: snapValueToGrid(vector.z, gridSize) + }; +} +export function snapPositiveSizeToGrid(size, gridSize = DEFAULT_GRID_SIZE) { + const step = assertGridSize(gridSize); + return { + x: snapPositiveSizeValue(size.x, step), + y: snapPositiveSizeValue(size.y, step), + z: snapPositiveSizeValue(size.z, step) + }; +} diff --git a/src/geometry/model-instance-collider-debug-mesh.js b/src/geometry/model-instance-collider-debug-mesh.js new file mode 100644 index 00000000..3fa70018 --- /dev/null +++ b/src/geometry/model-instance-collider-debug-mesh.js @@ -0,0 +1,119 @@ +import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, Vector3 } from "three"; +import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js"; +const DEBUG_COLLIDER_COLORS = { + simple: 0x87d2ff, + terrain: 0x7be7b4, + static: 0xffc66d, + dynamic: 0xff8b7a +}; +function createWireframeMaterial(color) { + return new MeshBasicMaterial({ + color, + wireframe: true, + transparent: true, + opacity: 0.85, + depthWrite: false, + toneMapped: false + }); +} +function markDebugMesh(mesh) { + mesh.userData.shadowIgnored = true; + mesh.userData.nonPickable = true; + mesh.renderOrder = 3_500; +} +function createBoxColliderDebugMesh(collider) { + const mesh = new Mesh(new BoxGeometry(collider.size.x, collider.size.y, collider.size.z), createWireframeMaterial(DEBUG_COLLIDER_COLORS.simple)); + mesh.position.set(collider.center.x, collider.center.y, collider.center.z); + markDebugMesh(mesh); + return mesh; +} +function createTriMeshColliderDebugMesh(collider) { + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(collider.vertices, 3)); + geometry.setIndex(Array.from(collider.indices)); + const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.static)); + markDebugMesh(mesh); + return mesh; +} +function createHeightfieldColliderDebugMesh(collider) { + const vertices = []; + const indices = []; + const width = collider.maxX - collider.minX; + const depth = collider.maxZ - collider.minZ; + for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) { + const zLerp = collider.cols === 1 ? 0 : zIndex / (collider.cols - 1); + const z = collider.minZ + depth * zLerp; + for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) { + const xLerp = collider.rows === 1 ? 0 : xIndex / (collider.rows - 1); + const x = collider.minX + width * xLerp; + const y = collider.heights[xIndex + zIndex * collider.rows]; + vertices.push(x, y, z); + } + } + for (let zIndex = 0; zIndex < collider.cols - 1; zIndex += 1) { + for (let xIndex = 0; xIndex < collider.rows - 1; xIndex += 1) { + const topLeft = xIndex + zIndex * collider.rows; + const topRight = topLeft + 1; + const bottomLeft = topLeft + collider.rows; + const bottomRight = bottomLeft + 1; + indices.push(topLeft, bottomLeft, bottomRight, topLeft, bottomRight, topRight); + } + } + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.terrain)); + markDebugMesh(mesh); + return mesh; +} +function createCompoundColliderDebugGroup(collider) { + const group = new Group(); + for (const piece of collider.pieces) { + const points = []; + for (let index = 0; index < piece.points.length; index += 3) { + points.push(new Vector3(piece.points[index], piece.points[index + 1], piece.points[index + 2])); + } + const mesh = new Mesh(new ConvexGeometry(points), createWireframeMaterial(DEBUG_COLLIDER_COLORS.dynamic)); + markDebugMesh(mesh); + group.add(mesh); + } + return group; +} +export function createModelColliderDebugGroup(collider) { + const group = new Group(); + switch (collider.kind) { + case "box": + group.add(createBoxColliderDebugMesh(collider)); + break; + case "trimesh": + group.add(createTriMeshColliderDebugMesh(collider)); + break; + case "heightfield": + group.add(createHeightfieldColliderDebugMesh(collider)); + break; + case "compound": + group.add(createCompoundColliderDebugGroup(collider)); + break; + } + group.userData.nonPickable = true; + return group; +} +function disposeMaterial(material) { + if (Array.isArray(material)) { + for (const item of material) { + item.dispose(); + } + return; + } + material.dispose(); +} +export function disposeModelColliderDebugGroup(group) { + group.traverse((object) => { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return; + } + maybeMesh.geometry.dispose(); + disposeMaterial(maybeMesh.material); + }); +} diff --git a/src/geometry/model-instance-collider-generation.js b/src/geometry/model-instance-collider-generation.js new file mode 100644 index 00000000..1fe2211e --- /dev/null +++ b/src/geometry/model-instance-collider-generation.js @@ -0,0 +1,419 @@ +import { Euler, Group, MathUtils, Matrix4, Mesh, Quaternion, Vector3 } from "three"; +const TERRAIN_GRID_EPSILON = 1e-4; +const DYNAMIC_TRIANGLE_TARGET = 48; +const DYNAMIC_SPLIT_DEPTH_LIMIT = 3; +export class ModelColliderGenerationError extends Error { + code; + constructor(code, message) { + super(message); + this.name = "ModelColliderGenerationError"; + this.code = code; + } +} +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function vector3ToVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function createBounds(min, max) { + return { + min: vector3ToVec3(min), + max: vector3ToVec3(max) + }; +} +function createModelTransform(modelInstance) { + return { + position: cloneVec3(modelInstance.position), + rotationDegrees: cloneVec3(modelInstance.rotationDegrees), + scale: cloneVec3(modelInstance.scale) + }; +} +function createModelTransformMatrix(modelInstance) { + const rotation = new Euler(MathUtils.degToRad(modelInstance.rotationDegrees.x), MathUtils.degToRad(modelInstance.rotationDegrees.y), MathUtils.degToRad(modelInstance.rotationDegrees.z), "XYZ"); + const quaternion = new Quaternion().setFromEuler(rotation); + return new Matrix4().compose(new Vector3(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z), quaternion, new Vector3(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z)); +} +function computeBoundsFromPoints(points) { + const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY); + const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY); + let hasPoint = false; + for (const point of points) { + hasPoint = true; + min.min(point); + max.max(point); + } + if (!hasPoint) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry."); + } + return createBounds(min, max); +} +function computeBoundsFromFloat32Points(points) { + if (points.length < 3) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry."); + } + const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY); + const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY); + for (let index = 0; index < points.length; index += 3) { + min.x = Math.min(min.x, points[index]); + min.y = Math.min(min.y, points[index + 1]); + min.z = Math.min(min.z, points[index + 2]); + max.x = Math.max(max.x, points[index]); + max.y = Math.max(max.y, points[index + 1]); + max.z = Math.max(max.z, points[index + 2]); + } + return createBounds(min, max); +} +function computeWorldBoundsFromLocalBox(localBounds, modelMatrix) { + const min = localBounds.min; + const max = localBounds.max; + const corners = [ + new Vector3(min.x, min.y, min.z), + new Vector3(min.x, min.y, max.z), + new Vector3(min.x, max.y, min.z), + new Vector3(min.x, max.y, max.z), + new Vector3(max.x, min.y, min.z), + new Vector3(max.x, min.y, max.z), + new Vector3(max.x, max.y, min.z), + new Vector3(max.x, max.y, max.z) + ]; + return computeBoundsFromPoints(corners.map((corner) => corner.applyMatrix4(modelMatrix))); +} +function readIndexedVertex(position, index, matrix) { + return new Vector3(position.getX(index), position.getY(index), position.getZ(index)).applyMatrix4(matrix); +} +function getMeshGeometry(object) { + const maybeMesh = object; + if (maybeMesh.isMesh !== true) { + return null; + } + return maybeMesh.geometry; +} +function collectMeshTriangleClusters(template) { + template.updateMatrixWorld(true); + const clusters = []; + template.traverse((object) => { + const geometry = getMeshGeometry(object); + if (geometry === null) { + return; + } + const position = geometry.getAttribute("position"); + if (position === undefined || position.itemSize < 3 || position.count < 3) { + return; + } + const matrix = object.matrixWorld; + const index = geometry.getIndex(); + const triangles = []; + if (index === null) { + for (let vertexIndex = 0; vertexIndex <= position.count - 3; vertexIndex += 3) { + triangles.push({ + a: readIndexedVertex(position, vertexIndex, matrix), + b: readIndexedVertex(position, vertexIndex + 1, matrix), + c: readIndexedVertex(position, vertexIndex + 2, matrix) + }); + } + } + else { + for (let triangleIndex = 0; triangleIndex <= index.count - 3; triangleIndex += 3) { + triangles.push({ + a: readIndexedVertex(position, index.getX(triangleIndex), matrix), + b: readIndexedVertex(position, index.getX(triangleIndex + 1), matrix), + c: readIndexedVertex(position, index.getX(triangleIndex + 2), matrix) + }); + } + } + if (triangles.length > 0) { + clusters.push({ + triangles + }); + } + }); + return clusters; +} +function flattenTriangleClusters(clusters) { + return clusters.flatMap((cluster) => cluster.triangles); +} +function buildTriMeshBuffers(triangles) { + const vertices = new Float32Array(triangles.length * 9); + const indices = new Uint32Array(triangles.length * 3); + let vertexOffset = 0; + for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) { + const triangle = triangles[triangleIndex]; + vertices[vertexOffset] = triangle.a.x; + vertices[vertexOffset + 1] = triangle.a.y; + vertices[vertexOffset + 2] = triangle.a.z; + vertices[vertexOffset + 3] = triangle.b.x; + vertices[vertexOffset + 4] = triangle.b.y; + vertices[vertexOffset + 5] = triangle.b.z; + vertices[vertexOffset + 6] = triangle.c.x; + vertices[vertexOffset + 7] = triangle.c.y; + vertices[vertexOffset + 8] = triangle.c.z; + indices[triangleIndex * 3] = triangleIndex * 3; + indices[triangleIndex * 3 + 1] = triangleIndex * 3 + 1; + indices[triangleIndex * 3 + 2] = triangleIndex * 3 + 2; + vertexOffset += 9; + } + return { + vertices, + indices + }; +} +function computeClusterCentroid(triangles) { + const centroid = { + x: 0, + y: 0, + z: 0 + }; + let pointCount = 0; + for (const triangle of triangles) { + centroid.x += triangle.a.x + triangle.b.x + triangle.c.x; + centroid.y += triangle.a.y + triangle.b.y + triangle.c.y; + centroid.z += triangle.a.z + triangle.b.z + triangle.c.z; + pointCount += 3; + } + return { + x: centroid.x / pointCount, + y: centroid.y / pointCount, + z: centroid.z / pointCount + }; +} +function getTriangleBounds(triangles) { + return computeBoundsFromPoints(triangles.flatMap((triangle) => [triangle.a, triangle.b, triangle.c])); +} +function splitTriangleCluster(triangles, depth) { + if (triangles.length <= DYNAMIC_TRIANGLE_TARGET || depth >= DYNAMIC_SPLIT_DEPTH_LIMIT) { + return { + kind: "leaf", + triangles + }; + } + const bounds = getTriangleBounds(triangles); + const size = { + x: bounds.max.x - bounds.min.x, + y: bounds.max.y - bounds.min.y, + z: bounds.max.z - bounds.min.z + }; + const splitAxis = size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z"; + const sortedTriangles = [...triangles].sort((left, right) => computeClusterCentroid([left])[splitAxis] - computeClusterCentroid([right])[splitAxis]); + const splitIndex = Math.floor(sortedTriangles.length * 0.5); + if (splitIndex <= 0 || splitIndex >= sortedTriangles.length) { + return { + kind: "leaf", + triangles + }; + } + return { + kind: "split", + left: sortedTriangles.slice(0, splitIndex), + right: sortedTriangles.slice(splitIndex) + }; +} +function collectConvexHullPointClouds(cluster, depth = 0) { + const split = splitTriangleCluster(cluster, depth); + if (split.kind === "leaf") { + return [dedupeTriangleClusterPoints(split.triangles)]; + } + return [...collectConvexHullPointClouds(split.left, depth + 1), ...collectConvexHullPointClouds(split.right, depth + 1)]; +} +function quantizeCoordinate(value) { + return (Math.round(value / TERRAIN_GRID_EPSILON) * TERRAIN_GRID_EPSILON).toFixed(4); +} +function dedupeTriangleClusterPoints(triangles) { + const pointLookup = new Map(); + for (const triangle of triangles) { + for (const point of [triangle.a, triangle.b, triangle.c]) { + const key = `${quantizeCoordinate(point.x)}:${quantizeCoordinate(point.y)}:${quantizeCoordinate(point.z)}`; + if (!pointLookup.has(key)) { + pointLookup.set(key, { + x: point.x, + y: point.y, + z: point.z + }); + } + } + } + if (pointLookup.size < 4) { + throw new ModelColliderGenerationError("unsupported-dynamic-model-collider", "Dynamic collision requires volumetric geometry that can form at least one convex hull."); + } + return new Float32Array(Array.from(pointLookup.values()).flatMap((point) => [point.x, point.y, point.z])); +} +function buildSimpleBoxCollider(modelInstance, asset) { + const boundingBox = asset.metadata.boundingBox; + if (boundingBox === null) { + throw new ModelColliderGenerationError("missing-model-collider-bounds", `Model instance ${modelInstance.id} cannot use simple collision because the asset does not have a measurable bounding box.`); + } + const localBounds = createBounds(new Vector3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z), new Vector3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z)); + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: modelInstance.assetId, + mode: "simple", + kind: "box", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + center: { + x: (boundingBox.min.x + boundingBox.max.x) * 0.5, + y: (boundingBox.min.y + boundingBox.max.y) * 0.5, + z: (boundingBox.min.z + boundingBox.max.z) * 0.5 + }, + size: cloneVec3(boundingBox.size), + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} +function buildTriMeshCollider(modelInstance, asset, loadedAsset) { + if (loadedAsset === undefined) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build ${modelInstance.collision.mode} collision until asset geometry has loaded.`); + } + const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template)); + if (triangles.length === 0) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use ${modelInstance.collision.mode} collision because the asset has no mesh triangles.`); + } + const buffers = buildTriMeshBuffers(triangles); + const localBounds = computeBoundsFromFloat32Points(buffers.vertices); + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + mode: "static", + kind: "trimesh", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + vertices: buffers.vertices, + indices: buffers.indices, + triangleCount: triangles.length, + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} +function buildTerrainCollider(modelInstance, asset, loadedAsset) { + if (loadedAsset === undefined) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build terrain collision until asset geometry has loaded.`); + } + const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template)); + if (triangles.length === 0) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use terrain collision because the asset has no mesh triangles.`); + } + const heightLookup = new Map(); + const xValues = new Map(); + const zValues = new Map(); + for (const triangle of triangles) { + for (const point of [triangle.a, triangle.b, triangle.c]) { + const xKey = quantizeCoordinate(point.x); + const zKey = quantizeCoordinate(point.z); + const key = `${xKey}:${zKey}`; + const previousPoint = heightLookup.get(key); + if (previousPoint !== undefined && Math.abs(previousPoint.y - point.y) > TERRAIN_GRID_EPSILON) { + throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a single-valued heightfield over X/Z.`); + } + heightLookup.set(key, { + x: point.x, + y: point.y, + z: point.z + }); + xValues.set(xKey, point.x); + zValues.set(zKey, point.z); + } + } + const sortedX = Array.from(xValues.values()).sort((left, right) => left - right); + const sortedZ = Array.from(zValues.values()).sort((left, right) => left - right); + if (sortedX.length < 2 || sortedZ.length < 2) { + throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh does not form a regular X/Z grid.`); + } + const expectedTriangleCount = (sortedX.length - 1) * (sortedZ.length - 1) * 2; + if (triangles.length !== expectedTriangleCount) { + throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a clean regular-grid terrain surface.`); + } + const heights = new Float32Array(sortedX.length * sortedZ.length); + for (let zIndex = 0; zIndex < sortedZ.length; zIndex += 1) { + for (let xIndex = 0; xIndex < sortedX.length; xIndex += 1) { + const key = `${quantizeCoordinate(sortedX[xIndex])}:${quantizeCoordinate(sortedZ[zIndex])}`; + const point = heightLookup.get(key); + if (point === undefined) { + throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is missing one or more regular-grid height samples.`); + } + heights[xIndex + zIndex * sortedX.length] = point.y; + } + } + const localBounds = computeBoundsFromPoints(Array.from(heightLookup.values(), (point) => new Vector3(point.x, point.y, point.z))); + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + mode: "terrain", + kind: "heightfield", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + rows: sortedX.length, + cols: sortedZ.length, + heights, + minX: sortedX[0], + maxX: sortedX.at(-1) ?? sortedX[0], + minZ: sortedZ[0], + maxZ: sortedZ.at(-1) ?? sortedZ[0], + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} +function buildDynamicCollider(modelInstance, asset, loadedAsset) { + if (loadedAsset === undefined) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build dynamic collision until asset geometry has loaded.`); + } + const triangleClusters = collectMeshTriangleClusters(loadedAsset.template); + if (triangleClusters.length === 0) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use dynamic collision because the asset has no mesh triangles.`); + } + const pieces = triangleClusters + .flatMap((cluster) => collectConvexHullPointClouds(cluster.triangles)) + .map((points, index) => ({ + id: `${modelInstance.id}-piece-${index + 1}`, + points, + localBounds: computeBoundsFromFloat32Points(points) + })); + if (pieces.length === 0) { + throw new ModelColliderGenerationError("unsupported-dynamic-model-collider", `Model instance ${modelInstance.id} could not derive any convex pieces for dynamic collision.`); + } + const localBounds = computeBoundsFromPoints(pieces.flatMap((piece) => { + const points = []; + for (let pointIndex = 0; pointIndex < piece.points.length; pointIndex += 3) { + points.push(new Vector3(piece.points[pointIndex], piece.points[pointIndex + 1], piece.points[pointIndex + 2])); + } + return points; + })); + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + mode: "dynamic", + kind: "compound", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + pieces, + decomposition: "spatial-bisect", + runtimeBehavior: "fixedQueryOnly", + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} +export function buildGeneratedModelCollider(modelInstance, asset, loadedAsset) { + switch (modelInstance.collision.mode) { + case "none": + return null; + case "simple": + return buildSimpleBoxCollider(modelInstance, asset); + case "static": + return buildTriMeshCollider(modelInstance, asset, loadedAsset); + case "terrain": + return buildTerrainCollider(modelInstance, asset, loadedAsset); + case "dynamic": + return buildDynamicCollider(modelInstance, asset, loadedAsset); + } +} diff --git a/src/interactions/interaction-links.js b/src/interactions/interaction-links.js new file mode 100644 index 00000000..d6c19a4e --- /dev/null +++ b/src/interactions/interaction-links.js @@ -0,0 +1,189 @@ +import { createOpaqueId } from "../core/ids"; +export const INTERACTION_TRIGGER_KINDS = ["enter", "exit", "click"]; +function assertNonEmptyString(value, label) { + if (value.trim().length === 0) { + throw new Error(`${label} must be non-empty.`); + } +} +function cloneAction(action) { + switch (action.type) { + case "teleportPlayer": + return { + type: "teleportPlayer", + targetEntityId: action.targetEntityId + }; + case "toggleVisibility": + return { + type: "toggleVisibility", + targetBrushId: action.targetBrushId, + visible: action.visible + }; + case "playAnimation": + return { + type: "playAnimation", + targetModelInstanceId: action.targetModelInstanceId, + clipName: action.clipName, + loop: action.loop + }; + case "stopAnimation": + return { + type: "stopAnimation", + targetModelInstanceId: action.targetModelInstanceId + }; + case "playSound": + return { + type: "playSound", + targetSoundEmitterId: action.targetSoundEmitterId + }; + case "stopSound": + return { + type: "stopSound", + targetSoundEmitterId: action.targetSoundEmitterId + }; + } +} +export function isInteractionTriggerKind(value) { + return value === "enter" || value === "exit" || value === "click"; +} +export function createTeleportPlayerInteractionLink(options) { + assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); + assertNonEmptyString(options.targetEntityId, "Teleport target entity id"); + return { + id: options.id ?? createOpaqueId("interaction-link"), + sourceEntityId: options.sourceEntityId, + trigger: options.trigger ?? "enter", + action: { + type: "teleportPlayer", + targetEntityId: options.targetEntityId + } + }; +} +export function createToggleVisibilityInteractionLink(options) { + assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); + assertNonEmptyString(options.targetBrushId, "Visibility target brush id"); + if (options.visible !== undefined && typeof options.visible !== "boolean") { + throw new Error("Visibility action visible must be a boolean when authored."); + } + return { + id: options.id ?? createOpaqueId("interaction-link"), + sourceEntityId: options.sourceEntityId, + trigger: options.trigger ?? "enter", + action: { + type: "toggleVisibility", + targetBrushId: options.targetBrushId, + visible: options.visible + } + }; +} +export function createPlayAnimationInteractionLink(options) { + assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); + assertNonEmptyString(options.targetModelInstanceId, "Play animation target model instance id"); + assertNonEmptyString(options.clipName, "Play animation clip name"); + return { + id: options.id ?? createOpaqueId("interaction-link"), + sourceEntityId: options.sourceEntityId, + trigger: options.trigger ?? "enter", + action: { + type: "playAnimation", + targetModelInstanceId: options.targetModelInstanceId, + clipName: options.clipName, + loop: options.loop + } + }; +} +export function createStopAnimationInteractionLink(options) { + assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); + assertNonEmptyString(options.targetModelInstanceId, "Stop animation target model instance id"); + return { + id: options.id ?? createOpaqueId("interaction-link"), + sourceEntityId: options.sourceEntityId, + trigger: options.trigger ?? "enter", + action: { + type: "stopAnimation", + targetModelInstanceId: options.targetModelInstanceId + } + }; +} +export function createPlaySoundInteractionLink(options) { + assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); + assertNonEmptyString(options.targetSoundEmitterId, "Play sound target sound emitter id"); + return { + id: options.id ?? createOpaqueId("interaction-link"), + sourceEntityId: options.sourceEntityId, + trigger: options.trigger ?? "enter", + action: { + type: "playSound", + targetSoundEmitterId: options.targetSoundEmitterId + } + }; +} +export function createStopSoundInteractionLink(options) { + assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); + assertNonEmptyString(options.targetSoundEmitterId, "Stop sound target sound emitter id"); + return { + id: options.id ?? createOpaqueId("interaction-link"), + sourceEntityId: options.sourceEntityId, + trigger: options.trigger ?? "enter", + action: { + type: "stopSound", + targetSoundEmitterId: options.targetSoundEmitterId + } + }; +} +export function cloneInteractionLink(link) { + return { + id: link.id, + sourceEntityId: link.sourceEntityId, + trigger: link.trigger, + action: cloneAction(link.action) + }; +} +export function areInteractionLinksEqual(left, right) { + if (left.id !== right.id || left.sourceEntityId !== right.sourceEntityId || left.trigger !== right.trigger) { + return false; + } + if (left.action.type !== right.action.type) { + return false; + } + switch (left.action.type) { + case "teleportPlayer": + return left.action.targetEntityId === right.action.targetEntityId; + case "toggleVisibility": + return (left.action.targetBrushId === right.action.targetBrushId && + left.action.visible === right.action.visible); + case "playAnimation": + return (left.action.targetModelInstanceId === right.action.targetModelInstanceId && + left.action.clipName === right.action.clipName && + left.action.loop === right.action.loop); + case "stopAnimation": + return left.action.targetModelInstanceId === right.action.targetModelInstanceId; + case "playSound": + return left.action.targetSoundEmitterId === right.action.targetSoundEmitterId; + case "stopSound": + return left.action.targetSoundEmitterId === right.action.targetSoundEmitterId; + default: { + // Exhaustive check — TypeScript should never reach here + const _exhaustive = left.action; + void _exhaustive; + return false; + } + } +} +export function cloneInteractionLinkRegistry(links) { + return Object.fromEntries(Object.entries(links).map(([linkId, link]) => [linkId, cloneInteractionLink(link)])); +} +export function compareInteractionLinks(left, right) { + if (left.sourceEntityId !== right.sourceEntityId) { + return left.sourceEntityId.localeCompare(right.sourceEntityId); + } + if (left.trigger !== right.trigger) { + return left.trigger.localeCompare(right.trigger); + } + return left.id.localeCompare(right.id); +} +export function getInteractionLinks(links) { + return Object.values(links).sort(compareInteractionLinks); +} +export function getInteractionLinksForSource(links, sourceEntityId) { + return getInteractionLinks(links).filter((link) => link.sourceEntityId === sourceEntityId); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 00000000..51553924 --- /dev/null +++ b/src/main.js @@ -0,0 +1,23 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./app/App"; +import "./app/app.css"; +import { createEditorStore } from "./app/editor-store"; +import { getBrowserStorageAccess, loadOrCreateSceneDocument } from "./serialization/local-draft-storage"; +const rootElement = document.getElementById("root"); +if (rootElement === null) { + throw new Error("Expected #root element to bootstrap the editor."); +} +const storageAccess = getBrowserStorageAccess(); +const bootstrapResult = loadOrCreateSceneDocument(storageAccess.storage); +const editorStore = createEditorStore({ + initialDocument: bootstrapResult.document, + initialViewportLayoutState: bootstrapResult.viewportLayoutState ?? undefined, + storage: storageAccess.storage +}); +const initialStatusMessage = [storageAccess.diagnostic, bootstrapResult.diagnostic].filter(Boolean).join(" ") || undefined; +if (import.meta.env.DEV) { + window.__webeditor3dEditorStore = editorStore; +} +ReactDOM.createRoot(rootElement).render(_jsx(React.StrictMode, { children: _jsx(App, { store: editorStore, initialStatusMessage: initialStatusMessage }) })); diff --git a/src/materials/starter-material-library.js b/src/materials/starter-material-library.js new file mode 100644 index 00000000..11c4cc58 --- /dev/null +++ b/src/materials/starter-material-library.js @@ -0,0 +1,46 @@ +export const STARTER_MATERIAL_LIBRARY = [ + { + id: "starter-amber-grid", + name: "Amber Grid", + baseColorHex: "#c79a63", + accentColorHex: "#5f3820", + pattern: "grid", + tags: ["starter", "wall"] + }, + { + id: "starter-concrete-checker", + name: "Concrete Checker", + baseColorHex: "#7d838c", + accentColorHex: "#5a616a", + pattern: "checker", + tags: ["starter", "floor"] + }, + { + id: "starter-hazard-stripe", + name: "Hazard Stripe", + baseColorHex: "#d1a245", + accentColorHex: "#211b16", + pattern: "stripes", + tags: ["starter", "warning"] + }, + { + id: "starter-night-diamond", + name: "Night Diamond", + baseColorHex: "#5a6985", + accentColorHex: "#1f2836", + pattern: "diamond", + tags: ["starter", "trim"] + } +]; +export function cloneMaterialDef(material) { + return { + ...material, + tags: [...material.tags] + }; +} +export function cloneMaterialRegistry(materials) { + return Object.fromEntries(Object.entries(materials).map(([materialId, material]) => [materialId, cloneMaterialDef(material)])); +} +export function createStarterMaterialRegistry() { + return Object.fromEntries(STARTER_MATERIAL_LIBRARY.map((material) => [material.id, cloneMaterialDef(material)])); +} diff --git a/src/materials/starter-material-textures.js b/src/materials/starter-material-textures.js new file mode 100644 index 00000000..be5ee7a2 --- /dev/null +++ b/src/materials/starter-material-textures.js @@ -0,0 +1,73 @@ +import { CanvasTexture, RepeatWrapping, SRGBColorSpace } from "three"; +export function createStarterMaterialSignature(material) { + return `${material.baseColorHex}|${material.accentColorHex}|${material.pattern}`; +} +function fillMaterialPattern(context, material, size) { + context.fillStyle = material.baseColorHex; + context.fillRect(0, 0, size, size); + context.strokeStyle = material.accentColorHex; + context.fillStyle = material.accentColorHex; + switch (material.pattern) { + case "grid": + context.lineWidth = Math.max(2, size / 32); + for (let offset = 0; offset <= size; offset += size / 4) { + context.beginPath(); + context.moveTo(offset, 0); + context.lineTo(offset, size); + context.stroke(); + context.beginPath(); + context.moveTo(0, offset); + context.lineTo(size, offset); + context.stroke(); + } + break; + case "checker": { + const checkerSize = size / 4; + for (let row = 0; row < 4; row += 1) { + for (let column = 0; column < 4; column += 1) { + if ((row + column) % 2 === 0) { + context.fillRect(column * checkerSize, row * checkerSize, checkerSize, checkerSize); + } + } + } + break; + } + case "stripes": + context.lineWidth = size / 6; + for (let offset = -size; offset <= size * 2; offset += size / 3) { + context.beginPath(); + context.moveTo(offset, size); + context.lineTo(offset + size, 0); + context.stroke(); + } + break; + case "diamond": + context.lineWidth = Math.max(2, size / 28); + for (let offset = -size; offset <= size; offset += size / 3) { + context.beginPath(); + context.moveTo(size * 0.5, offset); + context.lineTo(size - offset, size * 0.5); + context.lineTo(size * 0.5, size - offset); + context.lineTo(-offset, size * 0.5); + context.closePath(); + context.stroke(); + } + break; + } +} +export function createStarterMaterialTexture(material, size = 128) { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext("2d"); + if (context === null) { + throw new Error("2D canvas context is unavailable for starter material texture generation."); + } + fillMaterialPattern(context, material, size); + const texture = new CanvasTexture(canvas); + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.colorSpace = SRGBColorSpace; + texture.needsUpdate = true; + return texture; +} diff --git a/src/rendering/advanced-rendering.js b/src/rendering/advanced-rendering.js new file mode 100644 index 00000000..d92a8902 --- /dev/null +++ b/src/rendering/advanced-rendering.js @@ -0,0 +1,88 @@ +import { BasicShadowMap, DirectionalLight, HalfFloatType, Mesh, NoToneMapping, PCFShadowMap, PCFSoftShadowMap, PointLight, SpotLight, UnsignedByteType } from "three"; +import { BloomEffect, DepthOfFieldEffect, EffectComposer, EffectPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing"; +export function getAdvancedRenderingShadowMapType(shadowType) { + switch (shadowType) { + case "basic": + return BasicShadowMap; + case "pcf": + return PCFShadowMap; + case "pcfSoft": + return PCFSoftShadowMap; + } +} +export function getAdvancedRenderingToneMappingMode(mode) { + switch (mode) { + case "none": + return ToneMappingMode.LINEAR; + case "linear": + return ToneMappingMode.LINEAR; + case "reinhard": + return ToneMappingMode.REINHARD; + case "cineon": + return ToneMappingMode.CINEON; + case "acesFilmic": + return ToneMappingMode.ACES_FILMIC; + } +} +export function configureAdvancedRenderingRenderer(renderer, settings) { + renderer.shadowMap.enabled = settings.enabled && settings.shadows.enabled; + renderer.shadowMap.type = getAdvancedRenderingShadowMapType(settings.shadows.type); + renderer.toneMapping = NoToneMapping; + renderer.toneMappingExposure = settings.toneMapping.exposure; +} +export function createAdvancedRenderingComposer(renderer, scene, camera, settings) { + const composer = new EffectComposer(renderer, { + multisampling: 0, + frameBufferType: renderer.capabilities.isWebGL2 ? HalfFloatType : UnsignedByteType + }); + composer.addPass(new RenderPass(scene, camera)); + const effects = []; + if (settings.ambientOcclusion.enabled) { + effects.push(new SSAOEffect(camera, undefined, { + samples: settings.ambientOcclusion.samples, + radius: settings.ambientOcclusion.radius, + intensity: settings.ambientOcclusion.intensity + })); + } + if (settings.bloom.enabled) { + effects.push(new BloomEffect({ + intensity: settings.bloom.intensity, + luminanceThreshold: settings.bloom.threshold, + radius: settings.bloom.radius + })); + } + if (settings.depthOfField.enabled) { + effects.push(new DepthOfFieldEffect(camera, { + focusDistance: settings.depthOfField.focusDistance, + focalLength: settings.depthOfField.focalLength, + bokehScale: settings.depthOfField.bokehScale + })); + } + effects.push(new ToneMappingEffect({ + mode: getAdvancedRenderingToneMappingMode(settings.toneMapping.mode) + })); + effects.push(new SMAAEffect({ + preset: SMAAPreset.MEDIUM + })); + composer.addPass(new EffectPass(camera, ...effects)); + return composer; +} +export function applyAdvancedRenderingRenderableShadowFlags(root, enabled) { + root.traverse((object) => { + if (object.isMesh === true && object.userData.shadowIgnored !== true) { + const mesh = object; + mesh.castShadow = enabled; + mesh.receiveShadow = enabled; + } + }); +} +export function applyAdvancedRenderingLightShadowFlags(root, settings) { + const shadowEnabled = settings.enabled && settings.shadows.enabled; + root.traverse((object) => { + if (object instanceof DirectionalLight || object instanceof PointLight || object instanceof SpotLight) { + object.castShadow = shadowEnabled; + object.shadow.bias = settings.shadows.bias; + object.shadow.mapSize.set(settings.shadows.mapSize, settings.shadows.mapSize); + } + }); +} diff --git a/src/runner-web/RunnerCanvas.js b/src/runner-web/RunnerCanvas.js new file mode 100644 index 00000000..630693de --- /dev/null +++ b/src/runner-web/RunnerCanvas.js @@ -0,0 +1,55 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useRef, useState } from "react"; +import { RuntimeHost } from "../runtime-three/runtime-host"; +import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; +export function RunnerCanvas({ runtimeScene, projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets, navigationMode, onRuntimeMessageChange, onFirstPersonTelemetryChange, onInteractionPromptChange }) { + const containerRef = useRef(null); + const hostRef = useRef(null); + const [runnerMessage, setRunnerMessage] = useState(null); + const [interactionPrompt, setInteractionPrompt] = useState(null); + useEffect(() => { + const container = containerRef.current; + if (container === null) { + return; + } + const testCanvas = document.createElement("canvas"); + const hasWebGl = testCanvas.getContext("webgl2") !== null || + testCanvas.getContext("webgl") !== null || + testCanvas.getContext("experimental-webgl") !== null; + try { + const runtimeHost = new RuntimeHost({ + enableRendering: hasWebGl + }); + hostRef.current = runtimeHost; + runtimeHost.mount(container); + runtimeHost.setRuntimeMessageHandler(onRuntimeMessageChange); + runtimeHost.setFirstPersonTelemetryHandler(onFirstPersonTelemetryChange); + runtimeHost.setInteractionPromptHandler((prompt) => { + setInteractionPrompt(prompt); + onInteractionPromptChange(prompt); + }); + setRunnerMessage(hasWebGl ? null : "WebGL is unavailable in this browser environment. The runner shell is visible, but runtime rendering is disabled."); + return () => { + onInteractionPromptChange(null); + runtimeHost.dispose(); + hostRef.current = null; + }; + } + catch (error) { + const message = error instanceof Error ? error.message : "Runner initialization failed."; + setRunnerMessage(`Runner initialization failed: ${message}`); + onInteractionPromptChange(null); + return; + } + }, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]); + useEffect(() => { + hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets); + }, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]); + useEffect(() => { + hostRef.current?.loadScene(runtimeScene); + }, [runtimeScene]); + useEffect(() => { + hostRef.current?.setNavigationMode(navigationMode); + }, [navigationMode]); + return (_jsxs("div", { ref: containerRef, className: "runner-canvas", "data-testid": "runner-shell", "aria-label": "Built-in scene runner", style: createWorldBackgroundStyle(runtimeScene.world.background, runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null), children: [navigationMode === "firstPerson" ? _jsx("div", { className: "runner-canvas__crosshair", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" && interactionPrompt !== null ? (_jsxs("div", { className: "runner-canvas__prompt", "data-testid": "runner-interaction-prompt", role: "status", "aria-live": "polite", children: [_jsx("div", { className: "runner-canvas__prompt-badge", children: "Click" }), _jsx("div", { className: "runner-canvas__prompt-text", "data-testid": "runner-interaction-prompt-text", children: interactionPrompt.prompt }), _jsxs("div", { className: "runner-canvas__prompt-meta", "data-testid": "runner-interaction-prompt-meta", children: [interactionPrompt.distance.toFixed(1), "m away \u00B7 ", interactionPrompt.range.toFixed(1), "m range"] })] })) : null, runnerMessage === null ? null : (_jsxs("div", { className: "runner-canvas__fallback", role: "status", children: [_jsx("div", { className: "runner-canvas__fallback-title", children: "Runner Unavailable" }), _jsx("div", { children: runnerMessage })] }))] })); +} diff --git a/src/runtime-three/first-person-navigation-controller.js b/src/runtime-three/first-person-navigation-controller.js new file mode 100644 index 00000000..49ca6cbb --- /dev/null +++ b/src/runtime-three/first-person-navigation-controller.js @@ -0,0 +1,203 @@ +import { Euler, Vector3 } from "three"; +import { getFirstPersonPlayerEyeHeight } from "./player-collision"; +const LOOK_SENSITIVITY = 0.0022; +const MOVE_SPEED = 4.5; +const GRAVITY = 22; +const MAX_PITCH_RADIANS = Math.PI * 0.48; +function clampPitch(pitchRadians) { + return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians)); +} +function toEyePosition(feetPosition, eyeHeight) { + return { + x: feetPosition.x, + y: feetPosition.y + eyeHeight, + z: feetPosition.z + }; +} +export class FirstPersonNavigationController { + id = "firstPerson"; + context = null; + pressedKeys = new Set(); + cameraRotation = new Euler(0, 0, 0, "YXZ"); + forwardVector = new Vector3(); + rightVector = new Vector3(); + feetPosition = { + x: 0, + y: 0, + z: 0 + }; + yawRadians = 0; + pitchRadians = 0; + verticalVelocity = 0; + grounded = false; + pointerLocked = false; + initializedFromSpawn = false; + activate(ctx) { + this.context = ctx; + if (!this.initializedFromSpawn) { + const spawn = ctx.getRuntimeScene().spawn; + this.feetPosition = { + ...spawn.position + }; + this.yawRadians = (spawn.yawDegrees * Math.PI) / 180; + this.pitchRadians = 0; + this.verticalVelocity = 0; + this.grounded = false; + this.initializedFromSpawn = true; + } + window.addEventListener("keydown", this.handleKeyDown); + window.addEventListener("keyup", this.handleKeyUp); + window.addEventListener("blur", this.handleBlur); + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("pointerlockchange", this.handlePointerLockChange); + document.addEventListener("pointerlockerror", this.handlePointerLockError); + ctx.domElement.addEventListener("pointerdown", this.handlePointerDown); + this.syncPointerLockState(); + this.updateCameraTransform(); + this.publishTelemetry(); + } + deactivate(ctx) { + window.removeEventListener("keydown", this.handleKeyDown); + window.removeEventListener("keyup", this.handleKeyUp); + window.removeEventListener("blur", this.handleBlur); + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("pointerlockchange", this.handlePointerLockChange); + document.removeEventListener("pointerlockerror", this.handlePointerLockError); + ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown); + this.pressedKeys.clear(); + if (document.pointerLockElement === ctx.domElement) { + document.exitPointerLock(); + } + this.pointerLocked = false; + ctx.setRuntimeMessage(null); + ctx.setFirstPersonTelemetry(null); + this.context = null; + } + update(dt) { + if (this.context === null) { + return; + } + const playerShape = this.context.getRuntimeScene().playerCollider; + const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0); + const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0); + const inputLength = Math.hypot(inputX, inputZ); + let horizontalX = 0; + let horizontalZ = 0; + if (inputLength > 0) { + const normalizedInputX = inputX / inputLength; + const normalizedInputZ = inputZ / inputLength; + const moveDistance = MOVE_SPEED * dt; + this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians)); + this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians)); + horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance; + horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance; + } + if (playerShape.mode === "none") { + this.verticalVelocity = 0; + } + else { + this.verticalVelocity -= GRAVITY * dt; + } + const resolvedMotion = this.context.resolveFirstPersonMotion(this.feetPosition, { + x: horizontalX, + y: playerShape.mode === "none" ? 0 : this.verticalVelocity * dt, + z: horizontalZ + }, playerShape); + if (resolvedMotion === null) { + this.updateCameraTransform(); + this.publishTelemetry(); + return; + } + this.feetPosition = resolvedMotion.feetPosition; + this.grounded = resolvedMotion.grounded; + if (this.grounded && this.verticalVelocity < 0) { + this.verticalVelocity = 0; + } + this.updateCameraTransform(); + this.publishTelemetry(); + } + teleportTo(feetPosition, yawDegrees) { + this.feetPosition = { + ...feetPosition + }; + this.yawRadians = (yawDegrees * Math.PI) / 180; + this.pitchRadians = 0; + this.verticalVelocity = 0; + this.grounded = false; + this.updateCameraTransform(); + this.publishTelemetry(); + } + updateCameraTransform() { + if (this.context === null) { + return; + } + const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)); + this.cameraRotation.x = this.pitchRadians; + // Authoring yaw treats 0 degrees as facing +Z, while a three.js camera + // looks down -Z by default. Offset by 180 degrees so runtime view matches + // the authored PlayerStart marker and movement basis. + this.cameraRotation.y = this.yawRadians + Math.PI; + this.cameraRotation.z = 0; + this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z); + this.context.camera.rotation.copy(this.cameraRotation); + } + publishTelemetry() { + if (this.context === null) { + return; + } + this.context.setFirstPersonTelemetry({ + feetPosition: { + ...this.feetPosition + }, + eyePosition: toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)), + grounded: this.grounded, + pointerLocked: this.pointerLocked, + spawn: this.context.getRuntimeScene().spawn + }); + } + syncPointerLockState() { + if (this.context === null) { + return; + } + const pointerLocked = document.pointerLockElement === this.context.domElement; + this.pointerLocked = pointerLocked; + this.context.setRuntimeMessage(pointerLocked + ? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor." + : "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor."); + this.publishTelemetry(); + } + handleKeyDown = (event) => { + this.pressedKeys.add(event.code); + }; + handleKeyUp = (event) => { + this.pressedKeys.delete(event.code); + }; + handleBlur = () => { + this.pressedKeys.clear(); + }; + handleMouseMove = (event) => { + if (!this.pointerLocked) { + return; + } + this.yawRadians -= event.movementX * LOOK_SENSITIVITY; + this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY); + }; + handlePointerLockChange = () => { + this.syncPointerLockState(); + }; + handlePointerLockError = () => { + this.context?.setRuntimeMessage("Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback."); + }; + handlePointerDown = () => { + if (this.context === null || document.pointerLockElement === this.context.domElement) { + return; + } + const pointerLockCapableElement = this.context.domElement; + const pointerLockResult = pointerLockCapableElement.requestPointerLock(); + if (pointerLockResult instanceof Promise) { + pointerLockResult.catch(() => { + this.context?.setRuntimeMessage("Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation."); + }); + } + }; +} diff --git a/src/runtime-three/navigation-controller.js b/src/runtime-three/navigation-controller.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/runtime-three/navigation-controller.js @@ -0,0 +1 @@ +export {}; diff --git a/src/runtime-three/orbit-visitor-navigation-controller.js b/src/runtime-three/orbit-visitor-navigation-controller.js new file mode 100644 index 00000000..9a3d5f44 --- /dev/null +++ b/src/runtime-three/orbit-visitor-navigation-controller.js @@ -0,0 +1,117 @@ +import { Vector3 } from "three"; +const MIN_DISTANCE = 2; +const MAX_DISTANCE = 48; +const MIN_PITCH = 0.15; +const MAX_PITCH = Math.PI * 0.48; +function clampDistance(distance) { + return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance)); +} +function clampPitch(pitchRadians) { + return Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitchRadians)); +} +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +export class OrbitVisitorNavigationController { + id = "orbitVisitor"; + context = null; + lookAtVector = new Vector3(); + target = { + x: 0, + y: 0, + z: 0 + }; + distance = 8; + yawRadians = Math.PI * 0.25; + pitchRadians = Math.PI * 0.35; + dragging = false; + lastPointerClientX = 0; + lastPointerClientY = 0; + initializedFromScene = false; + activate(ctx) { + this.context = ctx; + if (!this.initializedFromScene) { + const runtimeScene = ctx.getRuntimeScene(); + const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target; + const focusDistance = runtimeScene.sceneBounds + ? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1 + : 8; + this.target = cloneVec3(focusPoint); + this.distance = clampDistance(focusDistance); + this.initializedFromScene = true; + } + ctx.domElement.addEventListener("pointerdown", this.handlePointerDown); + ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false }); + ctx.domElement.addEventListener("contextmenu", this.handleContextMenu); + window.addEventListener("pointermove", this.handlePointerMove); + window.addEventListener("pointerup", this.handlePointerUp); + ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom."); + ctx.setFirstPersonTelemetry(null); + this.updateCameraTransform(); + } + deactivate(ctx) { + ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown); + ctx.domElement.removeEventListener("wheel", this.handleWheel); + ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu); + window.removeEventListener("pointermove", this.handlePointerMove); + window.removeEventListener("pointerup", this.handlePointerUp); + ctx.setRuntimeMessage(null); + this.dragging = false; + this.context = null; + } + update(_dt) { + void _dt; + this.updateCameraTransform(); + } + setFocusPoint(target) { + this.target = cloneVec3(target); + this.updateCameraTransform(); + } + updateCameraTransform() { + if (this.context === null) { + return; + } + const horizontalDistance = Math.cos(this.pitchRadians) * this.distance; + const cameraPosition = { + x: this.target.x + Math.sin(this.yawRadians) * horizontalDistance, + y: this.target.y + Math.sin(this.pitchRadians) * this.distance, + z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance + }; + this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z); + this.lookAtVector.set(this.target.x, this.target.y, this.target.z); + this.context.camera.lookAt(this.lookAtVector); + } + handlePointerDown = (event) => { + if (event.button !== 0) { + return; + } + this.dragging = true; + this.lastPointerClientX = event.clientX; + this.lastPointerClientY = event.clientY; + }; + handlePointerMove = (event) => { + if (!this.dragging) { + return; + } + const deltaX = event.clientX - this.lastPointerClientX; + const deltaY = event.clientY - this.lastPointerClientY; + this.lastPointerClientX = event.clientX; + this.lastPointerClientY = event.clientY; + this.yawRadians -= deltaX * 0.008; + this.pitchRadians = clampPitch(this.pitchRadians + deltaY * 0.008); + }; + handlePointerUp = () => { + this.dragging = false; + }; + handleWheel = (event) => { + event.preventDefault(); + this.distance = clampDistance(this.distance + event.deltaY * 0.01); + }; + handleContextMenu = (event) => { + event.preventDefault(); + }; +} diff --git a/src/runtime-three/player-collision.js b/src/runtime-three/player-collision.js new file mode 100644 index 00000000..d0368fd9 --- /dev/null +++ b/src/runtime-three/player-collision.js @@ -0,0 +1,19 @@ +export const FIRST_PERSON_PLAYER_SHAPE = { + mode: "capsule", + radius: 0.3, + height: 1.8, + eyeHeight: 1.6 +}; +export function getFirstPersonPlayerEyeHeight(shape) { + return shape.eyeHeight; +} +export function getFirstPersonPlayerHeight(shape) { + switch (shape.mode) { + case "capsule": + return shape.height; + case "box": + return shape.size.y; + case "none": + return null; + } +} diff --git a/src/runtime-three/rapier-collision-world.js b/src/runtime-three/rapier-collision-world.js new file mode 100644 index 00000000..8df42b7c --- /dev/null +++ b/src/runtime-three/rapier-collision-world.js @@ -0,0 +1,272 @@ +import RAPIER from "@dimforge/rapier3d-compat"; +import { Euler, MathUtils, Quaternion } from "three"; +const CHARACTER_CONTROLLER_OFFSET = 0.01; +const COLLISION_EPSILON = 1e-5; +let rapierInitPromise = null; +function componentScale(vector, scale) { + return { + x: vector.x * scale.x, + y: vector.y * scale.y, + z: vector.z * scale.z + }; +} +function createRapierQuaternion(rotationDegrees) { + const quaternion = new Quaternion().setFromEuler(new Euler(MathUtils.degToRad(rotationDegrees.x), MathUtils.degToRad(rotationDegrees.y), MathUtils.degToRad(rotationDegrees.z), "XYZ")); + return { + x: quaternion.x, + y: quaternion.y, + z: quaternion.z, + w: quaternion.w + }; +} +function scaleVertices(vertices, scale) { + const scaledVertices = new Float32Array(vertices.length); + for (let index = 0; index < vertices.length; index += 3) { + scaledVertices[index] = vertices[index] * scale.x; + scaledVertices[index + 1] = vertices[index + 1] * scale.y; + scaledVertices[index + 2] = vertices[index + 2] * scale.z; + } + return scaledVertices; +} +function scaleBoundsCenter(bounds, scale) { + return { + x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x, + y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y, + z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z + }; +} +function createRapierHeightfieldHeights(collider) { + const heights = new Float32Array(collider.heights.length); + // Rapier's heightfield samples are column-major, with the Z axis varying + // fastest inside each X column. Our generated collider stores X-major rows + // for easier editor/debug mesh reconstruction, so transpose here. + for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) { + for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) { + heights[zIndex + xIndex * collider.cols] = collider.heights[xIndex + zIndex * collider.rows]; + } + } + return heights; +} +function createFixedBodyForModelCollider(world, collider) { + return world.createRigidBody(RAPIER.RigidBodyDesc.fixed() + .setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z) + .setRotation(createRapierQuaternion(collider.transform.rotationDegrees))); +} +function attachBrushCollider(world, collider) { + const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed() + .setTranslation(collider.center.x, collider.center.y, collider.center.z) + .setRotation(createRapierQuaternion(collider.rotationDegrees))); + const halfExtents = { + x: collider.size.x * 0.5, + y: collider.size.y * 0.5, + z: collider.size.z * 0.5 + }; + world.createCollider(RAPIER.ColliderDesc.cuboid(halfExtents.x, halfExtents.y, halfExtents.z), body); +} +function attachSimpleModelCollider(world, collider) { + const body = createFixedBodyForModelCollider(world, collider); + const scaledCenter = componentScale(collider.center, collider.transform.scale); + const scaledHalfExtents = componentScale({ + x: collider.size.x * 0.5, + y: collider.size.y * 0.5, + z: collider.size.z * 0.5 + }, collider.transform.scale); + world.createCollider(RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation(scaledCenter.x, scaledCenter.y, scaledCenter.z), body); +} +function attachStaticModelCollider(world, collider) { + const body = createFixedBodyForModelCollider(world, collider); + world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body); +} +function attachTerrainModelCollider(world, collider) { + if (collider.rows < 2 || collider.cols < 2) { + throw new Error(`Terrain collider ${collider.instanceId} must have at least a 2x2 height sample grid.`); + } + const body = createFixedBodyForModelCollider(world, collider); + const center = scaleBoundsCenter({ + min: { + x: collider.minX, + y: 0, + z: collider.minZ + }, + max: { + x: collider.maxX, + y: 0, + z: collider.maxZ + } + }, collider.transform.scale); + const rowSubdivisions = collider.rows - 1; + const colSubdivisions = collider.cols - 1; + world.createCollider( + // Rapier expects the number of grid subdivisions here, while our generated + // collider stores the sampled height grid dimensions. + RAPIER.ColliderDesc.heightfield(rowSubdivisions, colSubdivisions, createRapierHeightfieldHeights(collider), { + x: (collider.maxX - collider.minX) * collider.transform.scale.x, + y: collider.transform.scale.y, + z: (collider.maxZ - collider.minZ) * collider.transform.scale.z + }).setTranslation(center.x, center.y, center.z), body); +} +function attachDynamicModelCollider(world, collider) { + const body = createFixedBodyForModelCollider(world, collider); + for (const piece of collider.pieces) { + const scaledPoints = scaleVertices(piece.points, collider.transform.scale); + const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints); + if (descriptor === null) { + throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`); + } + world.createCollider(descriptor, body); + } +} +function attachModelCollider(world, collider) { + switch (collider.kind) { + case "box": + attachSimpleModelCollider(world, collider); + break; + case "trimesh": + attachStaticModelCollider(world, collider); + break; + case "heightfield": + attachTerrainModelCollider(world, collider); + break; + case "compound": + attachDynamicModelCollider(world, collider); + break; + } +} +function feetPositionToColliderCenter(feetPosition, shape) { + switch (shape.mode) { + case "capsule": { + const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5); + return { + x: feetPosition.x, + y: feetPosition.y + shape.radius + cylindricalHalfHeight, + z: feetPosition.z + }; + } + case "box": + return { + x: feetPosition.x, + y: feetPosition.y + shape.size.y * 0.5, + z: feetPosition.z + }; + case "none": + return { + ...feetPosition + }; + } +} +function colliderCenterToFeetPosition(center, shape) { + switch (shape.mode) { + case "capsule": { + const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5); + return { + x: center.x, + y: center.y - (shape.radius + cylindricalHalfHeight), + z: center.z + }; + } + case "box": + return { + x: center.x, + y: center.y - shape.size.y * 0.5, + z: center.z + }; + case "none": + return { + ...center + }; + } +} +function createPlayerCollider(world, rapier, playerShape) { + switch (playerShape.mode) { + case "capsule": + return world.createCollider(rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius)); + case "box": + return world.createCollider(rapier.ColliderDesc.cuboid(playerShape.size.x * 0.5, playerShape.size.y * 0.5, playerShape.size.z * 0.5)); + case "none": + return null; + } +} +export async function initializeRapierCollisionWorld() { + rapierInitPromise ??= RAPIER.init().then(() => RAPIER); + return rapierInitPromise; +} +export class RapierCollisionWorld { + world; + characterController; + playerCollider; + static async create(colliders, playerShape) { + const rapier = await initializeRapierCollisionWorld(); + const world = new rapier.World({ + x: 0, + y: 0, + z: 0 + }); + for (const collider of colliders) { + if (collider.source === "brush") { + attachBrushCollider(world, collider); + continue; + } + attachModelCollider(world, collider); + } + const playerCollider = createPlayerCollider(world, rapier, playerShape); + const characterController = playerCollider === null ? null : world.createCharacterController(CHARACTER_CONTROLLER_OFFSET); + if (characterController !== null) { + characterController.setUp({ x: 0, y: 1, z: 0 }); + characterController.setSlideEnabled(true); + characterController.enableSnapToGround(0.2); + characterController.enableAutostep(0.35, 0.15, false); + characterController.setMaxSlopeClimbAngle(Math.PI * 0.45); + characterController.setMinSlopeSlideAngle(Math.PI * 0.5); + } + world.step(); + return new RapierCollisionWorld(world, characterController, playerCollider); + } + constructor(world, characterController, playerCollider) { + this.world = world; + this.characterController = characterController; + this.playerCollider = playerCollider; + } + resolveFirstPersonMotion(feetPosition, motion, shape) { + if (this.playerCollider === null || this.characterController === null || shape.mode === "none") { + return { + feetPosition: { + x: feetPosition.x + motion.x, + y: feetPosition.y + motion.y, + z: feetPosition.z + motion.z + }, + grounded: false, + collidedAxes: { + x: false, + y: false, + z: false + } + }; + } + const currentCenter = feetPositionToColliderCenter(feetPosition, shape); + this.playerCollider.setTranslation(currentCenter); + this.characterController.computeColliderMovement(this.playerCollider, motion); + const correctedMovement = this.characterController.computedMovement(); + const collidedAxes = { + x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON, + y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON, + z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON + }; + const nextCenter = { + x: currentCenter.x + correctedMovement.x, + y: currentCenter.y + correctedMovement.y, + z: currentCenter.z + correctedMovement.z + }; + this.playerCollider.setTranslation(nextCenter); + return { + feetPosition: colliderCenterToFeetPosition(nextCenter, shape), + grounded: this.characterController.computedGrounded() || (motion.y < 0 && collidedAxes.y), + collidedAxes + }; + } + dispose() { + if (this.characterController !== null) { + this.world.removeCharacterController(this.characterController); + } + this.world.free(); + } +} diff --git a/src/runtime-three/runtime-audio-system.js b/src/runtime-three/runtime-audio-system.js new file mode 100644 index 00000000..5ceee803 --- /dev/null +++ b/src/runtime-three/runtime-audio-system.js @@ -0,0 +1,289 @@ +import { AudioListener, Group, PositionalAudio, Scene, Vector3 } from "three"; +const _listenerPosition = /*@__PURE__*/ new Vector3(); +const _emitterPosition = /*@__PURE__*/ new Vector3(); +function getErrorDetail(error) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return "Unknown error."; +} +function formatSoundEmitterLabel(entityId, link) { + return link === null ? entityId : `${entityId} (${link.id})`; +} +export function computeSoundEmitterDistanceGain(distance, refDistance, maxDistance) { + if (!Number.isFinite(distance) || !Number.isFinite(refDistance) || !Number.isFinite(maxDistance)) { + return 0; + } + if (distance <= refDistance) { + return 1; + } + if (maxDistance <= refDistance) { + return 0; + } + if (distance >= maxDistance) { + return 0; + } + const normalizedDistance = (distance - refDistance) / (maxDistance - refDistance); + const clampedDistance = Math.min(1, Math.max(0, normalizedDistance)); + const proximity = 1 - clampedDistance; + const easedProximity = proximity * proximity * proximity * proximity; + return easedProximity; +} +export class RuntimeAudioSystem { + camera; + scene; + soundGroup = new Group(); + soundEmitters = new Map(); + pendingPlayEmitterIds = new Set(); + listener; + runtimeScene = null; + projectAssets = {}; + loadedAudioAssets = {}; + runtimeMessageHandler; + currentRuntimeMessage = null; + unlockRequested = false; + constructor(scene, camera, runtimeMessageHandler) { + this.scene = scene; + this.camera = camera; + this.runtimeMessageHandler = runtimeMessageHandler; + this.scene.add(this.soundGroup); + let listener = null; + try { + listener = new AudioListener(); + this.camera.add(listener); + } + catch (error) { + console.warn(`Audio is unavailable in this browser environment: ${getErrorDetail(error)}`); + } + this.listener = listener; + } + setRuntimeMessageHandler(handler) { + this.runtimeMessageHandler = handler; + } + loadScene(runtimeScene) { + this.runtimeScene = runtimeScene; + this.rebuildSoundEmitters(); + this.queueAutoplayEmitters(); + } + updateAssets(projectAssets, loadedAudioAssets) { + this.projectAssets = projectAssets; + this.loadedAudioAssets = loadedAudioAssets; + this.rebuildSoundEmitters(); + this.queueAutoplayEmitters(); + } + updateListenerTransform() { + this.listener?.updateMatrixWorld(true); + this.updateSoundEmitterVolumes(); + } + handleUserGesture() { + if (this.listener === null) { + return; + } + const context = this.listener.context; + if (context.state === "running") { + if (this.unlockRequested) { + this.unlockRequested = false; + this.setRuntimeMessage(null); + } + return; + } + this.unlockRequested = true; + void context + .resume() + .then(() => { + this.unlockRequested = false; + this.flushPendingPlays(); + this.setRuntimeMessage(null); + }) + .catch((error) => { + this.setRuntimeMessage(`Audio unlock failed: ${getErrorDetail(error)}`); + }); + } + playSound(soundEmitterId, link) { + const soundEmitter = this.soundEmitters.get(soundEmitterId); + if (soundEmitter === undefined) { + this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} could not be found.`); + return; + } + if (this.listener === null) { + this.setRuntimeMessage("Audio is unavailable in this browser environment."); + return; + } + if (soundEmitter.buffer === null) { + const assetLabel = this.describeAudioAssetAvailability(soundEmitter.entity.audioAssetId); + this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} cannot play because ${assetLabel}.`); + console.warn(`playSound: ${soundEmitterId} has no playable audio buffer.`); + return; + } + if (this.listener.context.state !== "running") { + this.pendingPlayEmitterIds.add(soundEmitterId); + this.setRuntimeMessage("Audio is locked. Click the runner to enable sound."); + return; + } + this.playBufferedSound(soundEmitterId); + } + stopSound(soundEmitterId) { + this.pendingPlayEmitterIds.delete(soundEmitterId); + const soundEmitter = this.soundEmitters.get(soundEmitterId); + if (soundEmitter === undefined || soundEmitter.audio === null) { + return; + } + try { + soundEmitter.audio.stop(); + } + catch (error) { + console.warn(`stopSound: ${soundEmitterId} could not be stopped: ${getErrorDetail(error)}`); + } + } + dispose() { + for (const soundEmitterId of this.soundEmitters.keys()) { + this.stopSound(soundEmitterId); + } + this.pendingPlayEmitterIds.clear(); + for (const soundEmitter of this.soundEmitters.values()) { + this.soundGroup.remove(soundEmitter.group); + if (soundEmitter.audio !== null) { + soundEmitter.group.remove(soundEmitter.audio); + } + } + this.soundEmitters.clear(); + this.scene.remove(this.soundGroup); + if (this.listener !== null) { + this.camera.remove(this.listener); + } + } + setRuntimeMessage(message) { + if (this.currentRuntimeMessage === message) { + return; + } + this.currentRuntimeMessage = message; + this.runtimeMessageHandler?.(message); + } + rebuildSoundEmitters() { + if (this.runtimeScene === null) { + return; + } + for (const soundEmitter of this.soundEmitters.values()) { + this.stopSound(soundEmitter.entity.entityId); + this.soundGroup.remove(soundEmitter.group); + if (soundEmitter.audio !== null) { + soundEmitter.group.remove(soundEmitter.audio); + } + } + this.soundEmitters.clear(); + for (const entity of this.runtimeScene.entities.soundEmitters) { + const group = new Group(); + group.position.set(entity.position.x, entity.position.y, entity.position.z); + let audio = null; + if (this.listener !== null) { + audio = new PositionalAudio(this.listener); + this.configurePositionalAudio(audio, entity); + audio.position.set(0, 0, 0); + group.add(audio); + } + const buffer = this.resolveAudioBuffer(entity.audioAssetId); + if (audio !== null && buffer !== null) { + audio.setBuffer(buffer); + } + this.soundGroup.add(group); + this.soundEmitters.set(entity.entityId, { + entity, + group, + audio, + buffer + }); + } + } + resolveAudioBuffer(audioAssetId) { + if (audioAssetId === null) { + return null; + } + const loadedAsset = this.loadedAudioAssets[audioAssetId]; + if (loadedAsset !== undefined) { + return loadedAsset.buffer; + } + const asset = this.projectAssets[audioAssetId]; + if (asset === undefined) { + return null; + } + if (asset.kind !== "audio") { + return null; + } + return null; + } + describeAudioAssetAvailability(audioAssetId) { + if (audioAssetId === null) { + return "no assigned audio asset"; + } + const asset = this.projectAssets[audioAssetId]; + if (asset === undefined) { + return `missing audio asset ${audioAssetId}`; + } + if (asset.kind !== "audio") { + return `asset ${audioAssetId} is not an audio asset`; + } + return `audio asset ${audioAssetId} is unavailable`; + } + queueAutoplayEmitters() { + if (this.runtimeScene === null) { + return; + } + for (const entity of this.runtimeScene.entities.soundEmitters) { + if (entity.autoplay) { + this.pendingPlayEmitterIds.add(entity.entityId); + } + } + this.flushPendingPlays(); + } + flushPendingPlays() { + if (this.listener === null || this.listener.context.state !== "running") { + return; + } + const pendingEmitterIds = [...this.pendingPlayEmitterIds]; + this.pendingPlayEmitterIds.clear(); + for (const soundEmitterId of pendingEmitterIds) { + this.playBufferedSound(soundEmitterId); + } + } + playBufferedSound(soundEmitterId) { + const soundEmitter = this.soundEmitters.get(soundEmitterId); + if (soundEmitter === undefined || soundEmitter.audio === null || soundEmitter.buffer === null) { + return; + } + try { + soundEmitter.audio.stop(); + } + catch { + // three.js audio.stop() can throw when the underlying source is not active yet. + } + this.configurePositionalAudio(soundEmitter.audio, soundEmitter.entity); + this.updateSoundEmitterVolume(soundEmitter); + soundEmitter.audio.setBuffer(soundEmitter.buffer); + soundEmitter.audio.play(); + } + configurePositionalAudio(audio, entity) { + audio.setLoop(entity.loop); + audio.setRefDistance(entity.refDistance); + audio.setMaxDistance(entity.maxDistance); + audio.setDistanceModel("inverse"); + audio.setRolloffFactor(0); + } + updateSoundEmitterVolumes() { + if (this.listener === null) { + return; + } + for (const soundEmitter of this.soundEmitters.values()) { + this.updateSoundEmitterVolume(soundEmitter); + } + } + updateSoundEmitterVolume(soundEmitter) { + if (soundEmitter.audio === null) { + return; + } + this.camera.getWorldPosition(_listenerPosition); + soundEmitter.group.getWorldPosition(_emitterPosition); + const distance = _listenerPosition.distanceTo(_emitterPosition); + const attenuation = computeSoundEmitterDistanceGain(distance, soundEmitter.entity.refDistance, soundEmitter.entity.maxDistance); + soundEmitter.audio.setVolume(soundEmitter.entity.volume * attenuation); + } +} diff --git a/src/runtime-three/runtime-host.js b/src/runtime-three/runtime-host.js new file mode 100644 index 00000000..94491fb4 --- /dev/null +++ b/src/runtime-three/runtime-host.js @@ -0,0 +1,564 @@ +import { AmbientLight, AnimationClip, AnimationMixer, BoxGeometry, DirectionalLight, Group, LoopOnce, LoopRepeat, Mesh, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, Vector3, SpotLight, WebGLRenderer } from "three"; +import { EffectComposer } from "postprocessing"; +import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; +import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs"; +import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; +import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer } from "../rendering/advanced-rendering"; +import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings"; +import { FirstPersonNavigationController } from "./first-person-navigation-controller"; +import { RapierCollisionWorld } from "./rapier-collision-world"; +import { RuntimeInteractionSystem } from "./runtime-interaction-system"; +import { RuntimeAudioSystem } from "./runtime-audio-system"; +import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller"; +const FALLBACK_FACE_COLOR = 0x747d89; +export class RuntimeHost { + scene = new Scene(); + camera = new PerspectiveCamera(70, 1, 0.05, 1000); + cameraForward = new Vector3(); + domElement; + ambientLight = new AmbientLight(); + sunLight = new DirectionalLight(); + localLightGroup = new Group(); + brushGroup = new Group(); + modelGroup = new Group(); + firstPersonController = new FirstPersonNavigationController(); + orbitVisitorController = new OrbitVisitorNavigationController(); + interactionSystem = new RuntimeInteractionSystem(); + audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null); + brushMeshes = new Map(); + localLightObjects = new Map(); + modelRenderObjects = new Map(); + materialTextureCache = new Map(); + animationMixers = new Map(); + instanceAnimationClips = new Map(); + controllerContext; + renderer; + runtimeScene = null; + collisionWorld = null; + collisionWorldRequestId = 0; + currentWorld = null; + currentAdvancedRenderingSettings = null; + advancedRenderingComposer = null; + projectAssets = {}; + loadedModelAssets = {}; + loadedImageAssets = {}; + resizeObserver = null; + animationFrame = 0; + previousFrameTime = 0; + container = null; + activeController = null; + runtimeMessageHandler = null; + firstPersonTelemetryHandler = null; + interactionPromptHandler = null; + currentRuntimeMessage = null; + currentFirstPersonTelemetry = null; + currentInteractionPrompt = null; + constructor(options = {}) { + const enableRendering = options.enableRendering ?? true; + this.scene.add(this.ambientLight); + this.scene.add(this.sunLight); + this.scene.add(this.localLightGroup); + this.scene.add(this.brushGroup); + this.scene.add(this.modelGroup); + this.renderer = enableRendering ? new WebGLRenderer({ antialias: true, alpha: true }) : null; + this.domElement = this.renderer?.domElement ?? document.createElement("canvas"); + if (this.renderer !== null) { + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearAlpha(0); + } + else { + this.domElement.className = "runner-canvas__surface"; + } + this.controllerContext = { + camera: this.camera, + domElement: this.domElement, + getRuntimeScene: () => { + if (this.runtimeScene === null) { + throw new Error("Runtime scene has not been loaded."); + } + return this.runtimeScene; + }, + resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null, + setRuntimeMessage: (message) => { + if (message === this.currentRuntimeMessage) { + return; + } + this.currentRuntimeMessage = message; + this.runtimeMessageHandler?.(message); + }, + setFirstPersonTelemetry: (telemetry) => { + this.currentFirstPersonTelemetry = telemetry; + this.firstPersonTelemetryHandler?.(telemetry); + } + }; + } + mount(container) { + this.container = container; + container.appendChild(this.domElement); + this.domElement.addEventListener("click", this.handleRuntimeClick); + this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown); + this.resize(); + this.resizeObserver = new ResizeObserver(() => { + this.resize(); + }); + this.resizeObserver.observe(container); + this.previousFrameTime = performance.now(); + this.render(); + } + loadScene(runtimeScene) { + this.runtimeScene = runtimeScene; + this.currentWorld = runtimeScene.world; + this.interactionSystem.reset(); + this.setInteractionPrompt(null); + this.applyWorld(); + this.rebuildLocalLights(runtimeScene.localLights); + this.rebuildBrushMeshes(runtimeScene.brushes); + this.rebuildModelInstances(runtimeScene.modelInstances); + void this.rebuildCollisionWorld(runtimeScene.colliders, runtimeScene.playerCollider); + this.audioSystem.loadScene(runtimeScene); + } + updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets) { + this.projectAssets = projectAssets; + this.loadedModelAssets = loadedModelAssets; + this.loadedImageAssets = loadedImageAssets; + if (this.currentWorld !== null) { + this.applyWorld(); + } + if (this.runtimeScene !== null) { + this.rebuildModelInstances(this.runtimeScene.modelInstances); + } + this.audioSystem.updateAssets(projectAssets, loadedAudioAssets); + } + setNavigationMode(mode) { + if (this.runtimeScene === null) { + return; + } + const nextController = mode === "firstPerson" ? this.firstPersonController : this.orbitVisitorController; + if (this.activeController?.id === nextController.id) { + return; + } + if (this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null && nextController === this.orbitVisitorController) { + this.orbitVisitorController.setFocusPoint(this.currentFirstPersonTelemetry.feetPosition); + } + this.activeController?.deactivate(this.controllerContext); + this.interactionSystem.reset(); + this.setInteractionPrompt(null); + this.activeController = nextController; + this.activeController.activate(this.controllerContext); + } + setRuntimeMessageHandler(handler) { + this.runtimeMessageHandler = handler; + this.audioSystem.setRuntimeMessageHandler(handler); + } + setFirstPersonTelemetryHandler(handler) { + this.firstPersonTelemetryHandler = handler; + } + setInteractionPromptHandler(handler) { + this.interactionPromptHandler = handler; + } + dispose() { + if (this.animationFrame !== 0) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + } + this.activeController?.deactivate(this.controllerContext); + this.activeController = null; + this.setInteractionPrompt(null); + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.clearLocalLights(); + this.clearBrushMeshes(); + this.clearModelInstances(); + this.collisionWorldRequestId += 1; + this.clearCollisionWorld(); + this.audioSystem.dispose(); + this.advancedRenderingComposer?.dispose(); + this.advancedRenderingComposer = null; + this.currentAdvancedRenderingSettings = null; + if (this.renderer !== null) { + this.renderer.autoClear = true; + } + for (const cachedTexture of this.materialTextureCache.values()) { + cachedTexture.texture.dispose(); + } + this.materialTextureCache.clear(); + this.renderer?.dispose(); + this.domElement.removeEventListener("click", this.handleRuntimeClick); + this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown); + if (this.container !== null && this.container.contains(this.domElement)) { + this.container.removeChild(this.domElement); + } + this.container = null; + } + applyWorld() { + if (this.currentWorld === null) { + return; + } + const world = this.currentWorld; + this.ambientLight.color.set(world.ambientLight.colorHex); + this.ambientLight.intensity = world.ambientLight.intensity; + this.sunLight.color.set(world.sunLight.colorHex); + this.sunLight.intensity = world.sunLight.intensity; + this.sunLight.position + .set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z) + .normalize() + .multiplyScalar(18); + if (world.background.mode === "image") { + const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null; + this.scene.background = texture; + this.scene.environment = texture; + this.scene.environmentIntensity = world.background.environmentIntensity; + } + else { + this.scene.background = null; + this.scene.environment = null; + this.scene.environmentIntensity = 1; + } + if (this.renderer !== null) { + configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering); + this.syncAdvancedRenderingComposer(world.advancedRendering); + } + this.applyShadowState(); + } + async rebuildCollisionWorld(colliders, playerShape) { + const requestId = ++this.collisionWorldRequestId; + this.clearCollisionWorld(); + try { + const nextCollisionWorld = await RapierCollisionWorld.create(colliders, playerShape); + if (requestId !== this.collisionWorldRequestId) { + nextCollisionWorld.dispose(); + return; + } + this.collisionWorld = nextCollisionWorld; + } + catch (error) { + if (requestId !== this.collisionWorldRequestId) { + return; + } + const message = error instanceof Error ? error.message : "Runner collision initialization failed."; + this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`; + this.runtimeMessageHandler?.(this.currentRuntimeMessage); + } + } + clearCollisionWorld() { + this.collisionWorld?.dispose(); + this.collisionWorld = null; + } + syncAdvancedRenderingComposer(settings) { + if (this.renderer === null) { + return; + } + const shouldUseComposer = settings.enabled; + const settingsChanged = this.currentAdvancedRenderingSettings === null || + !areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings); + if (!shouldUseComposer) { + if (this.advancedRenderingComposer !== null) { + this.advancedRenderingComposer.dispose(); + this.advancedRenderingComposer = null; + } + this.currentAdvancedRenderingSettings = null; + this.renderer.autoClear = true; + return; + } + if (this.advancedRenderingComposer !== null && !settingsChanged) { + return; + } + if (this.advancedRenderingComposer !== null) { + this.advancedRenderingComposer.dispose(); + } + this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings); + this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings); + this.renderer.autoClear = false; + } + applyShadowState() { + if (this.currentWorld === null) { + return; + } + const advancedRendering = this.currentWorld.advancedRendering; + const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled; + applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering); + for (const renderGroup of this.localLightObjects.values()) { + applyAdvancedRenderingLightShadowFlags(renderGroup, advancedRendering); + } + for (const mesh of this.brushMeshes.values()) { + applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled); + } + for (const renderGroup of this.modelRenderObjects.values()) { + applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled); + } + } + rebuildLocalLights(localLights) { + this.clearLocalLights(); + for (const pointLight of localLights.pointLights) { + const renderObjects = this.createPointLightRuntimeObjects(pointLight); + this.localLightGroup.add(renderObjects.group); + this.localLightObjects.set(pointLight.entityId, renderObjects.group); + } + for (const spotLight of localLights.spotLights) { + const renderObjects = this.createSpotLightRuntimeObjects(spotLight); + this.localLightGroup.add(renderObjects.group); + this.localLightObjects.set(spotLight.entityId, renderObjects.group); + } + this.applyShadowState(); + } + createPointLightRuntimeObjects(pointLight) { + const group = new Group(); + const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance); + group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z); + light.position.set(0, 0, 0); + group.add(light); + return { + group + }; + } + createSpotLightRuntimeObjects(spotLight) { + const group = new Group(); + const light = new SpotLight(spotLight.colorHex, spotLight.intensity, spotLight.distance, (spotLight.angleDegrees * Math.PI) / 180, 0.18, 1); + const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize(); + const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction); + group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z); + group.quaternion.copy(orientation); + light.position.set(0, 0, 0); + light.target.position.set(0, 1, 0); + group.add(light); + group.add(light.target); + return { + group + }; + } + rebuildBrushMeshes(brushes) { + this.clearBrushMeshes(); + for (const brush of brushes) { + const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z); + applyBoxBrushFaceUvsToGeometry(geometry, brush); + const materials = [ + this.createFaceMaterial(brush.faces.posX.material), + this.createFaceMaterial(brush.faces.negX.material), + this.createFaceMaterial(brush.faces.posY.material), + this.createFaceMaterial(brush.faces.negY.material), + this.createFaceMaterial(brush.faces.posZ.material), + this.createFaceMaterial(brush.faces.negZ.material) + ]; + const mesh = new Mesh(geometry, materials); + mesh.position.set(brush.center.x, brush.center.y, brush.center.z); + mesh.rotation.set((brush.rotationDegrees.x * Math.PI) / 180, (brush.rotationDegrees.y * Math.PI) / 180, (brush.rotationDegrees.z * Math.PI) / 180); + this.brushGroup.add(mesh); + this.brushMeshes.set(brush.id, mesh); + } + this.applyShadowState(); + } + rebuildModelInstances(modelInstances) { + this.clearModelInstances(); + for (const modelInstance of modelInstances) { + const asset = this.projectAssets[modelInstance.assetId]; + const loadedAsset = this.loadedModelAssets[modelInstance.assetId]; + const renderGroup = createModelInstanceRenderGroup({ + id: modelInstance.instanceId, + kind: "modelInstance", + assetId: modelInstance.assetId, + name: modelInstance.name, + position: modelInstance.position, + rotationDegrees: modelInstance.rotationDegrees, + scale: modelInstance.scale, + collision: { + mode: "none", + visible: false + } + }, asset, loadedAsset, false); + this.modelGroup.add(renderGroup); + this.modelRenderObjects.set(modelInstance.instanceId, renderGroup); + if (loadedAsset?.animations && loadedAsset.animations.length > 0) { + const mixer = new AnimationMixer(renderGroup); + this.animationMixers.set(modelInstance.instanceId, mixer); + this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations); + if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) { + const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName); + if (clip) { + mixer.clipAction(clip).play(); + } + } + } + } + this.applyShadowState(); + } + createFaceMaterial(material) { + if (material === null) { + return new MeshStandardMaterial({ + color: FALLBACK_FACE_COLOR, + roughness: 0.9, + metalness: 0.05 + }); + } + return new MeshStandardMaterial({ + color: 0xffffff, + map: this.getOrCreateTexture(material), + roughness: 0.92, + metalness: 0.02 + }); + } + getOrCreateTexture(material) { + const signature = createStarterMaterialSignature(material); + const cachedTexture = this.materialTextureCache.get(material.id); + if (cachedTexture !== undefined && cachedTexture.signature === signature) { + return cachedTexture.texture; + } + cachedTexture?.texture.dispose(); + const texture = createStarterMaterialTexture(material); + this.materialTextureCache.set(material.id, { + signature, + texture + }); + return texture; + } + clearLocalLights() { + for (const renderGroup of this.localLightObjects.values()) { + this.localLightGroup.remove(renderGroup); + } + this.localLightObjects.clear(); + } + clearBrushMeshes() { + for (const mesh of this.brushMeshes.values()) { + this.brushGroup.remove(mesh); + mesh.geometry.dispose(); + for (const material of mesh.material) { + material.dispose(); + } + } + this.brushMeshes.clear(); + } + clearModelInstances() { + for (const mixer of this.animationMixers.values()) { + mixer.stopAllAction(); + } + this.animationMixers.clear(); + this.instanceAnimationClips.clear(); + for (const renderGroup of this.modelRenderObjects.values()) { + this.modelGroup.remove(renderGroup); + disposeModelInstance(renderGroup); + } + this.modelRenderObjects.clear(); + } + resize() { + if (this.container === null) { + return; + } + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (width === 0 || height === 0) { + return; + } + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.domElement.width = width; + this.domElement.height = height; + this.renderer?.setSize(width, height, false); + this.advancedRenderingComposer?.setSize(width, height); + } + render = () => { + this.animationFrame = window.requestAnimationFrame(this.render); + const now = performance.now(); + const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20); + this.previousFrameTime = now; + this.activeController?.update(dt); + this.audioSystem.updateListenerTransform(); + for (const mixer of this.animationMixers.values()) { + mixer.update(dt); + } + if (this.runtimeScene !== null && this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null) { + this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher()); + this.camera.getWorldDirection(this.cameraForward); + this.setInteractionPrompt(this.interactionSystem.resolveClickInteractionPrompt(this.currentFirstPersonTelemetry.eyePosition, { + x: this.cameraForward.x, + y: this.cameraForward.y, + z: this.cameraForward.z + }, this.runtimeScene)); + } + else { + this.setInteractionPrompt(null); + } + if (this.advancedRenderingComposer !== null) { + this.advancedRenderingComposer.render(dt); + return; + } + this.renderer?.render(this.scene, this.camera); + }; + applyTeleportPlayerAction(target) { + this.firstPersonController.teleportTo(target.position, target.yawDegrees); + } + applyToggleBrushVisibilityAction(brushId, visible) { + const mesh = this.brushMeshes.get(brushId); + if (mesh === undefined) { + return; + } + mesh.visible = visible ?? !mesh.visible; + } + applyPlayAnimationAction(instanceId, clipName, loop) { + const mixer = this.animationMixers.get(instanceId); + const clips = this.instanceAnimationClips.get(instanceId); + if (!mixer || !clips) { + console.warn(`playAnimation: no mixer for instance ${instanceId}`); + return; + } + const clip = AnimationClip.findByName(clips, clipName); + if (!clip) { + console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`); + return; + } + // LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops. + const action = mixer.clipAction(clip); + action.loop = loop === false ? LoopOnce : LoopRepeat; + action.clampWhenFinished = loop === false; + mixer.stopAllAction(); + action.reset().play(); + } + applyStopAnimationAction(instanceId) { + const mixer = this.animationMixers.get(instanceId); + if (!mixer) { + console.warn(`stopAnimation: no mixer for instance ${instanceId}`); + return; + } + mixer.stopAllAction(); + } + createInteractionDispatcher() { + return { + teleportPlayer: (target) => { + this.applyTeleportPlayerAction(target); + }, + toggleBrushVisibility: (brushId, visible) => { + this.applyToggleBrushVisibilityAction(brushId, visible); + }, + playAnimation: (instanceId, clipName, loop) => { + this.applyPlayAnimationAction(instanceId, clipName, loop); + }, + stopAnimation: (instanceId) => { + this.applyStopAnimationAction(instanceId); + }, + playSound: (soundEmitterId, link) => { + this.audioSystem.playSound(soundEmitterId, link); + }, + stopSound: (soundEmitterId) => { + this.audioSystem.stopSound(soundEmitterId); + } + }; + } + setInteractionPrompt(prompt) { + if (this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId && + this.currentInteractionPrompt?.prompt === prompt?.prompt && + this.currentInteractionPrompt?.distance === prompt?.distance && + this.currentInteractionPrompt?.range === prompt?.range) { + return; + } + this.currentInteractionPrompt = prompt; + this.interactionPromptHandler?.(prompt); + } + handleRuntimeClick = () => { + this.audioSystem.handleUserGesture(); + if (this.runtimeScene === null || this.activeController !== this.firstPersonController || this.currentInteractionPrompt === null) { + return; + } + this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher()); + }; + handleRuntimePointerDown = () => { + this.audioSystem.handleUserGesture(); + }; +} diff --git a/src/runtime-three/runtime-interaction-system.js b/src/runtime-three/runtime-interaction-system.js new file mode 100644 index 00000000..6ec8c44b --- /dev/null +++ b/src/runtime-three/runtime-interaction-system.js @@ -0,0 +1,163 @@ +const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75; +function subtractVec3(left, right) { + return { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z + }; +} +function scaleVec3(vector, scalar) { + return { + x: vector.x * scalar, + y: vector.y * scalar, + z: vector.z * scalar + }; +} +function dotVec3(left, right) { + return left.x * right.x + left.y * right.y + left.z * right.z; +} +function lengthSquaredVec3(vector) { + return dotVec3(vector, vector); +} +function distanceBetweenVec3(left, right) { + return Math.sqrt(lengthSquaredVec3(subtractVec3(left, right))); +} +function normalizeVec3(vector) { + const lengthSquared = lengthSquaredVec3(vector); + if (lengthSquared <= Number.EPSILON) { + return null; + } + return scaleVec3(vector, 1 / Math.sqrt(lengthSquared)); +} +function isPointInsideTriggerVolume(position, triggerVolume) { + const halfSize = { + x: triggerVolume.size.x * 0.5, + y: triggerVolume.size.y * 0.5, + z: triggerVolume.size.z * 0.5 + }; + return (position.x >= triggerVolume.position.x - halfSize.x && + position.x <= triggerVolume.position.x + halfSize.x && + position.y >= triggerVolume.position.y - halfSize.y && + position.y <= triggerVolume.position.y + halfSize.y && + position.z >= triggerVolume.position.z - halfSize.z && + position.z <= triggerVolume.position.z + halfSize.z); +} +function raySphereHitDistance(origin, direction, center, radius) { + const offset = subtractVec3(origin, center); + const halfB = dotVec3(offset, direction); + const c = dotVec3(offset, offset) - radius * radius; + const discriminant = halfB * halfB - c; + if (discriminant < 0) { + return null; + } + const discriminantRoot = Math.sqrt(discriminant); + const nearestHit = -halfB - discriminantRoot; + if (nearestHit >= 0) { + return nearestHit; + } + const farHit = -halfB + discriminantRoot; + return farHit >= 0 ? 0 : null; +} +function resolveTeleportTarget(runtimeScene, entityId) { + return runtimeScene.entities.teleportTargets.find((teleportTarget) => teleportTarget.entityId === entityId) ?? null; +} +function hasTriggerLinks(runtimeScene, sourceEntityId, trigger) { + return runtimeScene.interactionLinks.some((link) => link.sourceEntityId === sourceEntityId && link.trigger === trigger); +} +function getInteractableTargetRadius(interactable) { + return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius); +} +export class RuntimeInteractionSystem { + occupiedTriggerVolumes = new Set(); + reset() { + this.occupiedTriggerVolumes.clear(); + } + updatePlayerPosition(feetPosition, runtimeScene, dispatcher) { + for (const triggerVolume of runtimeScene.entities.triggerVolumes) { + const containsPlayer = isPointInsideTriggerVolume(feetPosition, triggerVolume); + const wasOccupied = this.occupiedTriggerVolumes.has(triggerVolume.entityId); + if (!wasOccupied && containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter")) { + this.dispatchLinks(triggerVolume.entityId, "enter", runtimeScene, dispatcher); + } + else if (wasOccupied && !containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit")) { + this.dispatchLinks(triggerVolume.entityId, "exit", runtimeScene, dispatcher); + } + if (containsPlayer) { + this.occupiedTriggerVolumes.add(triggerVolume.entityId); + } + else { + this.occupiedTriggerVolumes.delete(triggerVolume.entityId); + } + } + } + resolveClickInteractionPrompt(viewOrigin, viewDirection, runtimeScene) { + const normalizedViewDirection = normalizeVec3(viewDirection); + if (normalizedViewDirection === null) { + return null; + } + let bestPrompt = null; + let bestHitDistance = Number.POSITIVE_INFINITY; + for (const interactable of runtimeScene.entities.interactables) { + if (!interactable.enabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click")) { + continue; + } + const distance = distanceBetweenVec3(viewOrigin, interactable.position); + if (distance > interactable.radius) { + continue; + } + const hitDistance = raySphereHitDistance(viewOrigin, normalizedViewDirection, interactable.position, getInteractableTargetRadius(interactable)); + if (hitDistance === null) { + continue; + } + const nextPrompt = { + sourceEntityId: interactable.entityId, + prompt: interactable.prompt, + distance, + range: interactable.radius + }; + if (hitDistance < bestHitDistance || + (hitDistance === bestHitDistance && + (bestPrompt === null || + distance < bestPrompt.distance || + (distance === bestPrompt.distance && interactable.entityId.localeCompare(bestPrompt.sourceEntityId) < 0)))) { + bestHitDistance = hitDistance; + bestPrompt = nextPrompt; + } + } + return bestPrompt; + } + dispatchClickInteraction(sourceEntityId, runtimeScene, dispatcher) { + this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher); + } + dispatchLinks(sourceEntityId, trigger, runtimeScene, dispatcher) { + for (const link of runtimeScene.interactionLinks) { + if (link.sourceEntityId !== sourceEntityId || link.trigger !== trigger) { + continue; + } + switch (link.action.type) { + case "teleportPlayer": { + const teleportTarget = resolveTeleportTarget(runtimeScene, link.action.targetEntityId); + if (teleportTarget !== null) { + dispatcher.teleportPlayer(teleportTarget, link); + } + break; + } + case "toggleVisibility": + dispatcher.toggleBrushVisibility(link.action.targetBrushId, link.action.visible, link); + break; + case "playAnimation": + dispatcher.playAnimation(link.action.targetModelInstanceId, link.action.clipName, link.action.loop, link); + break; + case "stopAnimation": + dispatcher.stopAnimation(link.action.targetModelInstanceId, link); + break; + case "playSound": + dispatcher.playSound(link.action.targetSoundEmitterId, link); + break; + case "stopSound": + dispatcher.stopSound(link.action.targetSoundEmitterId, link); + break; + } + } + } +} diff --git a/src/runtime-three/runtime-scene-build.js b/src/runtime-three/runtime-scene-build.js new file mode 100644 index 00000000..a73c8634 --- /dev/null +++ b/src/runtime-three/runtime-scene-build.js @@ -0,0 +1,331 @@ +import { getModelInstances } from "../assets/model-instances"; +import { cloneWorldSettings } from "../document/world-settings"; +import { getEntityInstances, getPrimaryPlayerStartEntity } from "../entities/entity-instances"; +import { getBoxBrushBounds } from "../geometry/box-brush"; +import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation"; +import { cloneInteractionLink, getInteractionLinks } from "../interactions/interaction-links"; +import { cloneMaterialDef } from "../materials/starter-material-library"; +import { cloneFaceUvState } from "../document/brushes"; +import { assertRuntimeSceneBuildable } from "./runtime-scene-validation"; +import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision"; +function cloneVec3(vector) { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} +function resolveRuntimeMaterial(document, materialId) { + if (materialId === null) { + return null; + } + const material = document.materials[materialId]; + if (material === undefined) { + throw new Error(`Runtime build could not resolve material ${materialId}.`); + } + return cloneMaterialDef(material); +} +function buildRuntimeBrush(brush, document) { + return { + id: brush.id, + kind: "box", + center: cloneVec3(brush.center), + rotationDegrees: cloneVec3(brush.rotationDegrees), + size: cloneVec3(brush.size), + faces: { + posX: { + materialId: brush.faces.posX.materialId, + material: resolveRuntimeMaterial(document, brush.faces.posX.materialId), + uv: cloneFaceUvState(brush.faces.posX.uv) + }, + negX: { + materialId: brush.faces.negX.materialId, + material: resolveRuntimeMaterial(document, brush.faces.negX.materialId), + uv: cloneFaceUvState(brush.faces.negX.uv) + }, + posY: { + materialId: brush.faces.posY.materialId, + material: resolveRuntimeMaterial(document, brush.faces.posY.materialId), + uv: cloneFaceUvState(brush.faces.posY.uv) + }, + negY: { + materialId: brush.faces.negY.materialId, + material: resolveRuntimeMaterial(document, brush.faces.negY.materialId), + uv: cloneFaceUvState(brush.faces.negY.uv) + }, + posZ: { + materialId: brush.faces.posZ.materialId, + material: resolveRuntimeMaterial(document, brush.faces.posZ.materialId), + uv: cloneFaceUvState(brush.faces.posZ.uv) + }, + negZ: { + materialId: brush.faces.negZ.materialId, + material: resolveRuntimeMaterial(document, brush.faces.negZ.materialId), + uv: cloneFaceUvState(brush.faces.negZ.uv) + } + } + }; +} +function buildRuntimeCollider(brush) { + const bounds = getBoxBrushBounds(brush); + return { + kind: "box", + source: "brush", + brushId: brush.id, + center: cloneVec3(brush.center), + rotationDegrees: cloneVec3(brush.rotationDegrees), + size: cloneVec3(brush.size), + worldBounds: { + min: cloneVec3(bounds.min), + max: cloneVec3(bounds.max) + } + }; +} +function buildRuntimeModelInstance(modelInstance) { + return { + instanceId: modelInstance.id, + assetId: modelInstance.assetId, + name: modelInstance.name, + position: cloneVec3(modelInstance.position), + rotationDegrees: cloneVec3(modelInstance.rotationDegrees), + scale: cloneVec3(modelInstance.scale), + animationClipName: modelInstance.animationClipName, + animationAutoplay: modelInstance.animationAutoplay + }; +} +function getColliderBounds(collider) { + if (collider.source === "brush") { + return { + min: cloneVec3(collider.worldBounds.min), + max: cloneVec3(collider.worldBounds.max) + }; + } + return { + min: cloneVec3(collider.worldBounds.min), + max: cloneVec3(collider.worldBounds.max) + }; +} +function combineColliderBounds(colliders) { + if (colliders.length === 0) { + return null; + } + const firstBounds = getColliderBounds(colliders[0]); + const min = cloneVec3(firstBounds.min); + const max = cloneVec3(firstBounds.max); + for (const collider of colliders.slice(1)) { + const bounds = getColliderBounds(collider); + min.x = Math.min(min.x, bounds.min.x); + min.y = Math.min(min.y, bounds.min.y); + min.z = Math.min(min.z, bounds.min.z); + max.x = Math.max(max.x, bounds.max.x); + max.y = Math.max(max.y, bounds.max.y); + max.z = Math.max(max.z, bounds.max.z); + } + return { + min, + max, + center: { + x: (min.x + max.x) * 0.5, + y: (min.y + max.y) * 0.5, + z: (min.z + max.z) * 0.5 + }, + size: { + x: max.x - min.x, + y: max.y - min.y, + z: max.z - min.z + } + }; +} +function buildFallbackSpawn(sceneBounds) { + if (sceneBounds === null) { + return { + source: "fallback", + entityId: null, + position: { + x: 0, + y: 0, + z: -4 + }, + yawDegrees: 0 + }; + } + return { + source: "fallback", + entityId: null, + position: { + x: sceneBounds.center.x, + y: sceneBounds.max.y + 0.1, + z: sceneBounds.max.z + 3 + }, + yawDegrees: 180 + }; +} +function buildRuntimeSceneCollections(document) { + const runtimeEntities = { + playerStarts: [], + soundEmitters: [], + triggerVolumes: [], + teleportTargets: [], + interactables: [] + }; + const localLights = { + pointLights: [], + spotLights: [] + }; + for (const entity of getEntityInstances(document.entities)) { + switch (entity.kind) { + case "pointLight": + localLights.pointLights.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + colorHex: entity.colorHex, + intensity: entity.intensity, + distance: entity.distance + }); + break; + case "spotLight": + localLights.spotLights.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + direction: cloneVec3(entity.direction), + colorHex: entity.colorHex, + intensity: entity.intensity, + distance: entity.distance, + angleDegrees: entity.angleDegrees + }); + break; + case "playerStart": + runtimeEntities.playerStarts.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + yawDegrees: entity.yawDegrees, + collider: buildRuntimePlayerShape(entity) + }); + break; + case "soundEmitter": + runtimeEntities.soundEmitters.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + audioAssetId: entity.audioAssetId, + volume: entity.volume, + refDistance: entity.refDistance, + maxDistance: entity.maxDistance, + autoplay: entity.autoplay, + loop: entity.loop + }); + break; + case "triggerVolume": + runtimeEntities.triggerVolumes.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + size: cloneVec3(entity.size), + // Derive from links so flags are always correct regardless of stored entity state + triggerOnEnter: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "enter"), + triggerOnExit: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "exit") + }); + break; + case "teleportTarget": + runtimeEntities.teleportTargets.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + yawDegrees: entity.yawDegrees + }); + break; + case "interactable": + runtimeEntities.interactables.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + radius: entity.radius, + prompt: entity.prompt, + enabled: entity.enabled + }); + break; + default: + assertNever(entity); + } + } + return { + entities: runtimeEntities, + localLights + }; +} +function assertNever(value) { + throw new Error(`Unsupported runtime entity: ${String(value.kind)}`); +} +function buildRuntimePlayerShape(playerStartEntity) { + if (playerStartEntity === null) { + return FIRST_PERSON_PLAYER_SHAPE; + } + switch (playerStartEntity.collider.mode) { + case "capsule": + return { + mode: "capsule", + radius: playerStartEntity.collider.capsuleRadius, + height: playerStartEntity.collider.capsuleHeight, + eyeHeight: playerStartEntity.collider.eyeHeight + }; + case "box": + return { + mode: "box", + size: cloneVec3(playerStartEntity.collider.boxSize), + eyeHeight: playerStartEntity.collider.eyeHeight + }; + case "none": + return { + mode: "none", + eyeHeight: playerStartEntity.collider.eyeHeight + }; + } +} +export function buildRuntimeSceneFromDocument(document, options = {}) { + assertRuntimeSceneBuildable(document, { + navigationMode: options.navigationMode ?? "orbitVisitor", + loadedModelAssets: options.loadedModelAssets + }); + const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document)); + const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush)); + const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance); + const collections = buildRuntimeSceneCollections(document); + const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link)); + const playerStartEntity = getPrimaryPlayerStartEntity(document.entities); + const playerCollider = buildRuntimePlayerShape(playerStartEntity); + for (const modelInstance of getModelInstances(document.modelInstances)) { + const asset = document.assets[modelInstance.assetId]; + if (asset === undefined || asset.kind !== "model") { + continue; + } + const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]); + if (generatedCollider !== null) { + colliders.push(generatedCollider); + } + } + const combinedSceneBounds = combineColliderBounds(colliders); + const playerStart = playerStartEntity === null + ? null + : { + entityId: playerStartEntity.id, + position: cloneVec3(playerStartEntity.position), + yawDegrees: playerStartEntity.yawDegrees, + collider: playerCollider + }; + return { + world: cloneWorldSettings(document.world), + localLights: collections.localLights, + brushes, + colliders, + sceneBounds: combinedSceneBounds, + modelInstances, + entities: collections.entities, + interactionLinks, + playerStart, + playerCollider, + spawn: playerStart === null + ? buildFallbackSpawn(combinedSceneBounds) + : { + source: "playerStart", + entityId: playerStart.entityId, + position: cloneVec3(playerStart.position), + yawDegrees: playerStart.yawDegrees + } + }; +} diff --git a/src/runtime-three/runtime-scene-validation.js b/src/runtime-three/runtime-scene-validation.js new file mode 100644 index 00000000..68e620c9 --- /dev/null +++ b/src/runtime-three/runtime-scene-validation.js @@ -0,0 +1,42 @@ +import { getModelInstances } from "../assets/model-instances"; +import { assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary } from "../document/scene-document-validation"; +import { getPrimaryPlayerStartEntity } from "../entities/entity-instances"; +import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation"; +export function validateRuntimeSceneBuild(document, options) { + const diagnostics = []; + if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) { + diagnostics.push(createDiagnostic("error", "missing-player-start", "First-person run requires an authored Player Start. Place one or switch to Orbit Visitor.", "entities", "build")); + } + for (const modelInstance of getModelInstances(document.modelInstances)) { + const path = `modelInstances.${modelInstance.id}.collision.mode`; + const asset = document.assets[modelInstance.assetId]; + if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") { + continue; + } + try { + const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]); + if (generatedCollider?.mode === "dynamic") { + diagnostics.push(createDiagnostic("warning", "dynamic-model-collider-fixed-query-only", "Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.", path, "build")); + } + } + catch (error) { + const message = error instanceof Error ? error.message : "Imported model collision generation failed."; + const code = error instanceof ModelColliderGenerationError + ? error.code + : "invalid-model-instance-collision-mode"; + diagnostics.push(createDiagnostic("error", code, message, path, "build")); + } + } + return { + diagnostics, + errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), + warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning") + }; +} +export function assertRuntimeSceneBuildable(document, options) { + assertSceneDocumentIsValid(document); + const validation = validateRuntimeSceneBuild(document, options); + if (validation.errors.length > 0) { + throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`); + } +} diff --git a/src/serialization/local-draft-storage.js b/src/serialization/local-draft-storage.js new file mode 100644 index 00000000..0f345b6e --- /dev/null +++ b/src/serialization/local-draft-storage.js @@ -0,0 +1,202 @@ +import { createEmptySceneDocument } from "../document/scene-document"; +import { VIEWPORT_PANEL_IDS, cloneViewportLayoutState, createDefaultViewportLayoutState } from "../viewport-three/viewport-layout"; +import { parseSceneDocumentJson, serializeSceneDocument } from "./scene-document-json"; +export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft"; +const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1"; +function getErrorDetail(error) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return "Unknown error."; +} +function formatStorageDiagnostic(prefix, error) { + return `${prefix} ${getErrorDetail(error)}`; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} +function isFiniteNumber(value) { + return typeof value === "number" && Number.isFinite(value); +} +function parseViewportLayoutMode(value) { + return value === "single" || value === "quad" ? value : null; +} +function parseViewportPanelId(value) { + return typeof value === "string" && VIEWPORT_PANEL_IDS.includes(value) ? value : null; +} +function parseViewportLayoutState(value) { + if (!isRecord(value)) { + return null; + } + const layoutMode = parseViewportLayoutMode(value.layoutMode); + const activePanelId = parseViewportPanelId(value.activePanelId); + const viewportQuadSplit = isRecord(value.viewportQuadSplit) ? value.viewportQuadSplit : null; + const panels = isRecord(value.panels) ? value.panels : null; + if (layoutMode === null || activePanelId === null || viewportQuadSplit === null || panels === null) { + return null; + } + if (!isFiniteNumber(viewportQuadSplit.x) || !isFiniteNumber(viewportQuadSplit.y)) { + return null; + } + const defaultLayoutState = createDefaultViewportLayoutState(); + const nextLayoutState = cloneViewportLayoutState(defaultLayoutState); + nextLayoutState.layoutMode = layoutMode; + nextLayoutState.activePanelId = activePanelId; + nextLayoutState.viewportQuadSplit = { + x: viewportQuadSplit.x, + y: viewportQuadSplit.y + }; + for (const panelId of VIEWPORT_PANEL_IDS) { + const storedPanel = panels[panelId]; + if (!isRecord(storedPanel)) { + return null; + } + const storedViewMode = storedPanel.viewMode; + const storedDisplayMode = storedPanel.displayMode; + const storedCameraState = isRecord(storedPanel.cameraState) ? storedPanel.cameraState : null; + const storedPerspectiveOrbit = storedCameraState !== null && isRecord(storedCameraState.perspectiveOrbit) ? storedCameraState.perspectiveOrbit : null; + const storedTarget = storedCameraState !== null && isRecord(storedCameraState.target) ? storedCameraState.target : null; + if ((storedViewMode !== "perspective" && storedViewMode !== "top" && storedViewMode !== "front" && storedViewMode !== "side") || + (storedDisplayMode !== "normal" && storedDisplayMode !== "authoring" && storedDisplayMode !== "wireframe") || + storedCameraState === null || + storedPerspectiveOrbit === null || + storedTarget === null) { + return null; + } + if (!isFiniteNumber(storedTarget.x) || + !isFiniteNumber(storedTarget.y) || + !isFiniteNumber(storedTarget.z) || + !isFiniteNumber(storedPerspectiveOrbit.radius) || + !isFiniteNumber(storedPerspectiveOrbit.theta) || + !isFiniteNumber(storedPerspectiveOrbit.phi) || + !isFiniteNumber(storedCameraState.orthographicZoom)) { + return null; + } + nextLayoutState.panels[panelId] = { + viewMode: storedViewMode, + displayMode: storedDisplayMode, + cameraState: { + target: { + x: storedTarget.x, + y: storedTarget.y, + z: storedTarget.z + }, + perspectiveOrbit: { + radius: storedPerspectiveOrbit.radius, + theta: storedPerspectiveOrbit.theta, + phi: storedPerspectiveOrbit.phi + }, + orthographicZoom: storedCameraState.orthographicZoom + } + }; + } + return nextLayoutState; +} +function isStoredEditorDraftEnvelope(value) { + return isRecord(value) && value.format === EDITOR_DRAFT_ENVELOPE_FORMAT && "document" in value; +} +export function getBrowserStorageAccess() { + if (typeof window === "undefined") { + return { + storage: null, + diagnostic: null + }; + } + try { + return { + storage: window.localStorage, + diagnostic: null + }; + } + catch (error) { + return { + storage: null, + diagnostic: formatStorageDiagnostic("Browser local storage is unavailable.", error) + }; + } +} +export function getBrowserStorage() { + return getBrowserStorageAccess().storage; +} +export function saveSceneDocumentDraft(storage, document, viewportLayoutState = null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY) { + try { + const rawDocument = serializeSceneDocument(document); + storage.setItem(key, JSON.stringify({ + format: EDITOR_DRAFT_ENVELOPE_FORMAT, + document: JSON.parse(rawDocument), + viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState) + })); + return { + status: "saved", + message: "Local draft saved." + }; + } + catch (error) { + return { + status: "error", + message: formatStorageDiagnostic("Local draft could not be saved.", error) + }; + } +} +export function loadSceneDocumentDraft(storage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY) { + try { + const rawDocument = storage.getItem(key); + if (rawDocument === null) { + return { + status: "missing", + message: "No local draft was found." + }; + } + const parsedDraft = JSON.parse(rawDocument); + if (isStoredEditorDraftEnvelope(parsedDraft)) { + return { + status: "loaded", + document: parseSceneDocumentJson(JSON.stringify(parsedDraft.document)), + viewportLayoutState: parseViewportLayoutState(parsedDraft.viewportLayoutState ?? null), + message: "Local draft loaded." + }; + } + return { + status: "loaded", + document: parseSceneDocumentJson(rawDocument), + viewportLayoutState: null, + message: "Local draft loaded." + }; + } + catch (error) { + return { + status: "error", + message: formatStorageDiagnostic("Stored local draft could not be loaded.", error) + }; + } +} +export function loadOrCreateSceneDocument(storage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY) { + if (storage === null) { + return { + document: createEmptySceneDocument(), + viewportLayoutState: null, + diagnostic: null + }; + } + const draftResult = loadSceneDocumentDraft(storage, key); + switch (draftResult.status) { + case "loaded": + return { + document: draftResult.document, + viewportLayoutState: draftResult.viewportLayoutState, + diagnostic: null + }; + case "missing": + return { + document: createEmptySceneDocument(), + viewportLayoutState: null, + diagnostic: null + }; + case "error": + return { + document: createEmptySceneDocument(), + viewportLayoutState: null, + diagnostic: `${draftResult.message} Starting with a fresh empty document.` + }; + } +} diff --git a/src/serialization/scene-document-json.js b/src/serialization/scene-document-json.js new file mode 100644 index 00000000..8b6625ae --- /dev/null +++ b/src/serialization/scene-document-json.js @@ -0,0 +1,19 @@ +import { migrateSceneDocument } from "../document/migrate-scene-document"; +import { assertSceneDocumentIsValid } from "../document/scene-document-validation"; +export function serializeSceneDocument(document) { + assertSceneDocumentIsValid(document); + return JSON.stringify(document, null, 2); +} +export function parseSceneDocumentJson(source) { + let parsedValue; + try { + parsedValue = JSON.parse(source); + } + catch (error) { + const cause = error instanceof Error ? error.message : "Unknown JSON parse failure."; + throw new Error(`Scene document JSON could not be parsed: ${cause}`); + } + const document = migrateSceneDocument(parsedValue); + assertSceneDocumentIsValid(document); + return document; +} diff --git a/src/shared-ui/HierarchicalMenu.js b/src/shared-ui/HierarchicalMenu.js new file mode 100644 index 00000000..01cdeb2a --- /dev/null +++ b/src/shared-ui/HierarchicalMenu.js @@ -0,0 +1,36 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +function clampMenuPosition(position) { + const horizontalPadding = 12; + const verticalPadding = 12; + const estimatedMenuWidth = 300; + const estimatedMenuHeight = 420; + return { + x: Math.max(horizontalPadding, Math.min(position.x, window.innerWidth - estimatedMenuWidth - horizontalPadding)), + y: Math.max(verticalPadding, Math.min(position.y, window.innerHeight - estimatedMenuHeight - verticalPadding)) + }; +} +function renderHierarchicalMenuItems(items, onClose) { + return items.map((item, index) => { + if (item.kind === "separator") { + return _jsx("div", { className: "hierarchical-menu__separator", role: "separator" }, `separator-${index}`); + } + if (item.kind === "group") { + return (_jsxs("details", { className: "hierarchical-menu__group", children: [_jsxs("summary", { className: "hierarchical-menu__group-summary", "data-testid": item.testId, children: [_jsx("span", { className: "hierarchical-menu__group-label", children: item.label }), _jsx("span", { className: "hierarchical-menu__group-chevron", "aria-hidden": "true" })] }), _jsx("div", { className: "hierarchical-menu__children", children: renderHierarchicalMenuItems(item.children, onClose) })] }, `${item.label}-${index}`)); + } + return (_jsxs("button", { className: "hierarchical-menu__action", type: "button", role: "menuitem", "data-testid": item.testId, disabled: item.disabled, onClick: () => { + if (item.disabled) { + return; + } + item.onSelect(); + onClose(); + }, onPointerEnter: () => item.onHoverChange?.(true), onPointerLeave: () => item.onHoverChange?.(false), onFocus: () => item.onHoverChange?.(true), onBlur: () => item.onHoverChange?.(false), children: [_jsx("span", { className: "hierarchical-menu__action-label", children: item.label }), _jsx("span", { className: "hierarchical-menu__action-plus", "aria-hidden": "true", children: "+" })] }, `${item.label}-${index}`)); + }); +} +export function HierarchicalMenu({ title, position, items, onClose }) { + const clampedPosition = clampMenuPosition(position); + const style = { + left: `${clampedPosition.x}px`, + top: `${clampedPosition.y}px` + }; + return (_jsx("div", { className: "hierarchical-menu__backdrop", onPointerDown: onClose, role: "presentation", children: _jsxs("div", { className: "hierarchical-menu", role: "menu", "aria-label": title, style: style, onPointerDown: (event) => event.stopPropagation(), children: [_jsx("div", { className: "hierarchical-menu__title", children: title }), _jsx("div", { className: "hierarchical-menu__list", children: renderHierarchicalMenuItems(items, onClose) })] }) })); +} diff --git a/src/shared-ui/Panel.js b/src/shared-ui/Panel.js new file mode 100644 index 00000000..f009e223 --- /dev/null +++ b/src/shared-ui/Panel.js @@ -0,0 +1,7 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useId, useState } from "react"; +export function Panel({ title, children, defaultExpanded = true }) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const bodyId = useId(); + return (_jsxs("section", { className: `panel ${isExpanded ? "" : "panel--collapsed"}`, children: [_jsxs("button", { className: "panel__header", type: "button", "aria-expanded": isExpanded, "aria-controls": bodyId, onClick: () => setIsExpanded((expanded) => !expanded), children: [_jsx("span", { className: `panel__chevron ${isExpanded ? "panel__chevron--expanded" : ""}`, "aria-hidden": "true" }), _jsx("span", { children: title })] }), isExpanded ? (_jsx("div", { className: "panel__body", id: bodyId, children: children })) : null] })); +} diff --git a/src/shared-ui/world-background-style.js b/src/shared-ui/world-background-style.js new file mode 100644 index 00000000..e305efbb --- /dev/null +++ b/src/shared-ui/world-background-style.js @@ -0,0 +1,21 @@ +export function createWorldBackgroundStyle(background, imageUrl = null) { + if (background.mode === "solid") { + return { + backgroundColor: background.colorHex, + backgroundImage: "none" + }; + } + if (background.mode === "image") { + return { + backgroundColor: "#0d1116", + backgroundImage: imageUrl === null ? "none" : `url("${imageUrl}")`, + backgroundPosition: "center center", + backgroundRepeat: "no-repeat", + backgroundSize: "cover" + }; + } + return { + backgroundColor: background.bottomColorHex, + backgroundImage: `linear-gradient(180deg, ${background.topColorHex} 0%, ${background.bottomColorHex} 100%)` + }; +} diff --git a/src/viewport-three/ViewportCanvas.js b/src/viewport-three/ViewportCanvas.js new file mode 100644 index 00000000..3138d7ee --- /dev/null +++ b/src/viewport-three/ViewportCanvas.js @@ -0,0 +1,133 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useRef, useState } from "react"; +import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback"; +import { getWhiteboxSelectionModeLabel } from "../core/whitebox-selection-mode"; +import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; +import { getViewportPanelLabel } from "./viewport-layout"; +import { getViewportViewModeLabel } from "./viewport-view-modes"; +import { ViewportHost } from "./viewport-host"; +export function ViewportCanvas({ panelId, world, sceneDocument, projectAssets, loadedModelAssets, loadedImageAssets, whiteboxSelectionMode, whiteboxSnapEnabled, whiteboxSnapStep, selection, toolMode, toolPreview, transformSession, cameraState, viewMode, displayMode, layoutMode, isActivePanel, focusRequestId, focusSelection, onSelectionChange, onCommitCreation, onCameraStateChange, onToolPreviewChange, onTransformSessionChange, onTransformCommit, onTransformCancel }) { + const containerRef = useRef(null); + const hostRef = useRef(null); + const [viewportMessage, setViewportMessage] = useState(null); + const [hoveredWhiteboxLabel, setHoveredWhiteboxLabel] = useState(null); + useEffect(() => { + const container = containerRef.current; + if (container === null) { + return; + } + const testCanvas = document.createElement("canvas"); + const hasWebGl = testCanvas.getContext("webgl2") !== null || + testCanvas.getContext("webgl") !== null || + testCanvas.getContext("experimental-webgl") !== null; + if (!hasWebGl) { + setViewportMessage("WebGL is unavailable in this browser environment. The viewport shell is visible, but rendering is disabled."); + return; + } + try { + const viewportHost = new ViewportHost(); + hostRef.current = viewportHost; + viewportHost.setPanelId(panelId); + viewportHost.mount(container); + setViewportMessage(null); + return () => { + viewportHost.dispose(); + hostRef.current = null; + }; + } + catch (error) { + const message = error instanceof Error ? error.message : "Viewport initialization failed."; + setViewportMessage(`Viewport initialization failed: ${message}`); + return; + } + }, []); + useEffect(() => { + hostRef.current?.setPanelId(panelId); + }, [panelId]); + useEffect(() => { + hostRef.current?.updateWorld(world); + }, [world]); + useEffect(() => { + hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets); + }, [projectAssets, loadedModelAssets, loadedImageAssets]); + useEffect(() => { + hostRef.current?.setWhiteboxSnapSettings(whiteboxSnapEnabled, whiteboxSnapStep); + }, [whiteboxSnapEnabled, whiteboxSnapStep]); + useEffect(() => { + hostRef.current?.setWhiteboxSelectionMode(whiteboxSelectionMode); + }, [whiteboxSelectionMode]); + useEffect(() => { + hostRef.current?.updateDocument(sceneDocument, selection); + }, [sceneDocument, selection]); + useEffect(() => { + hostRef.current?.setViewMode(viewMode); + }, [viewMode]); + useEffect(() => { + hostRef.current?.setDisplayMode(displayMode); + }, [displayMode]); + useEffect(() => { + hostRef.current?.setCameraState(cameraState); + }, [cameraState]); + useEffect(() => { + hostRef.current?.setBrushSelectionChangeHandler(onSelectionChange); + }, [onSelectionChange]); + useEffect(() => { + hostRef.current?.setWhiteboxHoverLabelChangeHandler(setHoveredWhiteboxLabel); + }, []); + useEffect(() => { + hostRef.current?.setCameraStateChangeHandler(onCameraStateChange); + }, [onCameraStateChange]); + useEffect(() => { + hostRef.current?.setCreationPreviewChangeHandler((nextToolPreview) => { + onToolPreviewChange(nextToolPreview.kind === "create" + ? { + ...nextToolPreview, + sourcePanelId: panelId + } + : nextToolPreview); + }); + }, [onToolPreviewChange, panelId]); + useEffect(() => { + hostRef.current?.setCreationCommitHandler(onCommitCreation); + }, [onCommitCreation]); + useEffect(() => { + hostRef.current?.setTransformSessionChangeHandler(onTransformSessionChange); + }, [onTransformSessionChange]); + useEffect(() => { + hostRef.current?.setTransformCommitHandler(onTransformCommit); + }, [onTransformCommit]); + useEffect(() => { + hostRef.current?.setTransformCancelHandler(onTransformCancel); + }, [onTransformCancel]); + useEffect(() => { + hostRef.current?.setToolMode(toolMode); + }, [toolMode]); + useEffect(() => { + hostRef.current?.setCreationPreview(toolMode === "create" && toolPreview.kind === "create" ? toolPreview : null); + }, [toolMode, toolPreview]); + useEffect(() => { + hostRef.current?.setTransformSession(transformSession); + }, [transformSession]); + useEffect(() => { + if (focusRequestId === 0) { + return; + } + hostRef.current?.focusSelection(sceneDocument, focusSelection); + }, [focusRequestId, focusSelection, sceneDocument]); + const previewVisible = toolMode === "create" && toolPreview.kind === "create" && toolPreview.center !== null; + const transformPreviewVisible = transformSession.kind === "active"; + const selectionModeVisible = toolMode === "select"; + const selectedWhiteboxLabel = selectionModeVisible ? getWhiteboxSelectionFeedbackLabel(sceneDocument, selection) : null; + const showViewModeOverlay = layoutMode === "quad"; + const showOverlay = showViewModeOverlay || selectionModeVisible || previewVisible || transformPreviewVisible || selectedWhiteboxLabel !== null || hoveredWhiteboxLabel !== null; + return (_jsxs("div", { ref: containerRef, className: `viewport-canvas viewport-canvas--${toolMode} viewport-canvas--${viewMode} viewport-canvas--${displayMode} viewport-canvas--${layoutMode}`, "data-testid": `viewport-canvas-${panelId}`, "data-active": isActivePanel ? "true" : "false", "aria-label": `${getViewportPanelLabel(panelId)} editor viewport`, style: displayMode !== "normal" + ? { + backgroundColor: "#000000", + backgroundImage: "none" + } + : createWorldBackgroundStyle(world.background, world.background.mode === "image" ? loadedImageAssets[world.background.assetId]?.sourceUrl ?? null : null), children: [!showOverlay ? null : (_jsxs("div", { className: "viewport-canvas__overlay", "data-testid": `viewport-overlay-${panelId}`, children: [!showViewModeOverlay ? null : (_jsxs("div", { className: "viewport-canvas__overlay-badges", children: [_jsx("div", { className: "viewport-canvas__overlay-badge viewport-canvas__overlay-badge--view", children: getViewportViewModeLabel(viewMode) }), !selectionModeVisible ? null : (_jsx("div", { className: "viewport-canvas__overlay-badge viewport-canvas__overlay-badge--selection", "data-testid": `viewport-selection-mode-${panelId}`, children: getWhiteboxSelectionModeLabel(whiteboxSelectionMode) }))] })), showViewModeOverlay || !selectionModeVisible ? null : (_jsx("div", { className: "viewport-canvas__overlay-badges", children: _jsx("div", { className: "viewport-canvas__overlay-badge viewport-canvas__overlay-badge--selection", "data-testid": `viewport-selection-mode-${panelId}`, children: getWhiteboxSelectionModeLabel(whiteboxSelectionMode) }) })), !previewVisible ? null : (_jsxs("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-snap-preview-${panelId}`, children: ["Preview: ", toolPreview.center.x, ", ", toolPreview.center.y, ", ", toolPreview.center.z] })), !transformPreviewVisible ? null : (_jsx("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-transform-preview-${panelId}`, children: transformSession.kind !== "active" + ? null + : `${transformSession.operation}${transformSession.axisConstraint === null ? "" : ` · ${transformSession.axisConstraint.toUpperCase()}`}` })), selectedWhiteboxLabel === null ? null : (_jsxs("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-selected-whitebox-${panelId}`, children: ["Selected: ", selectedWhiteboxLabel] })), hoveredWhiteboxLabel === null ? null : (_jsxs("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-hovered-whitebox-${panelId}`, children: ["Hover: ", hoveredWhiteboxLabel] }))] })), viewportMessage === null ? null : (_jsxs("div", { className: "viewport-canvas__fallback", role: "status", children: [_jsx("div", { className: "viewport-canvas__fallback-title", children: "Viewport Unavailable" }), _jsx("div", { children: viewportMessage }), toolMode !== "create" || toolPreview.kind !== "create" ? null : (_jsx("button", { className: "toolbar__button toolbar__button--accent", type: "button", "data-testid": `viewport-fallback-create-${panelId}`, onClick: () => { + onCommitCreation(toolPreview); + }, children: "Commit Creation Preview" }))] }))] })); +} diff --git a/src/viewport-three/ViewportPanel.js b/src/viewport-three/ViewportPanel.js new file mode 100644 index 00000000..00318940 --- /dev/null +++ b/src/viewport-three/ViewportPanel.js @@ -0,0 +1,9 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { ViewportCanvas } from "./ViewportCanvas"; +import { getViewportDisplayModeLabel, getViewportPanelLabel } from "./viewport-layout"; +import { VIEWPORT_VIEW_MODES, getViewportViewModeLabel } from "./viewport-view-modes"; +export function ViewportPanel({ panelId, panelState, layoutMode, isActive, className, style, world, sceneDocument, projectAssets, loadedModelAssets, loadedImageAssets, whiteboxSelectionMode, whiteboxSnapEnabled, whiteboxSnapStep, selection, toolMode, toolPreview, transformSession, cameraState, focusRequestId, focusSelection, onActivatePanel, onSetPanelViewMode, onSetPanelDisplayMode, onCommitCreation, onCameraStateChange, onToolPreviewChange, onTransformSessionChange, onTransformCommit, onTransformCancel, onSelectionChange }) { + const shouldShow = layoutMode === "quad" || isActive; + const panelStyle = shouldShow ? style : { ...(style ?? {}), display: "none" }; + return (_jsxs("section", { className: `viewport-panel ${layoutMode === "single" ? "viewport-panel--single" : "viewport-panel--quad"} ${className ?? ""}`.trim(), "data-testid": `viewport-panel-${panelId}`, "data-active": isActive ? "true" : "false", "data-viewport-panel-id": panelId, "aria-hidden": shouldShow ? undefined : true, "aria-label": `${getViewportPanelLabel(panelId)} viewport panel`, style: panelStyle, onPointerDownCapture: () => onActivatePanel(panelId), onFocusCapture: () => onActivatePanel(panelId), children: [_jsxs("div", { className: "viewport-panel__header", children: [layoutMode !== "quad" ? null : (_jsx("div", { className: "viewport-panel__meta", children: _jsxs("div", { className: "viewport-panel__title-row", children: [_jsx("div", { className: "viewport-panel__title", children: getViewportPanelLabel(panelId) }), !isActive ? null : (_jsx("div", { className: "viewport-panel__active-badge", "data-testid": `viewport-panel-active-badge-${panelId}`, children: "Active" }))] }) })), _jsxs("div", { className: "viewport-panel__controls", children: [_jsx("div", { className: "viewport-panel__control-group", role: "group", "aria-label": `${getViewportPanelLabel(panelId)} view mode`, children: VIEWPORT_VIEW_MODES.map((viewMode) => (_jsx("button", { className: `viewport-panel__button ${panelState.viewMode === viewMode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": `viewport-panel-${panelId}-view-${viewMode}`, "aria-pressed": panelState.viewMode === viewMode, onClick: () => onSetPanelViewMode(panelId, viewMode), children: getViewportViewModeLabel(viewMode) }, viewMode))) }), _jsx("div", { className: "viewport-panel__control-group", role: "group", "aria-label": `${getViewportPanelLabel(panelId)} display mode`, children: ["normal", "authoring", "wireframe"].map((displayMode) => (_jsx("button", { className: `viewport-panel__button ${panelState.displayMode === displayMode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": `viewport-panel-${panelId}-display-${displayMode}`, "aria-pressed": panelState.displayMode === displayMode, onClick: () => onSetPanelDisplayMode(panelId, displayMode), children: getViewportDisplayModeLabel(displayMode) }, displayMode))) })] })] }), _jsx(ViewportCanvas, { panelId: panelId, world: world, sceneDocument: sceneDocument, projectAssets: projectAssets, loadedModelAssets: loadedModelAssets, loadedImageAssets: loadedImageAssets, whiteboxSelectionMode: whiteboxSelectionMode, whiteboxSnapEnabled: whiteboxSnapEnabled, whiteboxSnapStep: whiteboxSnapStep, selection: selection, toolMode: toolMode, toolPreview: toolPreview, transformSession: transformSession, cameraState: cameraState, viewMode: panelState.viewMode, displayMode: panelState.displayMode, layoutMode: layoutMode, isActivePanel: isActive, focusRequestId: focusRequestId, focusSelection: focusSelection, onSelectionChange: onSelectionChange, onCommitCreation: onCommitCreation, onCameraStateChange: onCameraStateChange, onToolPreviewChange: onToolPreviewChange, onTransformSessionChange: onTransformSessionChange, onTransformCommit: onTransformCommit, onTransformCancel: onTransformCancel })] })); +} diff --git a/src/viewport-three/viewport-entity-markers.js b/src/viewport-three/viewport-entity-markers.js new file mode 100644 index 00000000..b83cc63b --- /dev/null +++ b/src/viewport-three/viewport-entity-markers.js @@ -0,0 +1,36 @@ +import { BoxGeometry, CylinderGeometry, Mesh, MeshStandardMaterial, TorusGeometry } from "three"; +const SOUND_EMITTER_CABINET_SIZE = { + x: 0.38, + y: 0.5, + z: 0.18 +}; +const SOUND_EMITTER_FRONT_OFFSET = 0.1; +const SOUND_EMITTER_TWEETER_RADIUS = 0.045; +const SOUND_EMITTER_TWEETER_Y = 0.15; +const SOUND_EMITTER_WOOFER_RADIUS = 0.11; +const SOUND_EMITTER_WOOFER_Y = -0.08; +function createSpeakerMaterial(color, selected, emissiveIntensity, roughness, metalness) { + return new MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: selected ? emissiveIntensity : emissiveIntensity * 0.35, + roughness, + metalness + }); +} +export function createSoundEmitterMarkerMeshes(markerColor, selected) { + const cabinet = new Mesh(new BoxGeometry(SOUND_EMITTER_CABINET_SIZE.x, SOUND_EMITTER_CABINET_SIZE.y, SOUND_EMITTER_CABINET_SIZE.z), createSpeakerMaterial(0x23272e, selected, 0.08, 0.88, 0.02)); + const tweeterRing = new Mesh(new TorusGeometry(SOUND_EMITTER_TWEETER_RADIUS, 0.012, 8, 18), createSpeakerMaterial(markerColor, selected, 0.22, 0.4, 0.04)); + tweeterRing.rotation.x = Math.PI * 0.5; + tweeterRing.position.set(0, SOUND_EMITTER_TWEETER_Y, SOUND_EMITTER_FRONT_OFFSET); + const tweeterCone = new Mesh(new CylinderGeometry(SOUND_EMITTER_TWEETER_RADIUS * 0.58, SOUND_EMITTER_TWEETER_RADIUS * 0.58, 0.028, 18), createSpeakerMaterial(0x14171c, selected, 0.08, 0.68, 0.01)); + tweeterCone.rotation.x = Math.PI * 0.5; + tweeterCone.position.set(0, SOUND_EMITTER_TWEETER_Y, SOUND_EMITTER_FRONT_OFFSET + 0.006); + const wooferRing = new Mesh(new TorusGeometry(SOUND_EMITTER_WOOFER_RADIUS, 0.016, 8, 20), createSpeakerMaterial(markerColor, selected, 0.22, 0.42, 0.04)); + wooferRing.rotation.x = Math.PI * 0.5; + wooferRing.position.set(0, SOUND_EMITTER_WOOFER_Y, SOUND_EMITTER_FRONT_OFFSET); + const wooferCone = new Mesh(new CylinderGeometry(SOUND_EMITTER_WOOFER_RADIUS * 0.6, SOUND_EMITTER_WOOFER_RADIUS * 0.6, 0.032, 20), createSpeakerMaterial(0x14171c, selected, 0.08, 0.72, 0.01)); + wooferCone.rotation.x = Math.PI * 0.5; + wooferCone.position.set(0, SOUND_EMITTER_WOOFER_Y, SOUND_EMITTER_FRONT_OFFSET + 0.007); + return [cabinet, tweeterRing, tweeterCone, wooferRing, wooferCone]; +} diff --git a/src/viewport-three/viewport-focus.js b/src/viewport-three/viewport-focus.js new file mode 100644 index 00000000..4acf1739 --- /dev/null +++ b/src/viewport-three/viewport-focus.js @@ -0,0 +1,294 @@ +import { getSingleSelectedBrushId, getSingleSelectedEntityId, getSingleSelectedModelInstanceId } from "../core/selection"; +import { getBoxBrushBounds } from "../geometry/box-brush"; +const PLAYER_START_FOCUS_HALF_EXTENTS = { + x: 0.35, + y: 0.3, + z: 0.55 +}; +const TELEPORT_TARGET_FOCUS_HALF_EXTENTS = { + x: 0.42, + y: 0.28, + z: 0.42 +}; +function createEmptyBoundsAccumulator() { + return { + min: { + x: Number.POSITIVE_INFINITY, + y: Number.POSITIVE_INFINITY, + z: Number.POSITIVE_INFINITY + }, + max: { + x: Number.NEGATIVE_INFINITY, + y: Number.NEGATIVE_INFINITY, + z: Number.NEGATIVE_INFINITY + } + }; +} +function includeBounds(bounds, min, max) { + bounds.min.x = Math.min(bounds.min.x, min.x); + bounds.min.y = Math.min(bounds.min.y, min.y); + bounds.min.z = Math.min(bounds.min.z, min.z); + bounds.max.x = Math.max(bounds.max.x, max.x); + bounds.max.y = Math.max(bounds.max.y, max.y); + bounds.max.z = Math.max(bounds.max.z, max.z); +} +function finishBounds(bounds) { + if (!Number.isFinite(bounds.min.x) || !Number.isFinite(bounds.max.x)) { + return null; + } + const center = { + x: (bounds.min.x + bounds.max.x) * 0.5, + y: (bounds.min.y + bounds.max.y) * 0.5, + z: (bounds.min.z + bounds.max.z) * 0.5 + }; + const radius = Math.max(0.5, Math.hypot(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z) * 0.5); + return { + center, + radius + }; +} +function createBrushFocusTarget(brush) { + return { + center: { + ...brush.center + }, + radius: Math.max(0.5, Math.hypot(brush.size.x, brush.size.y, brush.size.z) * 0.5) + }; +} +function includeBrush(bounds, brush) { + const brushBounds = getBoxBrushBounds(brush); + includeBounds(bounds, brushBounds.min, brushBounds.max); +} +function includePlayerStart(bounds, position) { + includeBounds(bounds, { + x: position.x - PLAYER_START_FOCUS_HALF_EXTENTS.x, + y: position.y, + z: position.z - PLAYER_START_FOCUS_HALF_EXTENTS.z + }, { + x: position.x + PLAYER_START_FOCUS_HALF_EXTENTS.x, + y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y * 2, + z: position.z + PLAYER_START_FOCUS_HALF_EXTENTS.z + }); +} +function createBoundsFocusTarget(center, halfExtents, minimumRadius) { + return { + center, + radius: Math.max(minimumRadius, Math.hypot(halfExtents.x, halfExtents.y, halfExtents.z)) + }; +} +function createPlayerStartFocusTarget(position) { + return createBoundsFocusTarget({ + x: position.x, + y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y, + z: position.z + }, PLAYER_START_FOCUS_HALF_EXTENTS, 0.45); +} +function includeTeleportTarget(bounds, position) { + includeBounds(bounds, { + x: position.x - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x, + y: position.y, + z: position.z - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z + }, { + x: position.x + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x, + y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y * 2, + z: position.z + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z + }); +} +function createTeleportTargetFocusTarget(position) { + return createBoundsFocusTarget({ + x: position.x, + y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y, + z: position.z + }, TELEPORT_TARGET_FOCUS_HALF_EXTENTS, 0.45); +} +function getModelInstanceBoundingBox(modelInstance, asset) { + if (asset?.kind === "model" && asset.metadata.boundingBox !== null) { + const boundingBox = asset.metadata.boundingBox; + const scaledMin = { + x: boundingBox.min.x * modelInstance.scale.x, + y: boundingBox.min.y * modelInstance.scale.y, + z: boundingBox.min.z * modelInstance.scale.z + }; + const scaledMax = { + x: boundingBox.max.x * modelInstance.scale.x, + y: boundingBox.max.y * modelInstance.scale.y, + z: boundingBox.max.z * modelInstance.scale.z + }; + return { + center: { + x: modelInstance.position.x + (scaledMin.x + scaledMax.x) * 0.5, + y: modelInstance.position.y + (scaledMin.y + scaledMax.y) * 0.5, + z: modelInstance.position.z + (scaledMin.z + scaledMax.z) * 0.5 + }, + size: { + x: Math.abs(scaledMax.x - scaledMin.x), + y: Math.abs(scaledMax.y - scaledMin.y), + z: Math.abs(scaledMax.z - scaledMin.z) + } + }; + } + return { + center: { + ...modelInstance.position + }, + size: { + x: modelInstance.scale.x, + y: modelInstance.scale.y, + z: modelInstance.scale.z + } + }; +} +function includeModelInstance(bounds, modelInstance, asset) { + const modelBounds = getModelInstanceBoundingBox(modelInstance, asset); + const halfSize = { + x: modelBounds.size.x * 0.5, + y: modelBounds.size.y * 0.5, + z: modelBounds.size.z * 0.5 + }; + includeBounds(bounds, { + x: modelBounds.center.x - halfSize.x, + y: modelBounds.center.y - halfSize.y, + z: modelBounds.center.z - halfSize.z + }, { + x: modelBounds.center.x + halfSize.x, + y: modelBounds.center.y + halfSize.y, + z: modelBounds.center.z + halfSize.z + }); +} +function createModelInstanceFocusTarget(modelInstance, asset) { + const modelBounds = getModelInstanceBoundingBox(modelInstance, asset); + return createBoundsFocusTarget(modelBounds.center, { + x: modelBounds.size.x * 0.5, + y: modelBounds.size.y * 0.5, + z: modelBounds.size.z * 0.5 + }, 0.5); +} +function includeSphereEntity(bounds, position, radius) { + includeBounds(bounds, { + x: position.x - radius, + y: position.y - radius, + z: position.z - radius + }, { + x: position.x + radius, + y: position.y + radius, + z: position.z + radius + }); +} +function createSphereEntityFocusTarget(position, radius, minimumRadius) { + return { + center: { + x: position.x, + y: position.y, + z: position.z + }, + radius: Math.max(minimumRadius, radius) + }; +} +function includeTriggerVolume(bounds, position, size) { + const halfSize = { + x: size.x * 0.5, + y: size.y * 0.5, + z: size.z * 0.5 + }; + includeBounds(bounds, { + x: position.x - halfSize.x, + y: position.y - halfSize.y, + z: position.z - halfSize.z + }, { + x: position.x + halfSize.x, + y: position.y + halfSize.y, + z: position.z + halfSize.z + }); +} +function createTriggerVolumeFocusTarget(position, size) { + const halfSize = { + x: size.x * 0.5, + y: size.y * 0.5, + z: size.z * 0.5 + }; + return createBoundsFocusTarget({ + x: position.x, + y: position.y, + z: position.z + }, halfSize, 0.75); +} +function includeEntity(bounds, entity) { + switch (entity.kind) { + case "pointLight": + includeSphereEntity(bounds, entity.position, Math.max(0.5, entity.distance)); + break; + case "spotLight": + includeSphereEntity(bounds, entity.position, Math.max(0.75, entity.distance)); + break; + case "playerStart": + includePlayerStart(bounds, entity.position); + break; + case "soundEmitter": + includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.maxDistance)); + break; + case "triggerVolume": + includeTriggerVolume(bounds, entity.position, entity.size); + break; + case "teleportTarget": + includeTeleportTarget(bounds, entity.position); + break; + case "interactable": + includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.radius)); + break; + } +} +function createEntityFocusTarget(entity) { + switch (entity.kind) { + case "pointLight": + return createSphereEntityFocusTarget(entity.position, Math.max(0.6, entity.distance), 0.75); + case "spotLight": + return createSphereEntityFocusTarget(entity.position, Math.max(0.8, entity.distance), 0.9); + case "playerStart": + return createPlayerStartFocusTarget(entity.position); + case "soundEmitter": + return createSphereEntityFocusTarget(entity.position, entity.maxDistance, 0.75); + case "triggerVolume": + return createTriggerVolumeFocusTarget(entity.position, entity.size); + case "teleportTarget": + return createTeleportTargetFocusTarget(entity.position); + case "interactable": + return createSphereEntityFocusTarget(entity.position, entity.radius, 0.65); + } +} +function getSceneFocusTarget(document) { + const bounds = createEmptyBoundsAccumulator(); + for (const brush of Object.values(document.brushes)) { + includeBrush(bounds, brush); + } + for (const modelInstance of Object.values(document.modelInstances)) { + includeModelInstance(bounds, modelInstance, document.assets[modelInstance.assetId]); + } + for (const entity of Object.values(document.entities)) { + includeEntity(bounds, entity); + } + return finishBounds(bounds); +} +export function resolveViewportFocusTarget(document, selection) { + const selectedBrushId = getSingleSelectedBrushId(selection); + if (selectedBrushId !== null) { + const brush = document.brushes[selectedBrushId]; + if (brush !== undefined && brush.kind === "box") { + return createBrushFocusTarget(brush); + } + } + const selectedEntityId = getSingleSelectedEntityId(selection); + if (selectedEntityId !== null) { + const entity = document.entities[selectedEntityId]; + if (entity !== undefined) { + return createEntityFocusTarget(entity); + } + } + const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection); + if (selectedModelInstanceId !== null) { + const modelInstance = document.modelInstances[selectedModelInstanceId]; + if (modelInstance !== undefined) { + return createModelInstanceFocusTarget(modelInstance, document.assets[modelInstance.assetId]); + } + } + return getSceneFocusTarget(document); +} diff --git a/src/viewport-three/viewport-host.js b/src/viewport-three/viewport-host.js new file mode 100644 index 00000000..898ca01f --- /dev/null +++ b/src/viewport-three/viewport-host.js @@ -0,0 +1,2950 @@ +import { AmbientLight, AxesHelper, BufferGeometry, BoxGeometry, CanvasTexture, CapsuleGeometry, ConeGeometry, CylinderGeometry, DirectionalLight, EdgesGeometry, GridHelper, Group, Line, LineBasicMaterial, LineSegments, Material, Mesh, MeshBasicMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, Plane, PerspectiveCamera, PointLight, Quaternion, Raycaster, Scene, SphereGeometry, Spherical, TorusGeometry, SpotLight, Vector2, Vector3, WebGLRenderer } from "three"; +import { EffectComposer } from "postprocessing"; +import { areEditorSelectionsEqual, isBrushEdgeSelected, isBrushFaceSelected, isBrushSelected, isBrushVertexSelected, isModelInstanceSelected } from "../core/selection"; +import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback"; +import { cloneTransformSession, createInactiveTransformSession, createTransformPreviewFromTarget, createTransformSession, resolveTransformTarget, supportsTransformOperation, supportsTransformAxisConstraint } from "../core/transform-session"; +import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; +import { createModelInstance, createModelInstancePlacementPosition, DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, DEFAULT_MODEL_INSTANCE_SCALE, getModelInstances } from "../assets/model-instances"; +import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings"; +import { DEFAULT_INTERACTABLE_RADIUS, DEFAULT_PLAYER_START_BOX_SIZE, DEFAULT_PLAYER_START_CAPSULE_HEIGHT, DEFAULT_PLAYER_START_CAPSULE_RADIUS, DEFAULT_PLAYER_START_EYE_HEIGHT, DEFAULT_PLAYER_START_YAW_DEGREES, DEFAULT_POINT_LIGHT_DISTANCE, DEFAULT_SOUND_EMITTER_MAX_DISTANCE, DEFAULT_SOUND_EMITTER_REF_DISTANCE, DEFAULT_SPOT_LIGHT_ANGLE_DEGREES, DEFAULT_SPOT_LIGHT_DIRECTION, DEFAULT_SPOT_LIGHT_DISTANCE, DEFAULT_TELEPORT_TARGET_YAW_DEGREES, DEFAULT_TRIGGER_VOLUME_SIZE, getPlayerStartColliderHeight, getEntityInstances, normalizeYawDegrees } from "../entities/entity-instances"; +import { BOX_EDGE_IDS, BOX_FACE_IDS, BOX_VERTEX_IDS, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes"; +import { getBoxBrushEdgeAxis, getBoxBrushEdgeTransformMeta, getBoxBrushEdgeWorldSegment, getBoxBrushFaceAxis, getBoxBrushFaceTransformMeta, getBoxBrushFaceWorldCenter, getBoxBrushVertexSigns, getBoxBrushVertexWorldPosition, transformBoxBrushLocalPointToWorld, transformBoxBrushWorldVectorToLocal } from "../geometry/box-brush-components"; +import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs"; +import { createModelColliderDebugGroup } from "../geometry/model-instance-collider-debug-mesh"; +import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation"; +import { DEFAULT_GRID_SIZE, snapValueToGrid } from "../geometry/grid-snapping"; +import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; +import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer } from "../rendering/advanced-rendering"; +import { resolveViewportFocusTarget } from "./viewport-focus"; +import { createSoundEmitterMarkerMeshes } from "./viewport-entity-markers"; +import { getViewportViewModeDefinition, isOrthographicViewportViewMode } from "./viewport-view-modes"; +import { areViewportPanelCameraStatesEqual } from "./viewport-layout"; +import { areViewportToolPreviewsEqual } from "./viewport-transient-state"; +const BRUSH_SELECTED_EDGE_COLOR = 0xf7d2aa; +const BRUSH_HOVERED_EDGE_COLOR = 0xb7cbec; +const BRUSH_EDGE_COLOR = 0x0d1017; +const FALLBACK_FACE_COLOR = 0x747d89; +const HOVERED_FACE_FALLBACK_COLOR = 0xd9a56f; +const SELECTED_FACE_FALLBACK_COLOR = 0xcf7b42; +const HOVERED_FACE_EMISSIVE = 0x2f1d11; +const SELECTED_FACE_EMISSIVE = 0x4a2814; +const WHITEBOX_COMPONENT_COLOR = 0xb7cbec; +const WHITEBOX_COMPONENT_HOVERED_COLOR = 0xf3be8f; +const WHITEBOX_COMPONENT_SELECTED_COLOR = 0xcf7b42; +const WHITEBOX_COMPONENT_DEFAULT_OPACITY = 0.42; +const WHITEBOX_COMPONENT_HOVERED_OPACITY = 0.94; +const WHITEBOX_COMPONENT_SELECTED_OPACITY = 1; +const WHITEBOX_VERTEX_RADIUS = 0.08; +const WHITEBOX_EDGE_PICK_THRESHOLD = 0.16; +const PLAYER_START_COLOR = 0x7cb7ff; +const PLAYER_START_SELECTED_COLOR = 0xf3be8f; +const SOUND_EMITTER_COLOR = 0x72d7c9; +const SOUND_EMITTER_SELECTED_COLOR = 0xf4d37d; +const TRIGGER_VOLUME_COLOR = 0x9f8cff; +const TRIGGER_VOLUME_SELECTED_COLOR = 0xf0b07f; +const TELEPORT_TARGET_COLOR = 0x7ee0ff; +const TELEPORT_TARGET_SELECTED_COLOR = 0xf6c48a; +const INTERACTABLE_COLOR = 0x92de7e; +const INTERACTABLE_SELECTED_COLOR = 0xf1cf7e; +const BOX_CREATE_PREVIEW_FILL = 0x89b6ff; +const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f; +const PLACEMENT_PREVIEW_COLOR_HEX = "#89b6ff"; +const MIN_CAMERA_DISTANCE = 1.5; +const MAX_CAMERA_DISTANCE = 400; +const ORBIT_ROTATION_SPEED = 0.0085; +const ZOOM_SPEED = 0.0014; +const MIN_POLAR_ANGLE = 0.12; +const MAX_POLAR_ANGLE = Math.PI - 0.12; +const FOCUS_MARGIN = 1.35; +const ORTHOGRAPHIC_CAMERA_DISTANCE = 100; +const ORTHOGRAPHIC_FRUSTUM_HEIGHT = 20; +const MIN_ORTHOGRAPHIC_ZOOM = 0.25; +const MAX_ORTHOGRAPHIC_ZOOM = 20; +const GIZMO_AXIS_COLORS = { + x: 0xea655b, + y: 0x6ed06f, + z: 0x55a2ff +}; +const GIZMO_ACTIVE_COLOR = 0xf7d2aa; +const GIZMO_INACTIVE_OPACITY = 0.82; +const GIZMO_ACTIVE_OPACITY = 1; +const GIZMO_TRANSLATE_LENGTH = 1.2; +const GIZMO_SCALE_LENGTH = 1; +const GIZMO_ROTATE_RADIUS = 1.05; +const GIZMO_ROTATE_TUBE = 0.035; +const GIZMO_PICK_THICKNESS = 0.18; +const GIZMO_PICK_RING_TUBE = 0.14; +const GIZMO_CENTER_HANDLE_SIZE = 0.16; +const GIZMO_SCREEN_SIZE_PERSPECTIVE = 0.11; +const GIZMO_SCREEN_SIZE_ORTHOGRAPHIC = 1.4; +const GIZMO_RENDER_ORDER = 4_000; +const SCALE_SNAP_STEP = 0.1; +const MIN_SCALE_COMPONENT = 0.1; +const MIN_BOX_SIZE_COMPONENT = 0.01; +export class ViewportHost { + scene = new Scene(); + perspectiveCamera = new PerspectiveCamera(60, 1, 0.1, 1000); + orthographicCamera = new OrthographicCamera(-10, 10, 10, -10, 0.1, 1000); + renderer = new WebGLRenderer({ antialias: true, alpha: true }); + cameraTarget = new Vector3(0, 0, 0); + cameraOffset = new Vector3(); + cameraForward = new Vector3(); + cameraRight = new Vector3(); + cameraUp = new Vector3(); + cameraSpherical = new Spherical(); + gridHelpers = { + xz: new GridHelper(40, 40, 0xcf8354, 0x4e596b), + xy: new GridHelper(40, 40, 0xcf8354, 0x4e596b), + yz: new GridHelper(40, 40, 0xcf8354, 0x4e596b) + }; + ambientLight = new AmbientLight(); + sunLight = new DirectionalLight(); + localLightGroup = new Group(); + brushGroup = new Group(); + entityGroup = new Group(); + modelGroup = new Group(); + raycaster = new Raycaster(); + pointer = new Vector2(); + boxCreateIntersection = new Vector3(); + boxCreatePlane = new Plane(new Vector3(0, 1, 0), 0); + transformPlane = new Plane(new Vector3(0, 1, 0), 0); + transformIntersection = new Vector3(); + transformGizmoGroup = new Group(); + brushRenderObjects = new Map(); + entityRenderObjects = new Map(); + localLightRenderObjects = new Map(); + modelRenderObjects = new Map(); + materialTextureCache = new Map(); + currentDocument = null; + currentWorld = null; + currentAdvancedRenderingSettings = null; + advancedRenderingComposer = null; + currentSelection = { + kind: "none" + }; + hoveredSelection = { + kind: "none" + }; + whiteboxSelectionMode = "object"; + whiteboxSnapEnabled = true; + whiteboxSnapStep = DEFAULT_GRID_SIZE; + projectAssets = {}; + loadedModelAssets = {}; + loadedImageAssets = {}; + boxCreatePreviewMesh = new Mesh(new BoxGeometry(DEFAULT_BOX_BRUSH_SIZE.x, DEFAULT_BOX_BRUSH_SIZE.y, DEFAULT_BOX_BRUSH_SIZE.z), new MeshStandardMaterial({ + color: BOX_CREATE_PREVIEW_FILL, + emissive: BOX_CREATE_PREVIEW_FILL, + emissiveIntensity: 0.12, + roughness: 0.68, + metalness: 0.02, + transparent: true, + opacity: 0.22 + })); + boxCreatePreviewEdges = new LineSegments(new EdgesGeometry(this.boxCreatePreviewMesh.geometry), new LineBasicMaterial({ + color: BOX_CREATE_PREVIEW_EDGE + })); + resizeObserver = null; + animationFrame = 0; + container = null; + brushSelectionChangeHandler = null; + whiteboxHoverLabelChangeHandler = null; + creationPreviewChangeHandler = null; + creationCommitHandler = null; + cameraStateChangeHandler = null; + transformSessionChangeHandler = null; + transformCommitHandler = null; + transformCancelHandler = null; + toolMode = "select"; + viewMode = "perspective"; + displayMode = "normal"; + panelId = "topLeft"; + creationPreview = null; + creationPreviewTargetKey = null; + creationPreviewObject = null; + currentTransformSession = createInactiveTransformSession(); + activeCameraDragPointerId = null; + lastCameraDragClientPosition = null; + activeTransformDrag = null; + lastCanvasPointerPosition = null; + keyboardTransformPointerOrigin = null; + // Click-through cycling: track the last click position and the last picked object + // so repeated clicks at the same spot cycle through overlapping objects. + lastClickPointer = null; + lastClickSelectionKey = null; + constructor() { + this.perspectiveCamera.position.set(10, 9, 10); + this.perspectiveCamera.lookAt(this.cameraTarget); + this.updatePerspectiveCameraSphericalFromPose(); + this.updateOrthographicCameraFrustum(); + const axesHelper = new AxesHelper(2); + this.gridHelpers.xy.rotation.x = Math.PI * 0.5; + this.gridHelpers.yz.rotation.z = Math.PI * 0.5; + this.gridHelpers.xz.visible = true; + this.gridHelpers.xy.visible = false; + this.gridHelpers.yz.visible = false; + this.scene.add(this.gridHelpers.xz); + this.scene.add(this.gridHelpers.xy); + this.scene.add(this.gridHelpers.yz); + this.scene.add(axesHelper); + this.scene.add(this.ambientLight); + this.scene.add(this.sunLight); + this.scene.add(this.localLightGroup); + this.scene.add(this.brushGroup); + this.scene.add(this.entityGroup); + this.scene.add(this.modelGroup); + this.transformGizmoGroup.visible = false; + this.scene.add(this.transformGizmoGroup); + this.boxCreatePreviewMesh.visible = false; + this.boxCreatePreviewEdges.visible = false; + this.scene.add(this.boxCreatePreviewMesh); + this.scene.add(this.boxCreatePreviewEdges); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearAlpha(0); + this.applyViewModePose(); + } + setPanelId(panelId) { + this.panelId = panelId; + } + mount(container) { + this.container = container; + this.renderer.domElement.tabIndex = -1; + container.appendChild(this.renderer.domElement); + this.renderer.domElement.addEventListener("pointerdown", this.handlePointerDown); + this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove); + this.renderer.domElement.addEventListener("pointerup", this.handlePointerUp); + this.renderer.domElement.addEventListener("pointercancel", this.handlePointerUp); + this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave); + this.renderer.domElement.addEventListener("wheel", this.handleWheel, { passive: false }); + this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick); + this.renderer.domElement.addEventListener("contextmenu", this.handleContextMenu); + window.addEventListener("pointermove", this.handleWindowPointerMove); + this.resize(); + this.resizeObserver = new ResizeObserver(() => { + this.resize(); + }); + this.resizeObserver.observe(container); + this.render(); + } + updateWorld(world) { + this.currentWorld = world; + this.applyWorld(); + } + updateDocument(document, selection) { + this.currentDocument = document; + this.currentSelection = selection; + this.setHoveredSelection({ + kind: "none" + }); + this.rebuildLocalLights(document); + this.rebuildBrushMeshes(document, selection); + this.rebuildEntityMarkers(document, selection); + this.rebuildModelInstances(document, selection); + this.applyTransformPreview(); + this.syncTransformGizmo(); + } + updateAssets(projectAssets, loadedModelAssets, loadedImageAssets) { + this.projectAssets = projectAssets; + this.loadedModelAssets = loadedModelAssets; + this.loadedImageAssets = loadedImageAssets; + if (this.currentWorld !== null) { + this.applyWorld(); + } + if (this.currentDocument !== null) { + this.rebuildModelInstances(this.currentDocument, this.currentSelection); + this.applyTransformPreview(); + this.syncTransformGizmo(); + } + if (this.creationPreview?.target.kind === "model-instance") { + const currentPreview = this.creationPreview; + this.creationPreview = null; + this.clearCreationPreviewObject(); + this.syncCreationPreview(currentPreview); + } + } + setBrushSelectionChangeHandler(handler) { + this.brushSelectionChangeHandler = handler; + } + setWhiteboxHoverLabelChangeHandler(handler) { + this.whiteboxHoverLabelChangeHandler = handler; + this.emitWhiteboxHoverLabelChange(); + } + setCreationPreviewChangeHandler(handler) { + this.creationPreviewChangeHandler = handler; + } + setCreationCommitHandler(handler) { + this.creationCommitHandler = handler; + } + setCameraStateChangeHandler(handler) { + this.cameraStateChangeHandler = handler; + } + setTransformSessionChangeHandler(handler) { + this.transformSessionChangeHandler = handler; + } + setTransformCommitHandler(handler) { + this.transformCommitHandler = handler; + } + setTransformCancelHandler(handler) { + this.transformCancelHandler = handler; + } + setCameraState(cameraState) { + if (areViewportPanelCameraStatesEqual(this.createCameraStateSnapshot(), cameraState)) { + return; + } + this.cameraTarget.set(cameraState.target.x, cameraState.target.y, cameraState.target.z); + this.cameraSpherical.radius = cameraState.perspectiveOrbit.radius; + this.cameraSpherical.theta = cameraState.perspectiveOrbit.theta; + this.cameraSpherical.phi = cameraState.perspectiveOrbit.phi; + this.orthographicCamera.zoom = cameraState.orthographicZoom; + this.applyViewModePose(); + } + setCreationPreview(toolPreview) { + this.syncCreationPreview(toolPreview); + } + setWhiteboxSnapSettings(enabled, step) { + this.whiteboxSnapEnabled = enabled; + this.whiteboxSnapStep = step; + if (this.creationPreview !== null) { + this.syncCreationPreview(this.creationPreview); + } + this.applyTransformPreview(); + } + setWhiteboxSelectionMode(mode) { + if (this.whiteboxSelectionMode === mode) { + return; + } + this.whiteboxSelectionMode = mode; + this.lastClickPointer = null; + this.lastClickSelectionKey = null; + this.setHoveredSelection({ + kind: "none" + }); + this.refreshBrushPresentation(); + this.syncTransformGizmo(); + } + setTransformSession(transformSession) { + this.currentTransformSession = cloneTransformSession(transformSession); + if (this.currentTransformSession.kind === "none") { + this.activeTransformDrag = null; + this.keyboardTransformPointerOrigin = null; + } + else if (this.currentTransformSession.sourcePanelId === this.panelId && + this.currentTransformSession.source !== "gizmo" && + (this.keyboardTransformPointerOrigin === null || this.keyboardTransformPointerOrigin.sessionId !== this.currentTransformSession.id)) { + const pointerOrigin = this.getPointerOriginForTransformSession(); + this.keyboardTransformPointerOrigin = { + sessionId: this.currentTransformSession.id, + clientX: pointerOrigin.x, + clientY: pointerOrigin.y + }; + } + this.applyTransformPreview(); + this.syncTransformGizmo(); + } + setToolMode(toolMode) { + this.toolMode = toolMode; + this.lastClickPointer = null; + this.lastClickSelectionKey = null; + this.setHoveredSelection({ + kind: "none" + }); + if (toolMode !== "create") { + this.syncCreationPreview(null); + } + } + setViewMode(viewMode) { + if (this.viewMode === viewMode) { + return; + } + this.viewMode = viewMode; + this.lastClickPointer = null; + this.lastClickSelectionKey = null; + this.setHoveredSelection({ + kind: "none" + }); + this.applyViewModePose(); + if (this.currentAdvancedRenderingSettings !== null) { + this.syncAdvancedRenderingComposer(this.currentAdvancedRenderingSettings); + } + } + setDisplayMode(displayMode) { + if (this.displayMode === displayMode) { + return; + } + this.displayMode = displayMode; + this.applyWorld(); + if (this.currentDocument !== null) { + this.updateDocument(this.currentDocument, this.currentSelection); + } + } + focusSelection(document, selection) { + const focusTarget = resolveViewportFocusTarget(document, selection); + if (focusTarget === null) { + return; + } + this.cameraTarget.set(focusTarget.center.x, focusTarget.center.y, focusTarget.center.z); + if (this.viewMode === "perspective") { + const verticalHalfFov = (this.perspectiveCamera.fov * Math.PI) / 360; + const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(this.perspectiveCamera.aspect, 0.0001)); + const fitAngle = Math.max(0.1, Math.min(verticalHalfFov, horizontalHalfFov)); + const fitDistance = Math.min(MAX_CAMERA_DISTANCE, Math.max(MIN_CAMERA_DISTANCE, (focusTarget.radius / Math.sin(fitAngle)) * FOCUS_MARGIN)); + this.cameraSpherical.radius = fitDistance; + this.cameraSpherical.makeSafe(); + this.applyPerspectiveCameraPose(); + this.emitCameraStateChange(); + return; + } + const containerWidth = Math.max(1, this.container?.clientWidth ?? 1); + const containerHeight = Math.max(1, this.container?.clientHeight ?? 1); + const aspect = containerWidth / containerHeight; + const visibleWidth = ORTHOGRAPHIC_FRUSTUM_HEIGHT * aspect; + const fitSize = Math.max(0.5, focusTarget.radius * 2 * FOCUS_MARGIN); + const fitZoom = Math.min(visibleWidth, ORTHOGRAPHIC_FRUSTUM_HEIGHT) / fitSize; + this.orthographicCamera.zoom = Math.min(MAX_ORTHOGRAPHIC_ZOOM, Math.max(MIN_ORTHOGRAPHIC_ZOOM, fitZoom)); + this.applyOrthographicCameraPose(); + this.emitCameraStateChange(); + } + dispose() { + if (this.animationFrame !== 0) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + } + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.renderer.domElement.removeEventListener("pointerdown", this.handlePointerDown); + this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove); + this.renderer.domElement.removeEventListener("pointerup", this.handlePointerUp); + this.renderer.domElement.removeEventListener("pointercancel", this.handlePointerUp); + this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave); + this.renderer.domElement.removeEventListener("wheel", this.handleWheel); + this.renderer.domElement.removeEventListener("auxclick", this.handleAuxClick); + this.renderer.domElement.removeEventListener("contextmenu", this.handleContextMenu); + window.removeEventListener("pointermove", this.handleWindowPointerMove); + this.clearLocalLights(); + this.clearBrushMeshes(); + this.clearEntityMarkers(); + this.creationPreviewChangeHandler = null; + this.creationCommitHandler = null; + this.cameraStateChangeHandler = null; + this.transformSessionChangeHandler = null; + this.transformCommitHandler = null; + this.transformCancelHandler = null; + this.currentTransformSession = createInactiveTransformSession(); + this.clearTransformGizmo(); + this.activeTransformDrag = null; + this.keyboardTransformPointerOrigin = null; + this.syncCreationPreview(null); + this.advancedRenderingComposer?.dispose(); + this.advancedRenderingComposer = null; + this.currentAdvancedRenderingSettings = null; + this.renderer.autoClear = true; + for (const cachedTexture of this.materialTextureCache.values()) { + cachedTexture.texture.dispose(); + } + this.materialTextureCache.clear(); + this.boxCreatePreviewMesh.geometry.dispose(); + this.boxCreatePreviewMesh.material.dispose(); + this.boxCreatePreviewEdges.geometry.dispose(); + this.boxCreatePreviewEdges.material.dispose(); + this.renderer.dispose(); + if (this.container !== null && this.container.contains(this.renderer.domElement)) { + this.container.removeChild(this.renderer.domElement); + } + this.container = null; + } + getActiveCamera() { + return this.viewMode === "perspective" ? this.perspectiveCamera : this.orthographicCamera; + } + createCameraStateSnapshot() { + return { + target: { + x: this.cameraTarget.x, + y: this.cameraTarget.y, + z: this.cameraTarget.z + }, + perspectiveOrbit: { + radius: this.cameraSpherical.radius, + theta: this.cameraSpherical.theta, + phi: this.cameraSpherical.phi + }, + orthographicZoom: this.orthographicCamera.zoom + }; + } + emitCameraStateChange() { + this.cameraStateChangeHandler?.(this.createCameraStateSnapshot()); + } + updatePerspectiveCameraSphericalFromPose() { + this.cameraOffset.copy(this.perspectiveCamera.position).sub(this.cameraTarget); + this.cameraSpherical.setFromVector3(this.cameraOffset); + this.cameraSpherical.radius = Math.min(MAX_CAMERA_DISTANCE, Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius)); + this.cameraSpherical.phi = Math.min(MAX_POLAR_ANGLE, Math.max(MIN_POLAR_ANGLE, this.cameraSpherical.phi)); + this.cameraSpherical.makeSafe(); + } + updateOrthographicCameraFrustum() { + if (this.container === null) { + return; + } + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (width === 0 || height === 0) { + return; + } + const aspect = width / height; + const halfHeight = ORTHOGRAPHIC_FRUSTUM_HEIGHT * 0.5; + const halfWidth = halfHeight * aspect; + this.orthographicCamera.left = -halfWidth; + this.orthographicCamera.right = halfWidth; + this.orthographicCamera.top = halfHeight; + this.orthographicCamera.bottom = -halfHeight; + } + applyPerspectiveCameraPose() { + this.cameraSpherical.radius = Math.min(MAX_CAMERA_DISTANCE, Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius)); + this.cameraSpherical.phi = Math.min(MAX_POLAR_ANGLE, Math.max(MIN_POLAR_ANGLE, this.cameraSpherical.phi)); + this.cameraSpherical.makeSafe(); + this.cameraOffset.setFromSpherical(this.cameraSpherical); + this.perspectiveCamera.position.copy(this.cameraTarget).add(this.cameraOffset); + this.perspectiveCamera.lookAt(this.cameraTarget); + } + applyOrthographicCameraPose() { + const definition = getViewportViewModeDefinition(this.viewMode); + if (!isOrthographicViewportViewMode(this.viewMode) || definition.cameraDirection === null) { + return; + } + this.orthographicCamera.up.set(definition.cameraUp.x, definition.cameraUp.y, definition.cameraUp.z); + this.orthographicCamera.position.set(this.cameraTarget.x + definition.cameraDirection.x * ORTHOGRAPHIC_CAMERA_DISTANCE, this.cameraTarget.y + definition.cameraDirection.y * ORTHOGRAPHIC_CAMERA_DISTANCE, this.cameraTarget.z + definition.cameraDirection.z * ORTHOGRAPHIC_CAMERA_DISTANCE); + this.orthographicCamera.lookAt(this.cameraTarget); + this.orthographicCamera.zoom = Math.min(MAX_ORTHOGRAPHIC_ZOOM, Math.max(MIN_ORTHOGRAPHIC_ZOOM, this.orthographicCamera.zoom)); + this.orthographicCamera.updateProjectionMatrix(); + } + applyViewModePose() { + const definition = getViewportViewModeDefinition(this.viewMode); + this.gridHelpers.xz.visible = definition.gridPlane === "xz"; + this.gridHelpers.xy.visible = definition.gridPlane === "xy"; + this.gridHelpers.yz.visible = definition.gridPlane === "yz"; + if (definition.cameraType === "perspective") { + this.applyPerspectiveCameraPose(); + return; + } + this.updateOrthographicCameraFrustum(); + this.applyOrthographicCameraPose(); + } + createWireframeDisplayMaterial(material) { + const source = material; + return new MeshBasicMaterial({ + color: source.color?.getHex() ?? FALLBACK_FACE_COLOR, + wireframe: true, + transparent: source.transparent === true || (source.opacity ?? 1) < 1, + opacity: source.opacity ?? 1, + depthWrite: false + }); + } + applyWireframePresentation(object) { + object.traverse((child) => { + const maybeMesh = child; + if (maybeMesh.isMesh !== true) { + return; + } + if (Array.isArray(maybeMesh.material)) { + const originalMaterials = maybeMesh.material; + maybeMesh.material = originalMaterials.map((material) => this.createWireframeDisplayMaterial(material)); + for (const material of originalMaterials) { + material.dispose(); + } + return; + } + const originalMaterial = maybeMesh.material; + maybeMesh.material = this.createWireframeDisplayMaterial(originalMaterial); + originalMaterial.dispose(); + }); + } + getBoxCreatePlane() { + switch (this.viewMode) { + case "perspective": + case "top": + return this.boxCreatePlane.set(new Vector3(0, 1, 0), 0); + case "front": + return this.boxCreatePlane.set(new Vector3(0, 0, 1), 0); + case "side": + return this.boxCreatePlane.set(new Vector3(1, 0, 0), 0); + default: + return this.boxCreatePlane.set(new Vector3(0, 1, 0), 0); + } + } + applyWorld() { + if (this.currentWorld === null) { + return; + } + const world = this.currentWorld; + const rendererSettings = this.displayMode !== "normal" + ? { + ...cloneAdvancedRenderingSettings(world.advancedRendering), + enabled: false + } + : world.advancedRendering; + this.ambientLight.color.set(world.ambientLight.colorHex); + this.ambientLight.intensity = world.ambientLight.intensity; + this.sunLight.color.set(world.sunLight.colorHex); + this.sunLight.intensity = world.sunLight.intensity; + this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18); + this.ambientLight.visible = this.displayMode !== "wireframe"; + this.sunLight.visible = this.displayMode !== "wireframe"; + this.localLightGroup.visible = this.displayMode !== "wireframe"; + if (this.displayMode !== "normal") { + this.scene.background = null; + this.scene.environment = null; + this.scene.environmentIntensity = 1; + } + else if (world.background.mode === "image") { + const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null; + this.scene.background = texture; + this.scene.environment = texture; + this.scene.environmentIntensity = world.background.environmentIntensity; + } + else { + this.scene.background = null; + this.scene.environment = null; + this.scene.environmentIntensity = 1; + } + configureAdvancedRenderingRenderer(this.renderer, rendererSettings); + this.syncAdvancedRenderingComposer(rendererSettings); + this.applyShadowState(); + } + syncAdvancedRenderingComposer(settings) { + const shouldUseComposer = settings.enabled && this.displayMode === "normal" && this.viewMode === "perspective"; + const settingsChanged = this.currentAdvancedRenderingSettings === null || + !areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings); + if (!shouldUseComposer) { + if (this.advancedRenderingComposer !== null) { + this.advancedRenderingComposer.dispose(); + this.advancedRenderingComposer = null; + } + this.currentAdvancedRenderingSettings = settings.enabled ? cloneAdvancedRenderingSettings(settings) : null; + this.renderer.autoClear = true; + return; + } + if (this.advancedRenderingComposer !== null && !settingsChanged) { + return; + } + if (this.advancedRenderingComposer !== null) { + this.advancedRenderingComposer.dispose(); + } + this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.perspectiveCamera, settings); + this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings); + this.renderer.autoClear = false; + } + applyShadowState() { + if (this.currentWorld === null) { + return; + } + const advancedRendering = this.currentWorld.advancedRendering; + const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled && this.displayMode === "normal"; + const shadowSettings = this.displayMode === "normal" + ? advancedRendering + : { + ...advancedRendering, + enabled: false + }; + applyAdvancedRenderingLightShadowFlags(this.sunLight, shadowSettings); + for (const renderObjects of this.localLightRenderObjects.values()) { + applyAdvancedRenderingLightShadowFlags(renderObjects.group, shadowSettings); + } + for (const renderObjects of this.brushRenderObjects.values()) { + applyAdvancedRenderingRenderableShadowFlags(renderObjects.mesh, shadowsEnabled); + } + for (const renderGroup of this.modelRenderObjects.values()) { + applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled); + } + } + getPointerOriginForTransformSession() { + if (this.lastCanvasPointerPosition !== null) { + return this.lastCanvasPointerPosition; + } + const bounds = this.renderer.domElement.getBoundingClientRect(); + return { + x: bounds.left + bounds.width * 0.5, + y: bounds.top + bounds.height * 0.5 + }; + } + axisVector(axis) { + switch (axis) { + case "x": + return new Vector3(1, 0, 0); + case "y": + return new Vector3(0, 1, 0); + case "z": + return new Vector3(0, 0, 1); + } + } + normalizeDegrees(value) { + const normalized = value % 360; + return normalized < 0 ? normalized + 360 : normalized; + } + snapScaleValue(value) { + return Math.max(MIN_SCALE_COMPONENT, Math.round(value / SCALE_SNAP_STEP) * SCALE_SNAP_STEP); + } + snapWhiteboxPositionValue(value) { + return this.whiteboxSnapEnabled ? snapValueToGrid(value, this.whiteboxSnapStep) : value; + } + snapWhiteboxSizeValue(value) { + if (!Number.isFinite(value)) { + throw new Error("Whitebox box size values must be finite numbers."); + } + if (!this.whiteboxSnapEnabled) { + return Math.max(MIN_BOX_SIZE_COMPONENT, Math.abs(value)); + } + return Math.max(MIN_BOX_SIZE_COMPONENT, snapValueToGrid(Math.abs(value), this.whiteboxSnapStep)); + } + getAxisComponent(vector, axis) { + switch (axis) { + case "x": + return vector.x; + case "y": + return vector.y; + case "z": + return vector.z; + } + } + setAxisComponent(vector, axis, value) { + switch (axis) { + case "x": + return { + ...vector, + x: value + }; + case "y": + return { + ...vector, + y: value + }; + case "z": + return { + ...vector, + z: value + }; + } + } + getEffectiveRotationAxis(session) { + if (session.target.kind === "brushFace") { + return getBoxBrushFaceAxis(session.target.faceId); + } + if (session.target.kind === "brushEdge") { + return getBoxBrushEdgeAxis(session.target.edgeId); + } + if (session.target.kind === "entity" && session.target.initialRotation.kind === "yaw") { + return "y"; + } + return session.axisConstraint ?? "y"; + } + getTransformPivotPosition(session) { + if (session.preview.kind === "brush") { + const previewBrush = this.createPreviewBrushForSession(session); + if (previewBrush !== null) { + if (session.target.kind === "brushFace") { + return getBoxBrushFaceWorldCenter(previewBrush, session.target.faceId); + } + if (session.target.kind === "brushEdge") { + return getBoxBrushEdgeWorldSegment(previewBrush, session.target.edgeId).center; + } + if (session.target.kind === "brushVertex") { + return getBoxBrushVertexWorldPosition(previewBrush, session.target.vertexId); + } + } + } + switch (session.preview.kind) { + case "brush": + return session.preview.center; + case "modelInstance": + return session.preview.position; + case "entity": + return session.preview.position; + } + } + createPreviewBrushForSession(session) { + if (session.preview.kind !== "brush") { + return null; + } + if (session.target.kind !== "brush" && + session.target.kind !== "brushFace" && + session.target.kind !== "brushEdge" && + session.target.kind !== "brushVertex") { + return null; + } + const currentBrush = this.currentDocument?.brushes[session.target.brushId]; + if (currentBrush === undefined || currentBrush.kind !== "box") { + return null; + } + return { + ...currentBrush, + center: { + ...session.preview.center + }, + rotationDegrees: { + ...session.preview.rotationDegrees + }, + size: { + ...session.preview.size + } + }; + } + clearTransformGizmo() { + for (const child of [...this.transformGizmoGroup.children]) { + this.transformGizmoGroup.remove(child); + child.traverse((object) => { + const maybeMesh = object; + if (maybeMesh.isMesh === true) { + maybeMesh.geometry.dispose(); + if (Array.isArray(maybeMesh.material)) { + for (const material of maybeMesh.material) { + material.dispose(); + } + } + else { + maybeMesh.material.dispose(); + } + } + }); + } + this.transformGizmoGroup.visible = false; + } + markTransformHandleObject(object) { + object.renderOrder = GIZMO_RENDER_ORDER; + object.traverse((child) => { + child.renderOrder = GIZMO_RENDER_ORDER; + }); + return object; + } + createTransformHandleMaterial(color, isActive, transparent = false) { + return new MeshBasicMaterial({ + color, + transparent: transparent || isActive, + opacity: transparent ? 0.001 : isActive ? GIZMO_ACTIVE_OPACITY : GIZMO_INACTIVE_OPACITY, + depthWrite: false, + depthTest: false + }); + } + createTranslateHandle(axis, isActive) { + const axisVector = this.axisVector(axis); + const color = isActive ? GIZMO_ACTIVE_COLOR : GIZMO_AXIS_COLORS[axis]; + const group = new Group(); + const line = new Mesh(new CylinderGeometry(0.025, 0.025, GIZMO_TRANSLATE_LENGTH, 10), this.createTransformHandleMaterial(color, isActive)); + const arrow = new Mesh(new ConeGeometry(0.09, 0.28, 12), this.createTransformHandleMaterial(color, isActive)); + const pick = new Mesh(new CylinderGeometry(GIZMO_PICK_THICKNESS, GIZMO_PICK_THICKNESS, GIZMO_TRANSLATE_LENGTH + 0.36, 10), this.createTransformHandleMaterial(color, isActive, true)); + line.position.copy(axisVector).multiplyScalar(GIZMO_TRANSLATE_LENGTH * 0.5); + arrow.position.copy(axisVector).multiplyScalar(GIZMO_TRANSLATE_LENGTH + 0.18); + pick.position.copy(axisVector).multiplyScalar((GIZMO_TRANSLATE_LENGTH + 0.36) * 0.5); + if (axis === "x") { + line.rotation.z = -Math.PI * 0.5; + arrow.rotation.z = -Math.PI * 0.5; + pick.rotation.z = -Math.PI * 0.5; + } + else if (axis === "z") { + line.rotation.x = Math.PI * 0.5; + arrow.rotation.x = Math.PI * 0.5; + pick.rotation.x = Math.PI * 0.5; + } + pick.userData.transformAxisConstraint = axis; + group.add(line); + group.add(arrow); + group.add(pick); + return this.markTransformHandleObject(group); + } + createRotateHandle(axis, isActive) { + const color = isActive ? GIZMO_ACTIVE_COLOR : GIZMO_AXIS_COLORS[axis]; + const group = new Group(); + const ring = new Mesh(new TorusGeometry(GIZMO_ROTATE_RADIUS, GIZMO_ROTATE_TUBE, 8, 48), this.createTransformHandleMaterial(color, isActive)); + const pick = new Mesh(new TorusGeometry(GIZMO_ROTATE_RADIUS, GIZMO_PICK_RING_TUBE, 8, 36), this.createTransformHandleMaterial(color, isActive, true)); + if (axis === "x") { + ring.rotation.y = Math.PI * 0.5; + pick.rotation.y = Math.PI * 0.5; + } + else if (axis === "y") { + ring.rotation.x = Math.PI * 0.5; + pick.rotation.x = Math.PI * 0.5; + } + pick.userData.transformAxisConstraint = axis; + group.add(ring); + group.add(pick); + return this.markTransformHandleObject(group); + } + createScaleHandle(axis, isActive) { + const axisVector = this.axisVector(axis); + const color = isActive ? GIZMO_ACTIVE_COLOR : GIZMO_AXIS_COLORS[axis]; + const group = new Group(); + const line = new Mesh(new CylinderGeometry(0.022, 0.022, GIZMO_SCALE_LENGTH, 10), this.createTransformHandleMaterial(color, isActive)); + const cube = new Mesh(new BoxGeometry(0.16, 0.16, 0.16), this.createTransformHandleMaterial(color, isActive)); + const pick = new Mesh(new CylinderGeometry(GIZMO_PICK_THICKNESS, GIZMO_PICK_THICKNESS, GIZMO_SCALE_LENGTH + 0.3, 10), this.createTransformHandleMaterial(color, isActive, true)); + line.position.copy(axisVector).multiplyScalar(GIZMO_SCALE_LENGTH * 0.5); + cube.position.copy(axisVector).multiplyScalar(GIZMO_SCALE_LENGTH + 0.12); + pick.position.copy(axisVector).multiplyScalar((GIZMO_SCALE_LENGTH + 0.3) * 0.5); + if (axis === "x") { + line.rotation.z = -Math.PI * 0.5; + pick.rotation.z = -Math.PI * 0.5; + } + else if (axis === "z") { + line.rotation.x = Math.PI * 0.5; + pick.rotation.x = Math.PI * 0.5; + } + pick.userData.transformAxisConstraint = axis; + group.add(line); + group.add(cube); + group.add(pick); + return this.markTransformHandleObject(group); + } + createUniformScaleHandle(isActive) { + const mesh = new Mesh(new BoxGeometry(GIZMO_CENTER_HANDLE_SIZE, GIZMO_CENTER_HANDLE_SIZE, GIZMO_CENTER_HANDLE_SIZE), this.createTransformHandleMaterial(isActive ? GIZMO_ACTIVE_COLOR : 0xe6edf8, isActive)); + mesh.userData.transformAxisConstraint = null; + return this.markTransformHandleObject(mesh); + } + getDisplayedTransformSession() { + if (this.currentTransformSession.kind === "active") { + return this.currentTransformSession; + } + if (this.toolMode !== "select" || this.currentDocument === null) { + return null; + } + const transformTarget = resolveTransformTarget(this.currentDocument, this.currentSelection, this.whiteboxSelectionMode).target; + if (transformTarget === null || !supportsTransformOperation(transformTarget, "translate")) { + return null; + } + return { + kind: "active", + id: "__selection-translate-gizmo__", + source: "gizmo", + sourcePanelId: this.panelId, + operation: "translate", + axisConstraint: null, + target: transformTarget, + preview: createTransformPreviewFromTarget(transformTarget) + }; + } + syncTransformGizmo() { + this.clearTransformGizmo(); + const session = this.getDisplayedTransformSession(); + if (session === null) { + return; + } + const effectiveRotationAxis = session.operation === "rotate" ? this.getEffectiveRotationAxis(session) : null; + if (session.operation === "translate") { + this.transformGizmoGroup.add(this.createTranslateHandle("x", session.axisConstraint === "x")); + this.transformGizmoGroup.add(this.createTranslateHandle("y", session.axisConstraint === "y")); + this.transformGizmoGroup.add(this.createTranslateHandle("z", session.axisConstraint === "z")); + } + else if (session.operation === "rotate") { + for (const axis of ["x", "y", "z"]) { + if (!supportsTransformAxisConstraint(session, axis)) { + continue; + } + this.transformGizmoGroup.add(this.createRotateHandle(axis, effectiveRotationAxis === axis)); + } + } + else if (session.operation === "scale" && + (session.target.kind === "modelInstance" || + session.target.kind === "brush" || + session.target.kind === "brushFace" || + session.target.kind === "brushEdge")) { + for (const axis of ["x", "y", "z"]) { + this.transformGizmoGroup.add(this.createScaleHandle(axis, session.axisConstraint === axis)); + } + this.transformGizmoGroup.add(this.createUniformScaleHandle(session.axisConstraint === null)); + } + this.transformGizmoGroup.visible = this.transformGizmoGroup.children.length > 0; + this.updateTransformGizmoPose(); + } + updateTransformGizmoPose() { + const session = this.getDisplayedTransformSession(); + if (session === null || !this.transformGizmoGroup.visible) { + return; + } + const pivot = this.getTransformPivotPosition(session); + const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z); + this.transformGizmoGroup.position.copy(pivotVector); + let scale = GIZMO_SCREEN_SIZE_ORTHOGRAPHIC / Math.max(this.orthographicCamera.zoom, 0.0001); + if (this.viewMode === "perspective") { + scale = Math.max(0.5, pivotVector.distanceTo(this.perspectiveCamera.position) * GIZMO_SCREEN_SIZE_PERSPECTIVE); + } + this.transformGizmoGroup.scale.setScalar(scale); + } + getTransformPlaneForPivot(pivot) { + switch (this.viewMode) { + case "perspective": + case "top": + return this.transformPlane.set(new Vector3(0, 1, 0), -pivot.y); + case "front": + return this.transformPlane.set(new Vector3(0, 0, 1), -pivot.z); + case "side": + return this.transformPlane.set(new Vector3(1, 0, 0), -pivot.x); + } + } + setPointerFromClientPosition(clientX, clientY) { + const bounds = this.renderer.domElement.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) { + return false; + } + this.pointer.x = ((clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1); + return true; + } + getPointerPlaneIntersection(clientX, clientY, plane) { + if (!this.setPointerFromClientPosition(clientX, clientY)) { + return null; + } + this.raycaster.setFromCamera(this.pointer, this.getActiveCamera()); + if (this.raycaster.ray.intersectPlane(plane, this.transformIntersection) === null) { + return null; + } + return this.transformIntersection.clone(); + } + getFallbackWorldUnitsPerPixel(pivot) { + if (this.container === null) { + return 0; + } + const height = Math.max(1, this.container.clientHeight); + if (this.viewMode === "perspective") { + const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z); + const distance = pivotVector.distanceTo(this.perspectiveCamera.position); + const visibleHeight = 2 * Math.tan((this.perspectiveCamera.fov * Math.PI) / 360) * distance; + return visibleHeight / height; + } + return ORTHOGRAPHIC_FRUSTUM_HEIGHT / this.orthographicCamera.zoom / height; + } + getAxisMovementDistance(axis, pivot, origin, current) { + const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z); + const projectedStart = pivotVector.clone().project(this.getActiveCamera()); + const projectedEnd = pivotVector.clone().add(this.axisVector(axis)).project(this.getActiveCamera()); + const screenDelta = new Vector2(projectedEnd.x - projectedStart.x, projectedEnd.y - projectedStart.y); + const pointerDelta = new Vector2(current.x - origin.x, current.y - origin.y); + if (this.container !== null) { + screenDelta.set((screenDelta.x * this.container.clientWidth) * 0.5, (-screenDelta.y * this.container.clientHeight) * 0.5); + } + const axisLength = screenDelta.length(); + if (axisLength >= 0.0001) { + screenDelta.normalize(); + return pointerDelta.dot(screenDelta) / axisLength; + } + return -(current.y - origin.y) * this.getFallbackWorldUnitsPerPixel(pivot); + } + buildTransformPreviewFromPointer(session, origin, current, axisConstraint) { + const nextSession = cloneTransformSession(session); + nextSession.axisConstraint = axisConstraint; + switch (session.operation) { + case "translate": + nextSession.preview = this.buildTranslatedPreview(session, origin, current, axisConstraint); + return nextSession; + case "rotate": + nextSession.preview = this.buildRotatedPreview(session, origin, current, axisConstraint); + return nextSession; + case "scale": + nextSession.preview = this.buildScaledPreview(session, origin, current, axisConstraint); + return nextSession; + } + } + buildTranslatedPreview(session, origin, current, axisConstraint) { + if (session.target.kind === "brushFace" || session.target.kind === "brushEdge" || session.target.kind === "brushVertex") { + return this.buildComponentTranslatedBrushPreview(session, origin, current, axisConstraint); + } + const initialPosition = session.target.kind === "brush" ? session.target.initialCenter : session.target.kind === "modelInstance" ? session.target.initialPosition : session.target.initialPosition; + let nextPosition = { + ...initialPosition + }; + if (axisConstraint === null) { + const plane = this.getTransformPlaneForPivot(initialPosition); + const startIntersection = this.getPointerPlaneIntersection(origin.x, origin.y, plane); + const currentIntersection = this.getPointerPlaneIntersection(current.x, current.y, plane); + if (startIntersection !== null && currentIntersection !== null) { + const delta = currentIntersection.sub(startIntersection); + switch (this.viewMode) { + case "perspective": + case "top": + nextPosition = { + ...initialPosition, + x: this.snapWhiteboxPositionValue(initialPosition.x + delta.x), + z: this.snapWhiteboxPositionValue(initialPosition.z + delta.z) + }; + break; + case "front": + nextPosition = { + ...initialPosition, + x: this.snapWhiteboxPositionValue(initialPosition.x + delta.x), + y: this.snapWhiteboxPositionValue(initialPosition.y + delta.y) + }; + break; + case "side": + nextPosition = { + ...initialPosition, + y: this.snapWhiteboxPositionValue(initialPosition.y + delta.y), + z: this.snapWhiteboxPositionValue(initialPosition.z + delta.z) + }; + break; + } + } + } + else { + const axisDelta = this.getAxisMovementDistance(axisConstraint, initialPosition, origin, current); + nextPosition = this.setAxisComponent(nextPosition, axisConstraint, this.snapWhiteboxPositionValue(this.getAxisComponent(initialPosition, axisConstraint) + axisDelta)); + } + if (session.target.kind === "brush") { + return { + kind: "brush", + center: nextPosition, + rotationDegrees: { + ...session.target.initialRotationDegrees + }, + size: { + ...session.target.initialSize + } + }; + } + if (session.target.kind === "modelInstance") { + return { + kind: "modelInstance", + position: nextPosition, + rotationDegrees: { + ...session.target.initialRotationDegrees + }, + scale: { + ...session.target.initialScale + } + }; + } + return { + kind: "entity", + position: nextPosition, + rotation: session.target.initialRotation.kind === "yaw" + ? { + kind: "yaw", + yawDegrees: session.target.initialRotation.yawDegrees + } + : session.target.initialRotation.kind === "direction" + ? { + kind: "direction", + direction: { + ...session.target.initialRotation.direction + } + } + : { + kind: "none" + } + }; + } + buildRotatedPreview(session, origin, current, axisConstraint) { + if (session.target.kind === "brushFace" || session.target.kind === "brushEdge") { + return this.buildComponentRotatedBrushPreview(session, origin, current, axisConstraint); + } + const effectiveAxis = axisConstraint ?? this.getEffectiveRotationAxis(session); + const pointerDeltaDegrees = (current.x - origin.x - (current.y - origin.y)) * 0.5; + if (session.target.kind === "brush") { + const nextRotationDegrees = { + ...session.target.initialRotationDegrees + }; + nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + return { + kind: "brush", + center: { + ...session.target.initialCenter + }, + rotationDegrees: nextRotationDegrees, + size: { + ...session.target.initialSize + } + }; + } + if (session.target.kind === "modelInstance") { + const nextRotationDegrees = { + ...session.target.initialRotationDegrees + }; + nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + return { + kind: "modelInstance", + position: { + ...session.target.initialPosition + }, + rotationDegrees: nextRotationDegrees, + scale: { + ...session.target.initialScale + } + }; + } + if (session.target.kind !== "entity") { + throw new Error("Rotation previews are only supported for model instances and rotatable entities."); + } + if (session.target.initialRotation.kind === "yaw") { + return { + kind: "entity", + position: { + ...session.target.initialPosition + }, + rotation: { + kind: "yaw", + yawDegrees: normalizeYawDegrees(session.target.initialRotation.yawDegrees + pointerDeltaDegrees) + } + }; + } + if (session.target.initialRotation.kind === "direction") { + const direction = new Vector3(session.target.initialRotation.direction.x, session.target.initialRotation.direction.y, session.target.initialRotation.direction.z) + .normalize() + .applyAxisAngle(this.axisVector(effectiveAxis).normalize(), (pointerDeltaDegrees * Math.PI) / 180) + .normalize(); + return { + kind: "entity", + position: { + ...session.target.initialPosition + }, + rotation: { + kind: "direction", + direction: { + x: direction.x, + y: direction.y, + z: direction.z + } + } + }; + } + return { + kind: "entity", + position: { + ...session.target.initialPosition + }, + rotation: { + kind: "none" + } + }; + } + buildScaledPreview(session, origin, current, axisConstraint) { + if (session.target.kind === "brushFace" || session.target.kind === "brushEdge") { + return this.buildComponentScaledBrushPreview(session, origin, current, axisConstraint); + } + if (session.target.kind === "brush") { + const nextSize = { + ...session.target.initialSize + }; + if (axisConstraint === null) { + const uniformFactor = 1 + (current.x - origin.x - (current.y - origin.y)) * 0.01; + nextSize.x = this.snapWhiteboxSizeValue(session.target.initialSize.x * uniformFactor); + nextSize.y = this.snapWhiteboxSizeValue(session.target.initialSize.y * uniformFactor); + nextSize.z = this.snapWhiteboxSizeValue(session.target.initialSize.z * uniformFactor); + } + else { + const scaleFactor = 1 + this.getAxisMovementDistance(axisConstraint, session.target.initialCenter, origin, current) * 0.45; + nextSize[axisConstraint] = this.snapWhiteboxSizeValue(session.target.initialSize[axisConstraint] * scaleFactor); + } + return { + kind: "brush", + center: { + ...session.target.initialCenter + }, + rotationDegrees: { + ...session.target.initialRotationDegrees + }, + size: nextSize + }; + } + if (session.target.kind !== "modelInstance") { + throw new Error("Scale previews are only supported for model instances and whitebox boxes."); + } + const nextScale = { + ...session.target.initialScale + }; + if (axisConstraint === null) { + const uniformFactor = 1 + (current.x - origin.x - (current.y - origin.y)) * 0.01; + nextScale.x = this.snapScaleValue(session.target.initialScale.x * uniformFactor); + nextScale.y = this.snapScaleValue(session.target.initialScale.y * uniformFactor); + nextScale.z = this.snapScaleValue(session.target.initialScale.z * uniformFactor); + } + else { + const scaleFactor = 1 + this.getAxisMovementDistance(axisConstraint, session.target.initialPosition, origin, current) * 0.45; + nextScale[axisConstraint] = this.snapScaleValue(session.target.initialScale[axisConstraint] * scaleFactor); + } + return { + kind: "modelInstance", + position: { + ...session.target.initialPosition + }, + rotationDegrees: { + ...session.target.initialRotationDegrees + }, + scale: nextScale + }; + } + createTargetPreviewBrush(session) { + if (session.target.kind !== "brush" && + session.target.kind !== "brushFace" && + session.target.kind !== "brushEdge" && + session.target.kind !== "brushVertex") { + return null; + } + const currentBrush = this.currentDocument?.brushes[session.target.brushId]; + if (currentBrush === undefined || currentBrush.kind !== "box") { + return null; + } + return { + ...currentBrush, + center: { + ...session.target.initialCenter + }, + rotationDegrees: { + ...session.target.initialRotationDegrees + }, + size: { + ...session.target.initialSize + } + }; + } + createBrushPreviewFromExtents(brush, extents, rotationDegrees) { + const localCenter = { + x: (extents.min.x + extents.max.x) * 0.5, + y: (extents.min.y + extents.max.y) * 0.5, + z: (extents.min.z + extents.max.z) * 0.5 + }; + const centerOffset = transformBoxBrushLocalPointToWorld({ + ...brush, + center: { x: 0, y: 0, z: 0 }, + rotationDegrees: rotationDegrees ?? brush.rotationDegrees + }, localCenter); + const nextSize = { + x: this.snapWhiteboxSizeValue(extents.max.x - extents.min.x), + y: this.snapWhiteboxSizeValue(extents.max.y - extents.min.y), + z: this.snapWhiteboxSizeValue(extents.max.z - extents.min.z) + }; + return { + kind: "brush", + center: { + x: this.snapWhiteboxPositionValue(brush.center.x + centerOffset.x), + y: this.snapWhiteboxPositionValue(brush.center.y + centerOffset.y), + z: this.snapWhiteboxPositionValue(brush.center.z + centerOffset.z) + }, + rotationDegrees: { + ...(rotationDegrees ?? brush.rotationDegrees) + }, + size: nextSize + }; + } + getInitialBrushLocalExtents(brush) { + return { + min: { + x: -brush.size.x * 0.5, + y: -brush.size.y * 0.5, + z: -brush.size.z * 0.5 + }, + max: { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + } + }; + } + buildComponentTranslatedBrushPreview(session, origin, current, axisConstraint) { + const initialBrush = this.createTargetPreviewBrush(session); + if (initialBrush === null) { + throw new Error("Cannot build a component translation preview without a box brush target."); + } + const initialPivot = this.getTransformPivotPosition({ + ...session, + preview: { + kind: "brush", + center: { ...initialBrush.center }, + rotationDegrees: { ...initialBrush.rotationDegrees }, + size: { ...initialBrush.size } + } + }); + let worldDelta = { + x: 0, + y: 0, + z: 0 + }; + if (axisConstraint === null) { + const plane = this.getTransformPlaneForPivot(initialPivot); + const startIntersection = this.getPointerPlaneIntersection(origin.x, origin.y, plane); + const currentIntersection = this.getPointerPlaneIntersection(current.x, current.y, plane); + if (startIntersection !== null && currentIntersection !== null) { + const delta = currentIntersection.sub(startIntersection); + worldDelta = { + x: delta.x, + y: delta.y, + z: delta.z + }; + } + } + else { + const axisDelta = this.getAxisMovementDistance(axisConstraint, initialPivot, origin, current); + worldDelta = this.setAxisComponent(worldDelta, axisConstraint, axisDelta); + } + const localDelta = transformBoxBrushWorldVectorToLocal(initialBrush, worldDelta); + const extents = this.getInitialBrushLocalExtents(initialBrush); + if (session.target.kind === "brushFace") { + const meta = getBoxBrushFaceTransformMeta(session.target.faceId); + for (const axis of ["x", "y", "z"]) { + const axisDelta = localDelta[axis]; + if (axis === meta.axis) { + if (meta.sign > 0) { + extents.max[axis] += axisDelta; + } + else { + extents.min[axis] += axisDelta; + } + } + else { + extents.min[axis] += axisDelta; + extents.max[axis] += axisDelta; + } + } + } + else if (session.target.kind === "brushEdge") { + const meta = getBoxBrushEdgeTransformMeta(session.target.edgeId); + for (const axis of ["x", "y", "z"]) { + const axisDelta = localDelta[axis]; + const axisSign = meta.signs[axis]; + if (axisSign === null) { + extents.min[axis] += axisDelta; + extents.max[axis] += axisDelta; + continue; + } + if (axisSign > 0) { + extents.max[axis] += axisDelta; + } + else { + extents.min[axis] += axisDelta; + } + } + } + else if (session.target.kind === "brushVertex") { + const signs = getBoxBrushVertexSigns(session.target.vertexId); + for (const axis of ["x", "y", "z"]) { + if (signs[axis] > 0) { + extents.max[axis] += localDelta[axis]; + } + else { + extents.min[axis] += localDelta[axis]; + } + } + } + return this.createBrushPreviewFromExtents(initialBrush, extents); + } + buildComponentRotatedBrushPreview(session, origin, current, axisConstraint) { + const initialBrush = this.createTargetPreviewBrush(session); + if (initialBrush === null) { + throw new Error("Cannot build a component rotation preview without a box brush target."); + } + const effectiveAxis = axisConstraint ?? this.getEffectiveRotationAxis(session); + const pointerDeltaDegrees = (current.x - origin.x - (current.y - origin.y)) * 0.5; + const pivot = this.getTransformPivotPosition({ + ...session, + preview: { + kind: "brush", + center: { ...initialBrush.center }, + rotationDegrees: { ...initialBrush.rotationDegrees }, + size: { ...initialBrush.size } + } + }); + const axisVector = this.axisVector(effectiveAxis).normalize(); + const centerVector = new Vector3(initialBrush.center.x, initialBrush.center.y, initialBrush.center.z); + const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z); + const nextCenter = centerVector + .sub(pivotVector) + .applyAxisAngle(axisVector, (pointerDeltaDegrees * Math.PI) / 180) + .add(pivotVector); + const nextRotationDegrees = { + ...initialBrush.rotationDegrees + }; + nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + return { + kind: "brush", + center: { + x: this.snapWhiteboxPositionValue(nextCenter.x), + y: this.snapWhiteboxPositionValue(nextCenter.y), + z: this.snapWhiteboxPositionValue(nextCenter.z) + }, + rotationDegrees: nextRotationDegrees, + size: { + ...initialBrush.size + } + }; + } + buildComponentScaledBrushPreview(session, origin, current, axisConstraint) { + const initialBrush = this.createTargetPreviewBrush(session); + if (initialBrush === null) { + throw new Error("Cannot build a component scale preview without a box brush target."); + } + const extents = this.getInitialBrushLocalExtents(initialBrush); + if (session.target.kind === "brushFace") { + const meta = getBoxBrushFaceTransformMeta(session.target.faceId); + const scaleFactor = 1 + + this.getAxisMovementDistance(axisConstraint ?? meta.axis, this.getTransformPivotPosition(session), origin, current) * + 0.45; + if (meta.sign > 0) { + extents.max[meta.axis] = extents.min[meta.axis] + (extents.max[meta.axis] - extents.min[meta.axis]) * scaleFactor; + } + else { + extents.min[meta.axis] = extents.max[meta.axis] - (extents.max[meta.axis] - extents.min[meta.axis]) * scaleFactor; + } + } + else if (session.target.kind === "brushEdge") { + const meta = getBoxBrushEdgeTransformMeta(session.target.edgeId); + const affectedAxes = ["x", "y", "z"].filter((axis) => meta.signs[axis] !== null && (axisConstraint === null || axisConstraint === axis)); + const pivot = this.getTransformPivotPosition(session); + for (const axis of affectedAxes) { + const sign = meta.signs[axis]; + if (sign === null) { + continue; + } + const scaleFactor = 1 + this.getAxisMovementDistance(axis, pivot, origin, current) * 0.45; + if (sign > 0) { + extents.max[axis] = extents.min[axis] + (extents.max[axis] - extents.min[axis]) * scaleFactor; + } + else { + extents.min[axis] = extents.max[axis] - (extents.max[axis] - extents.min[axis]) * scaleFactor; + } + } + } + return this.createBrushPreviewFromExtents(initialBrush, extents); + } + applyBrushRenderObjectTransform(brushId, center, rotationDegrees, size) { + const renderObjects = this.brushRenderObjects.get(brushId); + const brush = this.currentDocument?.brushes[brushId]; + if (renderObjects === undefined || brush === undefined) { + return; + } + renderObjects.mesh.position.set(center.x, center.y, center.z); + renderObjects.mesh.rotation.set((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180); + renderObjects.mesh.scale.set(size.x / brush.size.x, size.y / brush.size.y, size.z / brush.size.z); + renderObjects.edges.position.set(center.x, center.y, center.z); + renderObjects.edges.rotation.set((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180); + renderObjects.edges.scale.set(size.x / brush.size.x, size.y / brush.size.y, size.z / brush.size.z); + } + applySpotLightGroupTransform(group, position, direction) { + const forward = new Vector3(direction.x, direction.y, direction.z).normalize(); + const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), forward); + group.position.set(position.x, position.y, position.z); + group.quaternion.copy(orientation); + } + applyEntityRenderObjectTransform(entity) { + const renderObjects = this.entityRenderObjects.get(entity.id); + if (renderObjects === undefined) { + return; + } + switch (entity.kind) { + case "pointLight": + case "soundEmitter": + case "triggerVolume": + case "interactable": + renderObjects.group.position.set(entity.position.x, entity.position.y, entity.position.z); + renderObjects.group.rotation.set(0, 0, 0); + renderObjects.group.quaternion.identity(); + break; + case "spotLight": + this.applySpotLightGroupTransform(renderObjects.group, entity.position, entity.direction); + break; + case "playerStart": + case "teleportTarget": + renderObjects.group.position.set(entity.position.x, entity.position.y, entity.position.z); + renderObjects.group.rotation.set(0, (entity.yawDegrees * Math.PI) / 180, 0); + break; + } + } + applyModelInstanceRenderObjectTransform(modelInstance) { + const renderGroup = this.modelRenderObjects.get(modelInstance.id); + if (renderGroup === undefined) { + return; + } + renderGroup.position.set(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z); + renderGroup.rotation.set((modelInstance.rotationDegrees.x * Math.PI) / 180, (modelInstance.rotationDegrees.y * Math.PI) / 180, (modelInstance.rotationDegrees.z * Math.PI) / 180); + renderGroup.scale.set(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z); + } + resetRenderObjectTransformsFromDocument() { + if (this.currentDocument === null) { + return; + } + for (const brush of Object.values(this.currentDocument.brushes)) { + this.applyBrushRenderObjectTransform(brush.id, brush.center, brush.rotationDegrees, brush.size); + } + for (const entity of getEntityInstances(this.currentDocument.entities)) { + this.applyEntityRenderObjectTransform(entity); + } + for (const modelInstance of getModelInstances(this.currentDocument.modelInstances)) { + this.applyModelInstanceRenderObjectTransform(modelInstance); + } + } + applyTransformPreview() { + this.resetRenderObjectTransformsFromDocument(); + if (this.currentTransformSession.kind !== "active") { + return; + } + switch (this.currentTransformSession.target.kind) { + case "brush": + case "brushFace": + case "brushEdge": + case "brushVertex": + if (this.currentTransformSession.preview.kind === "brush") { + this.applyBrushRenderObjectTransform(this.currentTransformSession.target.brushId, this.currentTransformSession.preview.center, this.currentTransformSession.preview.rotationDegrees, this.currentTransformSession.preview.size); + } + break; + case "modelInstance": + if (this.currentTransformSession.preview.kind === "modelInstance") { + this.applyModelInstanceRenderObjectTransform({ + ...createModelInstance({ + id: this.currentTransformSession.target.modelInstanceId, + assetId: this.currentTransformSession.target.assetId, + position: this.currentTransformSession.preview.position, + rotationDegrees: this.currentTransformSession.preview.rotationDegrees, + scale: this.currentTransformSession.preview.scale + }) + }); + } + break; + case "entity": { + if (this.currentTransformSession.preview.kind !== "entity" || this.currentDocument === null) { + break; + } + const currentEntity = this.currentDocument.entities[this.currentTransformSession.target.entityId]; + if (currentEntity === undefined) { + break; + } + switch (currentEntity.kind) { + case "pointLight": + case "soundEmitter": + case "triggerVolume": + case "interactable": + this.applyEntityRenderObjectTransform({ + ...currentEntity, + position: this.currentTransformSession.preview.position + }); + break; + case "spotLight": + this.applyEntityRenderObjectTransform({ + ...currentEntity, + position: this.currentTransformSession.preview.position, + direction: this.currentTransformSession.preview.rotation.kind === "direction" + ? this.currentTransformSession.preview.rotation.direction + : currentEntity.direction + }); + break; + case "playerStart": + case "teleportTarget": + this.applyEntityRenderObjectTransform({ + ...currentEntity, + position: this.currentTransformSession.preview.position, + yawDegrees: this.currentTransformSession.preview.rotation.kind === "yaw" + ? this.currentTransformSession.preview.rotation.yawDegrees + : currentEntity.yawDegrees + }); + break; + } + break; + } + } + } + rebuildLocalLights(document) { + this.clearLocalLights(); + for (const entity of getEntityInstances(document.entities)) { + switch (entity.kind) { + case "pointLight": { + const renderObjects = this.createPointLightRuntimeObjects(entity); + this.localLightGroup.add(renderObjects.group); + this.localLightRenderObjects.set(entity.id, renderObjects); + break; + } + case "spotLight": { + const renderObjects = this.createSpotLightRuntimeObjects(entity); + this.localLightGroup.add(renderObjects.group); + this.localLightRenderObjects.set(entity.id, renderObjects); + break; + } + } + } + this.applyShadowState(); + } + rebuildBrushMeshes(document, selection) { + this.clearBrushMeshes(); + for (const brush of Object.values(document.brushes)) { + const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z); + applyBoxBrushFaceUvsToGeometry(geometry, brush); + const materials = BOX_FACE_IDS.map((faceId) => this.createFaceMaterial(brush, faceId, document.materials[brush.faces[faceId].materialId ?? ""], this.getFaceHighlightState(brush.id, faceId))); + const mesh = new Mesh(geometry, materials); + const brushSelected = isBrushSelected(selection, brush.id); + mesh.userData.brushId = brush.id; + mesh.castShadow = false; + mesh.receiveShadow = false; + const edges = new LineSegments(new EdgesGeometry(geometry), new LineBasicMaterial({ + color: brushSelected ? BRUSH_SELECTED_EDGE_COLOR : BRUSH_EDGE_COLOR + })); + edges.visible = this.displayMode !== "wireframe"; + const edgeHelpers = BOX_EDGE_IDS.map((edgeId) => this.createEdgeHelper(brush, edgeId)); + const vertexHelpers = BOX_VERTEX_IDS.map((vertexId) => this.createVertexHelper(brush, vertexId)); + this.brushGroup.add(mesh); + this.brushGroup.add(edges); + for (const edgeHelper of edgeHelpers) { + this.brushGroup.add(edgeHelper.line); + } + for (const vertexHelper of vertexHelpers) { + this.brushGroup.add(vertexHelper.mesh); + } + this.brushRenderObjects.set(brush.id, { + mesh, + edges, + edgeHelpers, + vertexHelpers + }); + this.applyBrushRenderObjectTransform(brush.id, brush.center, brush.rotationDegrees, brush.size); + } + this.refreshBrushPresentation(); + this.applyShadowState(); + } + rebuildEntityMarkers(document, selection) { + this.clearEntityMarkers(); + for (const entity of getEntityInstances(document.entities)) { + const selected = selection.kind === "entities" && selection.ids.includes(entity.id); + const renderObjects = this.createEntityRenderObjects(entity, selected); + if (this.displayMode === "wireframe") { + this.applyWireframePresentation(renderObjects.group); + } + this.entityGroup.add(renderObjects.group); + this.entityRenderObjects.set(entity.id, renderObjects); + } + } + rebuildModelInstances(document, selection) { + this.clearModelInstances(); + for (const modelInstance of getModelInstances(document.modelInstances)) { + const selected = isModelInstanceSelected(selection, modelInstance.id); + const asset = this.projectAssets[modelInstance.assetId]; + const loadedAsset = this.loadedModelAssets[modelInstance.assetId]; + const renderGroup = createModelInstanceRenderGroup(modelInstance, asset, loadedAsset, selected, undefined, this.displayMode === "wireframe" ? "wireframe" : "normal"); + if (asset?.kind === "model" && modelInstance.collision.visible) { + try { + const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + if (generatedCollider !== null) { + renderGroup.add(createModelColliderDebugGroup(generatedCollider)); + } + } + catch { + // Validation surfaces unsupported collider modes; the viewport keeps rendering the model. + } + } + this.modelGroup.add(renderGroup); + this.modelRenderObjects.set(modelInstance.id, renderGroup); + } + this.applyShadowState(); + } + createEntityRenderObjects(entity, selected) { + switch (entity.kind) { + case "pointLight": + return this.createPointLightGizmoRenderObjects(entity.id, entity.position, entity.distance, entity.colorHex, selected); + case "spotLight": + return this.createSpotLightGizmoRenderObjects(entity.id, entity.position, entity.direction, entity.distance, entity.angleDegrees, entity.colorHex, selected); + case "playerStart": + return this.createPlayerStartRenderObjects(entity.id, entity.position, entity.yawDegrees, entity.collider, selected); + case "soundEmitter": + return this.createSoundEmitterRenderObjects(entity.id, entity.position, entity.refDistance, entity.maxDistance, selected); + case "triggerVolume": + return this.createTriggerVolumeRenderObjects(entity.id, entity.position, entity.size, selected); + case "teleportTarget": + return this.createTeleportTargetRenderObjects(entity.id, entity.position, entity.yawDegrees, selected); + case "interactable": + return this.createInteractableRenderObjects(entity.id, entity.position, entity.radius, selected); + } + } + tagEntityMesh(mesh, entityId, entityKind, group) { + mesh.userData.entityId = entityId; + mesh.userData.entityKind = entityKind; + group.add(mesh); + } + createPointLightGizmoRenderObjects(entityId, position, distance, colorHex, selected) { + const markerColor = colorHex; + const displayRadius = Math.max(0.5, distance); + const group = new Group(); + group.position.set(position.x, position.y, position.z); + const core = new Mesh(new SphereGeometry(0.16, 16, 12), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.22 : 0.1, + roughness: 0.28, + metalness: 0.05 + })); + const range = new Mesh(new SphereGeometry(displayRadius, 16, 12), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.08 : 0.03, + roughness: 0.85, + metalness: 0, + transparent: true, + opacity: selected ? 0.16 : 0.08, + wireframe: true + })); + range.userData.nonPickable = true; + for (const mesh of [core, range]) { + this.tagEntityMesh(mesh, entityId, "pointLight", group); + } + return { + group, + meshes: [core, range] + }; + } + createSpotLightGizmoRenderObjects(entityId, position, direction, distance, angleDegrees, colorHex, selected) { + const markerColor = colorHex; + const group = new Group(); + group.position.set(position.x, position.y, position.z); + const forward = new Vector3(direction.x, direction.y, direction.z).normalize(); + const coneLength = Math.max(0.85, distance); + const coneRadius = Math.max(0.16, Math.tan((angleDegrees * Math.PI) / 360) * coneLength); + const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), forward); + group.quaternion.copy(orientation); + const core = new Mesh(new SphereGeometry(0.16, 14, 10), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.24 : 0.1, + roughness: 0.28, + metalness: 0.05 + })); + const cone = new Mesh(new CylinderGeometry(coneRadius, 0, coneLength, 20, 1, true), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.08 : 0.03, + roughness: 0.85, + metalness: 0, + transparent: true, + opacity: selected ? 0.16 : 0.08, + wireframe: true + })); + cone.position.y = coneLength * 0.5; + cone.userData.nonPickable = true; + for (const mesh of [core, cone]) { + this.tagEntityMesh(mesh, entityId, "spotLight", group); + } + return { + group, + meshes: [core, cone] + }; + } + createSpotLightRuntimeObjects(entity) { + const group = new Group(); + const light = new SpotLight(entity.colorHex, entity.intensity, entity.distance, (entity.angleDegrees * Math.PI) / 180, 0.18, 1); + const direction = new Vector3(entity.direction.x, entity.direction.y, entity.direction.z).normalize(); + const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction); + group.position.set(entity.position.x, entity.position.y, entity.position.z); + group.quaternion.copy(orientation); + light.position.set(0, 0, 0); + light.target.position.set(0, 1, 0); + group.add(light); + group.add(light.target); + return { + group + }; + } + createPointLightRuntimeObjects(entity) { + const group = new Group(); + const light = new PointLight(entity.colorHex, entity.intensity, entity.distance); + group.position.set(entity.position.x, entity.position.y, entity.position.z); + light.position.set(0, 0, 0); + group.add(light); + return { + group + }; + } + createPlayerStartRenderObjects(entityId, position, yawDegrees, collider, selected) { + const markerColor = selected ? PLAYER_START_SELECTED_COLOR : PLAYER_START_COLOR; + const group = new Group(); + group.position.set(position.x, position.y, position.z); + const colliderMaterial = new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.14 : 0.05, + roughness: 0.5, + metalness: 0.02, + transparent: true, + opacity: selected ? 0.4 : 0.24 + }); + const arrowMaterial = new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.2 : 0.08, + roughness: 0.38, + metalness: 0.03 + }); + const meshes = []; + switch (collider.mode) { + case "capsule": { + const collisionMesh = new Mesh(new CapsuleGeometry(collider.capsuleRadius, Math.max(0, collider.capsuleHeight - collider.capsuleRadius * 2), 6, 12), colliderMaterial); + collisionMesh.position.y = collider.capsuleHeight * 0.5; + this.tagEntityMesh(collisionMesh, entityId, "playerStart", group); + meshes.push(collisionMesh); + break; + } + case "box": { + const collisionMesh = new Mesh(new BoxGeometry(collider.boxSize.x, collider.boxSize.y, collider.boxSize.z), colliderMaterial); + collisionMesh.position.y = collider.boxSize.y * 0.5; + this.tagEntityMesh(collisionMesh, entityId, "playerStart", group); + meshes.push(collisionMesh); + break; + } + case "none": + break; + } + const directionGroup = new Group(); + directionGroup.rotation.y = (yawDegrees * Math.PI) / 180; + group.add(directionGroup); + const colliderTop = getPlayerStartColliderHeight(collider) ?? 0.18; + const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), arrowMaterial); + body.position.set(0, colliderTop + 0.12, 0.06); + const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), arrowMaterial); + arrowHead.rotation.x = Math.PI * 0.5; + arrowHead.position.set(0, colliderTop + 0.12, 0.28); + for (const mesh of [body, arrowHead]) { + this.tagEntityMesh(mesh, entityId, "playerStart", directionGroup); + meshes.push(mesh); + } + return { + group, + meshes + }; + } + createSoundEmitterRenderObjects(entityId, position, refDistance, maxDistance, selected, markerColor = selected ? SOUND_EMITTER_SELECTED_COLOR : SOUND_EMITTER_COLOR) { + const displayRefDistance = Math.max(0.4, refDistance); + const displayMaxDistance = Math.max(displayRefDistance, maxDistance); + const group = new Group(); + group.position.set(position.x, position.y, position.z); + const speakerMeshes = createSoundEmitterMarkerMeshes(markerColor, selected); + const refDistanceShell = new Mesh(new SphereGeometry(displayRefDistance, 16, 12), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.1 : 0.03, + roughness: 0.8, + metalness: 0, + transparent: true, + opacity: selected ? 0.18 : 0.09, + wireframe: true + })); + refDistanceShell.userData.nonPickable = true; + const maxDistanceShell = new Mesh(new SphereGeometry(displayMaxDistance, 16, 12), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.06 : 0.015, + roughness: 0.82, + metalness: 0, + transparent: true, + opacity: selected ? 0.12 : 0.06, + wireframe: true + })); + maxDistanceShell.userData.nonPickable = true; + for (const mesh of [...speakerMeshes, refDistanceShell, maxDistanceShell]) { + this.tagEntityMesh(mesh, entityId, "soundEmitter", group); + } + return { + group, + meshes: [...speakerMeshes, refDistanceShell, maxDistanceShell] + }; + } + createTriggerVolumeRenderObjects(entityId, position, size, selected, markerColor = selected ? TRIGGER_VOLUME_SELECTED_COLOR : TRIGGER_VOLUME_COLOR) { + const group = new Group(); + group.position.set(position.x, position.y, position.z); + const fill = new Mesh(new BoxGeometry(size.x, size.y, size.z), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.1 : 0.03, + roughness: 0.7, + metalness: 0, + transparent: true, + opacity: selected ? 0.2 : 0.1 + })); + const outline = new Mesh(new BoxGeometry(size.x, size.y, size.z), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.12 : 0.04, + roughness: 0.9, + metalness: 0, + wireframe: true, + transparent: true, + opacity: 0.95 + })); + for (const mesh of [fill, outline]) { + this.tagEntityMesh(mesh, entityId, "triggerVolume", group); + } + return { + group, + meshes: [fill, outline] + }; + } + createTeleportTargetRenderObjects(entityId, position, yawDegrees, selected, markerColor = selected ? TELEPORT_TARGET_SELECTED_COLOR : TELEPORT_TARGET_COLOR) { + const group = new Group(); + group.position.set(position.x, position.y, position.z); + group.rotation.y = (yawDegrees * Math.PI) / 180; + const ring = new Mesh(new TorusGeometry(0.28, 0.045, 8, 24), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.18 : 0.08, + roughness: 0.42, + metalness: 0.04 + })); + ring.rotation.x = Math.PI * 0.5; + ring.position.y = 0.035; + const stem = new Mesh(new CylinderGeometry(0.04, 0.04, 0.3, 12), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.12 : 0.04, + roughness: 0.45, + metalness: 0.02 + })); + stem.position.y = 0.15; + const arrowHead = new Mesh(new ConeGeometry(0.12, 0.24, 14), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.18 : 0.06, + roughness: 0.36, + metalness: 0.03 + })); + arrowHead.rotation.x = Math.PI * 0.5; + arrowHead.position.set(0, 0.15, 0.34); + for (const mesh of [ring, stem, arrowHead]) { + this.tagEntityMesh(mesh, entityId, "teleportTarget", group); + } + return { + group, + meshes: [ring, stem, arrowHead] + }; + } + createInteractableRenderObjects(entityId, position, radius, selected, markerColor = selected ? INTERACTABLE_SELECTED_COLOR : INTERACTABLE_COLOR) { + const displayRadius = Math.max(0.45, radius); + const group = new Group(); + group.position.set(position.x, position.y, position.z); + const core = new Mesh(new SphereGeometry(0.16, 12, 10), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.18 : 0.08, + roughness: 0.34, + metalness: 0.04 + })); + const radiusRing = new Mesh(new TorusGeometry(displayRadius, 0.03, 8, 32), new MeshStandardMaterial({ + color: markerColor, + emissive: markerColor, + emissiveIntensity: selected ? 0.1 : 0.04, + roughness: 0.55, + metalness: 0.02 + })); + radiusRing.rotation.x = Math.PI * 0.5; + radiusRing.userData.nonPickable = true; + for (const mesh of [core, radiusRing]) { + this.tagEntityMesh(mesh, entityId, "interactable", group); + } + return { + group, + meshes: [core, radiusRing] + }; + } + emitWhiteboxHoverLabelChange() { + const label = this.currentDocument === null ? null : getWhiteboxSelectionFeedbackLabel(this.currentDocument, this.hoveredSelection); + this.whiteboxHoverLabelChangeHandler?.(label); + } + setHoveredSelection(selection) { + if (areEditorSelectionsEqual(this.hoveredSelection, selection)) { + return; + } + this.hoveredSelection = selection; + this.refreshBrushPresentation(); + this.emitWhiteboxHoverLabelChange(); + } + getFaceHighlightState(brushId, faceId) { + if (isBrushFaceSelected(this.currentSelection, brushId, faceId)) { + return "selected"; + } + if (this.hoveredSelection.kind === "brushFace" && this.hoveredSelection.brushId === brushId && this.hoveredSelection.faceId === faceId) { + return "hovered"; + } + return "none"; + } + createFaceMaterial(brush, faceId, material, highlightState) { + const face = brush.faces[faceId]; + const selectedFace = highlightState === "selected"; + const hoveredFace = highlightState === "hovered"; + const emphasizedFace = selectedFace || hoveredFace; + if (this.displayMode === "authoring") { + const colorHex = material === undefined || face.materialId === null + ? selectedFace + ? SELECTED_FACE_FALLBACK_COLOR + : hoveredFace + ? HOVERED_FACE_FALLBACK_COLOR + : FALLBACK_FACE_COLOR + : emphasizedFace + ? material.accentColorHex + : material.baseColorHex; + return new MeshBasicMaterial({ + color: colorHex, + transparent: true, + opacity: selectedFace ? 0.36 : hoveredFace ? 0.28 : 0.18, + wireframe: false + }); + } + if (this.displayMode === "wireframe") { + const colorHex = material === undefined || face.materialId === null + ? selectedFace + ? SELECTED_FACE_FALLBACK_COLOR + : hoveredFace + ? HOVERED_FACE_FALLBACK_COLOR + : FALLBACK_FACE_COLOR + : emphasizedFace + ? material.accentColorHex + : material.baseColorHex; + return new MeshBasicMaterial({ + color: colorHex, + wireframe: true, + transparent: true, + opacity: selectedFace ? 0.95 : hoveredFace ? 0.86 : 0.76, + depthWrite: false + }); + } + if (material === undefined || face.materialId === null) { + return new MeshStandardMaterial({ + color: selectedFace ? SELECTED_FACE_FALLBACK_COLOR : hoveredFace ? HOVERED_FACE_FALLBACK_COLOR : FALLBACK_FACE_COLOR, + emissive: selectedFace ? SELECTED_FACE_EMISSIVE : hoveredFace ? HOVERED_FACE_EMISSIVE : 0x000000, + emissiveIntensity: selectedFace ? 0.28 : hoveredFace ? 0.18 : 0, + roughness: 0.9, + metalness: 0.05 + }); + } + return new MeshStandardMaterial({ + color: 0xffffff, + map: this.getOrCreateTexture(material), + emissive: selectedFace ? SELECTED_FACE_EMISSIVE : hoveredFace ? HOVERED_FACE_EMISSIVE : 0x000000, + emissiveIntensity: selectedFace ? 0.32 : hoveredFace ? 0.18 : 0, + roughness: 0.92, + metalness: 0.02 + }); + } + getOrCreateTexture(material) { + const signature = createStarterMaterialSignature(material); + const cachedTexture = this.materialTextureCache.get(material.id); + if (cachedTexture !== undefined && cachedTexture.signature === signature) { + return cachedTexture.texture; + } + cachedTexture?.texture.dispose(); + const texture = createStarterMaterialTexture(material); + this.materialTextureCache.set(material.id, { + signature, + texture + }); + return texture; + } + createEdgeHelper(brush, edgeId) { + const segment = getBoxBrushEdgeWorldSegment(brush, edgeId); + const geometry = new BufferGeometry().setFromPoints([ + new Vector3(segment.start.x, segment.start.y, segment.start.z), + new Vector3(segment.end.x, segment.end.y, segment.end.z) + ]); + const line = new Line(geometry, new LineBasicMaterial({ + color: WHITEBOX_COMPONENT_COLOR, + transparent: true, + opacity: WHITEBOX_COMPONENT_DEFAULT_OPACITY, + depthTest: false + })); + line.userData.brushId = brush.id; + line.userData.brushEdgeId = edgeId; + return { + id: edgeId, + line + }; + } + createVertexHelper(brush, vertexId) { + const position = getBoxBrushVertexWorldPosition(brush, vertexId); + const mesh = new Mesh(new SphereGeometry(WHITEBOX_VERTEX_RADIUS, 10, 8), new MeshBasicMaterial({ + color: WHITEBOX_COMPONENT_COLOR, + transparent: true, + opacity: WHITEBOX_COMPONENT_DEFAULT_OPACITY, + depthTest: false + })); + mesh.position.set(position.x, position.y, position.z); + mesh.userData.brushId = brush.id; + mesh.userData.brushVertexId = vertexId; + return { + id: vertexId, + mesh + }; + } + refreshBrushPresentation() { + if (this.currentDocument === null) { + return; + } + for (const brush of Object.values(this.currentDocument.brushes)) { + const renderObjects = this.brushRenderObjects.get(brush.id); + if (renderObjects === undefined) { + continue; + } + const brushSelected = isBrushSelected(this.currentSelection, brush.id); + const brushHovered = this.hoveredSelection.kind === "brushes" && this.hoveredSelection.ids.includes(brush.id); + renderObjects.edges.material.color.setHex(brushSelected ? BRUSH_SELECTED_EDGE_COLOR : brushHovered && this.whiteboxSelectionMode === "object" ? BRUSH_HOVERED_EDGE_COLOR : BRUSH_EDGE_COLOR); + const previousMaterials = renderObjects.mesh.material; + renderObjects.mesh.material = BOX_FACE_IDS.map((faceId) => this.createFaceMaterial(brush, faceId, this.currentDocument?.materials[brush.faces[faceId].materialId ?? ""], this.getFaceHighlightState(brush.id, faceId))); + for (const material of previousMaterials) { + material.dispose(); + } + const hoveredEdgeId = this.hoveredSelection.kind === "brushEdge" && this.hoveredSelection.brushId === brush.id ? this.hoveredSelection.edgeId : null; + const hoveredVertexId = this.hoveredSelection.kind === "brushVertex" && this.hoveredSelection.brushId === brush.id ? this.hoveredSelection.vertexId : null; + for (const edgeHelper of renderObjects.edgeHelpers) { + const selected = isBrushEdgeSelected(this.currentSelection, brush.id, edgeHelper.id); + const hovered = hoveredEdgeId === edgeHelper.id; + edgeHelper.line.visible = this.whiteboxSelectionMode === "edge"; + edgeHelper.line.material.color.setHex(selected ? WHITEBOX_COMPONENT_SELECTED_COLOR : hovered ? WHITEBOX_COMPONENT_HOVERED_COLOR : WHITEBOX_COMPONENT_COLOR); + edgeHelper.line.material.opacity = selected + ? WHITEBOX_COMPONENT_SELECTED_OPACITY + : hovered + ? WHITEBOX_COMPONENT_HOVERED_OPACITY + : WHITEBOX_COMPONENT_DEFAULT_OPACITY; + } + for (const vertexHelper of renderObjects.vertexHelpers) { + const selected = isBrushVertexSelected(this.currentSelection, brush.id, vertexHelper.id); + const hovered = hoveredVertexId === vertexHelper.id; + vertexHelper.mesh.visible = this.whiteboxSelectionMode === "vertex"; + vertexHelper.mesh.material.color.setHex(selected ? WHITEBOX_COMPONENT_SELECTED_COLOR : hovered ? WHITEBOX_COMPONENT_HOVERED_COLOR : WHITEBOX_COMPONENT_COLOR); + vertexHelper.mesh.material.opacity = selected + ? WHITEBOX_COMPONENT_SELECTED_OPACITY + : hovered + ? WHITEBOX_COMPONENT_HOVERED_OPACITY + : WHITEBOX_COMPONENT_DEFAULT_OPACITY; + } + } + } + clearLocalLights() { + for (const renderObjects of this.localLightRenderObjects.values()) { + this.localLightGroup.remove(renderObjects.group); + } + this.localLightRenderObjects.clear(); + } + clearBrushMeshes() { + for (const renderObjects of this.brushRenderObjects.values()) { + this.brushGroup.remove(renderObjects.mesh); + this.brushGroup.remove(renderObjects.edges); + for (const edgeHelper of renderObjects.edgeHelpers) { + this.brushGroup.remove(edgeHelper.line); + edgeHelper.line.geometry.dispose(); + edgeHelper.line.material.dispose(); + } + for (const vertexHelper of renderObjects.vertexHelpers) { + this.brushGroup.remove(vertexHelper.mesh); + vertexHelper.mesh.geometry.dispose(); + vertexHelper.mesh.material.dispose(); + } + renderObjects.mesh.geometry.dispose(); + for (const material of renderObjects.mesh.material) { + material.dispose(); + } + renderObjects.edges.geometry.dispose(); + renderObjects.edges.material.dispose(); + } + this.brushRenderObjects.clear(); + } + clearEntityMarkers() { + for (const renderObjects of this.entityRenderObjects.values()) { + this.entityGroup.remove(renderObjects.group); + for (const mesh of renderObjects.meshes) { + mesh.geometry.dispose(); + if (Array.isArray(mesh.material)) { + for (const material of mesh.material) { + material.dispose(); + } + } + else { + mesh.material.dispose(); + } + } + } + this.entityRenderObjects.clear(); + } + clearModelInstances() { + for (const renderGroup of this.modelRenderObjects.values()) { + this.modelGroup.remove(renderGroup); + disposeModelInstance(renderGroup); + } + this.modelRenderObjects.clear(); + } + resize() { + if (this.container === null) { + return; + } + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (width === 0 || height === 0) { + return; + } + this.perspectiveCamera.aspect = width / height; + this.perspectiveCamera.updateProjectionMatrix(); + this.updateOrthographicCameraFrustum(); + this.orthographicCamera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + this.advancedRenderingComposer?.setSize(width, height); + } + pickTransformHandle(event) { + if (!this.transformGizmoGroup.visible) { + return null; + } + if (!this.setPointerFromClientPosition(event.clientX, event.clientY)) { + return null; + } + this.raycaster.setFromCamera(this.pointer, this.getActiveCamera()); + const hits = this.raycaster.intersectObjects(this.transformGizmoGroup.children, true); + for (const hit of hits) { + const axisConstraint = hit.object.userData.transformAxisConstraint; + if (axisConstraint === null || axisConstraint === "x" || axisConstraint === "y" || axisConstraint === "z") { + return { + axisConstraint + }; + } + } + return null; + } + getBrushPickableObjects() { + switch (this.whiteboxSelectionMode) { + case "object": + case "face": + return Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.mesh); + case "edge": + return Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.edgeHelpers.map((helper) => helper.line)).flat(); + case "vertex": + return Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.vertexHelpers.map((helper) => helper.mesh)).flat(); + } + } + createSelectionKey(selection) { + switch (selection.kind) { + case "none": + return null; + case "brushes": + return selection.ids.length === 1 ? `brush:${selection.ids[0]}` : null; + case "brushFace": + return `brushFace:${selection.brushId}:${selection.faceId}`; + case "brushEdge": + return `brushEdge:${selection.brushId}:${selection.edgeId}`; + case "brushVertex": + return `brushVertex:${selection.brushId}:${selection.vertexId}`; + case "entities": + return selection.ids.length === 1 ? `entity:${selection.ids[0]}` : null; + case "modelInstances": + return selection.ids.length === 1 ? `model:${selection.ids[0]}` : null; + } + } + createSelectionFromHit(hit) { + if (hit.object.userData.nonPickable === true) { + return null; + } + const entityId = hit.object.userData.entityId; + if (typeof entityId === "string") { + return { + kind: "entities", + ids: [entityId] + }; + } + const modelInstanceId = this.findModelInstanceId(hit.object); + if (modelInstanceId !== null) { + return { + kind: "modelInstances", + ids: [modelInstanceId] + }; + } + const brushId = hit.object.userData.brushId; + if (typeof brushId !== "string") { + return null; + } + const brushEdgeId = hit.object.userData.brushEdgeId; + if (typeof brushEdgeId === "string") { + return { + kind: "brushEdge", + brushId, + edgeId: brushEdgeId + }; + } + const brushVertexId = hit.object.userData.brushVertexId; + if (typeof brushVertexId === "string") { + return { + kind: "brushVertex", + brushId, + vertexId: brushVertexId + }; + } + if (this.whiteboxSelectionMode === "face") { + const faceMaterialIndex = hit.face?.materialIndex; + const faceId = typeof faceMaterialIndex === "number" ? BOX_FACE_IDS[faceMaterialIndex] ?? null : null; + if (faceId === null) { + return null; + } + return { + kind: "brushFace", + brushId, + faceId + }; + } + if (this.whiteboxSelectionMode === "object") { + return { + kind: "brushes", + ids: [brushId] + }; + } + return null; + } + getSelectionCandidates(event) { + if (!this.setPointerFromClientPosition(event.clientX, event.clientY)) { + return []; + } + this.raycaster.setFromCamera(this.pointer, this.getActiveCamera()); + this.raycaster.params.Line.threshold = this.whiteboxSelectionMode === "edge" ? WHITEBOX_EDGE_PICK_THRESHOLD : 1; + const hits = this.raycaster.intersectObjects([ + ...Array.from(this.entityRenderObjects.values(), (renderObjects) => renderObjects.group), + ...Array.from(this.modelRenderObjects.values()), + ...this.getBrushPickableObjects() + ], true); + const candidates = []; + const seenKeys = new Set(); + for (const hit of hits) { + const selection = this.createSelectionFromHit(hit); + if (selection === null) { + continue; + } + const key = this.createSelectionKey(selection); + if (key === null || seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + candidates.push({ + key, + selection + }); + } + return candidates; + } + handlePointerDown = (event) => { + this.lastCanvasPointerPosition = { + x: event.clientX, + y: event.clientY + }; + if (event.button === 1) { + event.preventDefault(); + this.activeCameraDragPointerId = event.pointerId; + this.lastCameraDragClientPosition = { + x: event.clientX, + y: event.clientY + }; + this.renderer.domElement.setPointerCapture(event.pointerId); + return; + } + if (event.button === 2) { + event.preventDefault(); + if (this.currentTransformSession.kind === "active") { + this.transformCancelHandler?.(); + } + return; + } + if (event.button !== 0) { + return; + } + const transformHandle = this.pickTransformHandle(event); + const interactionSession = this.currentTransformSession.kind === "active" + ? this.currentTransformSession.sourcePanelId === this.panelId + ? this.currentTransformSession + : null + : this.getDisplayedTransformSession(); + if (transformHandle !== null && interactionSession !== null) { + event.preventDefault(); + if (transformHandle.axisConstraint !== null && + !supportsTransformAxisConstraint(interactionSession, transformHandle.axisConstraint)) { + return; + } + const nextSession = this.buildTransformPreviewFromPointer(createTransformSession({ + source: "gizmo", + sourcePanelId: this.panelId, + operation: interactionSession.operation, + axisConstraint: transformHandle.axisConstraint, + target: interactionSession.target + }), { + x: event.clientX, + y: event.clientY + }, { + x: event.clientX, + y: event.clientY + }, transformHandle.axisConstraint); + this.currentTransformSession = nextSession; + this.applyTransformPreview(); + this.syncTransformGizmo(); + this.transformSessionChangeHandler?.(nextSession); + this.activeTransformDrag = { + pointerId: event.pointerId, + sessionId: nextSession.id, + axisConstraint: transformHandle.axisConstraint, + initialClientPosition: { + x: event.clientX, + y: event.clientY + } + }; + this.renderer.domElement.setPointerCapture(event.pointerId); + return; + } + if (this.currentTransformSession.kind === "active") { + if (this.currentTransformSession.sourcePanelId !== this.panelId) { + return; + } + if (this.currentTransformSession.source !== "gizmo" || this.currentTransformSession.sourcePanelId === this.panelId) { + event.preventDefault(); + this.transformCommitHandler?.(this.currentTransformSession); + return; + } + } + if (this.toolMode === "create" && this.creationPreview !== null) { + const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target); + const nextCreationPreview = { + ...this.creationPreview, + center: previewCenter + }; + this.syncCreationPreview(nextCreationPreview); + this.creationPreviewChangeHandler?.(nextCreationPreview); + if (previewCenter !== null) { + const committed = this.creationCommitHandler?.(nextCreationPreview) === true; + if (committed) { + this.syncCreationPreview(null); + this.creationPreviewChangeHandler?.({ kind: "none" }); + } + } + return; + } + const candidates = this.getSelectionCandidates(event); + if (candidates.length === 0) { + this.lastClickPointer = null; + this.lastClickSelectionKey = null; + this.brushSelectionChangeHandler?.({ + kind: "none" + }); + return; + } + // Determine whether this click is at the same spot as the last one. + const POINTER_TOLERANCE = 0.01; + const isSameSpot = this.lastClickPointer !== null && + Math.abs(this.pointer.x - this.lastClickPointer.x) < POINTER_TOLERANCE && + Math.abs(this.pointer.y - this.lastClickPointer.y) < POINTER_TOLERANCE; + let candidateIndex = 0; + if (isSameSpot && this.lastClickSelectionKey !== null) { + // Find where the previously selected item sits in the new hit list and advance by one. + const lastIndex = candidates.findIndex((c) => c.key === this.lastClickSelectionKey); + if (lastIndex !== -1) { + candidateIndex = (lastIndex + 1) % candidates.length; + } + } + this.lastClickPointer = { x: this.pointer.x, y: this.pointer.y }; + const chosen = candidates[candidateIndex]; + this.lastClickSelectionKey = chosen.key; + this.brushSelectionChangeHandler?.(chosen.selection); + }; + handlePointerMove = (event) => { + this.lastCanvasPointerPosition = { + x: event.clientX, + y: event.clientY + }; + if (this.activeCameraDragPointerId === event.pointerId && this.lastCameraDragClientPosition !== null) { + const deltaX = event.clientX - this.lastCameraDragClientPosition.x; + const deltaY = event.clientY - this.lastCameraDragClientPosition.y; + this.lastCameraDragClientPosition = { + x: event.clientX, + y: event.clientY + }; + if (this.viewMode === "perspective" && !event.shiftKey) { + this.orbitCamera(deltaX, deltaY); + } + else { + this.panCamera(deltaX, deltaY); + } + return; + } + if (this.activeTransformDrag !== null && + this.activeTransformDrag.pointerId === event.pointerId && + this.currentTransformSession.kind === "active" && + this.currentTransformSession.id === this.activeTransformDrag.sessionId) { + const nextSession = this.buildTransformPreviewFromPointer(this.currentTransformSession, this.activeTransformDrag.initialClientPosition, { + x: event.clientX, + y: event.clientY + }, this.activeTransformDrag.axisConstraint); + this.currentTransformSession = nextSession; + this.applyTransformPreview(); + this.syncTransformGizmo(); + this.transformSessionChangeHandler?.(nextSession); + return; + } + if (this.toolMode === "select") { + const hoveredCandidate = this.getSelectionCandidates(event)[0]?.selection ?? { kind: "none" }; + this.setHoveredSelection(hoveredCandidate); + return; + } + this.setHoveredSelection({ + kind: "none" + }); + if (this.toolMode !== "create" || this.creationPreview === null) { + return; + } + const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target); + const nextCreationPreview = { + ...this.creationPreview, + center: previewCenter + }; + this.syncCreationPreview(nextCreationPreview); + this.creationPreviewChangeHandler?.(nextCreationPreview); + }; + handlePointerUp = (event) => { + if (this.activeTransformDrag !== null && this.activeTransformDrag.pointerId === event.pointerId) { + if (this.renderer.domElement.hasPointerCapture(event.pointerId)) { + this.renderer.domElement.releasePointerCapture(event.pointerId); + } + const completedSession = this.currentTransformSession.kind === "active" ? this.currentTransformSession : null; + this.activeTransformDrag = null; + if (completedSession !== null) { + if (event.type === "pointercancel") { + this.transformCancelHandler?.(); + } + else { + this.transformCommitHandler?.(completedSession); + } + } + return; + } + if (this.activeCameraDragPointerId !== event.pointerId) { + return; + } + if (this.renderer.domElement.hasPointerCapture(event.pointerId)) { + this.renderer.domElement.releasePointerCapture(event.pointerId); + } + this.activeCameraDragPointerId = null; + this.lastCameraDragClientPosition = null; + this.emitCameraStateChange(); + }; + handlePointerLeave = () => { + if (this.activeCameraDragPointerId !== null) { + return; + } + this.setHoveredSelection({ + kind: "none" + }); + // Keep the shared creation preview alive across panel boundaries; the next + // viewport panel will update it as the pointer continues moving. + }; + handleWindowPointerMove = (event) => { + if (this.currentTransformSession.kind !== "active" || + this.currentTransformSession.sourcePanelId !== this.panelId || + this.currentTransformSession.source === "gizmo" || + this.keyboardTransformPointerOrigin === null || + this.keyboardTransformPointerOrigin.sessionId !== this.currentTransformSession.id) { + return; + } + const nextSession = this.buildTransformPreviewFromPointer(this.currentTransformSession, { + x: this.keyboardTransformPointerOrigin.clientX, + y: this.keyboardTransformPointerOrigin.clientY + }, { + x: event.clientX, + y: event.clientY + }, this.currentTransformSession.axisConstraint); + this.currentTransformSession = nextSession; + this.applyTransformPreview(); + this.syncTransformGizmo(); + this.transformSessionChangeHandler?.(nextSession); + }; + handleWheel = (event) => { + event.preventDefault(); + if (this.viewMode === "perspective") { + this.cameraSpherical.radius = Math.min(MAX_CAMERA_DISTANCE, Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius * Math.exp(event.deltaY * ZOOM_SPEED))); + this.applyPerspectiveCameraPose(); + this.emitCameraStateChange(); + return; + } + this.orthographicCamera.zoom = Math.min(MAX_ORTHOGRAPHIC_ZOOM, Math.max(MIN_ORTHOGRAPHIC_ZOOM, this.orthographicCamera.zoom * Math.exp(-event.deltaY * ZOOM_SPEED))); + this.orthographicCamera.updateProjectionMatrix(); + this.emitCameraStateChange(); + }; + handleAuxClick = (event) => { + if (event.button === 1 || event.button === 2) { + event.preventDefault(); + } + }; + handleContextMenu = (event) => { + event.preventDefault(); + }; + findModelInstanceId(object) { + let current = object; + while (current !== null) { + const modelInstanceId = current.userData.modelInstanceId; + if (typeof modelInstanceId === "string") { + return modelInstanceId; + } + current = current.parent; + } + return null; + } + orbitCamera(deltaX, deltaY) { + this.cameraSpherical.theta -= deltaX * ORBIT_ROTATION_SPEED; + this.cameraSpherical.phi -= deltaY * ORBIT_ROTATION_SPEED; + this.applyPerspectiveCameraPose(); + } + panCamera(deltaX, deltaY) { + if (this.container === null) { + return; + } + const width = Math.max(1, this.container.clientWidth); + const height = Math.max(1, this.container.clientHeight); + if (this.viewMode === "perspective") { + const visibleHeight = 2 * Math.tan((this.perspectiveCamera.fov * Math.PI) / 360) * this.cameraSpherical.radius; + const visibleWidth = visibleHeight * Math.max(this.perspectiveCamera.aspect, 0.0001); + this.perspectiveCamera.getWorldDirection(this.cameraForward); + this.cameraRight.crossVectors(this.cameraForward, this.perspectiveCamera.up).normalize(); + this.cameraUp.crossVectors(this.cameraRight, this.cameraForward).normalize(); + this.cameraTarget + .addScaledVector(this.cameraRight, (-deltaX / width) * visibleWidth) + .addScaledVector(this.cameraUp, (deltaY / height) * visibleHeight); + this.applyPerspectiveCameraPose(); + return; + } + const visibleHeight = ORTHOGRAPHIC_FRUSTUM_HEIGHT / this.orthographicCamera.zoom; + const visibleWidth = (this.orthographicCamera.right - this.orthographicCamera.left) / this.orthographicCamera.zoom; + this.orthographicCamera.getWorldDirection(this.cameraForward); + this.cameraRight.crossVectors(this.cameraForward, this.orthographicCamera.up).normalize(); + this.cameraUp.crossVectors(this.cameraRight, this.cameraForward).normalize(); + this.cameraTarget + .addScaledVector(this.cameraRight, (-deltaX / width) * visibleWidth) + .addScaledVector(this.cameraUp, (deltaY / height) * visibleHeight); + this.applyOrthographicCameraPose(); + } + getCreationPreviewCenter(event, target) { + switch (target.kind) { + case "box-brush": + return this.getBoxCreationPreviewCenter(event, DEFAULT_BOX_BRUSH_SIZE); + case "entity": + switch (target.entityKind) { + case "triggerVolume": + return this.getBoxCreationPreviewCenter(event, DEFAULT_TRIGGER_VOLUME_SIZE); + case "pointLight": + case "playerStart": + case "soundEmitter": + case "teleportTarget": + case "interactable": + case "spotLight": + return this.getPlanarCreationAnchor(event); + } + case "model-instance": { + const anchor = this.getPlanarCreationAnchor(event); + if (anchor === null) { + return null; + } + const asset = this.projectAssets[target.assetId]; + if (asset === undefined || asset.kind !== "model") { + return null; + } + return createModelInstancePlacementPosition(asset, anchor); + } + } + } + getPlanarCreationAnchor(event) { + const bounds = this.renderer.domElement.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) { + return null; + } + this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1); + this.raycaster.setFromCamera(this.pointer, this.getActiveCamera()); + if (this.raycaster.ray.intersectPlane(this.getBoxCreatePlane(), this.boxCreateIntersection) === null) { + return null; + } + switch (this.viewMode) { + case "perspective": + case "top": + return { + x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x), + y: this.snapWhiteboxPositionValue(0), + z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z) + }; + case "front": + return { + x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x), + y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y), + z: this.snapWhiteboxPositionValue(0) + }; + case "side": + return { + x: this.snapWhiteboxPositionValue(0), + y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y), + z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z) + }; + } + } + getBoxCreationPreviewCenter(event, size) { + const bounds = this.renderer.domElement.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) { + return null; + } + this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1); + this.raycaster.setFromCamera(this.pointer, this.getActiveCamera()); + if (this.raycaster.ray.intersectPlane(this.getBoxCreatePlane(), this.boxCreateIntersection) === null) { + return null; + } + switch (this.viewMode) { + case "perspective": + case "top": + return { + x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x), + y: this.snapWhiteboxPositionValue(size.y * 0.5), + z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z) + }; + case "front": + return { + x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x), + y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y), + z: this.snapWhiteboxPositionValue(size.z * 0.5) + }; + case "side": + return { + x: this.snapWhiteboxPositionValue(size.x * 0.5), + y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y), + z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z) + }; + } + } + getCreationPreviewTargetKey(target) { + switch (target.kind) { + case "box-brush": + return "box-brush"; + case "entity": + return `entity:${target.entityKind}:${target.audioAssetId}`; + case "model-instance": + return `model-instance:${target.assetId}`; + } + } + clearCreationPreviewObject() { + if (this.creationPreviewObject === null) { + this.creationPreviewTargetKey = null; + return; + } + this.scene.remove(this.creationPreviewObject); + disposeModelInstance(this.creationPreviewObject); + this.creationPreviewObject = null; + this.creationPreviewTargetKey = null; + } + createCreationPreviewObject(toolPreview) { + const previewPosition = toolPreview.center ?? { + x: 0, + y: 0, + z: 0 + }; + switch (toolPreview.target.kind) { + case "box-brush": { + const fallbackGroup = new Group(); + fallbackGroup.visible = false; + return fallbackGroup; + } + case "entity": { + let previewGroup; + switch (toolPreview.target.entityKind) { + case "pointLight": + previewGroup = this.createPointLightGizmoRenderObjects("creation-preview", previewPosition, DEFAULT_POINT_LIGHT_DISTANCE, PLACEMENT_PREVIEW_COLOR_HEX, false).group; + break; + case "spotLight": + previewGroup = this.createSpotLightGizmoRenderObjects("creation-preview", previewPosition, DEFAULT_SPOT_LIGHT_DIRECTION, DEFAULT_SPOT_LIGHT_DISTANCE, DEFAULT_SPOT_LIGHT_ANGLE_DEGREES, PLACEMENT_PREVIEW_COLOR_HEX, false).group; + break; + case "playerStart": + previewGroup = this.createPlayerStartRenderObjects("creation-preview", previewPosition, DEFAULT_PLAYER_START_YAW_DEGREES, { + mode: "capsule", + eyeHeight: DEFAULT_PLAYER_START_EYE_HEIGHT, + capsuleRadius: DEFAULT_PLAYER_START_CAPSULE_RADIUS, + capsuleHeight: DEFAULT_PLAYER_START_CAPSULE_HEIGHT, + boxSize: DEFAULT_PLAYER_START_BOX_SIZE + }, false).group; + break; + case "soundEmitter": + previewGroup = this.createSoundEmitterRenderObjects("creation-preview", previewPosition, DEFAULT_SOUND_EMITTER_REF_DISTANCE, DEFAULT_SOUND_EMITTER_MAX_DISTANCE, false, BOX_CREATE_PREVIEW_FILL).group; + break; + case "triggerVolume": + previewGroup = this.createTriggerVolumeRenderObjects("creation-preview", previewPosition, DEFAULT_TRIGGER_VOLUME_SIZE, false, BOX_CREATE_PREVIEW_FILL).group; + break; + case "teleportTarget": + previewGroup = this.createTeleportTargetRenderObjects("creation-preview", previewPosition, DEFAULT_TELEPORT_TARGET_YAW_DEGREES, false, BOX_CREATE_PREVIEW_FILL).group; + break; + case "interactable": + previewGroup = this.createInteractableRenderObjects("creation-preview", previewPosition, DEFAULT_INTERACTABLE_RADIUS, false, BOX_CREATE_PREVIEW_FILL).group; + break; + } + if (this.displayMode === "wireframe") { + this.applyWireframePresentation(previewGroup); + } + return previewGroup; + } + case "model-instance": { + const asset = this.projectAssets[toolPreview.target.assetId]; + const loadedAsset = this.loadedModelAssets[toolPreview.target.assetId]; + if (asset === undefined || asset.kind !== "model") { + const fallbackGroup = new Group(); + fallbackGroup.visible = false; + return fallbackGroup; + } + const dummyModelInstance = createModelInstance({ + assetId: toolPreview.target.assetId, + position: previewPosition, + rotationDegrees: DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, + scale: DEFAULT_MODEL_INSTANCE_SCALE + }); + return createModelInstanceRenderGroup(dummyModelInstance, asset, loadedAsset, false, BOX_CREATE_PREVIEW_FILL, this.displayMode === "wireframe" ? "wireframe" : "normal"); + } + } + throw new Error("Unsupported creation preview target."); + } + syncCreationPreview(toolPreview) { + const currentToolPreview = this.creationPreview === null ? { kind: "none" } : this.creationPreview; + const nextToolPreview = toolPreview === null ? { kind: "none" } : toolPreview; + if (areViewportToolPreviewsEqual(currentToolPreview, nextToolPreview)) { + return; + } + this.creationPreview = toolPreview === null ? null : { + kind: "create", + sourcePanelId: toolPreview.sourcePanelId, + target: toolPreview.target.kind === "entity" + ? { + kind: "entity", + entityKind: toolPreview.target.entityKind, + audioAssetId: toolPreview.target.audioAssetId + } + : toolPreview.target.kind === "model-instance" + ? { + kind: "model-instance", + assetId: toolPreview.target.assetId + } + : { + kind: "box-brush" + }, + center: toolPreview.center === null ? null : { ...toolPreview.center } + }; + if (toolPreview === null) { + this.boxCreatePreviewMesh.visible = false; + this.boxCreatePreviewEdges.visible = false; + this.clearCreationPreviewObject(); + return; + } + if (toolPreview.target.kind === "box-brush") { + this.boxCreatePreviewMesh.visible = toolPreview.center !== null; + this.boxCreatePreviewEdges.visible = toolPreview.center !== null; + if (toolPreview.center !== null) { + this.boxCreatePreviewMesh.position.set(toolPreview.center.x, toolPreview.center.y, toolPreview.center.z); + this.boxCreatePreviewEdges.position.set(toolPreview.center.x, toolPreview.center.y, toolPreview.center.z); + } + this.clearCreationPreviewObject(); + this.creationPreviewTargetKey = null; + return; + } + const nextTargetKey = this.getCreationPreviewTargetKey(toolPreview.target); + this.boxCreatePreviewMesh.visible = false; + this.boxCreatePreviewEdges.visible = false; + if (this.creationPreviewObject !== null && this.creationPreviewTargetKey === nextTargetKey) { + this.creationPreviewObject.visible = toolPreview.center !== null; + if (toolPreview.center !== null) { + this.creationPreviewObject.position.set(toolPreview.center.x, toolPreview.center.y, toolPreview.center.z); + } + this.creationPreviewTargetKey = nextTargetKey; + return; + } + this.clearCreationPreviewObject(); + const creationPreviewObject = this.createCreationPreviewObject(toolPreview); + creationPreviewObject.visible = toolPreview.center !== null; + this.scene.add(creationPreviewObject); + this.creationPreviewObject = creationPreviewObject; + this.creationPreviewTargetKey = nextTargetKey; + } + render = () => { + this.animationFrame = window.requestAnimationFrame(this.render); + this.updateTransformGizmoPose(); + if (this.advancedRenderingComposer !== null) { + this.advancedRenderingComposer.render(); + return; + } + this.renderer.render(this.scene, this.getActiveCamera()); + }; +} diff --git a/src/viewport-three/viewport-layout.js b/src/viewport-three/viewport-layout.js new file mode 100644 index 00000000..79344ef5 --- /dev/null +++ b/src/viewport-three/viewport-layout.js @@ -0,0 +1,126 @@ +export const VIEWPORT_LAYOUT_MODES = ["single", "quad"]; +export const VIEWPORT_PANEL_IDS = ["topLeft", "topRight", "bottomLeft", "bottomRight"]; +const DEFAULT_PERSPECTIVE_CAMERA_POSITION = { + x: 10, + y: 9, + z: 10 +}; +export const DEFAULT_VIEWPORT_LAYOUT_STATE = { + layoutMode: "single", + activePanelId: "topLeft", + panels: { + topLeft: { + viewMode: "perspective", + displayMode: "normal", + cameraState: createDefaultViewportPanelCameraState() + }, + topRight: { + viewMode: "top", + displayMode: "authoring", + cameraState: createDefaultViewportPanelCameraState() + }, + bottomLeft: { + viewMode: "front", + displayMode: "authoring", + cameraState: createDefaultViewportPanelCameraState() + }, + bottomRight: { + viewMode: "side", + displayMode: "authoring", + cameraState: createDefaultViewportPanelCameraState() + } + }, + viewportQuadSplit: { + x: 0.5, + y: 0.5 + } +}; +function createDefaultPerspectiveOrbitState() { + const { x, y, z } = DEFAULT_PERSPECTIVE_CAMERA_POSITION; + const radius = Math.sqrt(x * x + y * y + z * z); + return { + radius, + theta: Math.atan2(x, z), + phi: Math.acos(y / radius) + }; +} +export function createDefaultViewportPanelCameraState() { + return { + target: { + x: 0, + y: 0, + z: 0 + }, + perspectiveOrbit: createDefaultPerspectiveOrbitState(), + orthographicZoom: 1 + }; +} +export function cloneViewportPanelCameraState(cameraState) { + return { + target: { + ...cameraState.target + }, + perspectiveOrbit: { + ...cameraState.perspectiveOrbit + }, + orthographicZoom: cameraState.orthographicZoom + }; +} +export function areViewportPanelCameraStatesEqual(a, b) { + return (a.target.x === b.target.x && + a.target.y === b.target.y && + a.target.z === b.target.z && + a.perspectiveOrbit.radius === b.perspectiveOrbit.radius && + a.perspectiveOrbit.theta === b.perspectiveOrbit.theta && + a.perspectiveOrbit.phi === b.perspectiveOrbit.phi && + a.orthographicZoom === b.orthographicZoom); +} +export function cloneViewportPanelState(panelState) { + return { + viewMode: panelState.viewMode, + displayMode: panelState.displayMode, + cameraState: cloneViewportPanelCameraState(panelState.cameraState) + }; +} +export function cloneViewportLayoutState(layoutState) { + return { + layoutMode: layoutState.layoutMode, + activePanelId: layoutState.activePanelId, + panels: { + topLeft: cloneViewportPanelState(layoutState.panels.topLeft), + topRight: cloneViewportPanelState(layoutState.panels.topRight), + bottomLeft: cloneViewportPanelState(layoutState.panels.bottomLeft), + bottomRight: cloneViewportPanelState(layoutState.panels.bottomRight) + }, + viewportQuadSplit: { + ...layoutState.viewportQuadSplit + } + }; +} +export function createDefaultViewportLayoutState() { + return cloneViewportLayoutState(DEFAULT_VIEWPORT_LAYOUT_STATE); +} +const VIEWPORT_PANEL_LABELS = { + topLeft: "Top Left", + topRight: "Top Right", + bottomLeft: "Bottom Left", + bottomRight: "Bottom Right" +}; +const VIEWPORT_LAYOUT_MODE_LABELS = { + single: "Single View", + quad: "4-Panel" +}; +const VIEWPORT_DISPLAY_MODE_LABELS = { + normal: "Normal", + authoring: "Authoring", + wireframe: "Wireframe" +}; +export function getViewportPanelLabel(panelId) { + return VIEWPORT_PANEL_LABELS[panelId]; +} +export function getViewportLayoutModeLabel(layoutMode) { + return VIEWPORT_LAYOUT_MODE_LABELS[layoutMode]; +} +export function getViewportDisplayModeLabel(displayMode) { + return VIEWPORT_DISPLAY_MODE_LABELS[displayMode]; +} diff --git a/src/viewport-three/viewport-transient-state.js b/src/viewport-three/viewport-transient-state.js new file mode 100644 index 00000000..a1a75bca --- /dev/null +++ b/src/viewport-three/viewport-transient-state.js @@ -0,0 +1,77 @@ +import { areTransformSessionsEqual, cloneTransformSession, createInactiveTransformSession } from "../core/transform-session"; +export function createDefaultViewportTransientState() { + return { + toolPreview: { + kind: "none" + }, + transformSession: createInactiveTransformSession() + }; +} +export function cloneViewportToolPreview(toolPreview) { + if (toolPreview.kind === "none") { + return toolPreview; + } + return { + kind: "create", + sourcePanelId: toolPreview.sourcePanelId, + target: toolPreview.target.kind === "entity" + ? { + kind: "entity", + entityKind: toolPreview.target.entityKind, + audioAssetId: toolPreview.target.audioAssetId + } + : toolPreview.target.kind === "model-instance" + ? { + kind: "model-instance", + assetId: toolPreview.target.assetId + } + : { + kind: "box-brush" + }, + center: toolPreview.center === null ? null : { ...toolPreview.center } + }; +} +export function areViewportToolPreviewsEqual(left, right) { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "none" || right.kind === "none") { + return true; + } + if (left.kind !== "create" || right.kind !== "create") { + return false; + } + if (left.sourcePanelId !== right.sourcePanelId) { + return false; + } + if (left.target.kind !== right.target.kind) { + return false; + } + if (left.target.kind === "entity" && right.target.kind === "entity") { + if (left.target.entityKind !== right.target.entityKind || left.target.audioAssetId !== right.target.audioAssetId) { + return false; + } + } + if (left.target.kind === "model-instance" && right.target.kind === "model-instance" && left.target.assetId !== right.target.assetId) { + return false; + } + if (left.center === null || right.center === null) { + return left.center === right.center; + } + return left.center.x === right.center.x && left.center.y === right.center.y && left.center.z === right.center.z; +} +export function isViewportToolPreviewCompatible(toolMode, toolPreview) { + if (toolPreview.kind === "none") { + return true; + } + return toolMode === "create" && toolPreview.kind === "create"; +} +export function cloneViewportTransientState(transientState) { + return { + toolPreview: cloneViewportToolPreview(transientState.toolPreview), + transformSession: cloneTransformSession(transientState.transformSession) + }; +} +export function areViewportTransientStatesEqual(left, right) { + return areViewportToolPreviewsEqual(left.toolPreview, right.toolPreview) && areTransformSessionsEqual(left.transformSession, right.transformSession); +} diff --git a/src/viewport-three/viewport-view-modes.js b/src/viewport-three/viewport-view-modes.js new file mode 100644 index 00000000..54890f61 --- /dev/null +++ b/src/viewport-three/viewport-view-modes.js @@ -0,0 +1,89 @@ +export const VIEWPORT_VIEW_MODES = ["perspective", "top", "front", "side"]; +const VIEWPORT_VIEW_MODE_DEFINITIONS = { + perspective: { + id: "perspective", + label: "Perspective", + cameraType: "perspective", + cameraDirection: null, + cameraUp: { + x: 0, + y: 1, + z: 0 + }, + gridPlane: "xz", + snapAxis: "y", + controlHint: "Middle-drag orbits, Shift + middle-drag pans, wheel zooms, and Numpad Comma frames the selection." + }, + top: { + id: "top", + label: "Top", + cameraType: "orthographic", + cameraDirection: { + x: 0, + y: 1, + z: 0 + }, + cameraUp: { + x: 0, + y: 0, + z: -1 + }, + gridPlane: "xz", + snapAxis: "y", + controlHint: "Middle-drag pans, wheel zooms, and Numpad Comma frames the selection." + }, + front: { + id: "front", + label: "Front", + cameraType: "orthographic", + cameraDirection: { + x: 0, + y: 0, + z: 1 + }, + cameraUp: { + x: 0, + y: 1, + z: 0 + }, + gridPlane: "xy", + snapAxis: "z", + controlHint: "Middle-drag pans, wheel zooms, and Numpad Comma frames the selection." + }, + side: { + id: "side", + label: "Side", + cameraType: "orthographic", + cameraDirection: { + x: -1, + y: 0, + z: 0 + }, + cameraUp: { + x: 0, + y: 1, + z: 0 + }, + gridPlane: "yz", + snapAxis: "x", + controlHint: "Middle-drag pans, wheel zooms, and Numpad Comma frames the selection." + } +}; +export function getViewportViewModeDefinition(viewMode) { + return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode]; +} +export function getViewportViewModeLabel(viewMode) { + return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].label; +} +export function getViewportViewModeGridPlaneLabel(viewMode) { + return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].gridPlane.toUpperCase(); +} +export function getViewportViewModeControlHint(viewMode) { + return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].controlHint; +} +export function getViewportViewModeSnapAxis(viewMode) { + return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].snapAxis; +} +export function isOrthographicViewportViewMode(viewMode) { + return viewMode !== "perspective"; +} diff --git a/tests/domain/box-brush-face-editing.command.test.js b/tests/domain/box-brush-face-editing.command.test.js new file mode 100644 index 00000000..e08cfced --- /dev/null +++ b/tests/domain/box-brush-face-editing.command.test.js @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createCreateBoxBrushCommand } from "../../src/commands/create-box-brush-command"; +import { createSetBoxBrushFaceMaterialCommand } from "../../src/commands/set-box-brush-face-material-command"; +import { createSetBoxBrushFaceUvStateCommand } from "../../src/commands/set-box-brush-face-uv-state-command"; +describe("box brush face editing commands", () => { + it("applies a material to one box face and supports undo/redo", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand()); + const createdBrush = Object.values(store.getState().document.brushes)[0]; + store.executeCommand(createSetBoxBrushFaceMaterialCommand({ + brushId: createdBrush.id, + faceId: "posZ", + materialId: "starter-amber-grid" + })); + expect(store.getState().document.brushes[createdBrush.id].faces.posZ.materialId).toBe("starter-amber-grid"); + expect(store.getState().selection).toEqual({ + kind: "brushFace", + brushId: createdBrush.id, + faceId: "posZ" + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posZ.materialId).toBeNull(); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posZ.materialId).toBe("starter-amber-grid"); + }); + it("updates face UV state through an undoable command", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand()); + const createdBrush = Object.values(store.getState().document.brushes)[0]; + store.executeCommand(createSetBoxBrushFaceUvStateCommand({ + brushId: createdBrush.id, + faceId: "posY", + uvState: { + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.25, + y: 0.5 + }, + rotationQuarterTurns: 1, + flipU: true, + flipV: false + }, + label: "Adjust top face UVs" + })); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv).toEqual({ + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.25, + y: 0.5 + }, + rotationQuarterTurns: 1, + flipU: true, + flipV: false + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv).toEqual({ + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + }); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv.rotationQuarterTurns).toBe(1); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv.flipU).toBe(true); + }); +}); diff --git a/tests/domain/build-runtime-scene.test.js b/tests/domain/build-runtime-scene.test.js new file mode 100644 index 00000000..363487d1 --- /dev/null +++ b/tests/domain/build-runtime-scene.test.js @@ -0,0 +1,675 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry } from "three"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPointLightEntity, createInteractableEntity, createPlayerStartEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +import { createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink } from "../../src/interactions/interaction-links"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; +import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; +describe("buildRuntimeSceneFromDocument", () => { + it("builds runtime brush data, colliders, and an authored player spawn from the document", () => { + const brush = createBoxBrush({ + id: "brush-room-floor", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 8, + y: 1, + z: 8 + } + }); + brush.faces.posY.materialId = "starter-concrete-checker"; + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90, + collider: { + mode: "box", + eyeHeight: 1.4, + capsuleRadius: 0.3, + capsuleHeight: 1.8, + boxSize: { + x: 0.8, + y: 1.6, + z: 0.7 + } + } + }); + const soundEmitter = createSoundEmitterEntity({ + id: "entity-sound-lobby", + position: { + x: -1, + y: 1, + z: 0 + }, + audioAssetId: "asset-audio-lobby", + volume: 0.75, + refDistance: 8, + maxDistance: 24, + autoplay: true, + loop: false + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-door", + position: { + x: 0, + y: 1, + z: 2 + }, + size: { + x: 2, + y: 2, + z: 1 + }, + triggerOnEnter: true, + triggerOnExit: false + }); + const teleportTarget = createTeleportTargetEntity({ + id: "entity-teleport-target-main", + position: { + x: 6, + y: 0, + z: -3 + }, + yawDegrees: 270 + }); + const interactable = createInteractableEntity({ + id: "entity-interactable-console", + position: { + x: 1, + y: 1, + z: 1 + }, + radius: 1.5, + prompt: "Use Console", + enabled: true + }); + const pointLight = createPointLightEntity({ + id: "entity-point-light-main", + position: { + x: 2, + y: 3, + z: 1 + } + }); + const spotLight = createSpotLightEntity({ + id: "entity-spot-light-main", + position: { + x: -2, + y: 4, + z: 0 + }, + direction: { + x: 0.2, + y: -1, + z: 0.1 + } + }); + const imageAsset = { + id: "asset-background-panorama", + kind: "image", + sourceName: "skybox-panorama.svg", + mimeType: "image/svg+xml", + storageKey: createProjectAssetStorageKey("asset-background-panorama"), + byteLength: 2048, + metadata: { + kind: "image", + width: 512, + height: 256, + hasAlpha: false, + warnings: [] + } + }; + const audioAsset = { + id: "asset-audio-lobby", + kind: "audio", + sourceName: "lobby-loop.ogg", + mimeType: "audio/ogg", + storageKey: createProjectAssetStorageKey("asset-audio-lobby"), + byteLength: 4096, + metadata: { + kind: "audio", + durationSeconds: 3.25, + channelCount: 2, + sampleRateHz: 48000, + warnings: [] + } + }; + const modelAsset = { + id: "asset-model-triangle", + kind: "model", + sourceName: "tiny-triangle.gltf", + mimeType: "model/gltf+json", + storageKey: createProjectAssetStorageKey("asset-model-triangle"), + byteLength: 36, + metadata: { + kind: "model", + format: "gltf", + sceneName: "Fixture Triangle Scene", + nodeCount: 2, + meshCount: 1, + materialNames: ["Fixture Material"], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: 0, + y: 0, + z: 0 + }, + max: { + x: 1, + y: 1, + z: 0 + }, + size: { + x: 1, + y: 1, + z: 0 + } + }, + warnings: [] + } + }; + const modelInstance = createModelInstance({ + id: "model-instance-triangle", + assetId: modelAsset.id, + position: { + x: -2, + y: 0, + z: 4 + }, + rotationDegrees: { + x: 0, + y: 90, + z: 0 + }, + scale: { + x: 2, + y: 2, + z: 2 + } + }); + const document = { + ...createEmptySceneDocument({ name: "Runtime Slice" }), + brushes: { + [brush.id]: brush + }, + assets: { + [audioAsset.id]: audioAsset, + [modelAsset.id]: modelAsset, + [imageAsset.id]: imageAsset + }, + modelInstances: { + [modelInstance.id]: modelInstance + }, + entities: { + [playerStart.id]: playerStart, + [soundEmitter.id]: soundEmitter, + [triggerVolume.id]: triggerVolume, + [teleportTarget.id]: teleportTarget, + [interactable.id]: interactable, + [pointLight.id]: pointLight, + [spotLight.id]: spotLight + }, + interactionLinks: { + "link-teleport": createTeleportPlayerInteractionLink({ + id: "link-teleport", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetEntityId: teleportTarget.id + }), + "link-hide-brush": createToggleVisibilityInteractionLink({ + id: "link-hide-brush", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetBrushId: brush.id, + visible: false + }), + "link-click-teleport": createTeleportPlayerInteractionLink({ + id: "link-click-teleport", + sourceEntityId: interactable.id, + trigger: "click", + targetEntityId: teleportTarget.id + }) + } + }; + document.world.background = { + mode: "image", + assetId: imageAsset.id, + environmentIntensity: 0.75 + }; + document.world.ambientLight.intensity = 0.55; + document.world.sunLight.direction = { + x: -0.8, + y: 1.2, + z: 0.1 + }; + const runtimeScene = buildRuntimeSceneFromDocument(document); + expect(runtimeScene.world).toEqual(document.world); + expect(runtimeScene.world).not.toBe(document.world); + expect(runtimeScene.world.sunLight.direction).not.toBe(document.world.sunLight.direction); + expect(runtimeScene.brushes).toHaveLength(1); + expect(runtimeScene.modelInstances).toEqual([ + { + instanceId: "model-instance-triangle", + assetId: "asset-model-triangle", + name: undefined, + position: { + x: -2, + y: 0, + z: 4 + }, + rotationDegrees: { + x: 0, + y: 90, + z: 0 + }, + scale: { + x: 2, + y: 2, + z: 2 + } + } + ]); + expect(runtimeScene.brushes[0].rotationDegrees).toEqual({ + x: 0, + y: 0, + z: 0 + }); + expect(runtimeScene.brushes[0].faces.posY.material?.id).toBe("starter-concrete-checker"); + expect(runtimeScene.colliders).toEqual([ + { + kind: "box", + source: "brush", + brushId: "brush-room-floor", + center: { + x: 0, + y: -0.5, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 8, + y: 1, + z: 8 + }, + worldBounds: { + min: { + x: -4, + y: -1, + z: -4 + }, + max: { + x: 4, + y: 0, + z: 4 + } + } + } + ]); + expect(runtimeScene.sceneBounds).toEqual({ + min: { + x: -4, + y: -1, + z: -4 + }, + max: { + x: 4, + y: 0, + z: 4 + }, + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 8, + y: 1, + z: 8 + } + }); + expect(runtimeScene.entities).toEqual({ + playerStarts: [ + { + entityId: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90, + collider: { + mode: "box", + eyeHeight: 1.4, + size: { + x: 0.8, + y: 1.6, + z: 0.7 + } + } + } + ], + soundEmitters: [ + { + entityId: "entity-sound-lobby", + position: { + x: -1, + y: 1, + z: 0 + }, + audioAssetId: audioAsset.id, + volume: 0.75, + refDistance: 8, + maxDistance: 24, + autoplay: true, + loop: false + } + ], + triggerVolumes: [ + { + entityId: "entity-trigger-door", + position: { + x: 0, + y: 1, + z: 2 + }, + size: { + x: 2, + y: 2, + z: 1 + }, + triggerOnEnter: true, + triggerOnExit: true + } + ], + teleportTargets: [ + { + entityId: "entity-teleport-target-main", + position: { + x: 6, + y: 0, + z: -3 + }, + yawDegrees: 270 + } + ], + interactables: [ + { + entityId: "entity-interactable-console", + position: { + x: 1, + y: 1, + z: 1 + }, + radius: 1.5, + prompt: "Use Console", + enabled: true + } + ] + }); + expect(runtimeScene.localLights).toEqual({ + pointLights: [ + { + entityId: "entity-point-light-main", + position: { + x: 2, + y: 3, + z: 1 + }, + colorHex: "#ffffff", + intensity: 1.25, + distance: 8 + } + ], + spotLights: [ + { + entityId: "entity-spot-light-main", + position: { + x: -2, + y: 4, + z: 0 + }, + direction: { + x: 0.2, + y: -1, + z: 0.1 + }, + colorHex: "#ffffff", + intensity: 1.5, + distance: 12, + angleDegrees: 35 + } + ] + }); + expect(runtimeScene.interactionLinks).toEqual([ + { + id: "link-click-teleport", + sourceEntityId: "entity-interactable-console", + trigger: "click", + action: { + type: "teleportPlayer", + targetEntityId: "entity-teleport-target-main" + } + }, + { + id: "link-teleport", + sourceEntityId: "entity-trigger-door", + trigger: "enter", + action: { + type: "teleportPlayer", + targetEntityId: "entity-teleport-target-main" + } + }, + { + id: "link-hide-brush", + sourceEntityId: "entity-trigger-door", + trigger: "exit", + action: { + type: "toggleVisibility", + targetBrushId: "brush-room-floor", + visible: false + } + } + ]); + expect(runtimeScene.playerStart).toEqual({ + entityId: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90, + collider: { + mode: "box", + eyeHeight: 1.4, + size: { + x: 0.8, + y: 1.6, + z: 0.7 + } + } + }); + expect(runtimeScene.playerCollider).toEqual({ + mode: "box", + eyeHeight: 1.4, + size: { + x: 0.8, + y: 1.6, + z: 0.7 + } + }); + expect(runtimeScene.spawn).toEqual({ + source: "playerStart", + entityId: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90 + }); + }); + it("builds a deterministic fallback spawn when no PlayerStart is authored", () => { + const brush = createBoxBrush({ + id: "brush-room-wall", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 6, + y: 2, + z: 6 + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Fallback Runtime Scene" }), + brushes: { + [brush.id]: brush + } + }); + expect(runtimeScene.playerStart).toBeNull(); + expect(runtimeScene.playerCollider).toEqual({ + mode: "capsule", + radius: 0.3, + height: 1.8, + eyeHeight: 1.6 + }); + expect(runtimeScene.entities).toEqual({ + playerStarts: [], + soundEmitters: [], + triggerVolumes: [], + teleportTargets: [], + interactables: [] + }); + expect(runtimeScene.interactionLinks).toEqual([]); + expect(runtimeScene.spawn).toEqual({ + source: "fallback", + entityId: null, + position: { + x: 0, + y: 2.1, + z: 6 + }, + yawDegrees: 180 + }); + }); + it("blocks first-person runtime builds when PlayerStart is missing", () => { + expect(() => buildRuntimeSceneFromDocument(createEmptySceneDocument({ name: "Missing Player Start" }), { + navigationMode: "firstPerson" + })).toThrow("First-person run requires an authored Player Start"); + }); + it("adds generated imported-model colliders to the runtime scene build", () => { + const floorBrush = createBoxBrush({ + id: "brush-runtime-floor", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 8, + y: 1, + z: 8 + } + }); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-runtime-collider", new BoxGeometry(1, 2, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-runtime-collider", + assetId: asset.id, + position: { + x: 2, + y: 1, + z: 0 + }, + collision: { + mode: "static", + visible: true + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Imported Collider Scene" }), + assets: { + [asset.id]: asset + }, + brushes: { + [floorBrush.id]: floorBrush + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, { + loadedModelAssets: { + [asset.id]: loadedAsset + } + }); + expect(runtimeScene.colliders).toHaveLength(2); + expect(runtimeScene.colliders[1]).toMatchObject({ + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + kind: "trimesh", + mode: "static", + visible: true + }); + expect(runtimeScene.sceneBounds?.max.y).toBeGreaterThanOrEqual(2); + }); + it("preserves rotated whitebox box transforms for runner rendering and collision bounds", () => { + const brush = createBoxBrush({ + id: "brush-rotated-room", + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 4 + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Rotated Whitebox Scene" }), + brushes: { + [brush.id]: brush + } + }); + expect(runtimeScene.brushes[0]).toMatchObject({ + center: brush.center, + rotationDegrees: brush.rotationDegrees, + size: brush.size + }); + expect(runtimeScene.colliders[0]).toMatchObject({ + source: "brush", + brushId: brush.id, + center: brush.center, + rotationDegrees: brush.rotationDegrees, + size: brush.size + }); + expect(runtimeScene.sceneBounds?.min.x).toBeCloseTo(-0.8713203436); + expect(runtimeScene.sceneBounds?.max.x).toBeCloseTo(3.3713203436); + expect(runtimeScene.sceneBounds?.min.z).toBeCloseTo(-2.8713203436); + expect(runtimeScene.sceneBounds?.max.z).toBeCloseTo(1.3713203436); + }); +}); diff --git a/tests/domain/create-box-brush.command.test.js b/tests/domain/create-box-brush.command.test.js new file mode 100644 index 00000000..71a61f0a --- /dev/null +++ b/tests/domain/create-box-brush.command.test.js @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createCreateBoxBrushCommand } from "../../src/commands/create-box-brush-command"; +import { createMoveBoxBrushCommand } from "../../src/commands/move-box-brush-command"; +import { createRotateBoxBrushCommand } from "../../src/commands/rotate-box-brush-command"; +import { createResizeBoxBrushCommand } from "../../src/commands/resize-box-brush-command"; +import { createSetBoxBrushNameCommand } from "../../src/commands/set-box-brush-name-command"; +describe("box brush commands", () => { + it("creates a canonical box brush and supports undo/redo", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand({ + center: { + x: 1.2, + y: 1.1, + z: -0.6 + }, + size: { + x: 2.2, + y: 2.7, + z: 3.6 + } + })); + const brush = Object.values(store.getState().document.brushes)[0]; + expect(brush).toBeDefined(); + expect(brush.kind).toBe("box"); + expect(brush.center).toEqual({ + x: 1, + y: 1, + z: -1 + }); + expect(brush.rotationDegrees).toEqual({ + x: 0, + y: 0, + z: 0 + }); + expect(brush.size).toEqual({ + x: 2, + y: 3, + z: 4 + }); + expect(Object.keys(brush.faces)).toEqual(["posX", "negX", "posY", "negY", "posZ", "negZ"]); + expect(brush.faces.posX).toEqual({ + materialId: null, + uv: { + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + } + }); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [brush.id] + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes).toEqual({}); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toEqual(brush); + }); + it("moves and resizes a box brush through undoable commands", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand()); + const createdBrush = Object.values(store.getState().document.brushes)[0]; + store.executeCommand(createMoveBoxBrushCommand({ + brushId: createdBrush.id, + center: { + x: 2.4, + y: 3.2, + z: -1.7 + } + })); + store.executeCommand(createResizeBoxBrushCommand({ + brushId: createdBrush.id, + size: { + x: 4.2, + y: 1.2, + z: 0.2 + } + })); + expect(store.getState().document.brushes[createdBrush.id].center).toEqual({ + x: 2, + y: 3, + z: -2 + }); + expect(store.getState().document.brushes[createdBrush.id].size).toEqual({ + x: 4, + y: 1, + z: 1 + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].size).toEqual({ + x: 2, + y: 2, + z: 2 + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].center).toEqual({ + x: 0, + y: 1, + z: 0 + }); + expect(store.redo()).toBe(true); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].center).toEqual({ + x: 2, + y: 3, + z: -2 + }); + expect(store.getState().document.brushes[createdBrush.id].size).toEqual({ + x: 4, + y: 1, + z: 1 + }); + }); + it("preserves floating-point move, rotate, and scale authoring when snapping is disabled", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand({ + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + size: { + x: 2.5, + y: 3.25, + z: 4.75 + }, + snapToGrid: false + })); + const createdBrush = Object.values(store.getState().document.brushes)[0]; + store.executeCommand(createMoveBoxBrushCommand({ + brushId: createdBrush.id, + center: { + x: 2.125, + y: 3.375, + z: -1.625 + }, + snapToGrid: false + })); + store.executeCommand(createRotateBoxBrushCommand({ + brushId: createdBrush.id, + rotationDegrees: { + x: 12.5, + y: 37.5, + z: -8.25 + } + })); + store.executeCommand(createResizeBoxBrushCommand({ + brushId: createdBrush.id, + size: { + x: 3.5, + y: 1.75, + z: 5.125 + }, + snapToGrid: false + })); + expect(store.getState().document.brushes[createdBrush.id]).toMatchObject({ + center: { + x: 2.125, + y: 3.375, + z: -1.625 + }, + rotationDegrees: { + x: 12.5, + y: 37.5, + z: -8.25 + }, + size: { + x: 3.5, + y: 1.75, + z: 5.125 + } + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].size).toEqual({ + x: 2.5, + y: 3.25, + z: 4.75 + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].rotationDegrees).toEqual({ + x: 0, + y: 0, + z: 0 + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].center).toEqual({ + x: 1.25, + y: 1.5, + z: -0.75 + }); + expect(store.redo()).toBe(true); + expect(store.redo()).toBe(true); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id]).toMatchObject({ + center: { + x: 2.125, + y: 3.375, + z: -1.625 + }, + rotationDegrees: { + x: 12.5, + y: 37.5, + z: -8.25 + }, + size: { + x: 3.5, + y: 1.75, + z: 5.125 + } + }); + }); + it("renames a box brush through an undoable command", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand()); + const createdBrush = Object.values(store.getState().document.brushes)[0]; + store.executeCommand(createSetBoxBrushNameCommand({ + brushId: createdBrush.id, + name: "Entry Room" + })); + expect(store.getState().document.brushes[createdBrush.id].name).toBe("Entry Room"); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].name).toBeUndefined(); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].name).toBe("Entry Room"); + }); +}); diff --git a/tests/domain/create-empty-scene-document.test.js b/tests/domain/create-empty-scene-document.test.js new file mode 100644 index 00000000..3bd36ef1 --- /dev/null +++ b/tests/domain/create-empty-scene-document.test.js @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document"; +import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library"; +describe("createEmptySceneDocument", () => { + it("creates a versioned empty scene document", () => { + const document = createEmptySceneDocument(); + expect(document.version).toBe(SCENE_DOCUMENT_VERSION); + expect(document.name).toBe("Untitled Scene"); + expect(document.world).toEqual({ + background: { + mode: "solid", + colorHex: "#2f3947" + }, + ambientLight: { + colorHex: "#f7f1e8", + intensity: 1 + }, + sunLight: { + colorHex: "#fff1d5", + intensity: 1.75, + direction: { + x: -0.6, + y: 1, + z: 0.35 + } + }, + advancedRendering: { + enabled: false, + shadows: { + enabled: false, + mapSize: 2048, + type: "pcfSoft", + bias: -0.0005 + }, + ambientOcclusion: { + enabled: false, + intensity: 1, + radius: 0.5, + samples: 8 + }, + bloom: { + enabled: false, + intensity: 0.75, + threshold: 0.85, + radius: 0.35 + }, + toneMapping: { + mode: "acesFilmic", + exposure: 1 + }, + depthOfField: { + enabled: false, + focusDistance: 10, + focalLength: 0.03, + bokehScale: 1.5 + } + } + }); + expect(document.brushes).toEqual({}); + expect(document.entities).toEqual({}); + expect(document.modelInstances).toEqual({}); + expect(document.interactionLinks).toEqual({}); + expect(Object.keys(document.materials)).toEqual(STARTER_MATERIAL_LIBRARY.map((material) => material.id)); + }); +}); diff --git a/tests/domain/editor-store.test.js b/tests/domain/editor-store.test.js new file mode 100644 index 00000000..79c58e52 --- /dev/null +++ b/tests/domain/editor-store.test.js @@ -0,0 +1,360 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createCreateBoxBrushCommand } from "../../src/commands/create-box-brush-command"; +import { createSetSceneNameCommand } from "../../src/commands/set-scene-name-command"; +import { createTransformSession } from "../../src/core/transform-session"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +class MemoryStorage { + values = new Map(); + getItem(key) { + return this.values.get(key) ?? null; + } + setItem(key, value) { + this.values.set(key, value); + } + removeItem(key) { + this.values.delete(key); + } +} +class ThrowingStorage { + getItem() { + throw new Error("blocked read"); + } + setItem() { + throw new Error("blocked write"); + } + removeItem() { } +} +describe("EditorStore", () => { + it("returns a stable snapshot between store updates", () => { + const store = createEditorStore(); + const initialSnapshot = store.getState(); + const repeatedSnapshot = store.getState(); + expect(repeatedSnapshot).toBe(initialSnapshot); + store.executeCommand(createSetSceneNameCommand("Snapshot Scene")); + const updatedSnapshot = store.getState(); + expect(updatedSnapshot).not.toBe(initialSnapshot); + expect(updatedSnapshot.document.name).toBe("Snapshot Scene"); + }); + it("applies command history with undo and redo", () => { + const store = createEditorStore(); + store.executeCommand(createSetSceneNameCommand("Foundation Room")); + expect(store.getState().document.name).toBe("Foundation Room"); + expect(store.getState().canUndo).toBe(true); + expect(store.undo()).toBe(true); + expect(store.getState().document.name).toBe("Untitled Scene"); + expect(store.getState().canRedo).toBe(true); + expect(store.redo()).toBe(true); + expect(store.getState().document.name).toBe("Foundation Room"); + }); + it("saves and loads a local draft document", () => { + const storage = new MemoryStorage(); + const writerStore = createEditorStore({ storage }); + writerStore.executeCommand(createSetSceneNameCommand("Draft Scene")); + writerStore.setViewportLayoutMode("quad"); + writerStore.setActiveViewportPanel("bottomRight"); + writerStore.setViewportPanelViewMode("topLeft", "top"); + writerStore.setViewportPanelDisplayMode("topLeft", "wireframe"); + writerStore.setViewportPanelCameraState("topLeft", { + target: { + x: 6, + y: 2, + z: -4 + }, + perspectiveOrbit: { + radius: 18, + theta: 0.9, + phi: 1.1 + }, + orthographicZoom: 2.25 + }); + expect(writerStore.saveDraft()).toEqual({ + status: "saved", + message: "Local draft saved." + }); + const readerStore = createEditorStore({ + initialDocument: createEmptySceneDocument({ name: "Fresh Scene" }), + storage + }); + expect(readerStore.loadDraft()).toMatchObject({ + status: "loaded", + message: "Local draft loaded." + }); + expect(readerStore.getState().document.name).toBe("Draft Scene"); + expect(readerStore.getState().viewportLayoutMode).toBe("quad"); + expect(readerStore.getState().activeViewportPanelId).toBe("bottomRight"); + expect(readerStore.getState().viewportPanels.topLeft).toMatchObject({ + viewMode: "top", + displayMode: "wireframe", + cameraState: { + target: { + x: 6, + y: 2, + z: -4 + }, + perspectiveOrbit: { + radius: 18, + theta: 0.9, + phi: 1.1 + }, + orthographicZoom: 2.25 + } + }); + }); + it("fails gracefully when storage access throws", () => { + const store = createEditorStore({ storage: new ThrowingStorage() }); + expect(store.saveDraft()).toMatchObject({ + status: "error", + message: expect.stringContaining("blocked write") + }); + expect(store.loadDraft()).toMatchObject({ + status: "error", + message: expect.stringContaining("blocked read") + }); + }); + it("restores the previous editor tool when leaving play mode", () => { + const store = createEditorStore(); + store.setToolMode("create"); + store.enterPlayMode(); + expect(store.getState().toolMode).toBe("play"); + store.exitPlayMode(); + expect(store.getState().toolMode).toBe("create"); + }); + it("tracks viewport layout and per-panel state independently from the document", () => { + const store = createEditorStore(); + expect(store.getState().whiteboxSelectionMode).toBe("object"); + expect(store.getState().viewportLayoutMode).toBe("single"); + expect(store.getState().activeViewportPanelId).toBe("topLeft"); + expect(store.getState().viewportPanels.topLeft.viewMode).toBe("perspective"); + expect(store.getState().viewportPanels.topRight.viewMode).toBe("top"); + expect(store.getState().viewportPanels.topRight.displayMode).toBe("authoring"); + expect(store.getState().viewportQuadSplit).toEqual({ + x: 0.5, + y: 0.5 + }); + store.setViewportLayoutMode("quad"); + store.setActiveViewportPanel("bottomRight"); + store.setViewportPanelViewMode("bottomRight", "front"); + store.setViewportPanelDisplayMode("bottomRight", "normal"); + store.setViewportQuadSplit({ + x: 0.38, + y: 0.62 + }); + expect(store.getState().viewportLayoutMode).toBe("quad"); + expect(store.getState().activeViewportPanelId).toBe("bottomRight"); + expect(store.getState().viewportPanels.bottomRight.viewMode).toBe("front"); + expect(store.getState().viewportPanels.bottomRight.displayMode).toBe("normal"); + expect(store.getState().viewportQuadSplit).toEqual({ + x: 0.38, + y: 0.62 + }); + }); + it("tracks whitebox component selection mode independently from document state", () => { + const store = createEditorStore(); + store.setWhiteboxSelectionMode("face"); + expect(store.getState().whiteboxSelectionMode).toBe("face"); + store.setWhiteboxSelectionMode("edge"); + expect(store.getState().whiteboxSelectionMode).toBe("edge"); + store.setWhiteboxSelectionMode("vertex"); + expect(store.getState().whiteboxSelectionMode).toBe("vertex"); + store.setWhiteboxSelectionMode("object"); + expect(store.getState().whiteboxSelectionMode).toBe("object"); + }); + it("normalizes selected whitebox components back to the owning solid when switching to a different component mode", () => { + const store = createEditorStore(); + store.executeCommand(createCreateBoxBrushCommand()); + const createdBrush = Object.values(store.getState().document.brushes)[0]; + store.setWhiteboxSelectionMode("face"); + store.setSelection({ + kind: "brushFace", + brushId: createdBrush.id, + faceId: "posY" + }); + expect(store.getState().selection).toEqual({ + kind: "brushFace", + brushId: createdBrush.id, + faceId: "posY" + }); + store.setWhiteboxSelectionMode("edge"); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [createdBrush.id] + }); + store.setSelection({ + kind: "brushEdge", + brushId: createdBrush.id, + edgeId: "edgeX_posY_negZ" + }); + store.setWhiteboxSelectionMode("vertex"); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [createdBrush.id] + }); + store.setSelection({ + kind: "brushVertex", + brushId: createdBrush.id, + vertexId: "posX_posY_negZ" + }); + store.setWhiteboxSelectionMode("object"); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [createdBrush.id] + }); + }); + it("shares transient creation preview state across viewport panels", () => { + const store = createEditorStore(); + expect(store.getState().viewportTransientState.toolPreview).toEqual({ + kind: "none" + }); + store.setViewportToolPreview({ + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: { + x: 4, + y: 0, + z: 8 + } + }); + expect(store.getState().viewportTransientState.toolPreview).toEqual({ + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: { + x: 4, + y: 0, + z: 8 + } + }); + store.setViewportToolPreview({ + kind: "create", + sourcePanelId: "bottomRight", + target: { + kind: "entity", + entityKind: "pointLight", + audioAssetId: null + }, + center: { + x: 2, + y: 1, + z: -3 + } + }); + expect(store.getState().viewportTransientState.toolPreview).toEqual({ + kind: "create", + sourcePanelId: "bottomRight", + target: { + kind: "entity", + entityKind: "pointLight", + audioAssetId: null + }, + center: { + x: 2, + y: 1, + z: -3 + } + }); + store.clearViewportToolPreview("topRight"); + expect(store.getState().viewportTransientState.toolPreview).toEqual({ + kind: "create", + sourcePanelId: "bottomRight", + target: { + kind: "entity", + entityKind: "pointLight", + audioAssetId: null + }, + center: { + x: 2, + y: 1, + z: -3 + } + }); + store.clearViewportToolPreview("bottomRight"); + expect(store.getState().viewportTransientState.toolPreview).toEqual({ + kind: "none" + }); + }); + it("tracks a shared transient transform session and clears it when selection changes", () => { + const store = createEditorStore(); + store.setTransformSession(createTransformSession({ + source: "keyboard", + sourcePanelId: "bottomRight", + operation: "translate", + target: { + kind: "brush", + brushId: "brush-main", + initialCenter: { + x: 0, + y: 1, + z: 0 + }, + initialRotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + initialSize: { + x: 2, + y: 2, + z: 2 + } + } + })); + expect(store.getState().viewportTransientState.transformSession).toMatchObject({ + kind: "active", + source: "keyboard", + sourcePanelId: "bottomRight", + operation: "translate", + target: { + kind: "brush", + brushId: "brush-main" + }, + preview: { + kind: "brush", + center: { + x: 0, + y: 1, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 2 + } + } + }); + store.setSelection({ + kind: "brushes", + ids: ["brush-main"] + }); + expect(store.getState().viewportTransientState.transformSession).toEqual({ + kind: "none" + }); + }); + it("clears transient viewport preview when leaving create mode", () => { + const store = createEditorStore(); + store.setToolMode("create"); + store.setViewportToolPreview({ + kind: "create", + sourcePanelId: "bottomRight", + target: { + kind: "model-instance", + assetId: "asset-1" + }, + center: null + }); + store.setToolMode("select"); + expect(store.getState().viewportTransientState.toolPreview).toEqual({ + kind: "none" + }); + }); +}); diff --git a/tests/domain/entity.command.test.js b/tests/domain/entity.command.test.js new file mode 100644 index 00000000..e04119b1 --- /dev/null +++ b/tests/domain/entity.command.test.js @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createUpsertEntityCommand } from "../../src/commands/upsert-entity-command"; +import { createPointLightEntity, createSoundEmitterEntity, createSpotLightEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +describe("typed entity upsert command", () => { + it("places a Sound Emitter and restores the previous tool mode across undo and redo", () => { + const store = createEditorStore(); + const soundEmitter = createSoundEmitterEntity({ + position: { + x: 1, + y: 2, + z: 3 + }, + audioAssetId: null, + volume: 0.5, + refDistance: 5, + maxDistance: 12 + }); + store.setToolMode("create"); + store.executeCommand(createUpsertEntityCommand({ + entity: soundEmitter, + label: "Place sound emitter" + })); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().selection).toEqual({ + kind: "entities", + ids: [soundEmitter.id] + }); + expect(store.getState().document.entities[soundEmitter.id]).toEqual(soundEmitter); + expect(store.undo()).toBe(true); + expect(store.getState().toolMode).toBe("create"); + expect(store.getState().document.entities).toEqual({}); + expect(store.redo()).toBe(true); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[soundEmitter.id]).toEqual(soundEmitter); + }); + it("places a Point Light and restores the previous tool mode across undo and redo", () => { + const store = createEditorStore(); + const pointLight = createPointLightEntity({ + position: { + x: 2, + y: 3, + z: 4 + }, + colorHex: "#ccddee", + intensity: 1.5, + distance: 9 + }); + store.setToolMode("create"); + store.executeCommand(createUpsertEntityCommand({ + entity: pointLight, + label: "Place point light" + })); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().selection).toEqual({ + kind: "entities", + ids: [pointLight.id] + }); + expect(store.getState().document.entities[pointLight.id]).toEqual(pointLight); + expect(store.undo()).toBe(true); + expect(store.getState().toolMode).toBe("create"); + expect(store.getState().document.entities).toEqual({}); + expect(store.redo()).toBe(true); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[pointLight.id]).toEqual(pointLight); + }); + it("updates an existing Trigger Volume without changing its entity id", () => { + const store = createEditorStore(); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main", + size: { + x: 2, + y: 2, + z: 2 + } + }); + const movedTriggerVolume = createTriggerVolumeEntity({ + ...triggerVolume, + position: { + x: 4, + y: 1, + z: -2 + }, + size: { + x: 3, + y: 4, + z: 5 + }, + triggerOnEnter: false, + triggerOnExit: true + }); + store.executeCommand(createUpsertEntityCommand({ + entity: triggerVolume, + label: "Place trigger volume" + })); + store.setToolMode("create"); + store.executeCommand(createUpsertEntityCommand({ + entity: movedTriggerVolume, + label: "Update trigger volume" + })); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[triggerVolume.id]).toEqual(movedTriggerVolume); + expect(store.undo()).toBe(true); + expect(store.getState().toolMode).toBe("create"); + expect(store.getState().document.entities[triggerVolume.id]).toEqual(triggerVolume); + expect(store.redo()).toBe(true); + expect(store.getState().document.entities[triggerVolume.id]).toEqual(movedTriggerVolume); + }); + it("updates an existing Spot Light without changing its entity id", () => { + const store = createEditorStore(); + const spotLight = createSpotLightEntity({ + id: "entity-spot-main", + position: { + x: -3, + y: 4, + z: 2 + } + }); + const movedSpotLight = createSpotLightEntity({ + ...spotLight, + position: { + x: 5, + y: 6, + z: -4 + }, + direction: { + x: 0.5, + y: -1, + z: 0.25 + }, + colorHex: "#aaccee", + intensity: 2.25, + distance: 14, + angleDegrees: 50 + }); + store.executeCommand(createUpsertEntityCommand({ + entity: spotLight, + label: "Place spot light" + })); + store.setToolMode("create"); + store.executeCommand(createUpsertEntityCommand({ + entity: movedSpotLight, + label: "Update spot light" + })); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[spotLight.id]).toEqual(movedSpotLight); + expect(store.undo()).toBe(true); + expect(store.getState().toolMode).toBe("create"); + expect(store.getState().document.entities[spotLight.id]).toEqual(spotLight); + expect(store.redo()).toBe(true); + expect(store.getState().document.entities[spotLight.id]).toEqual(movedSpotLight); + }); +}); diff --git a/tests/domain/interaction-links.validation.test.js b/tests/domain/interaction-links.validation.test.js new file mode 100644 index 00000000..3bcf8d18 --- /dev/null +++ b/tests/domain/interaction-links.validation.test.js @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { validateSceneDocument } from "../../src/document/scene-document-validation"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { createInteractableEntity, createPlayerStartEntity, createSoundEmitterEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +import { createPlayAnimationInteractionLink, createPlaySoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, createStopSoundInteractionLink } from "../../src/interactions/interaction-links"; +describe("interaction link validation", () => { + it("accepts valid Trigger Volume and Interactable links", () => { + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const interactable = createInteractableEntity({ + id: "entity-interactable-main", + prompt: "Use Console" + }); + const teleportTarget = createTeleportTargetEntity({ + id: "entity-teleport-main" + }); + const audioAsset = { + id: "asset-audio-main", + kind: "audio", + sourceName: "lobby-loop.ogg", + mimeType: "audio/ogg", + storageKey: createProjectAssetStorageKey("asset-audio-main"), + byteLength: 4096, + metadata: { + kind: "audio", + durationSeconds: 4, + channelCount: 2, + sampleRateHz: 48000, + warnings: [] + } + }; + const soundEmitter = createSoundEmitterEntity({ + id: "entity-sound-main", + audioAssetId: audioAsset.id + }); + const brush = createBoxBrush({ + id: "brush-door" + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + }, + entities: { + [triggerVolume.id]: triggerVolume, + [interactable.id]: interactable, + [teleportTarget.id]: teleportTarget, + [soundEmitter.id]: soundEmitter + }, + assets: { + [audioAsset.id]: audioAsset + }, + interactionLinks: { + "link-teleport": createTeleportPlayerInteractionLink({ + id: "link-teleport", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetEntityId: teleportTarget.id + }), + "link-visibility": createToggleVisibilityInteractionLink({ + id: "link-visibility", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetBrushId: brush.id, + visible: false + }), + "link-click-teleport": createTeleportPlayerInteractionLink({ + id: "link-click-teleport", + sourceEntityId: interactable.id, + trigger: "click", + targetEntityId: teleportTarget.id + }), + "link-play-sound": createPlaySoundInteractionLink({ + id: "link-play-sound", + sourceEntityId: interactable.id, + trigger: "click", + targetSoundEmitterId: soundEmitter.id + }), + "link-stop-sound": createStopSoundInteractionLink({ + id: "link-stop-sound", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetSoundEmitterId: soundEmitter.id + }) + } + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual([]); + }); + it("detects sound playback links that target a sound emitter without an audio asset", () => { + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const soundEmitter = createSoundEmitterEntity({ + id: "entity-sound-main" + }); + const validation = validateSceneDocument({ + ...createEmptySceneDocument(), + entities: { + [triggerVolume.id]: triggerVolume, + [soundEmitter.id]: soundEmitter + }, + interactionLinks: { + "link-play-sound": createPlaySoundInteractionLink({ + id: "link-play-sound", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetSoundEmitterId: soundEmitter.id + }) + } + }); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "missing-sound-emitter-audio-asset", + path: "interactionLinks.link-play-sound.action.targetSoundEmitterId" + }) + ])); + }); + it("detects invalid interaction link source and target references", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main" + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const interactable = createInteractableEntity({ + id: "entity-interactable-main" + }); + const document = { + ...createEmptySceneDocument(), + entities: { + [playerStart.id]: playerStart, + [triggerVolume.id]: triggerVolume, + [interactable.id]: interactable + }, + interactionLinks: { + "link-invalid-source": createTeleportPlayerInteractionLink({ + id: "link-invalid-source", + sourceEntityId: playerStart.id, + trigger: "enter", + targetEntityId: "entity-missing-teleport-target" + }), + "link-invalid-visibility": createToggleVisibilityInteractionLink({ + id: "link-invalid-visibility", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetBrushId: "brush-missing" + }), + "link-invalid-click-trigger": createTeleportPlayerInteractionLink({ + id: "link-invalid-click-trigger", + sourceEntityId: interactable.id, + trigger: "enter", + targetEntityId: "entity-missing-teleport-target" + }) + } + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-interaction-source-kind", + path: "interactionLinks.link-invalid-source.sourceEntityId" + }), + expect.objectContaining({ + code: "missing-teleport-target-entity", + path: "interactionLinks.link-invalid-source.action.targetEntityId" + }), + expect.objectContaining({ + code: "missing-visibility-target-brush", + path: "interactionLinks.link-invalid-visibility.action.targetBrushId" + }), + expect.objectContaining({ + code: "unsupported-interaction-trigger", + path: "interactionLinks.link-invalid-click-trigger.trigger" + }), + expect.objectContaining({ + code: "missing-teleport-target-entity", + path: "interactionLinks.link-invalid-click-trigger.action.targetEntityId" + }) + ])); + }); + it("detects playAnimation links that reference a missing clip on the target model asset", () => { + const modelAsset = { + id: "asset-model-animated", + kind: "model", + sourceName: "animated.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-animated"), + byteLength: 1024, + metadata: { + kind: "model", + format: "glb", + sceneName: null, + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: ["Idle", "Run"], + boundingBox: null, + warnings: [] + } + }; + const modelInstance = createModelInstance({ + id: "model-instance-animated", + assetId: modelAsset.id + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const document = { + ...createEmptySceneDocument(), + assets: { + [modelAsset.id]: modelAsset + }, + modelInstances: { + [modelInstance.id]: modelInstance + }, + entities: { + [triggerVolume.id]: triggerVolume + }, + interactionLinks: { + "link-play-missing-clip": createPlayAnimationInteractionLink({ + id: "link-play-missing-clip", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetModelInstanceId: modelInstance.id, + clipName: "Walk" + }) + } + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "missing-play-animation-clip", + path: "interactionLinks.link-play-missing-clip.action.clipName" + }) + ])); + }); +}); diff --git a/tests/domain/model-import.test.js b/tests/domain/model-import.test.js new file mode 100644 index 00000000..15c47f4c --- /dev/null +++ b/tests/domain/model-import.test.js @@ -0,0 +1,54 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { importModelAssetFromFile, importModelAssetFromFiles, loadModelAssetFromStorage } from "../../src/assets/gltf-model-import"; +import { createInMemoryProjectAssetStorage } from "../../src/assets/project-asset-storage"; +const tinyGlbFixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.glb"); +const externalTriangleGltfPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf"); +const externalTriangleBinPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin"); +function createTestFile(bytes, name, type) { + const arrayBuffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(arrayBuffer).set(bytes); + return { + name, + type, + lastModified: Date.now(), + size: arrayBuffer.byteLength, + webkitRelativePath: "", + arrayBuffer: async () => arrayBuffer.slice(0) + }; +} +describe("model import", () => { + it("imports and reloads a tiny GLB fixture", async () => { + const storage = createInMemoryProjectAssetStorage(); + const fileBytes = await readFile(tinyGlbFixturePath); + const file = createTestFile(fileBytes, "tiny-triangle.glb", "model/gltf-binary"); + const importedModel = await importModelAssetFromFile(file, storage); + expect(importedModel.asset.mimeType).toBe("model/gltf-binary"); + expect(importedModel.asset.metadata.format).toBe("glb"); + expect(importedModel.asset.byteLength).toBe(fileBytes.byteLength); + expect(importedModel.modelInstance.assetId).toBe(importedModel.asset.id); + const storedAsset = await storage.getAsset(importedModel.asset.storageKey); + expect(Object.keys(storedAsset?.files ?? {})).toEqual(["tiny-triangle.glb"]); + const reloadedAsset = await loadModelAssetFromStorage(storage, importedModel.asset); + expect(reloadedAsset.metadata.format).toBe("glb"); + expect(reloadedAsset.template.children.length).toBeGreaterThan(0); + }); + it("imports and reloads a gltf fixture with external resources", async () => { + const storage = createInMemoryProjectAssetStorage(); + const gltfBytes = await readFile(externalTriangleGltfPath); + const binBytes = await readFile(externalTriangleBinPath); + const importedModel = await importModelAssetFromFiles([ + createTestFile(binBytes, "triangle.bin", "application/octet-stream"), + createTestFile(gltfBytes, "scene.gltf", "model/gltf+json") + ], storage); + expect(importedModel.asset.mimeType).toBe("model/gltf+json"); + expect(importedModel.asset.metadata.format).toBe("gltf"); + expect(importedModel.asset.byteLength).toBe(gltfBytes.byteLength + binBytes.byteLength); + const storedAsset = await storage.getAsset(importedModel.asset.storageKey); + expect(Object.keys(storedAsset?.files ?? {}).sort()).toEqual(["scene.gltf", "triangle.bin"]); + const reloadedAsset = await loadModelAssetFromStorage(storage, importedModel.asset); + expect(reloadedAsset.metadata.meshCount).toBe(1); + expect(reloadedAsset.template.children.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/domain/model-instance.command.test.js b/tests/domain/model-instance.command.test.js new file mode 100644 index 00000000..2563132c --- /dev/null +++ b/tests/domain/model-instance.command.test.js @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createImportModelAssetCommand } from "../../src/commands/import-model-asset-command"; +import { createUpsertModelInstanceCommand } from "../../src/commands/upsert-model-instance-command"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +describe("model instance commands", () => { + const modelAsset = { + id: "asset-model-triangle", + kind: "model", + sourceName: "tiny-triangle.gltf", + mimeType: "model/gltf+json", + storageKey: createProjectAssetStorageKey("asset-model-triangle"), + byteLength: 36, + metadata: { + kind: "model", + format: "gltf", + sceneName: "Fixture Triangle Scene", + nodeCount: 2, + meshCount: 1, + materialNames: ["Fixture Material"], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: 0, + y: 0, + z: 0 + }, + max: { + x: 1, + y: 1, + z: 0 + }, + size: { + x: 1, + y: 1, + z: 0 + } + }, + warnings: [] + } + }; + it("imports a model asset and placed model instance through undo and redo", () => { + const store = createEditorStore(); + const modelInstance = createModelInstance({ + id: "model-instance-triangle", + assetId: modelAsset.id, + name: "Fixture Triangle", + position: { + x: 4, + y: 2, + z: -3 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + scale: { + x: 1.5, + y: 2, + z: 1.5 + } + }); + store.executeCommand(createImportModelAssetCommand({ + asset: modelAsset, + modelInstance, + label: "Import fixture triangle" + })); + expect(store.getState().document.assets[modelAsset.id]).toEqual(modelAsset); + expect(store.getState().document.modelInstances[modelInstance.id]).toEqual(modelInstance); + expect(store.getState().selection).toEqual({ + kind: "modelInstances", + ids: [modelInstance.id] + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.assets).toEqual({}); + expect(store.getState().document.modelInstances).toEqual({}); + expect(store.redo()).toBe(true); + expect(store.getState().document.assets[modelAsset.id]).toEqual(modelAsset); + expect(store.getState().document.modelInstances[modelInstance.id]).toEqual(modelInstance); + }); + it("updates an existing model instance transform without changing the asset reference", () => { + const existingModelInstance = createModelInstance({ + id: "model-instance-triangle", + assetId: modelAsset.id, + position: { + x: 1, + y: 0, + z: 1 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + scale: { + x: 1, + y: 1, + z: 1 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Model Instance Scene" }), + assets: { + [modelAsset.id]: modelAsset + }, + modelInstances: { + [existingModelInstance.id]: existingModelInstance + } + } + }); + const updatedModelInstance = createModelInstance({ + id: existingModelInstance.id, + assetId: modelAsset.id, + name: existingModelInstance.name, + position: { + x: 5, + y: 1, + z: -2 + }, + rotationDegrees: { + x: 15, + y: 90, + z: 0 + }, + scale: { + x: 2, + y: 2, + z: 2 + } + }); + store.executeCommand(createUpsertModelInstanceCommand({ + modelInstance: updatedModelInstance, + label: "Update fixture triangle" + })); + expect(store.getState().document.modelInstances[existingModelInstance.id]).toEqual(updatedModelInstance); + expect(store.getState().document.assets[modelAsset.id]).toEqual(modelAsset); + expect(store.undo()).toBe(true); + expect(store.getState().document.modelInstances[existingModelInstance.id]).toEqual(existingModelInstance); + expect(store.redo()).toBe(true); + expect(store.getState().document.modelInstances[existingModelInstance.id]).toEqual(updatedModelInstance); + }); +}); diff --git a/tests/domain/player-start.command.test.js b/tests/domain/player-start.command.test.js new file mode 100644 index 00000000..eec2ec7e --- /dev/null +++ b/tests/domain/player-start.command.test.js @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createSetPlayerStartCommand } from "../../src/commands/set-player-start-command"; +describe("player start command", () => { + it("restores the previous tool mode across undo and redo when placing PlayerStart", () => { + const store = createEditorStore(); + store.setToolMode("create"); + store.executeCommand(createSetPlayerStartCommand({ + position: { + x: 2, + y: 0, + z: -2 + }, + yawDegrees: 90 + })); + const placedPlayerStart = Object.values(store.getState().document.entities)[0]; + expect(placedPlayerStart).toBeDefined(); + expect(store.getState().toolMode).toBe("select"); + expect(store.undo()).toBe(true); + expect(store.getState().toolMode).toBe("create"); + expect(store.getState().document.entities).toEqual({}); + expect(store.redo()).toBe(true); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[placedPlayerStart.id]).toEqual(placedPlayerStart); + }); + it("restores the previous tool mode across undo and redo when moving PlayerStart", () => { + const store = createEditorStore(); + store.executeCommand(createSetPlayerStartCommand({ + position: { + x: 0, + y: 0, + z: 0 + }, + yawDegrees: 0 + })); + const existingPlayerStart = Object.values(store.getState().document.entities)[0]; + store.setToolMode("create"); + store.executeCommand(createSetPlayerStartCommand({ + entityId: existingPlayerStart.id, + position: { + x: 4, + y: 0, + z: 1 + }, + yawDegrees: 180 + })); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[existingPlayerStart.id]).toMatchObject({ + position: { + x: 4, + y: 0, + z: 1 + }, + yawDegrees: 180 + }); + expect(store.undo()).toBe(true); + expect(store.getState().toolMode).toBe("create"); + expect(store.getState().document.entities[existingPlayerStart.id]).toEqual(existingPlayerStart); + expect(store.redo()).toBe(true); + expect(store.getState().toolMode).toBe("select"); + expect(store.getState().document.entities[existingPlayerStart.id]).toMatchObject({ + position: { + x: 4, + y: 0, + z: 1 + }, + yawDegrees: 180 + }); + }); +}); diff --git a/tests/domain/rapier-collision-world.test.js b/tests/domain/rapier-collision-world.test.js new file mode 100644 index 00000000..759742d3 --- /dev/null +++ b/tests/domain/rapier-collision-world.test.js @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry, PlaneGeometry } from "three"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPlayerStartEntity } from "../../src/entities/entity-instances"; +import { RapierCollisionWorld } from "../../src/runtime-three/rapier-collision-world"; +import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; +import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; +describe("RapierCollisionWorld", () => { + it("resolves first-person motion against brush and imported model colliders in one query path", async () => { + const floorBrush = createBoxBrush({ + id: "brush-floor", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 10, + y: 1, + z: 10 + } + }); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-crate", new BoxGeometry(1, 1, 1)); + const crateInstance = createModelInstance({ + id: "model-instance-crate", + assetId: asset.id, + position: { + x: 2, + y: 0.5, + z: 0 + }, + collision: { + mode: "static", + visible: true + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Brush And Model Collision Scene" }), + assets: { + [asset.id]: asset + }, + brushes: { + [floorBrush.id]: floorBrush + }, + modelInstances: { + [crateInstance.id]: crateInstance + } + }, { + loadedModelAssets: { + [asset.id]: loadedAsset + } + }); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); + try { + const landing = collisionWorld.resolveFirstPersonMotion({ + x: 0, + y: 2, + z: 0 + }, { + x: 0, + y: -3, + z: 0 + }, runtimeScene.playerCollider); + expect(landing.grounded).toBe(true); + expect(landing.feetPosition.y).toBeLessThan(0.02); + const blocked = collisionWorld.resolveFirstPersonMotion({ + x: 0, + y: 0, + z: 0 + }, { + x: 3, + y: 0, + z: 0 + }, runtimeScene.playerCollider); + expect(blocked.feetPosition.x).toBeLessThan(1.21); + expect(blocked.feetPosition.y).toBeLessThan(0.02); + expect(blocked.collidedAxes.x).toBe(true); + } + finally { + collisionWorld.dispose(); + } + }); + it("initializes and resolves first-person motion against terrain heightfield colliders", async () => { + const terrainGeometry = new PlaneGeometry(8, 8, 4, 4); + terrainGeometry.rotateX(-Math.PI / 2); + const positionAttribute = terrainGeometry.getAttribute("position"); + for (let index = 0; index < positionAttribute.count; index += 1) { + const x = positionAttribute.getX(index); + const z = positionAttribute.getZ(index); + positionAttribute.setY(index, 2 + x * 0.25 + z * 0.75); + } + positionAttribute.needsUpdate = true; + terrainGeometry.computeVertexNormals(); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-terrain", terrainGeometry); + const terrainInstance = createModelInstance({ + id: "model-instance-terrain", + assetId: asset.id, + position: { + x: 0, + y: 0, + z: 0 + }, + collision: { + mode: "terrain", + visible: true + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Terrain Collision Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [terrainInstance.id]: terrainInstance + } + }, { + loadedModelAssets: { + [asset.id]: loadedAsset + } + }); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); + try { + const highLanding = collisionWorld.resolveFirstPersonMotion({ + x: -2, + y: 6, + z: 2 + }, { + x: 0, + y: -8, + z: 0 + }, runtimeScene.playerCollider); + const lowLanding = collisionWorld.resolveFirstPersonMotion({ + x: 2, + y: 6, + z: -2 + }, { + x: 0, + y: -8, + z: 0 + }, runtimeScene.playerCollider); + expect(highLanding.grounded).toBe(true); + expect(highLanding.feetPosition.y).toBeGreaterThan(2.9); + expect(highLanding.feetPosition.y).toBeLessThan(3.1); + expect(lowLanding.grounded).toBe(true); + expect(lowLanding.feetPosition.y).toBeGreaterThan(0.9); + expect(lowLanding.feetPosition.y).toBeLessThan(1.1); + const traversed = collisionWorld.resolveFirstPersonMotion(highLanding.feetPosition, { + x: -1, + y: 0, + z: 0 + }, runtimeScene.playerCollider); + expect(traversed.feetPosition.x).toBeLessThan(-2.5); + expect(traversed.collidedAxes.x).toBe(false); + } + finally { + collisionWorld.dispose(); + } + }); + it("resolves motion against freely rotated whitebox box colliders", async () => { + const floorBrush = createBoxBrush({ + id: "brush-floor-rotated-wall", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 10, + y: 1, + z: 10 + } + }); + const wallBrush = createBoxBrush({ + id: "brush-wall-rotated", + center: { + x: 1.2, + y: 1, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + size: { + x: 0.4, + y: 2, + z: 4 + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Rotated Brush Collision Scene" }), + brushes: { + [floorBrush.id]: floorBrush, + [wallBrush.id]: wallBrush + } + }); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); + try { + const blocked = collisionWorld.resolveFirstPersonMotion({ + x: 0, + y: 0, + z: 0 + }, { + x: 2, + y: 0, + z: 0 + }, runtimeScene.playerCollider); + expect(blocked.collidedAxes.x).toBe(true); + expect(blocked.feetPosition.x).toBeLessThan(1.3); + expect(blocked.feetPosition.z).toBeGreaterThan(0.25); + expect(blocked.feetPosition.z).toBeLessThan(1.25); + } + finally { + collisionWorld.dispose(); + } + }); + it("uses the authored Player Start box collider in the Rapier motion path", async () => { + const floorBrush = createBoxBrush({ + id: "brush-floor-box-player", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 10, + y: 1, + z: 10 + } + }); + const wallBrush = createBoxBrush({ + id: "brush-wall-box-player", + center: { + x: 1.2, + y: 1, + z: 0 + }, + size: { + x: 0.4, + y: 2, + z: 4 + } + }); + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-box", + collider: { + mode: "box", + eyeHeight: 1.4, + capsuleRadius: 0.3, + capsuleHeight: 1.8, + boxSize: { + x: 0.8, + y: 1.6, + z: 0.8 + } + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Box Player Collider Scene" }), + brushes: { + [floorBrush.id]: floorBrush, + [wallBrush.id]: wallBrush + }, + entities: { + [playerStart.id]: playerStart + } + }); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); + try { + const landing = collisionWorld.resolveFirstPersonMotion({ + x: 0, + y: 2, + z: 0 + }, { + x: 0, + y: -3, + z: 0 + }, runtimeScene.playerCollider); + expect(landing.grounded).toBe(true); + expect(landing.feetPosition.y).toBeLessThan(0.02); + const blocked = collisionWorld.resolveFirstPersonMotion(landing.feetPosition, { + x: 2, + y: 0, + z: 0 + }, runtimeScene.playerCollider); + expect(blocked.collidedAxes.x).toBe(true); + expect(blocked.feetPosition.x).toBeLessThan(0.61); + } + finally { + collisionWorld.dispose(); + } + }); + it("supports authored Player Start collision mode none without world clipping", async () => { + const wallBrush = createBoxBrush({ + id: "brush-wall-no-collision", + center: { + x: 0.5, + y: 1, + z: 0 + }, + size: { + x: 0.4, + y: 2, + z: 4 + } + }); + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-none", + collider: { + mode: "none", + eyeHeight: 1.6, + capsuleRadius: 0.3, + capsuleHeight: 1.8, + boxSize: { + x: 0.6, + y: 1.8, + z: 0.6 + } + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "No Collision Player Scene" }), + brushes: { + [wallBrush.id]: wallBrush + }, + entities: { + [playerStart.id]: playerStart + } + }); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); + try { + const moved = collisionWorld.resolveFirstPersonMotion({ + x: 0, + y: 0, + z: 0 + }, { + x: 2, + y: 0, + z: 0 + }, runtimeScene.playerCollider); + expect(moved.collidedAxes.x).toBe(false); + expect(moved.feetPosition.x).toBe(2); + expect(moved.feetPosition.y).toBe(0); + } + finally { + collisionWorld.dispose(); + } + }); +}); diff --git a/tests/domain/runtime-audio-system.test.js b/tests/domain/runtime-audio-system.test.js new file mode 100644 index 00000000..80e723f4 --- /dev/null +++ b/tests/domain/runtime-audio-system.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { computeSoundEmitterDistanceGain } from "../../src/runtime-three/runtime-audio-system"; +describe("computeSoundEmitterDistanceGain", () => { + it("keeps full volume near the emitter and eases smoothly to silence at max distance", () => { + expect(computeSoundEmitterDistanceGain(4, 6, 24)).toBe(1); + expect(computeSoundEmitterDistanceGain(6, 6, 24)).toBe(1); + expect(computeSoundEmitterDistanceGain(12, 6, 24)).toBeCloseTo(0.198, 3); + expect(computeSoundEmitterDistanceGain(18, 6, 24)).toBeCloseTo(0.012, 3); + expect(computeSoundEmitterDistanceGain(24, 6, 24)).toBe(0); + expect(computeSoundEmitterDistanceGain(30, 6, 24)).toBe(0); + }); +}); diff --git a/tests/domain/runtime-interaction-system.test.js b/tests/domain/runtime-interaction-system.test.js new file mode 100644 index 00000000..bda09c5c --- /dev/null +++ b/tests/domain/runtime-interaction-system.test.js @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest"; +import { createPlayAnimationInteractionLink, createPlaySoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink } from "../../src/interactions/interaction-links"; +import { createDefaultWorldSettings } from "../../src/document/world-settings"; +import { RuntimeInteractionSystem } from "../../src/runtime-three/runtime-interaction-system"; +function createRuntimeSceneFixture() { + return { + world: { + ...createDefaultWorldSettings(), + background: { + mode: "solid", + colorHex: "#000000" + }, + ambientLight: { + colorHex: "#ffffff", + intensity: 1 + }, + sunLight: { + colorHex: "#ffffff", + intensity: 1, + direction: { + x: 0, + y: 1, + z: 0 + } + } + }, + brushes: [], + colliders: [], + sceneBounds: null, + localLights: { + pointLights: [], + spotLights: [] + }, + modelInstances: [], + entities: { + playerStarts: [], + soundEmitters: [ + { + entityId: "entity-sound-lobby", + position: { + x: 0, + y: 1, + z: 0 + }, + audioAssetId: "asset-audio-lobby", + volume: 0.75, + refDistance: 6, + maxDistance: 24, + autoplay: false, + loop: true + } + ], + triggerVolumes: [ + { + entityId: "entity-trigger-main", + position: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 2 + }, + triggerOnEnter: true, + triggerOnExit: true + } + ], + teleportTargets: [ + { + entityId: "entity-teleport-main", + position: { + x: 8, + y: 0, + z: -4 + }, + yawDegrees: 180 + } + ], + interactables: [ + { + entityId: "entity-interactable-console", + position: { + x: 0, + y: 1, + z: 1 + }, + radius: 2, + prompt: "Use Console", + enabled: true + }, + { + entityId: "entity-interactable-disabled", + position: { + x: 0.25, + y: 1, + z: 1 + }, + radius: 2, + prompt: "Disabled Prompt", + enabled: false + } + ] + }, + interactionLinks: [], + playerStart: null, + playerCollider: { + mode: "capsule", + radius: 0.3, + height: 1.8, + eyeHeight: 1.6 + }, + spawn: { + source: "fallback", + entityId: null, + position: { + x: 0, + y: 0, + z: 0 + }, + yawDegrees: 0 + } + }; +} +describe("RuntimeInteractionSystem", () => { + it("dispatches teleport player on Trigger Volume enter", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createTeleportPlayerInteractionLink({ + id: "link-teleport", + sourceEntityId: "entity-trigger-main", + trigger: "enter", + targetEntityId: "entity-teleport-main" + }) + ]; + const interactionSystem = new RuntimeInteractionSystem(); + const dispatches = []; + interactionSystem.updatePlayerPosition({ + x: 0, + y: 0, + z: 0 + }, runtimeScene, { + teleportPlayer: (target, link) => { + dispatches.push(`${link.id}:${target.entityId}:${target.position.x}`); + }, + toggleBrushVisibility: () => { + dispatches.push("toggle"); + }, + playAnimation: () => { }, + stopAnimation: () => { }, + playSound: () => { }, + stopSound: () => { } + }); + interactionSystem.updatePlayerPosition({ + x: 0.25, + y: 0, + z: 0.25 + }, runtimeScene, { + teleportPlayer: (target, link) => { + dispatches.push(`${link.id}:${target.entityId}:${target.position.x}`); + }, + toggleBrushVisibility: () => { + dispatches.push("toggle"); + }, + playAnimation: () => { }, + stopAnimation: () => { }, + playSound: () => { }, + stopSound: () => { } + }); + expect(dispatches).toEqual(["link-teleport:entity-teleport-main:8"]); + }); + it("dispatches animation actions with the authored target model instance and clip", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createPlayAnimationInteractionLink({ + id: "link-play-animation", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetModelInstanceId: "model-instance-animated", + clipName: "Walk", + loop: false + }), + createStopAnimationInteractionLink({ + id: "link-stop-animation", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetModelInstanceId: "model-instance-animated" + }) + ]; + const interactionSystem = new RuntimeInteractionSystem(); + const dispatches = []; + interactionSystem.dispatchClickInteraction("entity-interactable-console", runtimeScene, { + teleportPlayer: () => { + throw new Error("Teleport should not dispatch in this fixture."); + }, + toggleBrushVisibility: () => { + throw new Error("Visibility should not dispatch in this fixture."); + }, + playAnimation: (instanceId, clipName, loop, link) => { + dispatches.push(`${link.id}:${instanceId}:${clipName}:${loop === false ? "once" : "loop"}`); + }, + stopAnimation: (instanceId, link) => { + dispatches.push(`${link.id}:${instanceId}`); + }, + playSound: () => { }, + stopSound: () => { } + }); + expect(dispatches).toEqual([ + "link-play-animation:model-instance-animated:Walk:once", + "link-stop-animation:model-instance-animated" + ]); + }); + it("dispatches visibility actions only when exiting an occupied Trigger Volume", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createToggleVisibilityInteractionLink({ + id: "link-hide-door", + sourceEntityId: "entity-trigger-main", + trigger: "exit", + targetBrushId: "brush-door", + visible: false + }) + ]; + const interactionSystem = new RuntimeInteractionSystem(); + const dispatches = []; + interactionSystem.updatePlayerPosition({ + x: 0, + y: 0, + z: 0 + }, runtimeScene, { + teleportPlayer: () => { + throw new Error("Teleport should not dispatch in this fixture."); + }, + toggleBrushVisibility: (brushId, visible) => { + dispatches.push({ + brushId, + visible + }); + }, + playAnimation: () => { }, + stopAnimation: () => { }, + playSound: () => { }, + stopSound: () => { } + }); + interactionSystem.updatePlayerPosition({ + x: 3, + y: 0, + z: 0 + }, runtimeScene, { + teleportPlayer: () => { + throw new Error("Teleport should not dispatch in this fixture."); + }, + toggleBrushVisibility: (brushId, visible) => { + dispatches.push({ + brushId, + visible + }); + }, + playAnimation: () => { }, + stopAnimation: () => { }, + playSound: () => { }, + stopSound: () => { } + }); + expect(dispatches).toEqual([ + { + brushId: "brush-door", + visible: false + } + ]); + }); + it("shows a click prompt only for enabled interactables with authored click links inside range", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createTeleportPlayerInteractionLink({ + id: "link-click-teleport", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetEntityId: "entity-teleport-main" + }) + ]; + const interactionSystem = new RuntimeInteractionSystem(); + expect(interactionSystem.resolveClickInteractionPrompt({ + x: 0, + y: 1.6, + z: 0 + }, { + x: 0, + y: 0, + z: 1 + }, runtimeScene)).toEqual({ + sourceEntityId: "entity-interactable-console", + prompt: "Use Console", + distance: expect.any(Number), + range: 2 + }); + expect(interactionSystem.resolveClickInteractionPrompt({ + x: 0, + y: 1.6, + z: 0 + }, { + x: 1, + y: 0, + z: 0 + }, runtimeScene)).toBeNull(); + }); + it("dispatches click actions for the targeted Interactable", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createTeleportPlayerInteractionLink({ + id: "link-click-teleport", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetEntityId: "entity-teleport-main" + }) + ]; + const interactionSystem = new RuntimeInteractionSystem(); + const dispatches = []; + interactionSystem.dispatchClickInteraction("entity-interactable-console", runtimeScene, { + teleportPlayer: (target, link) => { + dispatches.push(`${link.id}:${target.entityId}:${target.position.x}`); + }, + toggleBrushVisibility: () => { + throw new Error("Visibility should not dispatch for this click fixture."); + }, + playAnimation: () => { }, + stopAnimation: () => { }, + playSound: () => { }, + stopSound: () => { } + }); + expect(dispatches).toEqual(["link-click-teleport:entity-teleport-main:8"]); + }); + it("dispatches play and stop sound actions for the targeted Sound Emitter", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createPlaySoundInteractionLink({ + id: "link-play-sound", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetSoundEmitterId: "entity-sound-lobby" + }), + createStopSoundInteractionLink({ + id: "link-stop-sound", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetSoundEmitterId: "entity-sound-lobby" + }) + ]; + const interactionSystem = new RuntimeInteractionSystem(); + const dispatches = []; + interactionSystem.dispatchClickInteraction("entity-interactable-console", runtimeScene, { + teleportPlayer: () => { + throw new Error("Teleport should not dispatch in this fixture."); + }, + toggleBrushVisibility: () => { + throw new Error("Visibility should not dispatch in this fixture."); + }, + playAnimation: () => { + throw new Error("Animation should not dispatch in this fixture."); + }, + stopAnimation: () => { + throw new Error("Animation should not dispatch in this fixture."); + }, + playSound: (soundEmitterId, link) => { + dispatches.push(`${link.id}:${soundEmitterId}`); + }, + stopSound: (soundEmitterId, link) => { + dispatches.push(`${link.id}:${soundEmitterId}`); + } + }); + expect(dispatches).toEqual(["link-play-sound:entity-sound-lobby", "link-stop-sound:entity-sound-lobby"]); + }); +}); diff --git a/tests/domain/runtime-scene-validation.test.js b/tests/domain/runtime-scene-validation.test.js new file mode 100644 index 00000000..91f0122d --- /dev/null +++ b/tests/domain/runtime-scene-validation.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry } from "three"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { validateRuntimeSceneBuild } from "../../src/runtime-three/runtime-scene-validation"; +import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; +describe("validateRuntimeSceneBuild", () => { + it("reports missing loaded geometry for collider modes that depend on imported mesh data", () => { + const { asset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static-validation", new BoxGeometry(1, 1, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-static-validation", + assetId: asset.id, + collision: { + mode: "static", + visible: false + } + }); + const validation = validateRuntimeSceneBuild({ + ...createEmptySceneDocument({ name: "Missing Model Geometry Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, { + navigationMode: "orbitVisitor", + loadedModelAssets: {} + }); + expect(validation.errors.map((diagnostic) => diagnostic.code)).toContain("missing-model-collider-geometry"); + }); + it("fails terrain mode clearly when the source mesh is incompatible with the heightfield path", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-terrain-validation", new BoxGeometry(1, 1, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-terrain-validation", + assetId: asset.id, + collision: { + mode: "terrain", + visible: true + } + }); + const validation = validateRuntimeSceneBuild({ + ...createEmptySceneDocument({ name: "Invalid Terrain Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, { + navigationMode: "orbitVisitor", + loadedModelAssets: { + [asset.id]: loadedAsset + } + }); + expect(validation.errors.map((diagnostic) => diagnostic.code)).toContain("unsupported-terrain-model-collider"); + }); + it("warns that dynamic collision currently participates as fixed queryable world geometry", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-dynamic-validation", new BoxGeometry(1, 1, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-dynamic-validation", + assetId: asset.id, + collision: { + mode: "dynamic", + visible: false + } + }); + const validation = validateRuntimeSceneBuild({ + ...createEmptySceneDocument({ name: "Dynamic Collider Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, { + navigationMode: "orbitVisitor", + loadedModelAssets: { + [asset.id]: loadedAsset + } + }); + expect(validation.errors).toEqual([]); + expect(validation.warnings.map((diagnostic) => diagnostic.code)).toContain("dynamic-model-collider-fixed-query-only"); + }); +}); diff --git a/tests/domain/scene-document-validation.test.js b/tests/domain/scene-document-validation.test.js new file mode 100644 index 00000000..a3038fac --- /dev/null +++ b/tests/domain/scene-document-validation.test.js @@ -0,0 +1,472 @@ +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { validateSceneDocument } from "../../src/document/scene-document-validation"; +import { createPointLightEntity, createInteractableEntity, createPlayerStartEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +describe("validateSceneDocument", () => { + it("accepts a valid first-room document", () => { + const brush = createBoxBrush({ + id: "brush-room-shell" + }); + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main" + }); + const document = { + ...createEmptySceneDocument({ name: "First Room" }), + brushes: { + [brush.id]: brush + }, + entities: { + [playerStart.id]: playerStart + } + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual([]); + expect(validation.warnings).toEqual([]); + }); + it("detects duplicate authored ids across collections", () => { + const brush = createBoxBrush({ + id: "shared-room-id" + }); + const playerStart = createPlayerStartEntity({ + id: "shared-room-id" + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + }, + entities: { + "entity-player-start-main": playerStart + } + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "entity-id-mismatch" + }), + expect.objectContaining({ + code: "duplicate-authored-id" + }) + ])); + }); + it("detects invalid box sizes and missing material references", () => { + const brush = createBoxBrush({ + id: "brush-invalid" + }); + brush.rotationDegrees.y = Number.NaN; + brush.size.x = 0; + brush.faces.posZ.materialId = "material-that-does-not-exist"; + const validation = validateSceneDocument({ + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-box-rotation", + path: "brushes.brush-invalid.rotationDegrees" + }), + expect.objectContaining({ + code: "invalid-box-size", + path: "brushes.brush-invalid.size" + }), + expect.objectContaining({ + code: "missing-material-ref", + path: "brushes.brush-invalid.faces.posZ.materialId" + }) + ])); + }); + it("detects invalid Player Start values", () => { + const validation = validateSceneDocument({ + ...createEmptySceneDocument(), + entities: { + "entity-player-start-main": { + id: "entity-player-start-main", + kind: "playerStart", + position: { + x: 0, + y: Number.NaN, + z: 0 + }, + yawDegrees: Number.NaN, + collider: { + mode: "capsule", + eyeHeight: 3, + capsuleRadius: 0.4, + capsuleHeight: 0.5, + boxSize: { + x: 0.6, + y: -1, + z: 0.6 + } + } + } + } + }); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-player-start-position" + }), + expect.objectContaining({ + code: "invalid-player-start-yaw" + }), + expect.objectContaining({ + code: "invalid-player-start-capsule-proportions" + }), + expect.objectContaining({ + code: "invalid-player-start-box-size" + }), + expect.objectContaining({ + code: "invalid-player-start-eye-height" + }) + ])); + }); + it("detects invalid typed entity values across the entity registry", () => { + const soundEmitter = createSoundEmitterEntity({ + id: "entity-sound-main" + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const teleportTarget = createTeleportTargetEntity({ + id: "entity-teleport-main" + }); + const interactable = createInteractableEntity({ + id: "entity-interactable-main" + }); + const validation = validateSceneDocument({ + ...createEmptySceneDocument(), + entities: { + [soundEmitter.id]: { + ...soundEmitter, + refDistance: Number.NaN + }, + [triggerVolume.id]: { + ...triggerVolume, + size: { + x: 0, + y: 2, + z: 2 + } + }, + [teleportTarget.id]: { + ...teleportTarget, + yawDegrees: Number.POSITIVE_INFINITY + }, + [interactable.id]: { + ...interactable, + prompt: " ", + enabled: "yes" + } + } + }); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-sound-emitter-ref-distance" + }), + expect.objectContaining({ + code: "invalid-trigger-volume-size" + }), + expect.objectContaining({ + code: "invalid-teleport-target-yaw" + }), + expect.objectContaining({ + code: "invalid-interactable-prompt" + }), + expect.objectContaining({ + code: "invalid-interactable-enabled" + }) + ])); + }); + it("detects missing and invalid audio asset references on Sound Emitters", () => { + const audioAsset = { + id: "asset-audio-main", + kind: "audio", + sourceName: "lobby-loop.ogg", + mimeType: "audio/ogg", + storageKey: createProjectAssetStorageKey("asset-audio-main"), + byteLength: 4096, + metadata: { + kind: "audio", + durationSeconds: 4.25, + channelCount: 2, + sampleRateHz: 48000, + warnings: [] + } + }; + const modelAsset = { + id: "asset-model-main", + kind: "model", + sourceName: "fixture.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-main"), + byteLength: 128, + metadata: { + kind: "model", + format: "glb", + sceneName: null, + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: null, + warnings: [] + } + }; + const missingAudioEmitter = createSoundEmitterEntity({ + id: "entity-sound-missing", + audioAssetId: "asset-missing-audio" + }); + const wrongKindAudioEmitter = createSoundEmitterEntity({ + id: "entity-sound-wrong-kind", + audioAssetId: modelAsset.id + }); + const validAudioEmitter = createSoundEmitterEntity({ + id: "entity-sound-valid", + audioAssetId: audioAsset.id + }); + const validation = validateSceneDocument({ + ...createEmptySceneDocument(), + assets: { + [audioAsset.id]: audioAsset, + [modelAsset.id]: modelAsset + }, + entities: { + [missingAudioEmitter.id]: missingAudioEmitter, + [wrongKindAudioEmitter.id]: wrongKindAudioEmitter, + [validAudioEmitter.id]: validAudioEmitter + } + }); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "missing-sound-emitter-audio-asset", + path: "entities.entity-sound-missing.audioAssetId" + }), + expect.objectContaining({ + code: "invalid-sound-emitter-audio-asset-kind", + path: "entities.entity-sound-wrong-kind.audioAssetId" + }) + ])); + }); + it("accepts authored point and spot lights with an active image background asset", () => { + const imageAsset = { + id: "asset-background-panorama", + kind: "image", + sourceName: "skybox-panorama.svg", + mimeType: "image/svg+xml", + storageKey: createProjectAssetStorageKey("asset-background-panorama"), + byteLength: 2048, + metadata: { + kind: "image", + width: 512, + height: 256, + hasAlpha: false, + warnings: ["Background images work best as a 2:1 equirectangular panorama."] + } + }; + const pointLight = createPointLightEntity({ + id: "entity-point-light-main", + position: { + x: 1, + y: 3, + z: -2 + } + }); + const spotLight = createSpotLightEntity({ + id: "entity-spot-light-main", + position: { + x: -1, + y: 4, + z: 2 + }, + direction: { + x: 0.25, + y: -1, + z: 0.15 + } + }); + const document = { + ...createEmptySceneDocument(), + assets: { + [imageAsset.id]: imageAsset + }, + entities: { + [pointLight.id]: pointLight, + [spotLight.id]: spotLight + } + }; + document.world.background = { + mode: "image", + assetId: imageAsset.id, + environmentIntensity: 0.5 + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual([]); + }); + it("detects invalid local light values and missing image background assets", () => { + const pointLight = createPointLightEntity({ + id: "entity-point-light-invalid" + }); + pointLight.colorHex = "not-a-color"; + pointLight.intensity = -1; + pointLight.distance = 0; + const spotLight = createSpotLightEntity({ + id: "entity-spot-light-invalid" + }); + spotLight.direction = { + x: 0, + y: 0, + z: 0 + }; + spotLight.distance = -2; + spotLight.angleDegrees = 180; + const document = { + ...createEmptySceneDocument(), + entities: { + [pointLight.id]: pointLight, + [spotLight.id]: spotLight + } + }; + document.world.background = { + mode: "image", + assetId: "asset-missing-background", + environmentIntensity: 0.5 + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-point-light-color" + }), + expect.objectContaining({ + code: "invalid-point-light-intensity" + }), + expect.objectContaining({ + code: "invalid-point-light-distance" + }), + expect.objectContaining({ + code: "invalid-spot-light-direction" + }), + expect.objectContaining({ + code: "invalid-spot-light-distance" + }), + expect.objectContaining({ + code: "invalid-spot-light-angle" + }), + expect.objectContaining({ + code: "missing-world-background-asset" + }) + ])); + }); + it("detects invalid world lighting and background settings", () => { + const document = createEmptySceneDocument(); + document.world.background = { + mode: "verticalGradient", + topColorHex: "sky-blue", + bottomColorHex: "#18212b" + }; + document.world.ambientLight.intensity = -0.25; + document.world.sunLight.direction = { + x: 0, + y: 0, + z: 0 + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-world-background-top-color", + path: "world.background.topColorHex" + }), + expect.objectContaining({ + code: "invalid-world-ambient-intensity", + path: "world.ambientLight.intensity" + }), + expect.objectContaining({ + code: "invalid-world-sun-direction", + path: "world.sunLight.direction" + }) + ])); + }); + it("detects invalid advanced rendering settings", () => { + const document = createEmptySceneDocument(); + document.world.advancedRendering = { + ...document.world.advancedRendering, + enabled: true, + shadows: { + ...document.world.advancedRendering.shadows, + mapSize: 3000, + type: "ultra", + bias: Number.NaN + }, + ambientOcclusion: { + ...document.world.advancedRendering.ambientOcclusion, + samples: 0 + }, + bloom: { + ...document.world.advancedRendering.bloom, + intensity: -0.25, + threshold: -1, + radius: -0.5 + }, + toneMapping: { + mode: "filmic", + exposure: 0 + }, + depthOfField: { + ...document.world.advancedRendering.depthOfField, + focalLength: 0, + bokehScale: -2 + } + }; + const validation = validateSceneDocument(document); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-advanced-rendering-shadow-map-size", + path: "world.advancedRendering.shadows.mapSize" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-shadow-type", + path: "world.advancedRendering.shadows.type" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-shadow-bias", + path: "world.advancedRendering.shadows.bias" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-ao-samples", + path: "world.advancedRendering.ambientOcclusion.samples" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-bloom-intensity", + path: "world.advancedRendering.bloom.intensity" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-bloom-threshold", + path: "world.advancedRendering.bloom.threshold" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-bloom-radius", + path: "world.advancedRendering.bloom.radius" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-tone-mapping-mode", + path: "world.advancedRendering.toneMapping.mode" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-tone-mapping-exposure", + path: "world.advancedRendering.toneMapping.exposure" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-dof-focal-length", + path: "world.advancedRendering.depthOfField.focalLength" + }), + expect.objectContaining({ + code: "invalid-advanced-rendering-dof-bokeh-scale", + path: "world.advancedRendering.depthOfField.bokehScale" + }) + ])); + }); +}); diff --git a/tests/domain/transform-session.command.test.js b/tests/domain/transform-session.command.test.js new file mode 100644 index 00000000..f0855c4b --- /dev/null +++ b/tests/domain/transform-session.command.test.js @@ -0,0 +1,581 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { createCommitTransformSessionCommand } from "../../src/commands/commit-transform-session-command"; +import { createTransformSession, resolveTransformTarget, supportsTransformAxisConstraint, supportsTransformOperation } from "../../src/core/transform-session"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPlayerStartEntity } from "../../src/entities/entity-instances"; +const modelAsset = { + id: "asset-model-transform-fixture", + kind: "model", + sourceName: "transform-fixture.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-transform-fixture"), + byteLength: 64, + metadata: { + kind: "model", + format: "glb", + sceneName: "Transform Fixture", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: -0.5, + y: 0, + z: -0.5 + }, + max: { + x: 0.5, + y: 1, + z: 0.5 + }, + size: { + x: 1, + y: 1, + z: 1 + } + }, + warnings: [] + } +}; +describe("transform session commit commands", () => { + it("resolves component transform targets in matching mode and enforces operation support", () => { + const brush = createBoxBrush({ + id: "brush-main" + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }; + const faceWrongModeResolved = resolveTransformTarget(document, { + kind: "brushFace", + brushId: brush.id, + faceId: "posZ" + }); + const faceResolved = resolveTransformTarget(document, { + kind: "brushFace", + brushId: brush.id, + faceId: "posZ" + }, "face"); + const edgeResolved = resolveTransformTarget(document, { + kind: "brushEdge", + brushId: brush.id, + edgeId: "edgeX_posY_negZ" + }, "edge"); + const vertexResolved = resolveTransformTarget(document, { + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_negZ" + }, "vertex"); + const faceModeBrushResolved = resolveTransformTarget(document, { + kind: "brushes", + ids: [brush.id] + }, "face"); + const objectResolved = resolveTransformTarget(document, { + kind: "brushes", + ids: [brush.id] + }); + expect(faceWrongModeResolved.target).toBeNull(); + expect(faceWrongModeResolved.message).toContain("Face mode"); + expect(faceResolved.target).toMatchObject({ + kind: "brushFace", + brushId: brush.id, + faceId: "posZ" + }); + expect(edgeResolved.target).toMatchObject({ + kind: "brushEdge", + brushId: brush.id, + edgeId: "edgeX_posY_negZ" + }); + expect(vertexResolved.target).toMatchObject({ + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_negZ" + }); + expect(faceModeBrushResolved.target).toBeNull(); + expect(faceModeBrushResolved.message).toContain("Object mode"); + expect(objectResolved.target).toMatchObject({ + kind: "brush", + brushId: brush.id, + initialCenter: brush.center, + initialRotationDegrees: brush.rotationDegrees, + initialSize: brush.size + }); + expect(objectResolved.target).not.toBeNull(); + expect(supportsTransformOperation(objectResolved.target, "translate")).toBe(true); + expect(supportsTransformOperation(objectResolved.target, "rotate")).toBe(true); + expect(supportsTransformOperation(objectResolved.target, "scale")).toBe(true); + expect(supportsTransformOperation(faceResolved.target, "translate")).toBe(true); + expect(supportsTransformOperation(faceResolved.target, "rotate")).toBe(true); + expect(supportsTransformOperation(faceResolved.target, "scale")).toBe(true); + expect(supportsTransformOperation(vertexResolved.target, "translate")).toBe(true); + expect(supportsTransformOperation(vertexResolved.target, "rotate")).toBe(false); + expect(supportsTransformOperation(vertexResolved.target, "scale")).toBe(false); + }); + it("applies axis-constraint rules across object and component transform sessions", () => { + const brush = createBoxBrush({ + id: "brush-axis-rules" + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }; + const faceTarget = resolveTransformTarget(document, { + kind: "brushFace", + brushId: brush.id, + faceId: "posX" + }, "face").target; + const edgeTarget = resolveTransformTarget(document, { + kind: "brushEdge", + brushId: brush.id, + edgeId: "edgeY_posX_posZ" + }, "edge").target; + const vertexTarget = resolveTransformTarget(document, { + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_posZ" + }, "vertex").target; + if (faceTarget === null || faceTarget.kind !== "brushFace") { + throw new Error("Expected a face transform target."); + } + if (edgeTarget === null || edgeTarget.kind !== "brushEdge") { + throw new Error("Expected an edge transform target."); + } + if (vertexTarget === null || vertexTarget.kind !== "brushVertex") { + throw new Error("Expected a vertex transform target."); + } + const faceRotateSession = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "rotate", + target: faceTarget + }); + const edgeScaleSession = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "scale", + target: edgeTarget + }); + const vertexTranslateSession = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "translate", + target: vertexTarget + }); + expect(supportsTransformAxisConstraint(faceRotateSession, "x")).toBe(true); + expect(supportsTransformAxisConstraint(faceRotateSession, "y")).toBe(false); + expect(supportsTransformAxisConstraint(faceRotateSession, "z")).toBe(false); + expect(supportsTransformAxisConstraint(edgeScaleSession, "x")).toBe(true); + expect(supportsTransformAxisConstraint(edgeScaleSession, "y")).toBe(false); + expect(supportsTransformAxisConstraint(edgeScaleSession, "z")).toBe(true); + expect(supportsTransformAxisConstraint(vertexTranslateSession, "x")).toBe(true); + expect(supportsTransformAxisConstraint(vertexTranslateSession, "y")).toBe(true); + expect(supportsTransformAxisConstraint(vertexTranslateSession, "z")).toBe(true); + }); + it("commits whitebox box rotate and scale transforms with undo and redo", () => { + const brush = createBoxBrush({ + id: "brush-transform-main", + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + size: { + x: 2.5, + y: 2, + z: 4 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Brush Transform Fixture" }), + brushes: { + [brush.id]: brush + } + } + }); + const target = resolveTransformTarget(store.getState().document, { + kind: "brushes", + ids: [brush.id] + }).target; + if (target === null || target.kind !== "brush") { + throw new Error("Expected a whitebox box transform target."); + } + const rotateSession = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "rotate", + target + }); + rotateSession.preview = { + kind: "brush", + center: { + ...brush.center + }, + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + ...brush.size + } + }; + store.executeCommand(createCommitTransformSessionCommand(store.getState().document, rotateSession)); + expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual({ + x: 0, + y: 37.5, + z: 12.5 + }); + const scaleTarget = resolveTransformTarget(store.getState().document, { + kind: "brushes", + ids: [brush.id] + }).target; + if (scaleTarget === null || scaleTarget.kind !== "brush") { + throw new Error("Expected a whitebox box transform target after rotation."); + } + const scaleSession = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "scale", + target: scaleTarget + }); + scaleSession.preview = { + kind: "brush", + center: { + ...brush.center + }, + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + x: 3.25, + y: 1.75, + z: 5.5 + } + }; + store.executeCommand(createCommitTransformSessionCommand(store.getState().document, scaleSession)); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + x: 3.25, + y: 1.75, + z: 5.5 + } + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + x: 2.5, + y: 2, + z: 4 + } + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toEqual(brush); + expect(store.redo()).toBe(true); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + x: 3.25, + y: 1.75, + z: 5.5 + } + }); + }); + it("commits a face transform preview and restores it through undo/redo", () => { + const brush = createBoxBrush({ + id: "brush-face-transform", + center: { x: 0, y: 1, z: 0 }, + size: { x: 2, y: 2, z: 2 } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Face Transform Fixture" }), + brushes: { + [brush.id]: brush + } + } + }); + const target = resolveTransformTarget(store.getState().document, { + kind: "brushFace", + brushId: brush.id, + faceId: "posX" + }, "face").target; + if (target === null || target.kind !== "brushFace") { + throw new Error("Expected a whitebox face transform target."); + } + const session = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "translate", + target + }); + session.preview = { + kind: "brush", + center: { x: 0.5, y: 1, z: 0 }, + rotationDegrees: { x: 0, y: 0, z: 0 }, + size: { x: 3, y: 2, z: 2 } + }; + store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + center: { x: 0.5, y: 1, z: 0 }, + size: { x: 3, y: 2, z: 2 } + }); + expect(store.getState().selection).toEqual({ + kind: "brushFace", + brushId: brush.id, + faceId: "posX" + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toEqual(brush); + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + center: { x: 0.5, y: 1, z: 0 }, + size: { x: 3, y: 2, z: 2 } + }); + }); + it("commits a vertex transform preview and preserves vertex selection through undo/redo", () => { + const brush = createBoxBrush({ + id: "brush-vertex-transform", + center: { x: 0, y: 1, z: 0 }, + size: { x: 2, y: 2, z: 2 } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Vertex Transform Fixture" }), + brushes: { + [brush.id]: brush + } + } + }); + const target = resolveTransformTarget(store.getState().document, { + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_posZ" + }, "vertex").target; + if (target === null || target.kind !== "brushVertex") { + throw new Error("Expected a whitebox vertex transform target."); + } + const session = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "translate", + target + }); + session.preview = { + kind: "brush", + center: { x: 0.5, y: 1.5, z: 0.5 }, + rotationDegrees: { x: 0, y: 0, z: 0 }, + size: { x: 3, y: 3, z: 3 } + }; + store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + center: { x: 0.5, y: 1.5, z: 0.5 }, + size: { x: 3, y: 3, z: 3 } + }); + expect(store.getState().selection).toEqual({ + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_posZ" + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[brush.id]).toEqual(brush); + expect(store.redo()).toBe(true); + expect(store.getState().selection).toEqual({ + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_posZ" + }); + }); + it("commits a model instance translate/rotate/scale transform with undo and redo", () => { + const modelInstance = createModelInstance({ + id: "model-instance-main", + assetId: modelAsset.id, + position: { + x: 0, + y: 1, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + scale: { + x: 1, + y: 1, + z: 1 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Transform Fixture" }), + assets: { + [modelAsset.id]: modelAsset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + } + }); + const target = resolveTransformTarget(store.getState().document, { + kind: "modelInstances", + ids: [modelInstance.id] + }).target; + if (target === null || target.kind !== "modelInstance") { + throw new Error("Expected a model instance transform target."); + } + const session = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "scale", + target + }); + session.preview = { + kind: "modelInstance", + position: { + x: 4, + y: 1, + z: -2 + }, + rotationDegrees: { + x: 0, + y: 90, + z: 0 + }, + scale: { + x: 1.5, + y: 2, + z: 1.5 + } + }; + store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); + expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ + position: { + x: 4, + y: 1, + z: -2 + }, + rotationDegrees: { + x: 0, + y: 90, + z: 0 + }, + scale: { + x: 1.5, + y: 2, + z: 1.5 + } + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.modelInstances[modelInstance.id]).toEqual(modelInstance); + expect(store.redo()).toBe(true); + expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ + position: { + x: 4, + y: 1, + z: -2 + }, + rotationDegrees: { + x: 0, + y: 90, + z: 0 + }, + scale: { + x: 1.5, + y: 2, + z: 1.5 + } + }); + }); + it("commits a rotatable entity transform with undo and redo", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main", + position: { + x: 0, + y: 0, + z: 0 + }, + yawDegrees: 0 + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Entity Transform Fixture" }), + entities: { + [playerStart.id]: playerStart + } + } + }); + const target = resolveTransformTarget(store.getState().document, { + kind: "entities", + ids: [playerStart.id] + }).target; + if (target === null || target.kind !== "entity") { + throw new Error("Expected an entity transform target."); + } + const session = createTransformSession({ + source: "keyboard", + sourcePanelId: "topLeft", + operation: "rotate", + target + }); + session.preview = { + kind: "entity", + position: { + x: 6, + y: 0, + z: -4 + }, + rotation: { + kind: "yaw", + yawDegrees: 90 + } + }; + store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); + expect(store.getState().document.entities[playerStart.id]).toMatchObject({ + position: { + x: 6, + y: 0, + z: -4 + }, + yawDegrees: 90 + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.entities[playerStart.id]).toEqual(playerStart); + expect(store.redo()).toBe(true); + expect(store.getState().document.entities[playerStart.id]).toMatchObject({ + position: { + x: 6, + y: 0, + z: -4 + }, + yawDegrees: 90 + }); + }); +}); diff --git a/tests/domain/world-settings.command.test.js b/tests/domain/world-settings.command.test.js new file mode 100644 index 00000000..b6d19d8b --- /dev/null +++ b/tests/domain/world-settings.command.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createSetWorldSettingsCommand } from "../../src/commands/set-world-settings-command"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { cloneWorldSettings } from "../../src/document/world-settings"; +describe("createSetWorldSettingsCommand", () => { + it("updates authored world settings and restores them through undo", () => { + const store = createEditorStore(); + const originalWorld = cloneWorldSettings(store.getState().document.world); + const nextWorld = cloneWorldSettings(originalWorld); + nextWorld.background = { + mode: "verticalGradient", + topColorHex: "#6e8db4", + bottomColorHex: "#18212b" + }; + nextWorld.ambientLight.intensity = 0.45; + nextWorld.advancedRendering.enabled = true; + nextWorld.advancedRendering.shadows.enabled = true; + nextWorld.advancedRendering.shadows.mapSize = 4096; + nextWorld.advancedRendering.toneMapping.mode = "reinhard"; + nextWorld.advancedRendering.toneMapping.exposure = 1.35; + store.executeCommand(createSetWorldSettingsCommand({ + label: "Set world lighting", + world: nextWorld + })); + expect(store.getState().document.world).toEqual(nextWorld); + expect(store.undo()).toBe(true); + expect(store.getState().document.world).toEqual(createEmptySceneDocument().world); + }); +}); diff --git a/tests/domain/world-settings.test.js b/tests/domain/world-settings.test.js new file mode 100644 index 00000000..ef1dea5e --- /dev/null +++ b/tests/domain/world-settings.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { areWorldSettingsEqual, changeWorldBackgroundMode, cloneWorldSettings, createDefaultWorldSettings } from "../../src/document/world-settings"; +describe("world settings helpers", () => { + it("clones world settings without retaining nested references", () => { + const source = createDefaultWorldSettings(); + const clone = cloneWorldSettings(source); + expect(clone).toEqual(source); + expect(clone).not.toBe(source); + expect(clone.background).not.toBe(source.background); + expect(clone.sunLight.direction).not.toBe(source.sunLight.direction); + expect(clone.advancedRendering).not.toBe(source.advancedRendering); + expect(clone.advancedRendering.shadows).not.toBe(source.advancedRendering.shadows); + }); + it("switches a solid background into a gradient while preserving the authored color as the top edge", () => { + const gradient = changeWorldBackgroundMode({ + mode: "solid", + colorHex: "#334455" + }, "verticalGradient"); + expect(gradient).toEqual({ + mode: "verticalGradient", + topColorHex: "#334455", + bottomColorHex: "#141a22" + }); + }); + it("switches and clones image backgrounds by asset id", () => { + const imageBackground = changeWorldBackgroundMode({ + mode: "solid", + colorHex: "#334455" + }, "image", "asset-background-panorama"); + expect(imageBackground).toEqual({ + mode: "image", + assetId: "asset-background-panorama", + environmentIntensity: 0.5 + }); + const nextImageBackground = changeWorldBackgroundMode(imageBackground, "image", "asset-background-panorama-2"); + expect(nextImageBackground).toEqual({ + mode: "image", + assetId: "asset-background-panorama-2", + environmentIntensity: 0.5 + }); + const world = createDefaultWorldSettings(); + world.background = nextImageBackground; + const clonedWorld = cloneWorldSettings(world); + expect(clonedWorld.background).toEqual(nextImageBackground); + expect(clonedWorld.background).not.toBe(world.background); + expect(areWorldSettingsEqual(world, clonedWorld)).toBe(true); + }); + it("compares authored world settings by value", () => { + const left = createDefaultWorldSettings(); + const right = cloneWorldSettings(left); + expect(areWorldSettingsEqual(left, right)).toBe(true); + right.sunLight.direction.x = right.sunLight.direction.x + 0.25; + expect(areWorldSettingsEqual(left, right)).toBe(false); + right.sunLight.direction.x = left.sunLight.direction.x; + right.advancedRendering.bloom.intensity = right.advancedRendering.bloom.intensity + 0.1; + expect(areWorldSettingsEqual(left, right)).toBe(false); + }); +}); diff --git a/tests/e2e/app-smoke.e2e.js b/tests/e2e/app-smoke.e2e.js new file mode 100644 index 00000000..1f9a213a --- /dev/null +++ b/tests/e2e/app-smoke.e2e.js @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; +test("app boots and shows the viewport shell", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await expect(page.getByTestId("toolbar-scene-name")).toHaveValue("Untitled Scene"); + await expect(page.getByTestId("viewport-shell")).toBeVisible(); + await expect(page.getByTestId("viewport-panel-topLeft")).toBeVisible(); + await expect(page.getByTestId("viewport-layout-single")).toBeVisible(); + await expect(page.getByTestId("viewport-layout-quad")).toBeVisible(); + await expect(page.getByRole("button", { name: "World" })).toBeVisible(); + await expect(page.getByTestId("world-background-mode-value")).toBeVisible(); + await expect(page.getByTestId("enter-run-mode")).toBeVisible(); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/box-brush-authoring.e2e.js b/tests/e2e/box-brush-authoring.e2e.js new file mode 100644 index 00000000..cea7a530 --- /dev/null +++ b/tests/e2e/box-brush-authoring.e2e.js @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import { beginBoxCreation, clickViewport, getEditorStoreSnapshot } from "./viewport-test-helpers"; +test("user can create a whitebox box with float transforms and keep it through reload and run mode", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await beginBoxCreation(page); + const creationSnapshot = await getEditorStoreSnapshot(page); + expect(creationSnapshot).toMatchObject({ + toolMode: "create", + viewportTransientState: { + toolPreview: { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: null + } + } + }); + await page.keyboard.press("Escape"); + const cancelledSnapshot = await getEditorStoreSnapshot(page); + expect(cancelledSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + await beginBoxCreation(page); + await clickViewport(page); + await page.getByTestId("whitebox-snap-toggle").click(); + const committedSnapshot = await getEditorStoreSnapshot(page); + expect(committedSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible(); + await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible(); + await expect(page.getByTestId("apply-brush-position")).toHaveCount(0); + await expect(page.getByTestId("apply-brush-size")).toHaveCount(0); + await page.getByTestId("brush-center-x").fill("1.25"); + await page.getByTestId("brush-center-x").press("Tab"); + await page.getByTestId("brush-center-y").fill("2.125"); + await page.getByTestId("brush-center-y").press("Tab"); + await page.getByTestId("brush-rotation-y").fill("37.5"); + await page.getByTestId("brush-rotation-y").press("Tab"); + await page.getByTestId("brush-size-z").fill("4.5"); + await page.getByTestId("brush-size-z").press("Tab"); + await page.getByTestId("selected-brush-name").fill("Entry Room"); + await page.getByTestId("selected-brush-name").press("Tab"); + await expect(page.getByTestId("selected-brush-name")).toHaveValue("Entry Room"); + await page.getByRole("button", { name: "Save Draft" }).click(); + await page.reload(); + await expect(page.getByRole("button", { name: /^Entry Room$/ })).toBeVisible(); + await page.getByRole("button", { name: /^Entry Room$/ }).click(); + await expect(page.getByTestId("brush-center-x")).toHaveValue("1.25"); + await expect(page.getByTestId("brush-center-y")).toHaveValue("2.125"); + await expect(page.getByTestId("brush-rotation-y")).toHaveValue("37.5"); + await expect(page.getByTestId("brush-size-z")).toHaveValue("4.5"); + await expect(page.getByTestId("viewport-overlay-topLeft")).toHaveCount(0); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); +test("switching selection while a transform input is active does not overwrite the newly selected brush", async ({ page }) => { + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await beginBoxCreation(page); + await clickViewport(page); + await beginBoxCreation(page); + await clickViewport(page); + const outlinerButtons = page.getByTestId("outliner-brush-list").getByRole("button"); + await outlinerButtons.nth(0).click(); + await page.getByTestId("brush-size-z").fill("4"); + await outlinerButtons.nth(1).click(); + await expect(page.getByText("1 solid selected (Whitebox Box 2)")).toBeVisible(); + await expect(page.getByTestId("brush-size-z")).toHaveValue("2"); + await outlinerButtons.nth(0).click(); + await expect(page.getByTestId("brush-size-z")).toHaveValue("4"); +}); diff --git a/tests/e2e/entities-foundation.e2e.js b/tests/e2e/entities-foundation.e2e.js new file mode 100644 index 00000000..8cf79092 --- /dev/null +++ b/tests/e2e/entities-foundation.e2e.js @@ -0,0 +1,155 @@ +import { expect, test } from "@playwright/test"; +import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers"; +test("user can place and select typed entities from the entity foundation workflow", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-sound-emitter").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "soundEmitter", audioAssetId: null }, { x: 4, y: 1, z: -6 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + const soundEmitterSnapshot = await getEditorStoreSnapshot(page); + expect(soundEmitterSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + const selectedSoundEmitterId = soundEmitterSnapshot.selection.kind === "entities" ? soundEmitterSnapshot.selection.ids?.[0] ?? null : null; + expect(selectedSoundEmitterId).not.toBeNull(); + const selectedSoundEmitter = soundEmitterSnapshot.document.entities[selectedSoundEmitterId]; + if (selectedSoundEmitter === undefined) { + throw new Error("Placed sound emitter is missing from the document snapshot."); + } + expect(selectedSoundEmitter.position).toMatchObject({ + x: 4, + y: 1, + z: -6 + }); + await expect(page.getByTestId("sound-emitter-ref-distance")).toHaveValue("6"); + await expect(page.getByTestId("sound-emitter-max-distance")).toHaveValue("24"); + await page.getByTestId("sound-emitter-ref-distance").fill("9"); + await page.getByTestId("sound-emitter-ref-distance").press("Tab"); + await page.getByTestId("sound-emitter-autoplay").click(); + await page.getByTestId("sound-emitter-loop").click(); + await expect(page.getByTestId("sound-emitter-autoplay")).toBeChecked(); + await expect(page.getByTestId("sound-emitter-loop")).toBeChecked(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-interactable").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "interactable", audioAssetId: null }, { x: -8, y: 1, z: 12 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + const interactableSnapshot = await getEditorStoreSnapshot(page); + expect(interactableSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + await expect(page.getByTestId("interactable-prompt")).toHaveValue("Use"); + await page + .locator('[data-testid^="outliner-entity-"]') + .filter({ hasText: "Sound Emitter" }) + .first() + .click(); + await expect(page.getByTestId("sound-emitter-ref-distance")).toHaveValue("9"); + await expect(page.getByTestId("sound-emitter-autoplay")).toBeChecked(); + await expect(page.getByTestId("sound-emitter-loop")).toBeChecked(); + await expect(page.getByTestId("interactable-prompt")).toHaveCount(0); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); +test("shift+a opens the add menu at the cursor", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.mouse.move(420, 260); + await page.keyboard.press("Shift+A"); + await expect(page.getByRole("menu", { name: "Add" })).toBeVisible(); + await page.getByTestId("add-menu-lights").click(); + await page.getByTestId("add-menu-point-light").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "pointLight", audioAssetId: null }, { x: 12, y: 3, z: -4 }); + await clickViewport(page, "topLeft"); + await expect(page.getByTestId("point-light-intensity")).toHaveValue("1.25"); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); +test("escape cancels a typed entity creation preview", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-player-start").click(); + const creationSnapshot = await getEditorStoreSnapshot(page); + expect(creationSnapshot).toMatchObject({ + toolMode: "create", + viewportTransientState: { + toolPreview: { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "entity", + entityKind: "playerStart", + audioAssetId: null + }, + center: null + } + } + }); + await page.keyboard.press("Escape"); + const cancelledSnapshot = await getEditorStoreSnapshot(page); + expect(cancelledSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/face-material-authoring.e2e.js b/tests/e2e/face-material-authoring.e2e.js new file mode 100644 index 00000000..0b1a71b9 --- /dev/null +++ b/tests/e2e/face-material-authoring.e2e.js @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { beginBoxCreation, clickViewport } from "./viewport-test-helpers"; +test("user can assign a face material through the UI and keep it through a draft reload", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await beginBoxCreation(page); + await clickViewport(page); + await page.getByTestId("face-button-posZ").click(); + await page.getByTestId("material-button-starter-amber-grid").click(); + await expect(page.getByTestId("selected-face-material-name")).toContainText("Amber Grid"); + await page.getByRole("button", { name: "Save Draft" }).click(); + await page.reload(); + await page.getByRole("button", { name: /Whitebox Box 1/ }).click(); + await page.getByTestId("face-button-posZ").click(); + await expect(page.getByTestId("selected-face-material-name")).toContainText("Amber Grid"); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/first-room-workflow.e2e.js b/tests/e2e/first-room-workflow.e2e.js new file mode 100644 index 00000000..b11ce95e --- /dev/null +++ b/tests/e2e/first-room-workflow.e2e.js @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; +import { beginBoxCreation, clickViewport, setViewportCreationPreview } from "./viewport-test-helpers"; +test("first-room workflow covers create, texture, save/load, and run", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await beginBoxCreation(page); + await clickViewport(page); + await page.getByTestId("face-button-posZ").click(); + await page.getByTestId("material-button-starter-amber-grid").click(); + await page.getByRole("button", { name: "First Person" }).click(); + await expect(page.getByTestId("status-message")).toContainText("Author a Player Start before running"); + await expect(page.getByTestId("status-run-preflight")).toContainText("Blocked"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-player-start").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("player-start-position-x").fill("2"); + await page.getByTestId("player-start-position-x").press("Tab"); + await page.getByTestId("player-start-position-z").fill("-2"); + await page.getByTestId("player-start-position-z").press("Tab"); + await page.getByTestId("player-start-yaw").fill("90"); + await page.getByTestId("player-start-yaw").press("Tab"); + await expect(page.getByTestId("status-run-preflight")).toContainText("Ready for First Person"); + await page.getByRole("button", { name: "Save Draft" }).click(); + await beginBoxCreation(page); + await clickViewport(page); + await expect(page.getByRole("button", { name: /Whitebox Box 2/ })).toBeVisible(); + await page.getByRole("button", { name: "Load Draft" }).click(); + await expect(page.getByRole("button", { name: /Whitebox Box 2/ })).toHaveCount(0); + await page.getByRole("button", { name: /Whitebox Box 1/ }).click(); + await page.getByTestId("face-button-posZ").click(); + await expect(page.getByTestId("selected-face-material-name")).toContainText("Amber Grid"); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-spawn-state")).toContainText("Player Start"); + await expect(page.getByTestId("runner-player-position")).toContainText("2.00,"); + await expect(page.getByTestId("runner-player-position")).toContainText(", -2.00"); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/import-draco-model-asset.e2e.js b/tests/e2e/import-draco-model-asset.e2e.js new file mode 100644 index 00000000..15b7d13d --- /dev/null +++ b/tests/e2e/import-draco-model-asset.e2e.js @@ -0,0 +1,59 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers"; +const fixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle-draco.glb"); +test("imports a draco-compressed glb asset, places an instance, and survives reload", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-import").click(); + await page.getByTestId("import-menu-model").click(); + await page.locator('input[type="file"][accept*="gltf"]').setInputFiles(fixturePath); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(1); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-models").click(); + const addMenu = page.getByRole("menu", { name: "Add" }); + await expect(addMenu.getByRole("menuitem", { name: "tiny-triangle-draco.glb" })).toBeVisible(); + await addMenu.getByRole("menuitem", { name: "tiny-triangle-draco.glb" }).click(); + const importedSnapshot = await getEditorStoreSnapshot(page); + const importedModelAsset = Object.values(importedSnapshot.document.assets).find((asset) => asset.kind === "model" && asset.sourceName === "tiny-triangle-draco.glb"); + if (importedModelAsset === undefined) { + throw new Error("Imported model asset was not found in the document snapshot."); + } + await setViewportCreationPreview(page, "topLeft", { kind: "model-instance", assetId: importedModelAsset.id }, { x: 84, y: 0, z: -88 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2); + const snapshot = await getEditorStoreSnapshot(page); + const selectedModelInstanceId = snapshot.selection.kind === "modelInstances" ? snapshot.selection.ids?.[0] ?? null : null; + expect(selectedModelInstanceId).not.toBeNull(); + const selectedModelInstance = snapshot.document.modelInstances[selectedModelInstanceId]; + if (selectedModelInstance === undefined) { + throw new Error("Placed model instance is missing from the document snapshot."); + } + expect(selectedModelInstance.position).toMatchObject({ + x: 84, + z: -88 + }); + await page.getByRole("button", { name: "Save Draft" }).dispatchEvent("click"); + await expect(page.getByTestId("status-message")).toContainText("Local draft saved."); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-models").click(); + await expect(page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "tiny-triangle-draco.glb" })).toBeVisible(); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2); + await expect(page.getByTestId("asset-status-message")).toHaveCount(0); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/import-external-model-asset.e2e.js b/tests/e2e/import-external-model-asset.e2e.js new file mode 100644 index 00000000..21075456 --- /dev/null +++ b/tests/e2e/import-external-model-asset.e2e.js @@ -0,0 +1,53 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers"; +const gltfFixturePath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf"); +const binFixturePath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin"); +test("imports a gltf asset with external resources and places an instance", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-import").click(); + await page.getByTestId("import-menu-model").click(); + await page.locator('input[type="file"][accept*="gltf"]').setInputFiles([gltfFixturePath, binFixturePath]); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(1); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-models").click(); + const addMenu = page.getByRole("menu", { name: "Add" }); + await expect(addMenu.getByRole("menuitem", { name: "scene.gltf" })).toBeVisible(); + await addMenu.getByRole("menuitem", { name: "scene.gltf" }).hover(); + await expect(page.getByTestId("status-asset-hover")).toContainText("Storage key:"); + await addMenu.getByRole("menuitem", { name: "scene.gltf" }).click(); + const importedSnapshot = await getEditorStoreSnapshot(page); + const importedModelAsset = Object.values(importedSnapshot.document.assets).find((asset) => asset.kind === "model" && asset.sourceName === "scene.gltf"); + if (importedModelAsset === undefined) { + throw new Error("Imported model asset was not found in the document snapshot."); + } + await setViewportCreationPreview(page, "topLeft", { kind: "model-instance", assetId: importedModelAsset.id }, { x: 88, y: 0, z: -84 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2); + const snapshot = await getEditorStoreSnapshot(page); + const selectedModelInstanceId = snapshot.selection.kind === "modelInstances" ? snapshot.selection.ids?.[0] ?? null : null; + expect(selectedModelInstanceId).not.toBeNull(); + const selectedModelInstance = snapshot.document.modelInstances[selectedModelInstanceId]; + if (selectedModelInstance === undefined) { + throw new Error("Placed model instance is missing from the document snapshot."); + } + expect(selectedModelInstance.position).toMatchObject({ + x: 88, + z: -84 + }); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/import-model-asset.e2e.js b/tests/e2e/import-model-asset.e2e.js new file mode 100644 index 00000000..13cd0596 --- /dev/null +++ b/tests/e2e/import-model-asset.e2e.js @@ -0,0 +1,101 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers"; +const fixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.gltf"); +test("imports a model asset, places an instance, and survives reload", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-import").click(); + await expect(page.getByTestId("import-menu-model")).toBeVisible(); + await expect(page.getByTestId("import-menu-environment")).toBeVisible(); + await expect(page.getByTestId("import-menu-audio")).toBeVisible(); + await page.getByTestId("import-menu-model").click(); + await page.locator('input[type="file"][accept*="gltf"]').setInputFiles(fixturePath); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(1); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-models").click(); + const addMenu = page.getByRole("menu", { name: "Add" }); + await expect(addMenu.getByRole("menuitem", { name: "tiny-triangle.gltf" })).toBeVisible(); + await addMenu.getByRole("menuitem", { name: "tiny-triangle.gltf" }).hover(); + await expect(page.getByTestId("status-asset-hover")).toContainText("Storage key:"); + await addMenu.getByRole("menuitem", { name: "tiny-triangle.gltf" }).click(); + const importedSnapshot = await getEditorStoreSnapshot(page); + expect(importedSnapshot).toMatchObject({ + toolMode: "create", + viewportTransientState: { + toolPreview: { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "model-instance", + assetId: expect.any(String) + }, + center: null + } + } + }); + const importedModelAsset = Object.values(importedSnapshot.document.assets).find((asset) => asset.kind === "model" && asset.sourceName === "tiny-triangle.gltf"); + if (importedModelAsset === undefined) { + throw new Error("Imported model asset was not found in the document snapshot."); + } + await page.keyboard.press("Escape"); + const cancelledSnapshot = await getEditorStoreSnapshot(page); + expect(cancelledSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-models").click(); + await page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "tiny-triangle.gltf" }).click(); + await setViewportCreationPreview(page, "topLeft", { kind: "model-instance", assetId: importedModelAsset.id }, { x: 92, y: 0, z: -76 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + const committedSnapshot = await getEditorStoreSnapshot(page); + expect(committedSnapshot).toMatchObject({ + toolMode: "select", + viewportTransientState: { + toolPreview: { + kind: "none" + } + } + }); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2); + const snapshot = committedSnapshot; + const selectedModelInstanceId = snapshot.selection.kind === "modelInstances" ? snapshot.selection.ids?.[0] ?? null : null; + expect(selectedModelInstanceId).not.toBeNull(); + const selectedModelInstance = snapshot.document.modelInstances[selectedModelInstanceId]; + if (selectedModelInstance === undefined) { + throw new Error("Placed model instance is missing from the document snapshot."); + } + expect(selectedModelInstance.position).toMatchObject({ + x: 92, + z: -76 + }); + await page.getByRole("button", { name: "Save Draft" }).dispatchEvent("click"); + await expect(page.getByTestId("status-message")).toContainText("Local draft saved."); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-models").click(); + await expect(page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "tiny-triangle.gltf" })).toBeVisible(); + await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2); + await expect(page.getByTestId("asset-status-message")).toHaveCount(0); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/local-lights-and-background.e2e.js b/tests/e2e/local-lights-and-background.e2e.js new file mode 100644 index 00000000..453bc4e0 --- /dev/null +++ b/tests/e2e/local-lights-and-background.e2e.js @@ -0,0 +1,71 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers"; +const panoramaFixturePath = path.resolve(process.cwd(), "fixtures/assets/skybox-panorama.svg"); +test("local lights and background images persist through editor and runner flows", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-import").click(); + await page.getByTestId("import-menu-environment").click(); + await page.locator('input[type="file"][accept*="image"]').setInputFiles(panoramaFixturePath); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-environments").click(); + const addMenu = page.getByRole("menu", { name: "Add" }); + await expect(addMenu.getByRole("menuitem", { name: "skybox-panorama.svg" })).toBeVisible(); + await addMenu.getByRole("menuitem", { name: "skybox-panorama.svg" }).click(); + await expect(page.getByTestId("world-background-mode-value")).toContainText("Image"); + await expect(page.getByTestId("world-background-asset-value")).toContainText("skybox-panorama.svg"); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /url/); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-lights").click(); + await page.getByTestId("add-menu-point-light").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "pointLight", audioAssetId: null }, { x: 12, y: 3, z: -4 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + await expect(page.getByTestId("point-light-distance")).toHaveValue("8"); + await page.getByTestId("point-light-distance").fill("12"); + await page.getByTestId("point-light-distance").press("Tab"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-lights").click(); + await page.getByTestId("add-menu-spot-light").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "spotLight", audioAssetId: null }, { x: -10, y: 4, z: 6 }); + await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible(); + await clickViewport(page, "topLeft"); + await expect(page.getByTestId("spot-light-angle")).toHaveValue("35"); + await page.getByTestId("spot-light-angle").fill("48"); + await page.getByTestId("spot-light-angle").press("Tab"); + await page.getByTestId("spot-light-direction-y").fill("-0.9"); + await page.getByTestId("spot-light-direction-y").press("Tab"); + await expect(page.locator('[data-testid^="outliner-entity-"]')).toHaveCount(2); + await page.getByRole("button", { name: "Save Draft" }).click(); + await expect(page.getByTestId("status-message")).toContainText("Local draft saved."); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-assets").click(); + await page.getByTestId("add-menu-assets-environments").click(); + const reloadedAddMenu = page.getByRole("menu", { name: "Add" }); + await expect(reloadedAddMenu.getByRole("menuitem", { name: "skybox-panorama.svg" })).toBeVisible(); + await reloadedAddMenu.getByRole("menuitem", { name: "skybox-panorama.svg" }).click(); + await expect(page.locator('[data-testid^="outliner-entity-"]')).toHaveCount(2); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /url/); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-shell")).toHaveCSS("background-image", /url/); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/orthographic-views.e2e.js b/tests/e2e/orthographic-views.e2e.js new file mode 100644 index 00000000..0e61bef5 --- /dev/null +++ b/tests/e2e/orthographic-views.e2e.js @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; +import { beginBoxCreation, clickViewport, getViewportOverlay, getViewportPanel } from "./viewport-test-helpers"; +test("orthographic panel controls keep brush authoring and selection behavior intact", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await beginBoxCreation(page); + await clickViewport(page, "topLeft"); + await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible(); + await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible(); + await expect(page.getByTestId("viewport-active-panel")).toHaveCount(0); + await expect(page.getByTestId("viewport-panel-topLeft-view-perspective")).toHaveAttribute("aria-pressed", "true"); + await expect(getViewportOverlay(page, "topLeft")).toBeVisible(); + await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object"); + await page.getByTestId("viewport-panel-topLeft-view-top").dispatchEvent("click"); + await expect(page.getByTestId("viewport-panel-topLeft-view-top")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object"); + await page.getByTestId("viewport-panel-topLeft-view-front").dispatchEvent("click"); + await expect(page.getByTestId("viewport-panel-topLeft-view-front")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object"); + await page.getByTestId("viewport-panel-topLeft-view-side").dispatchEvent("click"); + await expect(page.getByTestId("viewport-panel-topLeft-view-side")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object"); + await page.getByTestId("viewport-panel-topLeft-display-authoring").dispatchEvent("click"); + await expect(page.getByTestId("viewport-panel-topLeft-display-authoring")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-color", "rgb(0, 0, 0)"); + await expect(getViewportPanel(page, "topLeft")).toHaveAttribute("data-active", "true"); + await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible(); + await page.getByTestId("viewport-panel-topLeft-display-wireframe").dispatchEvent("click"); + await expect(page.getByTestId("viewport-panel-topLeft-display-wireframe")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-color", "rgb(0, 0, 0)"); + await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible(); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/runner-v1.e2e.js b/tests/e2e/runner-v1.e2e.js new file mode 100644 index 00000000..abf73ef7 --- /dev/null +++ b/tests/e2e/runner-v1.e2e.js @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers"; +test("user can place PlayerStart, enter run mode, and spawn from it", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-player-start").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("player-start-position-x").fill("4"); + await page.getByTestId("player-start-position-x").press("Tab"); + await page.getByTestId("player-start-position-z").fill("-2"); + await page.getByTestId("player-start-position-z").press("Tab"); + await page.getByTestId("player-start-yaw").fill("90"); + await page.getByTestId("player-start-yaw").press("Tab"); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-spawn-state")).toContainText("Player Start"); + await expect(page.getByTestId("runner-player-position")).toContainText("4.00, 0.00, -2.00"); + await page.getByTestId("runner-mode-orbit-visitor").click(); + await expect(page.getByTestId("runner-mode-orbit-visitor")).toHaveClass(/toolbar__button--active/); + await page.getByTestId("exit-run-mode").click(); + await expect(page.getByTestId("viewport-shell")).toBeVisible(); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/runtime-click-interaction.e2e.js b/tests/e2e/runtime-click-interaction.e2e.js new file mode 100644 index 00000000..68bfcd4f --- /dev/null +++ b/tests/e2e/runtime-click-interaction.e2e.js @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers"; +test("Interactable click prompt can teleport the player in run mode", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-player-start").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-interactable").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "interactable", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("interactable-position-y").fill("1"); + await page.getByTestId("interactable-position-y").press("Tab"); + await page.getByTestId("interactable-position-z").fill("1"); + await page.getByTestId("interactable-position-z").press("Tab"); + await page.getByTestId("interactable-radius").fill("4"); + await page.getByTestId("interactable-radius").press("Tab"); + await page.getByTestId("interactable-prompt").fill("Use Console"); + await page.getByTestId("interactable-prompt").press("Tab"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-teleport-target").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "teleportTarget", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("teleportTarget-position-x").fill("6"); + await page.getByTestId("teleportTarget-position-x").press("Tab"); + await page + .locator('[data-testid^="outliner-entity-"]') + .filter({ hasText: "Interactable" }) + .first() + .click(); + await page.getByTestId("add-interactable-teleport-link").click(); + await page.getByRole("button", { name: "First Person" }).first().click(); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-interaction-prompt")).toBeVisible(); + await expect(page.getByTestId("runner-interaction-prompt-text")).toContainText("Use Console"); + await page.locator('[data-testid="runner-shell"] canvas').click(); + await expect(page.getByTestId("runner-player-position")).toContainText("6.00,"); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/runtime-trigger-teleport.e2e.js b/tests/e2e/runtime-trigger-teleport.e2e.js new file mode 100644 index 00000000..3d27cf25 --- /dev/null +++ b/tests/e2e/runtime-trigger-teleport.e2e.js @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; +import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers"; +test("Trigger Volume enter can teleport the player to a Teleport Target", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-player-start").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-trigger-volume").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "triggerVolume", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-entities").click(); + await page.getByTestId("add-menu-teleport-target").click(); + await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "teleportTarget", audioAssetId: null }, { x: 0, y: 0, z: 0 }); + await clickViewport(page, "topLeft"); + await page.getByTestId("teleportTarget-position-x").fill("6"); + await page.getByTestId("teleportTarget-position-x").press("Tab"); + await page + .locator('[data-testid^="outliner-entity-"]') + .filter({ hasText: "Trigger Volume" }) + .first() + .click(); + await page.getByTestId("add-trigger-teleport-link").click(); + await page.getByRole("button", { name: "First Person" }).first().click(); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-player-position")).toContainText("6.00,"); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/viewport-quad-layout.e2e.js b/tests/e2e/viewport-quad-layout.e2e.js new file mode 100644 index 00000000..534983fd --- /dev/null +++ b/tests/e2e/viewport-quad-layout.e2e.js @@ -0,0 +1,134 @@ +import { expect, test } from "@playwright/test"; +import { beginBoxCreation, clickViewport, getEditorStoreSnapshot, getViewportPanel, setSharedBoxCreationPreview } from "./viewport-test-helpers"; +test("quad viewport layout shows four linked panels with shared selection and active panel state", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await beginBoxCreation(page); + await clickViewport(page, "topLeft"); + await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible(); + await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible(); + await page.getByTestId("viewport-layout-quad").click(); + await expect(page.getByTestId("viewport-panel-topLeft")).toBeVisible(); + await expect(page.getByTestId("viewport-panel-topRight")).toBeVisible(); + await expect(page.getByTestId("viewport-panel-bottomLeft")).toBeVisible(); + await expect(page.getByTestId("viewport-panel-bottomRight")).toBeVisible(); + const initialLayoutSnapshot = await getEditorStoreSnapshot(page); + expect(initialLayoutSnapshot.viewportQuadSplit).toEqual({ + x: 0.5, + y: 0.5 + }); + const dragSplitter = async (testId, deltaX, deltaY) => { + const splitter = page.getByTestId(testId); + const box = await splitter.boundingBox(); + if (box === null) { + throw new Error(`Missing splitter handle: ${testId}`); + } + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.5 + deltaX, box.y + box.height * 0.5 + deltaY); + await page.mouse.up(); + }; + await dragSplitter("viewport-quad-splitter-center", 80, 48); + const afterCenterResizeSnapshot = await getEditorStoreSnapshot(page); + expect(afterCenterResizeSnapshot.viewportQuadSplit.x).toBeGreaterThan(0.5); + expect(afterCenterResizeSnapshot.viewportQuadSplit.y).toBeGreaterThan(0.5); + await dragSplitter("viewport-quad-splitter-vertical", -60, 0); + const afterVerticalResizeSnapshot = await getEditorStoreSnapshot(page); + expect(afterVerticalResizeSnapshot.viewportQuadSplit.x).toBeLessThan(afterCenterResizeSnapshot.viewportQuadSplit.x); + expect(Math.abs(afterVerticalResizeSnapshot.viewportQuadSplit.y - afterCenterResizeSnapshot.viewportQuadSplit.y)).toBeLessThan(0.02); + await dragSplitter("viewport-quad-splitter-horizontal", 0, -40); + const afterHorizontalResizeSnapshot = await getEditorStoreSnapshot(page); + expect(Math.abs(afterHorizontalResizeSnapshot.viewportQuadSplit.x - afterVerticalResizeSnapshot.viewportQuadSplit.x)).toBeLessThan(0.02); + expect(afterHorizontalResizeSnapshot.viewportQuadSplit.y).toBeLessThan(afterVerticalResizeSnapshot.viewportQuadSplit.y); + await expect(page.getByTestId("viewport-panel-topLeft-view-perspective")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-panel-topLeft-display-normal")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-panel-topRight-view-top")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-panel-topRight-display-authoring")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-canvas-topRight")).toHaveCSS("background-color", "rgb(0, 0, 0)"); + await expect(page.getByTestId("viewport-panel-bottomLeft-view-front")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-panel-bottomLeft-display-authoring")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-panel-bottomRight-view-side")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-panel-bottomRight-display-authoring")).toHaveAttribute("aria-pressed", "true"); + for (const panelId of ["topLeft", "topRight", "bottomLeft", "bottomRight"]) { + await expect(getViewportPanel(page, panelId).locator(".viewport-panel__subtitle")).toHaveCount(0); + await expect(getViewportPanel(page, panelId).locator(".viewport-canvas__overlay-text")).toHaveCount(0); + } + await setSharedBoxCreationPreview(page, "topLeft", { x: 4, y: 0, z: 8 }); + const initialSnapshot = await getEditorStoreSnapshot(page); + expect(initialSnapshot).toMatchObject({ + viewportTransientState: { + toolPreview: { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: { + x: 4, + y: 0, + z: 8 + } + } + } + }); + await getViewportPanel(page, "topRight").click({ position: { x: 16, y: 16 }, force: true }); + await page.getByTestId("viewport-panel-topRight-view-side").dispatchEvent("click"); + await expect(getViewportPanel(page, "topRight")).toHaveAttribute("data-active", "true"); + await expect(page.getByTestId("viewport-panel-topRight-view-side")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible(); + const transferredSnapshot = await getEditorStoreSnapshot(page); + expect(transferredSnapshot).toMatchObject({ + viewportTransientState: { + toolPreview: { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: { + x: 4, + y: 0, + z: 8 + } + } + } + }); + await getViewportPanel(page, "topLeft").click({ position: { x: 16, y: 16 }, force: true }); + await page.getByTestId("viewport-panel-topLeft-display-authoring").dispatchEvent("click"); + await expect(getViewportPanel(page, "topLeft")).toHaveAttribute("data-active", "true"); + await expect(page.getByTestId("viewport-panel-topLeft-display-authoring")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-color", "rgb(0, 0, 0)"); + await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible(); + const finalSnapshot = await getEditorStoreSnapshot(page); + expect(finalSnapshot).toMatchObject({ + viewportTransientState: { + toolPreview: { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: { + x: 4, + y: 0, + z: 8 + } + } + } + }); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/viewport-test-helpers.js b/tests/e2e/viewport-test-helpers.js new file mode 100644 index 00000000..c5554ef6 --- /dev/null +++ b/tests/e2e/viewport-test-helpers.js @@ -0,0 +1,105 @@ +export const DEFAULT_VIEWPORT_PANEL_ID = "topLeft"; +export function getViewportPanel(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) { + return page.getByTestId(`viewport-panel-${panelId}`); +} +export function getViewportCanvas(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) { + return getViewportPanel(page, panelId).locator("canvas"); +} +export function getViewportOverlay(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) { + return page.getByTestId(`viewport-overlay-${panelId}`); +} +export async function getEditorStoreSnapshot(page) { + return page.evaluate(() => { + const store = window.__webeditor3dEditorStore; + if (store === undefined) { + throw new Error("Editor store debug hook is unavailable."); + } + return store.getState(); + }); +} +export async function getViewportToolPreview(page) { + const snapshot = await getEditorStoreSnapshot(page); + return snapshot.viewportTransientState.toolPreview; +} +export async function replaceSceneDocument(page, document) { + await page.evaluate((nextDocument) => { + const store = window.__webeditor3dEditorStore; + if (store === undefined) { + throw new Error("Editor store debug hook is unavailable."); + } + store.replaceDocument(nextDocument); + }, document); +} +export async function setViewportCreationPreview(page, panelId, target, center) { + await page.evaluate(({ sourcePanelId, nextTarget, nextCenter }) => { + const store = window.__webeditor3dEditorStore; + if (store === undefined) { + throw new Error("Editor store debug hook is unavailable."); + } + store.setViewportToolPreview({ + kind: "create", + sourcePanelId, + target: nextTarget, + center: nextCenter + }); + }, { + sourcePanelId: panelId, + nextTarget: target, + nextCenter: center + }); +} +export async function clearViewportCreationPreview(page) { + await page.evaluate(() => { + const store = window.__webeditor3dEditorStore; + if (store === undefined) { + throw new Error("Editor store debug hook is unavailable."); + } + store.setViewportToolPreview({ + kind: "none" + }); + }); +} +export async function beginBoxCreation(page) { + await page.getByTestId("outliner-add-button").click(); + await page.getByTestId("add-menu-box").click(); +} +export async function clickViewport(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) { + const viewportPanel = getViewportPanel(page, panelId); + await viewportPanel.click({ position: { x: 16, y: 16 }, force: true }); + const fallbackButton = viewportPanel.getByTestId(`viewport-fallback-create-${panelId}`); + if ((await fallbackButton.count()) > 0) { + await fallbackButton.click(); + return; + } + const viewportCanvas = getViewportCanvas(page, panelId); + if ((await viewportCanvas.count()) > 0) { + await viewportCanvas.click(); + return; + } +} +export async function clickViewportAtRatio(page, panelId, xRatio, yRatio) { + const viewportCanvas = getViewportCanvas(page, panelId); + if ((await viewportCanvas.count()) > 0) { + const canvasBox = await viewportCanvas.boundingBox(); + if (canvasBox !== null) { + await viewportCanvas.click({ + position: { + x: canvasBox.width * xRatio, + y: canvasBox.height * yRatio + } + }); + return; + } + } + const viewportPanel = getViewportPanel(page, panelId); + const box = await viewportPanel.boundingBox(); + if (box === null) { + throw new Error(`Missing viewport panel for ${panelId}.`); + } + await page.mouse.click(box.x + box.width * xRatio, box.y + box.height * yRatio); +} +export async function setSharedBoxCreationPreview(page, panelId, center) { + return setViewportCreationPreview(page, panelId, { + kind: "box-brush" + }, center); +} diff --git a/tests/e2e/whitebox-component-selection.e2e.js b/tests/e2e/whitebox-component-selection.e2e.js new file mode 100644 index 00000000..878b4b8a --- /dev/null +++ b/tests/e2e/whitebox-component-selection.e2e.js @@ -0,0 +1,121 @@ +import { expect, test } from "@playwright/test"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { clickViewportAtRatio, getEditorStoreSnapshot, replaceSceneDocument } from "./viewport-test-helpers"; +test("whitebox component selection modes keep object picking intentional across perspective and orthographic panes", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + test.skip((await page.getByText("Viewport Unavailable").count()) > 0, "WebGL is unavailable in this Playwright environment."); + const brush = createBoxBrush({ + id: "brush-selection-modes-main", + name: "Selection Fixture", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 2 + } + }); + await replaceSceneDocument(page, { + ...createEmptySceneDocument({ name: "Selection Modes Scene" }), + brushes: { + [brush.id]: brush + } + }); + await page.evaluate(({ target }) => { + const store = window.__webeditor3dEditorStore; + if (store === undefined) { + throw new Error("Editor store debug hook is unavailable."); + } + const topLeftCameraState = store.getState().viewportPanels.topLeft.cameraState; + store.setViewportPanelCameraState("topLeft", { + ...topLeftCameraState, + target, + perspectiveOrbit: { + radius: 4.5, + theta: 0.72, + phi: 1.08 + } + }); + }, { target: brush.center }); + await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object"); + await clickViewportAtRatio(page, "topLeft", 0.5, 0.52); + let snapshot = await getEditorStoreSnapshot(page); + expect(snapshot.whiteboxSelectionMode).toBe("object"); + expect(snapshot.selection).toMatchObject({ + kind: "brushes", + ids: [brush.id] + }); + await page.getByTestId("viewport-layout-quad").click(); + await page.evaluate(({ target }) => { + const store = window.__webeditor3dEditorStore; + if (store === undefined) { + throw new Error("Editor store debug hook is unavailable."); + } + const topRightCameraState = store.getState().viewportPanels.topRight.cameraState; + store.setViewportPanelViewMode("topRight", "top"); + store.setViewportPanelCameraState("topRight", { + ...topRightCameraState, + target, + orthographicZoom: 8 + }); + }, { target: brush.center }); + await page.getByTestId("whitebox-selection-mode-face").click(); + await expect(page.getByTestId("viewport-selection-mode-topRight")).toHaveText("Face"); + await clickViewportAtRatio(page, "topRight", 0.5, 0.5); + snapshot = await getEditorStoreSnapshot(page); + expect(snapshot.whiteboxSelectionMode).toBe("face"); + expect(snapshot.selection).toMatchObject({ + kind: "brushFace", + brushId: brush.id, + faceId: "posY" + }); + await expect(page.getByTestId("viewport-panel-active-badge-topRight")).toBeVisible(); + await page.getByTestId("whitebox-selection-mode-edge").click(); + await expect(page.getByTestId("viewport-selection-mode-topRight")).toHaveText("Edge"); + await clickViewportAtRatio(page, "topRight", 0.5, 0.12); + snapshot = await getEditorStoreSnapshot(page); + expect(snapshot.whiteboxSelectionMode).toBe("edge"); + expect(snapshot.selection).toMatchObject({ + kind: "brushEdge", + brushId: brush.id, + edgeId: "edgeX_posY_negZ" + }); + await page.getByTestId("whitebox-selection-mode-vertex").click(); + await expect(page.getByTestId("viewport-selection-mode-topRight")).toHaveText("Vertex"); + await clickViewportAtRatio(page, "topRight", 0.88, 0.12); + snapshot = await getEditorStoreSnapshot(page); + expect(snapshot.whiteboxSelectionMode).toBe("vertex"); + expect(snapshot.selection).toMatchObject({ + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_negZ" + }); + await page.getByTestId("whitebox-selection-mode-object").click(); + await clickViewportAtRatio(page, "topRight", 0.5, 0.5); + snapshot = await getEditorStoreSnapshot(page); + expect(snapshot.whiteboxSelectionMode).toBe("object"); + expect(snapshot.selection).toMatchObject({ + kind: "brushes", + ids: [brush.id] + }); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/world-environment.e2e.js b/tests/e2e/world-environment.e2e.js new file mode 100644 index 00000000..edc55ab6 --- /dev/null +++ b/tests/e2e/world-environment.e2e.js @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; +async function setColorInput(locator, value) { + await locator.evaluate((element, nextValue) => { + const input = element; + input.value = nextValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, value); +} +test("world environment settings persist and carry into the runner", async ({ page }) => { + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + await page.getByTestId("world-background-mode-gradient").click(); + await setColorInput(page.getByTestId("world-background-top-color"), "#6a87ab"); + await setColorInput(page.getByTestId("world-background-bottom-color"), "#151b23"); + await setColorInput(page.getByTestId("world-ambient-color"), "#d4e2ff"); + await page.getByTestId("world-ambient-intensity").fill("0.45"); + await page.getByTestId("world-ambient-intensity").press("Tab"); + await page.getByTestId("world-sun-intensity").fill("2.25"); + await page.getByTestId("world-sun-intensity").press("Tab"); + await page.getByTestId("world-sun-direction-x").fill("-1"); + await page.getByTestId("world-sun-direction-x").press("Tab"); + await expect(page.getByTestId("world-background-mode-value")).toContainText("Vertical Gradient"); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /linear-gradient/); + await page.getByRole("button", { name: "Save Draft" }).click(); + await page.getByTestId("world-background-mode-solid").click(); + await setColorInput(page.getByTestId("world-background-solid-color"), "#223344"); + await page.getByTestId("world-ambient-intensity").fill("0.9"); + await page.getByTestId("world-ambient-intensity").press("Tab"); + await page.getByRole("button", { name: "Load Draft" }).click(); + await expect(page.getByTestId("world-background-mode-value")).toContainText("Vertical Gradient"); + await expect(page.getByTestId("world-ambient-intensity")).toHaveValue("0.45"); + await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /linear-gradient/); + await page.getByTestId("enter-run-mode").click(); + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-shell")).toHaveCSS("background-image", /linear-gradient/); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/geometry/box-brush-geometry.test.js b/tests/geometry/box-brush-geometry.test.js new file mode 100644 index 00000000..d27886f7 --- /dev/null +++ b/tests/geometry/box-brush-geometry.test.js @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { getBoxBrushBounds, getBoxBrushCornerPositions } from "../../src/geometry/box-brush"; +describe("box brush geometry", () => { + it("builds finite bounds and eight corner positions from canonical box data", () => { + const brush = createBoxBrush({ + center: { + x: 2, + y: 4, + z: -3 + }, + size: { + x: 6, + y: 2, + z: 4 + } + }); + expect(getBoxBrushBounds(brush)).toEqual({ + min: { + x: -1, + y: 3, + z: -5 + }, + max: { + x: 5, + y: 5, + z: -1 + } + }); + const corners = getBoxBrushCornerPositions(brush); + expect(corners).toHaveLength(8); + expect(new Set(corners.map((corner) => `${corner.x}:${corner.y}:${corner.z}`)).size).toBe(8); + expect(corners.every((corner) => Number.isFinite(corner.x) && Number.isFinite(corner.y) && Number.isFinite(corner.z))).toBe(true); + }); + it("derives rotated world bounds from authored box rotation without changing stable corner count", () => { + const brush = createBoxBrush({ + center: { + x: 0, + y: 1, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 4 + } + }); + const bounds = getBoxBrushBounds(brush); + const corners = getBoxBrushCornerPositions(brush); + expect(bounds.min.x).toBeCloseTo(-2.1213203436); + expect(bounds.max.x).toBeCloseTo(2.1213203436); + expect(bounds.min.z).toBeCloseTo(-2.1213203436); + expect(bounds.max.z).toBeCloseTo(2.1213203436); + expect(corners).toHaveLength(8); + expect(new Set(corners.map((corner) => `${corner.x}:${corner.y}:${corner.z}`)).size).toBe(8); + }); +}); diff --git a/tests/geometry/box-face-uvs.test.js b/tests/geometry/box-face-uvs.test.js new file mode 100644 index 00000000..3eeeb539 --- /dev/null +++ b/tests/geometry/box-face-uvs.test.js @@ -0,0 +1,60 @@ +import { BoxGeometry } from "three"; +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { applyBoxBrushFaceUvsToGeometry, createFitToFaceBoxBrushFaceUvState, transformProjectedFaceUv } from "../../src/geometry/box-face-uvs"; +describe("box face UV projection", () => { + it("fit-to-face produces finite UVs normalized across the target face", () => { + const brush = createBoxBrush({ + size: { + x: 4, + y: 2, + z: 6 + } + }); + brush.faces.posZ.uv = createFitToFaceBoxBrushFaceUvState(brush, "posZ"); + const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z); + applyBoxBrushFaceUvsToGeometry(geometry, brush); + const uvAttribute = geometry.getAttribute("uv"); + const indexAttribute = geometry.getIndex(); + const posZGroup = geometry.groups.find((group) => group.materialIndex === 4); + expect(indexAttribute).not.toBeNull(); + expect(posZGroup).toBeDefined(); + const uniqueVertexIndices = new Set(); + for (let indexOffset = posZGroup.start; indexOffset < posZGroup.start + posZGroup.count; indexOffset += 1) { + uniqueVertexIndices.add(indexAttribute.getX(indexOffset)); + } + const uvValues = Array.from(uniqueVertexIndices, (vertexIndex) => ({ + u: uvAttribute.getX(vertexIndex), + v: uvAttribute.getY(vertexIndex) + })); + expect(uvValues).toHaveLength(4); + expect(uvValues.every((uv) => Number.isFinite(uv.u) && Number.isFinite(uv.v))).toBe(true); + expect(Math.min(...uvValues.map((uv) => uv.u))).toBeCloseTo(0); + expect(Math.max(...uvValues.map((uv) => uv.u))).toBeCloseTo(1); + expect(Math.min(...uvValues.map((uv) => uv.v))).toBeCloseTo(0); + expect(Math.max(...uvValues.map((uv) => uv.v))).toBeCloseTo(1); + }); + it("applies rotation, scale, and offset deterministically to projected UVs", () => { + const transformedUv = transformProjectedFaceUv({ + x: 4, + y: 0 + }, { + x: 4, + y: 2 + }, { + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.5, + y: 1 + }, + rotationQuarterTurns: 1, + flipU: true, + flipV: false + }); + expect(transformedUv.x).toBeCloseTo(2.5); + expect(transformedUv.y).toBeCloseTo(-0.25); + }); +}); diff --git a/tests/geometry/model-instance-collider-generation.test.js b/tests/geometry/model-instance-collider-generation.test.js new file mode 100644 index 00000000..33e076fa --- /dev/null +++ b/tests/geometry/model-instance-collider-generation.test.js @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry } from "three"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { buildGeneratedModelCollider } from "../../src/geometry/model-instance-collider-generation"; +import { createFixtureLoadedModelAsset, createFixtureLoadedModelAssetFromGeometry, createFixtureModelAssetRecord } from "../helpers/model-collider-fixtures"; +describe("buildGeneratedModelCollider", () => { + it("builds a simple oriented box collider from asset bounds", () => { + const { asset } = createFixtureLoadedModelAssetFromGeometry("asset-model-simple", new BoxGeometry(2, 4, 6)); + const modelInstance = createModelInstance({ + id: "model-instance-simple", + assetId: asset.id, + collision: { + mode: "simple", + visible: true + } + }); + const collider = buildGeneratedModelCollider(modelInstance, asset); + expect(collider).not.toBeNull(); + expect(collider).toMatchObject({ + kind: "box", + mode: "simple", + visible: true, + center: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 2, + y: 4, + z: 6 + } + }); + }); + it("builds a static triangle-mesh collider from loaded model geometry", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static", new BoxGeometry(2, 1, 3)); + const modelInstance = createModelInstance({ + id: "model-instance-static", + assetId: asset.id, + collision: { + mode: "static", + visible: false + } + }); + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + expect(collider).not.toBeNull(); + expect(collider?.kind).toBe("trimesh"); + if (collider === null || collider.kind !== "trimesh") { + throw new Error("Expected a trimesh collider."); + } + expect(collider.mode).toBe("static"); + expect(Array.from(collider.vertices)).toSatisfy((values) => values.every(Number.isFinite)); + expect(Array.from(collider.indices)).toSatisfy((values) => values.every(Number.isInteger)); + }); + it("builds a terrain heightfield from a regular-grid mesh", () => { + const geometry = new PlaneGeometry(4, 4, 2, 2); + geometry.rotateX(-Math.PI * 0.5); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-terrain", geometry); + const modelInstance = createModelInstance({ + id: "model-instance-terrain", + assetId: asset.id, + collision: { + mode: "terrain", + visible: true + } + }); + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + expect(collider).not.toBeNull(); + expect(collider?.kind).toBe("heightfield"); + if (collider === null || collider.kind !== "heightfield") { + throw new Error("Expected a heightfield collider."); + } + expect(collider).toMatchObject({ + mode: "terrain", + rows: 3, + cols: 3 + }); + expect(Array.from(collider.heights)).toSatisfy((values) => values.every(Number.isFinite)); + }); + it("fails terrain mode for meshes that are not a clean regular-grid terrain surface", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-invalid-terrain", new BoxGeometry(2, 2, 2)); + const modelInstance = createModelInstance({ + id: "model-instance-invalid-terrain", + assetId: asset.id, + collision: { + mode: "terrain", + visible: false + } + }); + expect(() => buildGeneratedModelCollider(modelInstance, asset, loadedAsset)).toThrow("cannot use terrain collision"); + }); + it("builds explicit convex compound pieces for dynamic mode", () => { + const template = new Group(); + const material = new MeshBasicMaterial(); + const leftBox = new Mesh(new BoxGeometry(1, 1, 1), material); + const rightBox = new Mesh(new BoxGeometry(1, 2, 1), material); + leftBox.position.set(-1.25, 0.5, 0); + rightBox.position.set(1.25, 1, 0); + template.add(leftBox); + template.add(rightBox); + template.updateMatrixWorld(true); + const asset = createFixtureModelAssetRecord("asset-model-dynamic", template); + const loadedAsset = createFixtureLoadedModelAsset(asset, template); + const modelInstance = createModelInstance({ + id: "model-instance-dynamic", + assetId: asset.id, + collision: { + mode: "dynamic", + visible: true + } + }); + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + expect(collider).not.toBeNull(); + expect(collider).toMatchObject({ + kind: "compound", + mode: "dynamic", + decomposition: "spatial-bisect", + runtimeBehavior: "fixedQueryOnly" + }); + expect(collider?.kind === "compound" ? collider.pieces.length : 0).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/helpers/model-collider-fixtures.js b/tests/helpers/model-collider-fixtures.js new file mode 100644 index 00000000..e857c23a --- /dev/null +++ b/tests/helpers/model-collider-fixtures.js @@ -0,0 +1,84 @@ +import { Box3, Group, Mesh, MeshBasicMaterial } from "three"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +function countMeshes(group) { + let count = 0; + group.traverse((object) => { + if (object.isMesh === true) { + count += 1; + } + }); + return count; +} +function countNodes(group) { + let count = 0; + group.traverse(() => { + count += 1; + }); + return count; +} +function createBoundingBox(group) { + const bounds = new Box3().setFromObject(group); + if (bounds.isEmpty()) { + return null; + } + return { + min: { + x: bounds.min.x, + y: bounds.min.y, + z: bounds.min.z + }, + max: { + x: bounds.max.x, + y: bounds.max.y, + z: bounds.max.z + }, + size: { + x: bounds.max.x - bounds.min.x, + y: bounds.max.y - bounds.min.y, + z: bounds.max.z - bounds.min.z + } + }; +} +export function createFixtureModelAssetRecord(id, template, sourceName = `${id}.glb`) { + template.updateMatrixWorld(true); + return { + id, + kind: "model", + sourceName, + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey(id), + byteLength: 128, + metadata: { + kind: "model", + format: "glb", + sceneName: sourceName, + nodeCount: countNodes(template), + meshCount: countMeshes(template), + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: createBoundingBox(template), + warnings: [] + } + }; +} +export function createFixtureLoadedModelAsset(asset, template) { + template.updateMatrixWorld(true); + return { + assetId: asset.id, + storageKey: asset.storageKey, + metadata: asset.metadata, + template, + animations: [] + }; +} +export function createFixtureLoadedModelAssetFromGeometry(assetId, geometry) { + const template = new Group(); + template.add(new Mesh(geometry, new MeshBasicMaterial())); + template.updateMatrixWorld(true); + const asset = createFixtureModelAssetRecord(assetId, template); + return { + asset, + loadedAsset: createFixtureLoadedModelAsset(asset, template) + }; +} diff --git a/tests/serialization/local-draft-storage.test.js b/tests/serialization/local-draft-storage.test.js new file mode 100644 index 00000000..4f2deb33 --- /dev/null +++ b/tests/serialization/local-draft-storage.test.js @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document"; +import { serializeSceneDocument } from "../../src/serialization/scene-document-json"; +import { DEFAULT_SCENE_DRAFT_STORAGE_KEY, getBrowserStorageAccess, loadOrCreateSceneDocument, loadSceneDocumentDraft, saveSceneDocumentDraft } from "../../src/serialization/local-draft-storage"; +import { createDefaultViewportLayoutState } from "../../src/viewport-three/viewport-layout"; +class MemoryStorage { + values = new Map(); + getItem(key) { + return this.values.get(key) ?? null; + } + setItem(key, value) { + this.values.set(key, value); + } + removeItem(key) { + this.values.delete(key); + } +} +class ThrowingStorage { + options; + constructor(options = {}) { + this.options = options; + } + getItem() { + if (this.options.onGetItem !== undefined) { + throw this.options.onGetItem; + } + return null; + } + setItem() { + if (this.options.onSetItem !== undefined) { + throw this.options.onSetItem; + } + } + removeItem() { + if (this.options.onRemoveItem !== undefined) { + throw this.options.onRemoveItem; + } + } +} +describe("local draft storage", () => { + it("falls back to a fresh document when stored draft JSON is invalid", () => { + const storage = new MemoryStorage(); + storage.setItem(DEFAULT_SCENE_DRAFT_STORAGE_KEY, "{invalid-json"); + const result = loadOrCreateSceneDocument(storage); + expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION); + expect(result.document).toEqual(createEmptySceneDocument()); + expect(result.diagnostic).toContain("Stored local draft could not be loaded."); + expect(result.diagnostic).toContain("Starting with a fresh empty document."); + }); + it("reports browser storage access failures without throwing", () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage"); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new Error("access denied"); + } + }); + try { + const result = getBrowserStorageAccess(); + expect(result.storage).toBeNull(); + expect(result.diagnostic).toContain("Browser local storage is unavailable."); + expect(result.diagnostic).toContain("access denied"); + } + finally { + if (originalDescriptor !== undefined) { + Object.defineProperty(window, "localStorage", originalDescriptor); + } + else { + Reflect.deleteProperty(window, "localStorage"); + } + } + }); + it("returns an error result when reading from storage throws", () => { + const result = loadSceneDocumentDraft(new ThrowingStorage({ + onGetItem: new Error("blocked read") + })); + expect(result.status).toBe("error"); + expect(result.message).toContain("blocked read"); + }); + it("returns an error result when saving to storage throws", () => { + const result = saveSceneDocumentDraft(new ThrowingStorage({ + onSetItem: new Error("quota exceeded") + }), createEmptySceneDocument()); + expect(result.status).toBe("error"); + expect(result.message).toContain("quota exceeded"); + }); + it("stores and restores editor viewport layout state alongside the document draft", () => { + const storage = new MemoryStorage(); + const viewportLayoutState = createDefaultViewportLayoutState(); + viewportLayoutState.layoutMode = "quad"; + viewportLayoutState.activePanelId = "bottomRight"; + viewportLayoutState.panels.topLeft.displayMode = "wireframe"; + viewportLayoutState.panels.topLeft.cameraState.target = { + x: 8, + y: 3, + z: -5 + }; + viewportLayoutState.panels.topLeft.cameraState.perspectiveOrbit.theta = 1.25; + viewportLayoutState.panels.topLeft.cameraState.orthographicZoom = 2.5; + expect(saveSceneDocumentDraft(storage, createEmptySceneDocument({ name: "Viewport Draft" }), viewportLayoutState)).toEqual({ + status: "saved", + message: "Local draft saved." + }); + const result = loadSceneDocumentDraft(storage); + expect(result).toMatchObject({ + status: "loaded", + document: { + name: "Viewport Draft" + }, + viewportLayoutState: { + layoutMode: "quad", + activePanelId: "bottomRight", + panels: { + topLeft: { + displayMode: "wireframe", + cameraState: { + target: { + x: 8, + y: 3, + z: -5 + }, + perspectiveOrbit: { + theta: 1.25 + }, + orthographicZoom: 2.5 + } + } + } + } + }); + }); + it("loads older raw scene-document drafts without requiring viewport layout state", () => { + const storage = new MemoryStorage(); + storage.setItem(DEFAULT_SCENE_DRAFT_STORAGE_KEY, serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" }))); + const result = loadSceneDocumentDraft(storage); + expect(result).toMatchObject({ + status: "loaded", + document: { + name: "Legacy Draft" + }, + viewportLayoutState: null + }); + }); + it("refuses to save an invalid scene document draft", () => { + const invalidBrush = createBoxBrush({ + id: "brush-invalid" + }); + invalidBrush.faces.posX.materialId = "missing-material"; + const result = saveSceneDocumentDraft(new MemoryStorage(), { + ...createEmptySceneDocument(), + brushes: { + [invalidBrush.id]: invalidBrush + } + }); + expect(result.status).toBe("error"); + expect(result.message).toContain("validation error"); + }); +}); diff --git a/tests/serialization/project-asset-storage.test.js b/tests/serialization/project-asset-storage.test.js new file mode 100644 index 00000000..3b1b051a --- /dev/null +++ b/tests/serialization/project-asset-storage.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { createInMemoryProjectAssetStorage } from "../../src/assets/project-asset-storage"; +describe("project asset storage", () => { + it("stores, clones, and deletes binary asset file packages", async () => { + const storage = createInMemoryProjectAssetStorage(); + const storageKey = createProjectAssetStorageKey("asset-model-triangle"); + const bytes = new Uint8Array([0, 1, 2, 3, 4]).buffer; + const sidecarBytes = new Uint8Array([9, 8, 7]).buffer; + await storage.putAsset(storageKey, { + files: { + "tiny-triangle.gltf": { + bytes, + mimeType: "model/gltf+json" + }, + "triangle.bin": { + bytes: sidecarBytes, + mimeType: "application/octet-stream" + } + } + }); + const loadedAsset = await storage.getAsset(storageKey); + expect(loadedAsset).not.toBeNull(); + expect(Object.keys(loadedAsset?.files ?? {})).toEqual(["tiny-triangle.gltf", "triangle.bin"]); + expect(Array.from(new Uint8Array(loadedAsset?.files["tiny-triangle.gltf"]?.bytes ?? new ArrayBuffer(0)))).toEqual([0, 1, 2, 3, 4]); + expect(Array.from(new Uint8Array(loadedAsset?.files["triangle.bin"]?.bytes ?? new ArrayBuffer(0)))).toEqual([9, 8, 7]); + await storage.deleteAsset(storageKey); + await expect(storage.getAsset(storageKey)).resolves.toBeNull(); + }); +}); diff --git a/tests/serialization/scene-document-json.test.js b/tests/serialization/scene-document-json.test.js new file mode 100644 index 00000000..d23a2180 --- /dev/null +++ b/tests/serialization/scene-document-json.test.js @@ -0,0 +1,1380 @@ +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION, ENTITY_NAMES_SCENE_DOCUMENT_VERSION, ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION, FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION, IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION, MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION, PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_VERSION, SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION, TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION, WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document"; +import { migrateSceneDocument } from "../../src/document/migrate-scene-document"; +import { createPointLightEntity, createInteractableEntity, createPlayerStartEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +import { createPlayAnimationInteractionLink, createPlaySoundInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink } from "../../src/interactions/interaction-links"; +import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json"; +describe("scene document JSON", () => { + it("round-trips the current empty schema", () => { + const document = createEmptySceneDocument({ name: "Bootstrap Scene" }); + const serializedDocument = serializeSceneDocument(document); + expect(parseSceneDocumentJson(serializedDocument)).toEqual(document); + }); + it("round-trips a document containing a canonical box brush", () => { + const brush = createBoxBrush({ + id: "brush-box-room", + name: "Entry Room", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 4, + y: 2, + z: 6 + } + }); + const document = { + ...createEmptySceneDocument({ name: "Brush Scene" }), + brushes: { + [brush.id]: brush + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips floating-point whitebox box transforms without accidental snapping", () => { + const brush = createBoxBrush({ + id: "brush-float-transform", + center: { + x: 1.25, + y: 1.5, + z: -0.875 + }, + rotationDegrees: { + x: 12.5, + y: 37.5, + z: -8.25 + }, + size: { + x: 2.5, + y: 3.25, + z: 4.75 + } + }); + const document = { + ...createEmptySceneDocument({ name: "Float Transform Scene" }), + brushes: { + [brush.id]: brush + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips per-face material and UV state", () => { + const brush = createBoxBrush({ + id: "brush-face-room", + center: { + x: 2, + y: 2, + z: -1 + }, + size: { + x: 6, + y: 4, + z: 8 + } + }); + brush.faces.posX.materialId = "starter-amber-grid"; + brush.faces.posX.uv = { + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.25, + y: 0.5 + }, + rotationQuarterTurns: 3, + flipU: true, + flipV: true + }; + const document = { + ...createEmptySceneDocument({ name: "Face UV Scene" }), + brushes: { + [brush.id]: brush + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips authored world environment settings", () => { + const document = createEmptySceneDocument({ name: "World Environment Scene" }); + document.world.background = { + mode: "verticalGradient", + topColorHex: "#6a87ab", + bottomColorHex: "#151b23" + }; + document.world.ambientLight = { + colorHex: "#d4e2ff", + intensity: 0.45 + }; + document.world.sunLight = { + colorHex: "#ffd8a6", + intensity: 2.25, + direction: { + x: -1, + y: 0.8, + z: 0.2 + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips authored advanced rendering settings", () => { + const document = createEmptySceneDocument({ name: "Advanced Rendering Scene" }); + document.world.advancedRendering = { + enabled: true, + shadows: { + enabled: true, + mapSize: 4096, + type: "pcf", + bias: -0.001 + }, + ambientOcclusion: { + enabled: true, + intensity: 1.4, + radius: 0.75, + samples: 16 + }, + bloom: { + enabled: true, + intensity: 1.2, + threshold: 0.9, + radius: 0.4 + }, + toneMapping: { + mode: "acesFilmic", + exposure: 1.25 + }, + depthOfField: { + enabled: true, + focusDistance: 12, + focalLength: 0.045, + bokehScale: 1.8 + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("migrates legacy documents without advanced rendering settings to defaults", () => { + const emptyScene = createEmptySceneDocument({ name: "Legacy Advanced Rendering Scene" }); + const { advancedRendering: _advancedRendering, ...legacyWorld } = emptyScene.world; + const migratedDocument = migrateSceneDocument({ + version: SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION, + name: emptyScene.name, + world: legacyWorld, + materials: emptyScene.materials, + textures: emptyScene.textures, + assets: emptyScene.assets, + brushes: emptyScene.brushes, + modelInstances: emptyScene.modelInstances, + entities: emptyScene.entities, + interactionLinks: emptyScene.interactionLinks + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.world.advancedRendering).toEqual(emptyScene.world.advancedRendering); + }); + it("round-trips authored local lights and an image background asset", () => { + const imageAsset = { + id: "asset-background-panorama", + kind: "image", + sourceName: "skybox-panorama.svg", + mimeType: "image/svg+xml", + storageKey: createProjectAssetStorageKey("asset-background-panorama"), + byteLength: 2048, + metadata: { + kind: "image", + width: 512, + height: 256, + hasAlpha: false, + warnings: [] + } + }; + const pointLight = createPointLightEntity({ + id: "entity-point-light-main", + position: { + x: 2, + y: 3, + z: 1 + }, + colorHex: "#ffddaa", + intensity: 1.5, + distance: 10 + }); + const spotLight = createSpotLightEntity({ + id: "entity-spot-light-main", + position: { + x: -2, + y: 4, + z: 0 + }, + direction: { + x: 0.25, + y: -1, + z: 0.15 + }, + colorHex: "#d6e6ff", + intensity: 2, + distance: 14, + angleDegrees: 42 + }); + const document = { + ...createEmptySceneDocument({ name: "Local Light and Background Scene" }), + assets: { + [imageAsset.id]: imageAsset + }, + entities: { + [pointLight.id]: pointLight, + [spotLight.id]: spotLight + } + }; + document.world.background = { + mode: "image", + assetId: imageAsset.id, + environmentIntensity: 0.75 + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips a document containing an authored PlayerStart entity", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main", + name: "Main Spawn", + position: { + x: 4, + y: 0, + z: -2 + }, + yawDegrees: 135, + collider: { + mode: "box", + eyeHeight: 1.4, + capsuleRadius: 0.3, + capsuleHeight: 1.8, + boxSize: { + x: 0.8, + y: 1.6, + z: 0.7 + } + } + }); + const document = { + ...createEmptySceneDocument({ name: "Player Start Scene" }), + entities: { + [playerStart.id]: playerStart + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("migrates version 14 documents without entity names", () => { + const pointLight = createPointLightEntity({ + id: "entity-point-light-legacy", + position: { + x: 2, + y: 3, + z: 1 + }, + colorHex: "#ffeeaa", + intensity: 1.75, + distance: 9 + }); + const legacyDocument = { + ...createEmptySceneDocument({ name: "Legacy Entity Name Scene" }), + version: 14, + entities: { + [pointLight.id]: pointLight + } + }; + const migratedDocument = migrateSceneDocument(legacyDocument); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.entities[pointLight.id]).toEqual(pointLight); + }); + it("migrates version 15 model instances to include default collider settings", () => { + const asset = { + id: "asset-model-legacy-collider", + kind: "model", + sourceName: "legacy-collider.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-legacy-collider"), + byteLength: 64, + metadata: { + kind: "model", + format: "glb", + sceneName: "Legacy Collider Scene", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: -0.5, + y: 0, + z: -0.5 + }, + max: { + x: 0.5, + y: 1, + z: 0.5 + }, + size: { + x: 1, + y: 1, + z: 1 + } + }, + warnings: [] + } + }; + const migratedDocument = migrateSceneDocument({ + ...createEmptySceneDocument({ name: "Legacy Model Collider Scene" }), + version: ENTITY_NAMES_SCENE_DOCUMENT_VERSION, + assets: { + [asset.id]: asset + }, + modelInstances: { + "model-instance-legacy-collider": { + id: "model-instance-legacy-collider", + kind: "modelInstance", + assetId: asset.id, + position: { + x: 1, + y: 0, + z: -2 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + scale: { + x: 1, + y: 1, + z: 1 + } + } + } + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.modelInstances["model-instance-legacy-collider"].collision).toEqual({ + mode: "none", + visible: false + }); + }); + it("migrates version 17 box brushes to include default whitebox rotation", () => { + const migratedDocument = migrateSceneDocument({ + ...createEmptySceneDocument({ name: "Legacy Whitebox Transform Scene" }), + version: PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION, + brushes: { + "brush-legacy-room": { + id: "brush-legacy-room", + kind: "box", + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + size: { + x: 2.5, + y: 3.25, + z: 4.75 + }, + faces: { + posX: { materialId: null, uv: createBoxBrush().faces.posX.uv }, + negX: { materialId: null, uv: createBoxBrush().faces.negX.uv }, + posY: { materialId: null, uv: createBoxBrush().faces.posY.uv }, + negY: { materialId: null, uv: createBoxBrush().faces.negY.uv }, + posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv }, + negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv } + } + } + } + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.brushes["brush-legacy-room"]).toMatchObject({ + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + size: { + x: 2.5, + y: 3.25, + z: 4.75 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + } + }); + }); + it("migrates version 16 Player Start entities to include default collider settings", () => { + const playerStart = { + id: "entity-player-start-legacy-collider", + kind: "playerStart", + position: { + x: 1, + y: 0, + z: -3 + }, + yawDegrees: 45 + }; + const legacyDocument = { + ...createEmptySceneDocument({ name: "Legacy Player Collider Scene" }), + version: IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, + entities: { + [playerStart.id]: playerStart + } + }; + const migratedDocument = migrateSceneDocument(legacyDocument); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.entities[playerStart.id]).toEqual(createPlayerStartEntity({ + ...playerStart + })); + }); + it("round-trips the initial typed entity registry without mixing entities into model instances", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main" + }); + const audioAsset = { + id: "asset-audio-main", + kind: "audio", + sourceName: "lobby-loop.ogg", + mimeType: "audio/ogg", + storageKey: createProjectAssetStorageKey("asset-audio-main"), + byteLength: 4096, + metadata: { + kind: "audio", + durationSeconds: 4.5, + channelCount: 2, + sampleRateHz: 48000, + warnings: [] + } + }; + const soundEmitter = createSoundEmitterEntity({ + id: "entity-sound-main", + position: { + x: 1, + y: 2, + z: 3 + }, + audioAssetId: audioAsset.id, + volume: 0.6, + refDistance: 7, + maxDistance: 18, + autoplay: true, + loop: true + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main", + position: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 2, + y: 3, + z: 4 + } + }); + const teleportTarget = createTeleportTargetEntity({ + id: "entity-teleport-main", + position: { + x: -3, + y: 0, + z: 5 + }, + yawDegrees: 180 + }); + const interactable = createInteractableEntity({ + id: "entity-interactable-main", + position: { + x: 2, + y: 1, + z: -2 + }, + radius: 1.25, + prompt: "Open Door", + enabled: true + }); + const document = { + ...createEmptySceneDocument({ name: "Typed Entity Scene" }), + assets: { + [audioAsset.id]: audioAsset + }, + entities: { + [playerStart.id]: playerStart, + [soundEmitter.id]: soundEmitter, + [triggerVolume.id]: triggerVolume, + [teleportTarget.id]: teleportTarget, + [interactable.id]: interactable + } + }; + const roundTripDocument = parseSceneDocumentJson(serializeSceneDocument(document)); + expect(roundTripDocument).toEqual(document); + expect(roundTripDocument.modelInstances).toEqual({}); + }); + it("round-trips authored playSound and stopSound interaction links", () => { + const audioAsset = { + id: "asset-audio-main", + kind: "audio", + sourceName: "lobby-loop.ogg", + mimeType: "audio/ogg", + storageKey: createProjectAssetStorageKey("asset-audio-main"), + byteLength: 4096, + metadata: { + kind: "audio", + durationSeconds: 4.5, + channelCount: 2, + sampleRateHz: 48000, + warnings: [] + } + }; + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const soundEmitter = createSoundEmitterEntity({ + id: "entity-sound-main", + audioAssetId: audioAsset.id + }); + const playLink = createPlaySoundInteractionLink({ + id: "link-play-sound", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetSoundEmitterId: soundEmitter.id + }); + const stopLink = createStopSoundInteractionLink({ + id: "link-stop-sound", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetSoundEmitterId: soundEmitter.id + }); + const document = { + ...createEmptySceneDocument({ name: "Sound Link Scene" }), + assets: { + [audioAsset.id]: audioAsset + }, + entities: { + [triggerVolume.id]: triggerVolume, + [soundEmitter.id]: soundEmitter + }, + interactionLinks: { + [playLink.id]: playLink, + [stopLink.id]: stopLink + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips imported model assets and placed model instances", () => { + const asset = { + id: "asset-model-triangle", + kind: "model", + sourceName: "tiny-triangle.gltf", + mimeType: "model/gltf+json", + storageKey: createProjectAssetStorageKey("asset-model-triangle"), + byteLength: 36, + metadata: { + kind: "model", + format: "gltf", + sceneName: "Fixture Triangle Scene", + nodeCount: 2, + meshCount: 1, + materialNames: ["Fixture Material"], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: 0, + y: 0, + z: 0 + }, + max: { + x: 1, + y: 1, + z: 0 + }, + size: { + x: 1, + y: 1, + z: 0 + } + }, + warnings: [] + } + }; + const modelInstance = createModelInstance({ + id: "model-instance-triangle", + assetId: asset.id, + name: "Fixture Triangle", + position: { + x: 4, + y: 2, + z: -3 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + scale: { + x: 1.5, + y: 2, + z: 1.5 + } + }); + const document = { + ...createEmptySceneDocument({ name: "Imported Asset Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips authored model-instance collision settings", () => { + const asset = { + id: "asset-model-collider", + kind: "model", + sourceName: "collision-test.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-collider"), + byteLength: 64, + metadata: { + kind: "model", + format: "glb", + sceneName: "Collision Test Scene", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: -1, + y: 0, + z: -1 + }, + max: { + x: 1, + y: 2, + z: 1 + }, + size: { + x: 2, + y: 2, + z: 2 + } + }, + warnings: [] + } + }; + const modelInstance = createModelInstance({ + id: "model-instance-collider", + assetId: asset.id, + collision: { + mode: "dynamic", + visible: true + } + }); + const document = { + ...createEmptySceneDocument({ name: "Model Collision Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("round-trips canonical interaction links", () => { + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const interactable = createInteractableEntity({ + id: "entity-interactable-main", + prompt: "Use Console" + }); + const teleportTarget = createTeleportTargetEntity({ + id: "entity-teleport-main" + }); + const brush = createBoxBrush({ + id: "brush-door" + }); + const document = { + ...createEmptySceneDocument({ name: "Interaction Scene" }), + brushes: { + [brush.id]: brush + }, + entities: { + [triggerVolume.id]: triggerVolume, + [interactable.id]: interactable, + [teleportTarget.id]: teleportTarget + }, + interactionLinks: { + "link-teleport": createTeleportPlayerInteractionLink({ + id: "link-teleport", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetEntityId: teleportTarget.id + }), + "link-hide-door": createToggleVisibilityInteractionLink({ + id: "link-hide-door", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetBrushId: brush.id, + visible: false + }), + "link-click-teleport": createTeleportPlayerInteractionLink({ + id: "link-click-teleport", + sourceEntityId: interactable.id, + trigger: "click", + targetEntityId: teleportTarget.id + }) + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("migrates the foundation schema to the current schema version", () => { + const migratedDocument = migrateSceneDocument({ + version: 1, + name: "Foundation Scene", + world: createEmptySceneDocument().world, + materials: {}, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.brushes).toEqual({}); + expect(migratedDocument.name).toBe("Foundation Scene"); + expect(Object.keys(migratedDocument.materials)).toEqual(STARTER_MATERIAL_LIBRARY.map((material) => material.id)); + }); + it("migrates slice 3.0 documents to the current schema version without changing empty asset collections", () => { + const migratedDocument = migrateSceneDocument({ + version: MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION, + name: "Imported Asset Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.assets).toEqual({}); + expect(migratedDocument.modelInstances).toEqual({}); + }); + it("migrates slice 1.1 box brushes to explicit per-face UV state", () => { + const migratedDocument = migrateSceneDocument({ + version: 2, + name: "Legacy Brush Scene", + world: createEmptySceneDocument().world, + materials: {}, + textures: {}, + assets: {}, + brushes: { + "brush-legacy": { + id: "brush-legacy", + kind: "box", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 4, + y: 2, + z: 6 + }, + faces: { + posX: { materialId: null }, + negX: { materialId: null }, + posY: { materialId: null }, + negY: { materialId: null }, + posZ: { materialId: "starter-amber-grid" }, + negZ: { materialId: null } + } + } + }, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.brushes["brush-legacy"].faces.posZ.materialId).toBe("starter-amber-grid"); + expect(migratedDocument.brushes["brush-legacy"].faces.posZ.uv).toEqual({ + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + }); + }); + it("migrates slice 1.2 face materials to the PlayerStart-capable schema", () => { + const migratedDocument = migrateSceneDocument({ + version: 3, + name: "Legacy Face Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.entities).toEqual({}); + }); + it("migrates runner-v1 documents to authored brush names without changing existing content", () => { + const migratedDocument = migrateSceneDocument({ + version: 4, + name: "Runner V1 Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: { + "brush-room-shell": { + id: "brush-room-shell", + kind: "box", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 4, + y: 2, + z: 6 + }, + faces: { + posX: { materialId: null, uv: createBoxBrush().faces.posX.uv }, + negX: { materialId: null, uv: createBoxBrush().faces.negX.uv }, + posY: { materialId: null, uv: createBoxBrush().faces.posY.uv }, + negY: { materialId: null, uv: createBoxBrush().faces.negY.uv }, + posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv }, + negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv } + } + } + }, + modelInstances: {}, + entities: { + "entity-player-start-main": { + id: "entity-player-start-main", + kind: "playerStart", + position: { + x: 2, + y: 0, + z: -2 + }, + yawDegrees: 45 + } + }, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.brushes["brush-room-shell"].name).toBeUndefined(); + expect(migratedDocument.entities["entity-player-start-main"]).toMatchObject({ + kind: "playerStart", + yawDegrees: 45 + }); + }); + it("migrates slice 1.4 documents to the world-environment schema without changing authored solid backgrounds", () => { + const migratedDocument = migrateSceneDocument({ + version: FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION, + name: "First Room Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.world.background).toEqual({ + mode: "solid", + colorHex: "#2f3947" + }); + }); + it("migrates slice 3.2 documents with local lights and skyboxes to the current schema version", () => { + const imageAsset = { + id: "asset-background-panorama", + kind: "image", + sourceName: "skybox-panorama.svg", + mimeType: "image/svg+xml", + storageKey: createProjectAssetStorageKey("asset-background-panorama"), + byteLength: 2048, + metadata: { + kind: "image", + width: 512, + height: 256, + hasAlpha: false, + warnings: [] + } + }; + const pointLight = createPointLightEntity({ + id: "entity-point-light-main" + }); + const spotLight = createSpotLightEntity({ + id: "entity-spot-light-main" + }); + const migratedDocument = migrateSceneDocument({ + version: LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION, + name: "Local Light Scene", + world: { + ...createEmptySceneDocument().world, + background: { + mode: "image", + assetId: imageAsset.id + } + }, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: { + [imageAsset.id]: imageAsset + }, + brushes: {}, + modelInstances: {}, + entities: { + [pointLight.id]: pointLight, + [spotLight.id]: spotLight + }, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.world.background).toEqual({ + mode: "image", + assetId: imageAsset.id, + environmentIntensity: 0.5 + }); + expect(migratedDocument.entities[pointLight.id]).toEqual(pointLight); + expect(migratedDocument.entities[spotLight.id]).toEqual(spotLight); + expect(migratedDocument.assets[imageAsset.id]).toEqual(imageAsset); + }); + it("migrates slice 1.5 documents to the typed-entity schema without changing supported authored entities", () => { + const migratedDocument = migrateSceneDocument({ + version: WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION, + name: "World Environment Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: { + "entity-player-start-main": { + id: "entity-player-start-main", + kind: "playerStart", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90 + } + }, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.entities["entity-player-start-main"]).toMatchObject({ + kind: "playerStart", + yawDegrees: 90 + }); + }); + it("migrates slice 2.1 documents to the interaction-link schema with empty interaction links", () => { + const migratedDocument = migrateSceneDocument({ + version: ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION, + name: "Entity Foundation Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: { + "entity-trigger-main": { + id: "entity-trigger-main", + kind: "triggerVolume", + position: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 2 + }, + triggerOnEnter: true, + triggerOnExit: false + } + }, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.interactionLinks).toEqual({}); + expect(migratedDocument.entities["entity-trigger-main"]).toMatchObject({ + kind: "triggerVolume" + }); + }); + it("migrates slice 2.2 documents to the click-capable interaction schema without changing existing links", () => { + const migratedDocument = migrateSceneDocument({ + version: TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION, + name: "Trigger Action Target Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: { + "entity-trigger-main": { + id: "entity-trigger-main", + kind: "triggerVolume", + position: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 2 + }, + triggerOnEnter: true, + triggerOnExit: false + }, + "entity-teleport-main": { + id: "entity-teleport-main", + kind: "teleportTarget", + position: { + x: 4, + y: 0, + z: -2 + }, + yawDegrees: 90 + } + }, + interactionLinks: { + "link-teleport": { + id: "link-teleport", + sourceEntityId: "entity-trigger-main", + trigger: "enter", + action: { + type: "teleportPlayer", + targetEntityId: "entity-teleport-main" + } + } + } + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.interactionLinks).toEqual({ + "link-teleport": { + id: "link-teleport", + sourceEntityId: "entity-trigger-main", + trigger: "enter", + action: { + type: "teleportPlayer", + targetEntityId: "entity-teleport-main" + } + } + }); + }); + it("migrates v11 documents to v12 with animation fields defaulted to undefined on model instances", () => { + const asset = { + id: "asset-model-anim", + kind: "model", + sourceName: "animated.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-anim"), + byteLength: 1024, + metadata: { + kind: "model", + format: "glb", + sceneName: null, + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: ["Walk"], + boundingBox: null, + warnings: [] + } + }; + const migratedDocument = migrateSceneDocument({ + version: 11, + name: "V11 Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: { [asset.id]: asset }, + brushes: {}, + modelInstances: { + "mi-1": { + id: "mi-1", + kind: "modelInstance", + assetId: asset.id, + position: { x: 0, y: 0, z: 0 }, + rotationDegrees: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } + }, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.modelInstances["mi-1"].animationClipName).toBeUndefined(); + expect(migratedDocument.modelInstances["mi-1"].animationAutoplay).toBeUndefined(); + }); + it("migrates v12 sound emitters to the current schema version", () => { + const migratedDocument = migrateSceneDocument({ + version: ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION, + name: "Legacy Sound Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: { + "entity-sound-main": { + id: "entity-sound-main", + kind: "soundEmitter", + position: { + x: 1, + y: 2, + z: 3 + }, + radius: 9, + gain: 0.4, + autoplay: true, + loop: false + } + }, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.entities["entity-sound-main"]).toEqual({ + id: "entity-sound-main", + kind: "soundEmitter", + position: { + x: 1, + y: 2, + z: 3 + }, + audioAssetId: null, + volume: 0.4, + refDistance: 9, + maxDistance: 9, + autoplay: true, + loop: false + }); + }); + it("migrates v13 documents without the advanced rendering block to the current schema version", () => { + const emptyScene = createEmptySceneDocument(); + const { advancedRendering: _advancedRendering, ...legacyWorld } = emptyScene.world; + const migratedDocument = migrateSceneDocument({ + version: SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION, + name: "Legacy Spatial Audio Scene", + world: legacyWorld, + materials: emptyScene.materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.world.advancedRendering).toEqual(emptyScene.world.advancedRendering); + }); + it("round-trips authored playAnimation and stopAnimation interaction links", () => { + const asset = { + id: "asset-model-anim", + kind: "model", + sourceName: "animated.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-anim"), + byteLength: 1024, + metadata: { + kind: "model", + format: "glb", + sceneName: null, + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: ["Walk", "Run"], + boundingBox: null, + warnings: [] + } + }; + const modelInstance = createModelInstance({ + id: "mi-animated", + assetId: asset.id, + animationClipName: "Walk", + animationAutoplay: true + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + const playLink = createPlayAnimationInteractionLink({ + id: "link-play", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetModelInstanceId: modelInstance.id, + clipName: "Walk" + }); + const stopLink = createStopAnimationInteractionLink({ + id: "link-stop", + sourceEntityId: triggerVolume.id, + trigger: "exit", + targetModelInstanceId: modelInstance.id + }); + const document = { + ...createEmptySceneDocument({ name: "Animation Scene" }), + assets: { [asset.id]: asset }, + modelInstances: { [modelInstance.id]: modelInstance }, + entities: { [triggerVolume.id]: triggerVolume }, + interactionLinks: { + [playLink.id]: playLink, + [stopLink.id]: stopLink + } + }; + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("rejects a v12 document where a playAnimation action has an empty clipName", () => { + const asset = { + id: "asset-model-anim", + kind: "model", + sourceName: "animated.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-anim"), + byteLength: 1024, + metadata: { + kind: "model", + format: "glb", + sceneName: null, + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: null, + warnings: [] + } + }; + expect(() => migrateSceneDocument({ + version: SCENE_DOCUMENT_VERSION, + name: "Bad Animation Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: { [asset.id]: asset }, + brushes: {}, + modelInstances: {}, + entities: { + "entity-trigger-main": { + id: "entity-trigger-main", + kind: "triggerVolume", + position: { x: 0, y: 0, z: 0 }, + size: { x: 2, y: 2, z: 2 }, + triggerOnEnter: true, + triggerOnExit: false + } + }, + interactionLinks: { + "link-bad-play": { + id: "link-bad-play", + sourceEntityId: "entity-trigger-main", + trigger: "enter", + action: { + type: "playAnimation", + targetModelInstanceId: "mi-animated", + clipName: "" + } + } + } + })).toThrow(); + }); + it("rejects unsupported versions", () => { + expect(() => migrateSceneDocument({ + version: 99, + name: "Legacy", + world: {}, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + })).toThrow("Unsupported scene document version"); + }); + it("rejects duplicate authored ids after migration and validation", () => { + expect(() => parseSceneDocumentJson(JSON.stringify({ + version: SCENE_DOCUMENT_VERSION, + name: "Duplicate Id Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: { + "brush-room-shell": { + id: "shared-id", + kind: "box", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 2 + }, + faces: { + posX: { materialId: null, uv: createBoxBrush().faces.posX.uv }, + negX: { materialId: null, uv: createBoxBrush().faces.negX.uv }, + posY: { materialId: null, uv: createBoxBrush().faces.posY.uv }, + negY: { materialId: null, uv: createBoxBrush().faces.negY.uv }, + posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv }, + negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv } + } + } + }, + modelInstances: {}, + entities: { + "shared-id": { + id: "shared-id", + kind: "playerStart", + position: { + x: 0, + y: 0, + z: 0 + }, + yawDegrees: 0 + } + }, + interactionLinks: {} + }))).toThrow("Duplicate authored id shared-id"); + }); +}); diff --git a/tests/setup/vitest.setup.js b/tests/setup/vitest.setup.js new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/tests/setup/vitest.setup.js @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/tests/unit/audio-assets.test.js b/tests/unit/audio-assets.test.js new file mode 100644 index 00000000..35494755 --- /dev/null +++ b/tests/unit/audio-assets.test.js @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryProjectAssetStorage } from "../../src/assets/project-asset-storage"; +import { importAudioAssetFromFile, loadAudioAssetFromStorage } from "../../src/assets/audio-assets"; +describe("audio asset import and storage", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("persists audio through the generic project asset storage and reloads decoded buffers", async () => { + const decodedBuffer = { + duration: 2.5, + numberOfChannels: 2, + sampleRate: 44100 + }; + const decodeCalls = []; + const closeCalls = []; + class MockAudioContext { + state = "running"; + async decodeAudioData(bytes) { + decodeCalls.push(bytes); + return decodedBuffer; + } + async close() { + closeCalls.push(1); + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + vi.stubGlobal("webkitAudioContext", MockAudioContext); + const storage = createInMemoryProjectAssetStorage(); + const fileBytes = new Uint8Array([1, 2, 3, 4]).buffer; + const file = { + name: "lobby-loop.ogg", + type: "audio/ogg", + webkitRelativePath: "", + arrayBuffer: async () => fileBytes + }; + const importedAudio = await importAudioAssetFromFile(file, storage); + const storedAsset = await storage.getAsset(importedAudio.asset.storageKey); + const reloadedAudio = await loadAudioAssetFromStorage(storage, importedAudio.asset); + expect(importedAudio.asset).toMatchObject({ + kind: "audio", + sourceName: "lobby-loop.ogg", + mimeType: "audio/ogg", + byteLength: fileBytes.byteLength + }); + expect(importedAudio.asset.metadata).toMatchObject({ + kind: "audio", + durationSeconds: 2.5, + channelCount: 2, + sampleRateHz: 44100 + }); + expect(storedAsset).toEqual({ + files: { + "lobby-loop.ogg": { + bytes: fileBytes, + mimeType: "audio/ogg" + } + } + }); + expect(reloadedAudio.assetId).toBe(importedAudio.asset.id); + expect(reloadedAudio.storageKey).toBe(importedAudio.asset.storageKey); + expect(reloadedAudio.metadata).toEqual(importedAudio.asset.metadata); + expect(reloadedAudio.buffer).toBe(decodedBuffer); + expect(decodeCalls).toHaveLength(2); + expect(closeCalls).toHaveLength(2); + }); +}); diff --git a/tests/unit/entity-instances.test.js b/tests/unit/entity-instances.test.js new file mode 100644 index 00000000..a4d96d05 --- /dev/null +++ b/tests/unit/entity-instances.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_POINT_LIGHT_COLOR_HEX, DEFAULT_POINT_LIGHT_DISTANCE, DEFAULT_POINT_LIGHT_INTENSITY, DEFAULT_PLAYER_START_BOX_SIZE, DEFAULT_PLAYER_START_CAPSULE_HEIGHT, DEFAULT_PLAYER_START_CAPSULE_RADIUS, DEFAULT_PLAYER_START_EYE_HEIGHT, DEFAULT_SPOT_LIGHT_ANGLE_DEGREES, DEFAULT_SPOT_LIGHT_COLOR_HEX, DEFAULT_SPOT_LIGHT_DISTANCE, DEFAULT_SPOT_LIGHT_DIRECTION, DEFAULT_SPOT_LIGHT_INTENSITY, DEFAULT_INTERACTABLE_PROMPT, DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID, DEFAULT_SOUND_EMITTER_MAX_DISTANCE, DEFAULT_SOUND_EMITTER_REF_DISTANCE, DEFAULT_SOUND_EMITTER_VOLUME, DEFAULT_TRIGGER_VOLUME_SIZE, createPointLightEntity, createDefaultEntityInstance, createInteractableEntity, createSpotLightEntity, getEntityRegistryEntry } from "../../src/entities/entity-instances"; +describe("entity registry defaults", () => { + it("creates explicit typed defaults for each supported entity kind", () => { + expect(createDefaultEntityInstance("playerStart")).toMatchObject({ + kind: "playerStart", + position: { x: 0, y: 0, z: 0 }, + yawDegrees: 0, + collider: { + mode: "capsule", + eyeHeight: DEFAULT_PLAYER_START_EYE_HEIGHT, + capsuleRadius: DEFAULT_PLAYER_START_CAPSULE_RADIUS, + capsuleHeight: DEFAULT_PLAYER_START_CAPSULE_HEIGHT, + boxSize: DEFAULT_PLAYER_START_BOX_SIZE + } + }); + expect(createDefaultEntityInstance("pointLight")).toMatchObject({ + kind: "pointLight", + position: { x: 0, y: 0, z: 0 }, + colorHex: DEFAULT_POINT_LIGHT_COLOR_HEX, + intensity: DEFAULT_POINT_LIGHT_INTENSITY, + distance: DEFAULT_POINT_LIGHT_DISTANCE + }); + expect(createDefaultEntityInstance("spotLight")).toMatchObject({ + kind: "spotLight", + position: { x: 0, y: 0, z: 0 }, + direction: DEFAULT_SPOT_LIGHT_DIRECTION, + colorHex: DEFAULT_SPOT_LIGHT_COLOR_HEX, + intensity: DEFAULT_SPOT_LIGHT_INTENSITY, + distance: DEFAULT_SPOT_LIGHT_DISTANCE, + angleDegrees: DEFAULT_SPOT_LIGHT_ANGLE_DEGREES + }); + expect(createDefaultEntityInstance("soundEmitter")).toMatchObject({ + kind: "soundEmitter", + position: { x: 0, y: 0, z: 0 }, + audioAssetId: DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID, + volume: DEFAULT_SOUND_EMITTER_VOLUME, + refDistance: DEFAULT_SOUND_EMITTER_REF_DISTANCE, + maxDistance: DEFAULT_SOUND_EMITTER_MAX_DISTANCE, + autoplay: false, + loop: false + }); + expect(createDefaultEntityInstance("triggerVolume")).toMatchObject({ + kind: "triggerVolume", + position: { x: 0, y: 0, z: 0 }, + size: DEFAULT_TRIGGER_VOLUME_SIZE, + triggerOnEnter: true, + triggerOnExit: false + }); + expect(createDefaultEntityInstance("teleportTarget")).toMatchObject({ + kind: "teleportTarget", + position: { x: 0, y: 0, z: 0 }, + yawDegrees: 0 + }); + expect(createDefaultEntityInstance("interactable")).toMatchObject({ + kind: "interactable", + position: { x: 0, y: 0, z: 0 }, + radius: 1.5, + prompt: DEFAULT_INTERACTABLE_PROMPT, + enabled: true + }); + }); + it("keeps entity metadata and prompt validation explicit", () => { + expect(getEntityRegistryEntry("triggerVolume")).toMatchObject({ + kind: "triggerVolume", + label: "Trigger Volume" + }); + expect(createInteractableEntity({ + prompt: " Open " + }).prompt).toBe("Open"); + expect(() => createInteractableEntity({ + prompt: " " + })).toThrow("Interactable prompt must be non-empty."); + expect(() => createPointLightEntity({ + distance: 0 + })).toThrow("Point Light distance must be a finite number greater than zero."); + expect(() => createSpotLightEntity({ + direction: { + x: 0, + y: 0, + z: 0 + } + })).toThrow("Spot Light direction must not be the zero vector."); + }); +}); diff --git a/tests/unit/package-scripts.test.js b/tests/unit/package-scripts.test.js new file mode 100644 index 00000000..d01b39f1 --- /dev/null +++ b/tests/unit/package-scripts.test.js @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +function readPackageManifest() { + return JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")); +} +describe("package scripts", () => { + it("exposes the expected verification script contract", () => { + const packageManifest = readPackageManifest(); + expect(packageManifest.scripts).toBeDefined(); + expect(packageManifest.scripts?.["test"]).toBeDefined(); + expect(packageManifest.scripts?.["test:browser"]).toBeDefined(); + expect(packageManifest.scripts?.["test:e2e"]).toBeDefined(); + expect(packageManifest.scripts?.["typecheck"]).toBeDefined(); + expect(packageManifest.scripts?.["test:typecheck"]).toBeDefined(); + }); +}); diff --git a/tests/unit/transform-foundation.integration.test.js b/tests/unit/transform-foundation.integration.test.js new file mode 100644 index 00000000..3dd6f928 --- /dev/null +++ b/tests/unit/transform-foundation.integration.test.js @@ -0,0 +1,494 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "../../src/app/App"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPlayerStartEntity } from "../../src/entities/entity-instances"; +const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { + const viewportHostInstances = []; + class MockViewportHost { + panelId = null; + setPanelId = vi.fn((panelId) => { + this.panelId = panelId; + }); + mount = vi.fn(); + dispose = vi.fn(); + updateWorld = vi.fn(); + updateAssets = vi.fn(); + updateDocument = vi.fn(); + setViewMode = vi.fn(); + setDisplayMode = vi.fn(); + setCameraState = vi.fn(); + setBrushSelectionChangeHandler = vi.fn(); + setCameraStateChangeHandler = vi.fn(); + setCreationPreviewChangeHandler = vi.fn(); + setCreationCommitHandler = vi.fn(); + setTransformSessionChangeHandler = vi.fn(); + setTransformCommitHandler = vi.fn(); + setTransformCancelHandler = vi.fn(); + setWhiteboxHoverLabelChangeHandler = vi.fn(); + setWhiteboxSelectionMode = vi.fn(); + setWhiteboxSnapSettings = vi.fn(); + setToolMode = vi.fn(); + setCreationPreview = vi.fn(); + setTransformSession = vi.fn(); + focusSelection = vi.fn(); + constructor() { + viewportHostInstances.push(this); + } + } + return { + MockViewportHost, + viewportHostInstances + }; +}); +vi.mock("../../src/viewport-three/viewport-host", () => ({ + ViewportHost: MockViewportHost +})); +vi.mock("../../src/assets/project-asset-storage", () => ({ + getBrowserProjectAssetStorageAccess: vi.fn(async () => ({ + storage: null, + diagnostic: null + })) +})); +const modelAsset = { + id: "asset-model-transform-integration", + kind: "model", + sourceName: "transform-fixture.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-transform-integration"), + byteLength: 64, + metadata: { + kind: "model", + format: "glb", + sceneName: "Transform Fixture", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: -0.5, + y: 0, + z: -0.5 + }, + max: { + x: 0.5, + y: 1, + z: 0.5 + }, + size: { + x: 1, + y: 1, + z: 1 + } + }, + warnings: [] + } +}; +function getTopLeftViewportHost() { + const viewportHost = viewportHostInstances.find((instance) => instance.panelId === "topLeft"); + if (viewportHost === undefined) { + throw new Error("Top-left viewport host was not mounted."); + } + return viewportHost; +} +async function renderTransformFixtureApp() { + const brush = createBoxBrush({ + id: "brush-transform-main", + name: "Brush Transform Fixture", + center: { + x: 0, + y: 1, + z: 0 + } + }); + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-transform", + name: "Player Start Fixture", + position: { + x: 2, + y: 0, + z: -2 + }, + yawDegrees: 0 + }); + const modelInstance = createModelInstance({ + id: "model-instance-transform-main", + assetId: modelAsset.id, + name: "Model Transform Fixture", + position: { + x: -3, + y: 0, + z: 3 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Transform Fixture" }), + brushes: { + [brush.id]: brush + }, + assets: { + [modelAsset.id]: modelAsset + }, + entities: { + [playerStart.id]: playerStart + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + } + }); + render(_jsx(App, { store: store })); + await waitFor(() => { + expect(viewportHostInstances.length).toBeGreaterThan(0); + expect(getTopLeftViewportHost().setTransformCommitHandler).toHaveBeenCalled(); + }); + return { + store, + brush, + playerStart, + modelInstance, + viewportHost: getTopLeftViewportHost() + }; +} +async function renderQuadTransformFixtureApp() { + const fixture = await renderTransformFixtureApp(); + act(() => { + fixture.store.setViewportLayoutMode("quad"); + }); + return fixture; +} +function getLatestTransformSession(store) { + const transformSession = store.getState().viewportTransientState.transformSession; + if (transformSession.kind !== "active") { + throw new Error("Expected an active transform session."); + } + return transformSession; +} +function emitTransformPreview(viewportHost, transformSession) { + const handler = viewportHost.setTransformSessionChangeHandler.mock.calls.at(-1)?.[0]; + if (handler === undefined) { + throw new Error("Transform session change handler was not registered."); + } + act(() => { + handler(transformSession); + }); +} +function commitTransform(viewportHost, transformSession) { + const handler = viewportHost.setTransformCommitHandler.mock.calls.at(-1)?.[0]; + if (handler === undefined) { + throw new Error("Transform commit handler was not registered."); + } + act(() => { + handler(transformSession); + }); +} +describe("transform foundation integration", () => { + beforeEach(() => { + viewportHostInstances.length = 0; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => ({})); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("moves a whole brush through keyboard entry, axis constraint, and viewport commit", async () => { + const { store, brush, viewportHost } = await renderTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); + }); + fireEvent.keyDown(window, { + key: "g", + code: "KeyG" + }); + expect(store.getState().viewportTransientState.transformSession).toMatchObject({ + kind: "active", + operation: "translate", + axisConstraint: null, + target: { + kind: "brush", + brushId: brush.id + } + }); + fireEvent.keyDown(window, { + key: "x", + code: "KeyX" + }); + expect(store.getState().viewportTransientState.transformSession).toMatchObject({ + kind: "active", + axisConstraint: "x" + }); + const previewSession = { + ...getLatestTransformSession(store), + preview: { + kind: "brush", + center: { + x: 6, + y: brush.center.y, + z: brush.center.z + }, + rotationDegrees: { + ...brush.rotationDegrees + }, + size: { + ...brush.size + } + } + }; + emitTransformPreview(viewportHost, previewSession); + commitTransform(viewportHost, previewSession); + expect(store.getState().viewportTransientState.transformSession).toEqual({ + kind: "none" + }); + expect(store.getState().document.brushes[brush.id].center).toEqual({ + x: 6, + y: brush.center.y, + z: brush.center.z + }); + }); + it("rotates and scales a whole whitebox box through the shared transform controller", async () => { + const { store, brush, viewportHost } = await renderTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); + }); + fireEvent.click(screen.getByTestId("transform-rotate-button")); + const rotatePreviewSession = { + ...getLatestTransformSession(store), + preview: { + kind: "brush", + center: { + ...brush.center + }, + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + ...brush.size + } + } + }; + emitTransformPreview(viewportHost, rotatePreviewSession); + commitTransform(viewportHost, rotatePreviewSession); + expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual({ + x: 0, + y: 37.5, + z: 12.5 + }); + fireEvent.click(screen.getByTestId("transform-scale-button")); + const scalePreviewSession = { + ...getLatestTransformSession(store), + preview: { + kind: "brush", + center: { + ...brush.center + }, + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + x: 3.5, + y: 2.5, + z: 4.5 + } + } + }; + emitTransformPreview(viewportHost, scalePreviewSession); + commitTransform(viewportHost, scalePreviewSession); + expect(store.getState().document.brushes[brush.id]).toMatchObject({ + rotationDegrees: { + x: 0, + y: 37.5, + z: 12.5 + }, + size: { + x: 3.5, + y: 2.5, + z: 4.5 + } + }); + }); + it("keeps transform controls coherent across object and component modes", async () => { + const { store, brush } = await renderTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); + }); + expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); + await act(async () => { + fireEvent.click(screen.getByTestId("whitebox-selection-mode-face")); + }); + expect(store.getState().whiteboxSelectionMode).toBe("face"); + expect(screen.getByTestId("transform-translate-button")).toBeDisabled(); + expect(screen.getByTestId("transform-rotate-button")).toBeDisabled(); + expect(screen.getByTestId("transform-scale-button")).toBeDisabled(); + act(() => { + store.setSelection({ + kind: "brushFace", + brushId: brush.id, + faceId: "posY" + }); + }); + expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); + expect(screen.getByTestId("transform-rotate-button")).not.toBeDisabled(); + expect(screen.getByTestId("transform-scale-button")).not.toBeDisabled(); + await act(async () => { + fireEvent.click(screen.getByTestId("whitebox-selection-mode-vertex")); + }); + act(() => { + store.setSelection({ + kind: "brushVertex", + brushId: brush.id, + vertexId: "posX_posY_posZ" + }); + }); + expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); + expect(screen.getByTestId("transform-rotate-button")).toBeDisabled(); + expect(screen.getByTestId("transform-scale-button")).toBeDisabled(); + await act(async () => { + fireEvent.click(screen.getByTestId("whitebox-selection-mode-object")); + }); + expect(store.getState().whiteboxSelectionMode).toBe("object"); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [brush.id] + }); + expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); + }); + it("moves an entity through the shared transform controller", async () => { + const { store, playerStart, viewportHost } = await renderTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Player Start Fixture$/ })); + }); + fireEvent.keyDown(window, { + key: "g", + code: "KeyG" + }); + const previewSession = { + ...getLatestTransformSession(store), + preview: { + kind: "entity", + position: { + x: 8, + y: 0, + z: -4 + }, + rotation: { + kind: "yaw", + yawDegrees: playerStart.yawDegrees + } + } + }; + emitTransformPreview(viewportHost, previewSession); + commitTransform(viewportHost, previewSession); + expect(store.getState().document.entities[playerStart.id]).toMatchObject({ + position: { + x: 8, + y: 0, + z: -4 + } + }); + }); + it("cancels an active transform with Escape without committing preview changes", async () => { + const { store, playerStart, viewportHost } = await renderTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Player Start Fixture$/ })); + }); + fireEvent.keyDown(window, { + key: "g", + code: "KeyG" + }); + emitTransformPreview(viewportHost, { + ...getLatestTransformSession(store), + preview: { + kind: "entity", + position: { + x: 12, + y: 0, + z: -6 + }, + rotation: { + kind: "yaw", + yawDegrees: playerStart.yawDegrees + } + } + }); + fireEvent.keyDown(window, { + key: "Escape", + code: "Escape" + }); + expect(store.getState().viewportTransientState.transformSession).toEqual({ + kind: "none" + }); + expect(store.getState().document.entities[playerStart.id]).toEqual(playerStart); + }); + it("moves a model instance through the shared transform controller", async () => { + const { store, modelInstance, viewportHost } = await renderTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Model Transform Fixture$/ })); + }); + fireEvent.keyDown(window, { + key: "g", + code: "KeyG" + }); + const previewSession = { + ...getLatestTransformSession(store), + preview: { + kind: "modelInstance", + position: { + x: -1, + y: 0, + z: 7 + }, + rotationDegrees: { + ...modelInstance.rotationDegrees + }, + scale: { + ...modelInstance.scale + } + } + }; + emitTransformPreview(viewportHost, previewSession); + commitTransform(viewportHost, previewSession); + expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ + position: { + x: -1, + y: 0, + z: 7 + } + }); + }); + it("uses the hovered quad viewport as the active transform panel for keyboard entry", async () => { + const { store, brush } = await renderQuadTransformFixtureApp(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); + }); + fireEvent.pointerMove(screen.getByTestId("viewport-panel-bottomRight"), { + clientX: 24, + clientY: 24 + }); + fireEvent.keyDown(window, { + key: "g", + code: "KeyG" + }); + expect(store.getState().activeViewportPanelId).toBe("bottomRight"); + expect(store.getState().viewportTransientState.transformSession).toMatchObject({ + kind: "active", + operation: "translate", + sourcePanelId: "bottomRight", + target: { + kind: "brush", + brushId: brush.id + } + }); + }); +}); diff --git a/tests/unit/viewport-canvas.test.js b/tests/unit/viewport-canvas.test.js new file mode 100644 index 00000000..b2b084b5 --- /dev/null +++ b/tests/unit/viewport-canvas.test.js @@ -0,0 +1,92 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createInactiveTransformSession } from "../../src/core/transform-session"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { ViewportCanvas } from "../../src/viewport-three/ViewportCanvas"; +import { createDefaultViewportPanelCameraState } from "../../src/viewport-three/viewport-layout"; +const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { + const viewportHostInstances = []; + class MockViewportHost { + mount = vi.fn(); + dispose = vi.fn(); + updateWorld = vi.fn(); + updateAssets = vi.fn(); + updateDocument = vi.fn(); + setViewMode = vi.fn(); + setDisplayMode = vi.fn(); + setCameraState = vi.fn(); + setBrushSelectionChangeHandler = vi.fn(); + setCameraStateChangeHandler = vi.fn(); + setCreationPreviewChangeHandler = vi.fn(); + setCreationCommitHandler = vi.fn(); + setTransformSessionChangeHandler = vi.fn(); + setTransformCommitHandler = vi.fn(); + setTransformCancelHandler = vi.fn(); + setWhiteboxHoverLabelChangeHandler = vi.fn(); + setWhiteboxSelectionMode = vi.fn(); + setWhiteboxSnapSettings = vi.fn(); + setToolMode = vi.fn(); + setCreationPreview = vi.fn(); + setTransformSession = vi.fn(); + setPanelId = vi.fn(); + focusSelection = vi.fn(); + constructor() { + viewportHostInstances.push(this); + } + } + return { + MockViewportHost, + viewportHostInstances + }; +}); +vi.mock("../../src/viewport-three/viewport-host", () => ({ + ViewportHost: MockViewportHost +})); +describe("ViewportCanvas", () => { + beforeEach(() => { + viewportHostInstances.length = 0; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => ({})); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("wires the creation commit handler into the viewport host", async () => { + const sceneDocument = createEmptySceneDocument(); + const cameraState = createDefaultViewportPanelCameraState(); + const toolPreview = { + kind: "create", + sourcePanelId: "topLeft", + target: { + kind: "box-brush" + }, + center: null + }; + const onCommitCreation = vi.fn(() => true); + const onCameraStateChange = vi.fn((_cameraState) => undefined); + const onToolPreviewChange = vi.fn((_toolPreview) => undefined); + const onTransformSessionChange = vi.fn((_transformSession) => undefined); + const onTransformCommit = vi.fn((_transformSession) => undefined); + const onTransformCancel = vi.fn(() => undefined); + const onSelectionChange = vi.fn(); + render(_jsx(ViewportCanvas, { panelId: "topLeft", world: sceneDocument.world, sceneDocument: sceneDocument, projectAssets: sceneDocument.assets, loadedModelAssets: {}, loadedImageAssets: {}, whiteboxSelectionMode: "object", whiteboxSnapEnabled: true, whiteboxSnapStep: 1, selection: { kind: "none" }, toolMode: "create", toolPreview: toolPreview, transformSession: createInactiveTransformSession(), cameraState: cameraState, viewMode: "perspective", displayMode: "authoring", layoutMode: "single", isActivePanel: true, focusRequestId: 0, focusSelection: { kind: "none" }, onSelectionChange: onSelectionChange, onCommitCreation: onCommitCreation, onCameraStateChange: onCameraStateChange, onToolPreviewChange: onToolPreviewChange, onTransformSessionChange: onTransformSessionChange, onTransformCommit: onTransformCommit, onTransformCancel: onTransformCancel })); + await waitFor(() => { + expect(viewportHostInstances).toHaveLength(1); + expect(viewportHostInstances[0].setCreationCommitHandler).toHaveBeenCalledTimes(1); + }); + const registeredHandler = viewportHostInstances[0].setCreationCommitHandler.mock.calls[0][0]; + expect(registeredHandler(toolPreview)).toBe(true); + expect(onCommitCreation).toHaveBeenCalledWith(toolPreview); + }); + it("applies and subscribes to persisted camera state through the viewport host", async () => { + const sceneDocument = createEmptySceneDocument(); + const cameraState = createDefaultViewportPanelCameraState(); + const onCameraStateChange = vi.fn((_cameraState) => undefined); + render(_jsx(ViewportCanvas, { panelId: "topLeft", world: sceneDocument.world, sceneDocument: sceneDocument, projectAssets: sceneDocument.assets, loadedModelAssets: {}, loadedImageAssets: {}, whiteboxSelectionMode: "object", whiteboxSnapEnabled: true, whiteboxSnapStep: 1, selection: { kind: "none" }, toolMode: "select", toolPreview: { kind: "none" }, transformSession: createInactiveTransformSession(), cameraState: cameraState, viewMode: "perspective", displayMode: "normal", layoutMode: "single", isActivePanel: true, focusRequestId: 0, focusSelection: { kind: "none" }, onSelectionChange: vi.fn(), onCommitCreation: vi.fn(() => true), onCameraStateChange: onCameraStateChange, onToolPreviewChange: vi.fn(), onTransformSessionChange: vi.fn(), onTransformCommit: vi.fn(), onTransformCancel: vi.fn() })); + await waitFor(() => { + expect(viewportHostInstances).toHaveLength(1); + expect(viewportHostInstances[0].setCameraState).toHaveBeenCalledWith(cameraState); + expect(viewportHostInstances[0].setCameraStateChangeHandler).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/viewport-entity-markers.test.js b/tests/unit/viewport-entity-markers.test.js new file mode 100644 index 00000000..3f1db253 --- /dev/null +++ b/tests/unit/viewport-entity-markers.test.js @@ -0,0 +1,23 @@ +import { BoxGeometry, CylinderGeometry, SphereGeometry, TorusGeometry } from "three"; +import { describe, expect, it } from "vitest"; +import { createSoundEmitterMarkerMeshes } from "../../src/viewport-three/viewport-entity-markers"; +describe("createSoundEmitterMarkerMeshes", () => { + it("builds a speaker-like marker instead of a sphere", () => { + const meshes = createSoundEmitterMarkerMeshes(0x72d7c9, false); + expect(meshes).toHaveLength(5); + expect(meshes[0].geometry).toBeInstanceOf(BoxGeometry); + expect(meshes[1].geometry).toBeInstanceOf(TorusGeometry); + expect(meshes[2].geometry).toBeInstanceOf(CylinderGeometry); + expect(meshes[3].geometry).toBeInstanceOf(TorusGeometry); + expect(meshes[4].geometry).toBeInstanceOf(CylinderGeometry); + expect(meshes.some((mesh) => mesh.geometry instanceof SphereGeometry)).toBe(false); + expect(meshes[0].position).toMatchObject({ + x: 0, + y: 0, + z: 0 + }); + expect(meshes[1].position.y).toBeGreaterThan(meshes[3].position.y); + expect(meshes[1].position.z).toBeGreaterThan(0); + expect(meshes[3].position.z).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/viewport-focus.test.js b/tests/unit/viewport-focus.test.js new file mode 100644 index 00000000..86c7ebd4 --- /dev/null +++ b/tests/unit/viewport-focus.test.js @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPointLightEntity, createPlayerStartEntity, createSpotLightEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +import { resolveViewportFocusTarget } from "../../src/viewport-three/viewport-focus"; +describe("resolveViewportFocusTarget", () => { + it("frames the selected brush", () => { + const brush = createBoxBrush({ + id: "brush-room", + center: { + x: 3, + y: 2, + z: -1 + }, + size: { + x: 6, + y: 4, + z: 2 + } + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }; + expect(resolveViewportFocusTarget(document, { + kind: "brushes", + ids: [brush.id] + })).toEqual({ + center: { + x: 3, + y: 2, + z: -1 + }, + radius: Math.hypot(6, 4, 2) * 0.5 + }); + }); + it("frames rotated whitebox boxes around their authored center with a stable object radius", () => { + const brush = createBoxBrush({ + id: "brush-rotated-room", + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + rotationDegrees: { + x: 0, + y: 45, + z: 0 + }, + size: { + x: 2, + y: 2, + z: 4 + } + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }; + expect(resolveViewportFocusTarget(document, { + kind: "brushes", + ids: [brush.id] + })).toEqual({ + center: { + x: 1.25, + y: 1.5, + z: -0.75 + }, + radius: Math.hypot(2, 2, 4) * 0.5 + }); + }); + it("frames the owning brush when a face is selected", () => { + const brush = createBoxBrush({ + id: "brush-face-room" + }); + const document = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }; + const focusTarget = resolveViewportFocusTarget(document, { + kind: "brushFace", + brushId: brush.id, + faceId: "posZ" + }); + expect(focusTarget?.center).toEqual(brush.center); + expect(focusTarget?.radius).toBe(Math.hypot(2, 2, 2) * 0.5); + }); + it("frames the selected Player Start helper", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main", + position: { + x: 4, + y: 0, + z: -2 + }, + yawDegrees: 90 + }); + const document = { + ...createEmptySceneDocument(), + entities: { + [playerStart.id]: playerStart + } + }; + const focusTarget = resolveViewportFocusTarget(document, { + kind: "entities", + ids: [playerStart.id] + }); + expect(focusTarget?.center).toEqual({ + x: 4, + y: 0.3, + z: -2 + }); + expect(focusTarget?.radius).toBeGreaterThan(0.6); + }); + it("frames the selected Point Light helper", () => { + const pointLight = createPointLightEntity({ + id: "entity-point-light-main", + position: { + x: 2, + y: 3, + z: -1 + }, + distance: 8 + }); + const document = { + ...createEmptySceneDocument(), + entities: { + [pointLight.id]: pointLight + } + }; + const focusTarget = resolveViewportFocusTarget(document, { + kind: "entities", + ids: [pointLight.id] + }); + expect(focusTarget).toEqual({ + center: { + x: 2, + y: 3, + z: -1 + }, + radius: 8 + }); + }); + it("frames the selected Spot Light helper", () => { + const spotLight = createSpotLightEntity({ + id: "entity-spot-light-main", + position: { + x: -2, + y: 4, + z: 1 + }, + distance: 12 + }); + const document = { + ...createEmptySceneDocument(), + entities: { + [spotLight.id]: spotLight + } + }; + const focusTarget = resolveViewportFocusTarget(document, { + kind: "entities", + ids: [spotLight.id] + }); + expect(focusTarget).toEqual({ + center: { + x: -2, + y: 4, + z: 1 + }, + radius: 12 + }); + }); + it("frames a selected Trigger Volume around its authored bounds", () => { + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main", + position: { + x: 3, + y: 2, + z: -1 + }, + size: { + x: 4, + y: 6, + z: 2 + } + }); + const document = { + ...createEmptySceneDocument(), + entities: { + [triggerVolume.id]: triggerVolume + } + }; + const focusTarget = resolveViewportFocusTarget(document, { + kind: "entities", + ids: [triggerVolume.id] + }); + expect(focusTarget).toEqual({ + center: { + x: 3, + y: 2, + z: -1 + }, + radius: Math.hypot(2, 3, 1) + }); + }); + it("frames the authored scene when nothing is selected and returns null when the scene is empty", () => { + const brush = createBoxBrush({ + id: "brush-room" + }); + const populatedDocument = { + ...createEmptySceneDocument(), + brushes: { + [brush.id]: brush + } + }; + expect(resolveViewportFocusTarget(populatedDocument, { kind: "none" })).toEqual({ + center: { + x: 0, + y: 1, + z: 0 + }, + radius: Math.hypot(2, 2, 2) * 0.5 + }); + expect(resolveViewportFocusTarget(createEmptySceneDocument(), { kind: "none" })).toBeNull(); + }); +}); diff --git a/tests/unit/viewport-layout.test.js b/tests/unit/viewport-layout.test.js new file mode 100644 index 00000000..6b447208 --- /dev/null +++ b/tests/unit/viewport-layout.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultViewportLayoutState, getViewportDisplayModeLabel, getViewportLayoutModeLabel, getViewportPanelLabel } from "../../src/viewport-three/viewport-layout"; +describe("viewport layout", () => { + it("defaults to a quad-ready panel arrangement with orthographic authoring panes", () => { + const layout = createDefaultViewportLayoutState(); + expect(layout.layoutMode).toBe("single"); + expect(layout.activePanelId).toBe("topLeft"); + expect(layout.panels.topLeft).toMatchObject({ + viewMode: "perspective", + displayMode: "normal" + }); + expect(layout.panels.topRight).toMatchObject({ + viewMode: "top", + displayMode: "authoring" + }); + expect(layout.panels.bottomLeft).toMatchObject({ + viewMode: "front", + displayMode: "authoring" + }); + expect(layout.panels.bottomRight).toMatchObject({ + viewMode: "side", + displayMode: "authoring" + }); + expect(layout.viewportQuadSplit).toEqual({ + x: 0.5, + y: 0.5 + }); + }); + it("exposes readable labels for the layout and panel chrome", () => { + expect(getViewportLayoutModeLabel("single")).toBe("Single View"); + expect(getViewportLayoutModeLabel("quad")).toBe("4-Panel"); + expect(getViewportDisplayModeLabel("authoring")).toBe("Authoring"); + expect(getViewportDisplayModeLabel("wireframe")).toBe("Wireframe"); + expect(getViewportPanelLabel("topRight")).toBe("Top Right"); + }); +}); diff --git a/tests/unit/viewport-view-modes.test.js b/tests/unit/viewport-view-modes.test.js new file mode 100644 index 00000000..ec249a68 --- /dev/null +++ b/tests/unit/viewport-view-modes.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { getViewportViewModeControlHint, getViewportViewModeDefinition, getViewportViewModeGridPlaneLabel, getViewportViewModeLabel } from "../../src/viewport-three/viewport-view-modes"; +describe("viewport view modes", () => { + it("defines the orthographic axes and grid planes explicitly", () => { + expect(getViewportViewModeDefinition("top")).toMatchObject({ + label: "Top", + cameraType: "orthographic", + cameraDirection: { + x: 0, + y: 1, + z: 0 + }, + cameraUp: { + x: 0, + y: 0, + z: -1 + }, + gridPlane: "xz", + snapAxis: "y" + }); + expect(getViewportViewModeDefinition("front")).toMatchObject({ + label: "Front", + cameraType: "orthographic", + cameraDirection: { + x: 0, + y: 0, + z: 1 + }, + cameraUp: { + x: 0, + y: 1, + z: 0 + }, + gridPlane: "xy", + snapAxis: "z" + }); + expect(getViewportViewModeDefinition("side")).toMatchObject({ + label: "Side", + cameraType: "orthographic", + cameraDirection: { + x: -1, + y: 0, + z: 0 + }, + cameraUp: { + x: 0, + y: 1, + z: 0 + }, + gridPlane: "yz", + snapAxis: "x" + }); + }); + it("exposes readable labels and grid hints for the UI", () => { + expect(getViewportViewModeLabel("perspective")).toBe("Perspective"); + expect(getViewportViewModeLabel("top")).toBe("Top"); + expect(getViewportViewModeGridPlaneLabel("front")).toBe("XY"); + expect(getViewportViewModeControlHint("perspective")).toContain("orbits"); + expect(getViewportViewModeControlHint("side")).toContain("pans"); + }); +}); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..a220b886 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 5173 + } +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..d16b002d --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,10 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; +export default mergeConfig(viteConfig, defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./tests/setup/vitest.setup.ts"], + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"] + } +}));