From 9b7706bf5b00db515d328cd988fc09fee8402a81 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 11 Apr 2026 15:48:39 +0200 Subject: [PATCH] auto-git: [unlink] playwright.config.js [unlink] src/app/App.js [unlink] src/app/editor-store.js [unlink] src/app/use-editor-store.js [unlink] src/assets/audio-assets.js [unlink] src/assets/gltf-model-import.js [unlink] src/assets/image-assets.js [unlink] src/assets/model-instance-labels.js [unlink] src/assets/model-instance-rendering.js [unlink] src/assets/model-instances.js [unlink] src/assets/project-asset-storage.js [unlink] src/assets/project-assets.js [unlink] src/commands/brush-command-helpers.js [unlink] src/commands/command-history.js [unlink] src/commands/command.js [unlink] src/commands/commit-transform-session-command.js [unlink] src/commands/create-box-brush-command.js [unlink] src/commands/delete-box-brush-command.js [unlink] src/commands/delete-entity-command.js [unlink] src/commands/delete-interaction-link-command.js [unlink] src/commands/delete-model-instance-command.js [unlink] src/commands/duplicate-selection-command.js [unlink] src/commands/import-audio-asset-command.js [unlink] src/commands/import-background-image-asset-command.js [unlink] src/commands/import-model-asset-command.js [unlink] src/commands/move-box-brush-command.js [unlink] src/commands/resize-box-brush-command.js [unlink] src/commands/rotate-box-brush-command.js [unlink] src/commands/set-box-brush-face-material-command.js [unlink] src/commands/set-box-brush-face-uv-state-command.js [unlink] src/commands/set-box-brush-name-command.js [unlink] src/commands/set-box-brush-transform-command.js [unlink] src/commands/set-box-brush-volume-settings-command.js [unlink] src/commands/set-entity-name-command.js [unlink] src/commands/set-model-instance-name-command.js [unlink] src/commands/set-player-start-command.js [unlink] src/commands/set-scene-name-command.js [unlink] src/commands/set-world-settings-command.js [unlink] src/commands/upsert-entity-command.js [unlink] src/commands/upsert-interaction-link-command.js [unlink] src/commands/upsert-model-instance-command.js [unlink] src/core/ids.js [unlink] src/core/selection.js [unlink] src/core/tool-mode.js [unlink] src/core/transform-session.js [unlink] src/core/vector.js [unlink] src/core/whitebox-selection-feedback.js [unlink] src/core/whitebox-selection-mode.js [unlink] src/document/brushes.js [unlink] src/document/migrate-scene-document.js [unlink] src/document/scene-document-validation.js [unlink] src/document/scene-document.js [unlink] src/document/world-settings.js [unlink] src/entities/entity-instances.js [unlink] src/entities/entity-labels.js [unlink] src/geometry/box-brush-components.js [unlink] src/geometry/box-brush-mesh.js [unlink] src/geometry/box-brush.js [unlink] src/geometry/box-face-uvs.js [unlink] src/geometry/grid-snapping.js [unlink] src/geometry/model-instance-collider-debug-mesh.js [unlink] src/geometry/model-instance-collider-generation.js [unlink] src/interactions/interaction-links.js [unlink] src/main.js [unlink] src/materials/starter-material-library.js [unlink] src/materials/starter-material-textures.js [unlink] src/rendering/advanced-rendering.js [unlink] src/rendering/fog-material.js [unlink] src/rendering/planar-reflection.js [unlink] src/rendering/water-material.js [unlink] src/runner-web/RunnerCanvas.js [unlink] src/runtime-three/first-person-navigation-controller.js [unlink] src/runtime-three/navigation-controller.js [unlink] src/runtime-three/orbit-visitor-navigation-controller.js [unlink] src/runtime-three/player-collision.js [unlink] src/runtime-three/rapier-collision-world.js [unlink] src/runtime-three/runtime-audio-system.js [unlink] src/runtime-three/runtime-host.js [unlink] src/runtime-three/runtime-interaction-system.js [unlink] src/runtime-three/runtime-scene-build.js [unlink] src/runtime-three/runtime-scene-validation.js [unlink] src/runtime-three/underwater-fog.js [unlink] src/serialization/local-draft-storage.js [unlink] src/serialization/scene-document-json.js [unlink] src/shared-ui/HierarchicalMenu.js [unlink] src/shared-ui/Panel.js [unlink] src/shared-ui/world-background-style.js [unlink] src/viewport-three/ViewportCanvas.js [unlink] src/viewport-three/ViewportPanel.js [unlink] src/viewport-three/viewport-entity-markers.js [unlink] src/viewport-three/viewport-focus.js [unlink] src/viewport-three/viewport-host.js [unlink] src/viewport-three/viewport-layout.js [unlink] src/viewport-three/viewport-transient-state.js [unlink] src/viewport-three/viewport-view-modes.js [unlink] vite.config.js [unlink] vitest.config.js --- playwright.config.js | 29 - src/app/App.js | 3615 ----------------- 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 | 592 --- 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 | 200 - 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/duplicate-selection-command.js | 237 -- 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 | 61 - 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 | 95 - .../set-box-brush-volume-settings-command.js | 33 - 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 | 596 --- src/core/vector.js | 5 - src/core/whitebox-selection-feedback.js | 23 - src/core/whitebox-selection-mode.js | 10 - src/document/brushes.js | 395 -- src/document/migrate-scene-document.js | 1303 ------ src/document/scene-document-validation.js | 690 ---- src/document/scene-document.js | 36 - src/document/world-settings.js | 254 -- src/entities/entity-instances.js | 501 --- src/entities/entity-labels.js | 45 - src/geometry/box-brush-components.js | 168 - src/geometry/box-brush-mesh.js | 401 -- 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 | 136 - src/rendering/fog-material.js | 202 - src/rendering/planar-reflection.js | 50 - src/rendering/water-material.js | 1059 ----- src/runner-web/RunnerCanvas.js | 56 - .../first-person-navigation-controller.js | 240 -- 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 | 267 -- src/runtime-three/runtime-audio-system.js | 289 -- src/runtime-three/runtime-host.js | 991 ----- .../runtime-interaction-system.js | 163 - src/runtime-three/runtime-scene-build.js | 380 -- src/runtime-three/runtime-scene-validation.js | 51 - src/runtime-three/underwater-fog.js | 44 - 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 | 130 - src/viewport-three/ViewportPanel.js | 30 - src/viewport-three/viewport-entity-markers.js | 36 - src/viewport-three/viewport-focus.js | 294 -- src/viewport-three/viewport-host.js | 3356 --------------- src/viewport-three/viewport-layout.js | 126 - .../viewport-transient-state.js | 77 - src/viewport-three/viewport-view-modes.js | 89 - vite.config.js | 13 - vitest.config.js | 10 - 97 files changed, 21624 deletions(-) delete mode 100644 playwright.config.js delete mode 100644 src/app/App.js delete mode 100644 src/app/editor-store.js delete mode 100644 src/app/use-editor-store.js delete mode 100644 src/assets/audio-assets.js delete mode 100644 src/assets/gltf-model-import.js delete mode 100644 src/assets/image-assets.js delete mode 100644 src/assets/model-instance-labels.js delete mode 100644 src/assets/model-instance-rendering.js delete mode 100644 src/assets/model-instances.js delete mode 100644 src/assets/project-asset-storage.js delete mode 100644 src/assets/project-assets.js delete mode 100644 src/commands/brush-command-helpers.js delete mode 100644 src/commands/command-history.js delete mode 100644 src/commands/command.js delete mode 100644 src/commands/commit-transform-session-command.js delete mode 100644 src/commands/create-box-brush-command.js delete mode 100644 src/commands/delete-box-brush-command.js delete mode 100644 src/commands/delete-entity-command.js delete mode 100644 src/commands/delete-interaction-link-command.js delete mode 100644 src/commands/delete-model-instance-command.js delete mode 100644 src/commands/duplicate-selection-command.js delete mode 100644 src/commands/import-audio-asset-command.js delete mode 100644 src/commands/import-background-image-asset-command.js delete mode 100644 src/commands/import-model-asset-command.js delete mode 100644 src/commands/move-box-brush-command.js delete mode 100644 src/commands/resize-box-brush-command.js delete mode 100644 src/commands/rotate-box-brush-command.js delete mode 100644 src/commands/set-box-brush-face-material-command.js delete mode 100644 src/commands/set-box-brush-face-uv-state-command.js delete mode 100644 src/commands/set-box-brush-name-command.js delete mode 100644 src/commands/set-box-brush-transform-command.js delete mode 100644 src/commands/set-box-brush-volume-settings-command.js delete mode 100644 src/commands/set-entity-name-command.js delete mode 100644 src/commands/set-model-instance-name-command.js delete mode 100644 src/commands/set-player-start-command.js delete mode 100644 src/commands/set-scene-name-command.js delete mode 100644 src/commands/set-world-settings-command.js delete mode 100644 src/commands/upsert-entity-command.js delete mode 100644 src/commands/upsert-interaction-link-command.js delete mode 100644 src/commands/upsert-model-instance-command.js delete mode 100644 src/core/ids.js delete mode 100644 src/core/selection.js delete mode 100644 src/core/tool-mode.js delete mode 100644 src/core/transform-session.js delete mode 100644 src/core/vector.js delete mode 100644 src/core/whitebox-selection-feedback.js delete mode 100644 src/core/whitebox-selection-mode.js delete mode 100644 src/document/brushes.js delete mode 100644 src/document/migrate-scene-document.js delete mode 100644 src/document/scene-document-validation.js delete mode 100644 src/document/scene-document.js delete mode 100644 src/document/world-settings.js delete mode 100644 src/entities/entity-instances.js delete mode 100644 src/entities/entity-labels.js delete mode 100644 src/geometry/box-brush-components.js delete mode 100644 src/geometry/box-brush-mesh.js delete mode 100644 src/geometry/box-brush.js delete mode 100644 src/geometry/box-face-uvs.js delete mode 100644 src/geometry/grid-snapping.js delete mode 100644 src/geometry/model-instance-collider-debug-mesh.js delete mode 100644 src/geometry/model-instance-collider-generation.js delete mode 100644 src/interactions/interaction-links.js delete mode 100644 src/main.js delete mode 100644 src/materials/starter-material-library.js delete mode 100644 src/materials/starter-material-textures.js delete mode 100644 src/rendering/advanced-rendering.js delete mode 100644 src/rendering/fog-material.js delete mode 100644 src/rendering/planar-reflection.js delete mode 100644 src/rendering/water-material.js delete mode 100644 src/runner-web/RunnerCanvas.js delete mode 100644 src/runtime-three/first-person-navigation-controller.js delete mode 100644 src/runtime-three/navigation-controller.js delete mode 100644 src/runtime-three/orbit-visitor-navigation-controller.js delete mode 100644 src/runtime-three/player-collision.js delete mode 100644 src/runtime-three/rapier-collision-world.js delete mode 100644 src/runtime-three/runtime-audio-system.js delete mode 100644 src/runtime-three/runtime-host.js delete mode 100644 src/runtime-three/runtime-interaction-system.js delete mode 100644 src/runtime-three/runtime-scene-build.js delete mode 100644 src/runtime-three/runtime-scene-validation.js delete mode 100644 src/runtime-three/underwater-fog.js delete mode 100644 src/serialization/local-draft-storage.js delete mode 100644 src/serialization/scene-document-json.js delete mode 100644 src/shared-ui/HierarchicalMenu.js delete mode 100644 src/shared-ui/Panel.js delete mode 100644 src/shared-ui/world-background-style.js delete mode 100644 src/viewport-three/ViewportCanvas.js delete mode 100644 src/viewport-three/ViewportPanel.js delete mode 100644 src/viewport-three/viewport-entity-markers.js delete mode 100644 src/viewport-three/viewport-focus.js delete mode 100644 src/viewport-three/viewport-host.js delete mode 100644 src/viewport-three/viewport-layout.js delete mode 100644 src/viewport-three/viewport-transient-state.js delete mode 100644 src/viewport-three/viewport-view-modes.js delete mode 100644 vite.config.js delete mode 100644 vitest.config.js diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index 10786c6c..00000000 --- a/playwright.config.js +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 638bd315..00000000 --- a/src/app/App.js +++ /dev/null @@ -1,3615 +0,0 @@ -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 { createDuplicateSelectionCommand } from "../commands/duplicate-selection-command"; -import { createImportAudioAssetCommand } from "../commands/import-audio-asset-command"; -import { createImportBackgroundImageAssetCommand } from "../commands/import-background-image-asset-command"; -import { createImportModelAssetCommand } from "../commands/import-model-asset-command"; -import { 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 selectionCanBeDuplicated(selection) { - switch (selection.kind) { - case "brushes": - case "entities": - case "modelInstances": - return selection.ids.length > 0; - case "brushFace": - case "brushEdge": - case "brushVertex": - return true; - case "none": - return false; - } -} -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); - const isDuplicateShortcut = event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && event.code === "KeyD"; - if (addMenuPosition !== null) { - if (isDeletionKey) { - event.preventDefault(); - } - return; - } - if (isDuplicateShortcut) { - const duplicated = handleDuplicateSelection(); - if (duplicated) { - event.preventDefault(); - } - return; - } - 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 navigationMode = overrides.navigationMode ?? selectedPlayerStart.navigationMode; - const colliderMode = overrides.colliderMode ?? playerStartColliderModeDraft; - const nextEntity = createPlayerStartEntity({ - id: selectedPlayerStart.id, - name: selectedPlayerStart.name, - position: snappedPosition, - yawDegrees, - navigationMode, - 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 handleDuplicateSelection = () => { - if (!selectionCanBeDuplicated(editorState.selection)) { - return false; - } - try { - store.executeCommand(createDuplicateSelectionCommand()); - const duplicatedState = store.getState(); - const duplicatedSelection = duplicatedState.selection; - const canGrabDuplicatedSelection = (duplicatedSelection.kind === "brushes" || duplicatedSelection.kind === "entities" || duplicatedSelection.kind === "modelInstances") && - duplicatedSelection.ids.length === 1; - if (canGrabDuplicatedSelection) { - const transformSourcePanelId = layoutMode === "quad" ? hoveredViewportPanelId ?? activePanelId : activePanelId; - const transformTargetResult = resolveTransformTarget(duplicatedState.document, duplicatedSelection, whiteboxSelectionMode); - const transformTarget = transformTargetResult.target; - if (transformTarget === null) { - setStatusMessage(transformTargetResult.message ?? "Duplicated selection, but could not start move transform."); - return true; - } - if (duplicatedState.activeViewportPanelId !== transformSourcePanelId) { - store.setActiveViewportPanel(transformSourcePanelId); - } - store.setTransformSession(createTransformSession({ - source: "keyboard", - sourcePanelId: transformSourcePanelId, - operation: "translate", - target: transformTarget - })); - setStatusMessage(`Move ${getTransformTargetLabel(transformTarget).toLowerCase()} in ${getViewportPanelLabel(transformSourcePanelId)}. Move the pointer, press X/Y/Z to constrain, click or press Enter to commit, Escape cancels.`); - } - else { - setStatusMessage("Duplicated selection."); - } - return true; - } - catch (error) { - setStatusMessage(getErrorMessage(error)); - 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: "Locomotion" }), _jsx("div", { className: "value", children: activeNavigationMode === "firstPerson" - ? firstPersonTelemetry?.locomotionState === "swimming" - ? "Swimming" - : firstPersonTelemetry?.locomotionState === "flying" - ? "Flying" - : "Grounded" - : "n/a" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Water Volume" }), _jsx("div", { className: "value", children: activeNavigationMode === "firstPerson" ? (firstPersonTelemetry?.inWaterVolume ? "inside" : "outside") : "n/a" })] }), _jsxs("div", { className: "stat-card", children: [_jsx("div", { className: "label", children: "Fog Volume" }), _jsx("div", { className: "value", children: activeNavigationMode === "firstPerson" ? (firstPersonTelemetry?.inFogVolume ? "inside" : "outside") : "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: runtimeInteractionPrompt === null ? "No target" : "Ready" }), _jsx("div", { className: "material-summary", "data-testid": "runner-interaction-summary", children: runtimeInteractionPrompt === null - ? "Aim at an authored Interactable or Scene Exit and click when a prompt appears." - : `Click "${runtimeInteractionPrompt.prompt}" within ${runtimeInteractionPrompt.range.toFixed(1)}m.` })] }), runtimeMessage === null ? null : _jsx("div", { className: "info-banner", children: runtimeMessage }), _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." })] }) })] }), _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 deleted file mode 100644 index 100229d9..00000000 --- a/src/app/editor-store.js +++ /dev/null @@ -1,391 +0,0 @@ -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 deleted file mode 100644 index a1fadac6..00000000 --- a/src/app/use-editor-store.js +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index b99aab9f..00000000 --- a/src/assets/audio-assets.js +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index b5ac87d3..00000000 --- a/src/assets/gltf-model-import.js +++ /dev/null @@ -1,592 +0,0 @@ -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) { - // Use SkeletonUtils.clone so that SkinnedMesh.skeleton.bones are remapped - // to the cloned hierarchy. A plain scene.clone(true) leaves the bones array - // pointing at the original loader's nodes, which are gone after parsing, - // making every skinned mesh invisible at runtime. - return cloneSkeleton(scene); -} -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 deleted file mode 100644 index 0fb5aa21..00000000 --- a/src/assets/image-assets.js +++ /dev/null @@ -1,306 +0,0 @@ -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 deleted file mode 100644 index 8eab72e2..00000000 --- a/src/assets/model-instance-labels.js +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 7b264ce0..00000000 --- a/src/assets/model-instance-rendering.js +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index eeed2006..00000000 --- a/src/assets/model-instances.js +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 52d50c41..00000000 --- a/src/assets/project-asset-storage.js +++ /dev/null @@ -1,177 +0,0 @@ -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 deleted file mode 100644 index 9e17a0dd..00000000 --- a/src/assets/project-assets.js +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 42d90a0e..00000000 --- a/src/commands/brush-command-helpers.js +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 46d981aa..00000000 --- a/src/commands/command-history.js +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index cb0ff5c3..00000000 --- a/src/commands/command.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/commands/commit-transform-session-command.js b/src/commands/commit-transform-session-command.js deleted file mode 100644 index b2a4fd6c..00000000 --- a/src/commands/commit-transform-session-command.js +++ /dev/null @@ -1,200 +0,0 @@ -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, - geometry: session.preview.geometry, - 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, - geometry: session.preview.geometry, - 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, - geometry: session.preview.geometry, - 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 deleted file mode 100644 index 4fc09259..00000000 --- a/src/commands/create-box-brush-command.js +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 87b6c48d..00000000 --- a/src/commands/delete-box-brush-command.js +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index fcc5fbc5..00000000 --- a/src/commands/delete-entity-command.js +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 481935bd..00000000 --- a/src/commands/delete-interaction-link-command.js +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 8c46143c..00000000 --- a/src/commands/delete-model-instance-command.js +++ /dev/null @@ -1,64 +0,0 @@ -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/duplicate-selection-command.js b/src/commands/duplicate-selection-command.js deleted file mode 100644 index fa429f1e..00000000 --- a/src/commands/duplicate-selection-command.js +++ /dev/null @@ -1,237 +0,0 @@ -import { cloneModelInstance } from "../assets/model-instances"; -import { createOpaqueId } from "../core/ids"; -import { cloneEditorSelection } from "../core/selection"; -import { cloneBoxBrush } from "../document/brushes"; -import { cloneEntityInstance } from "../entities/entity-instances"; - -function duplicateBrush(brush) { - const duplicatedBrush = cloneBoxBrush(brush); - duplicatedBrush.id = createOpaqueId("brush"); - return duplicatedBrush; -} - -function duplicateEntity(entity) { - const duplicatedEntity = cloneEntityInstance(entity); - duplicatedEntity.id = createOpaqueId(`entity-${duplicatedEntity.kind}`); - return duplicatedEntity; -} - -function duplicateModelInstance(modelInstance) { - const duplicatedModelInstance = cloneModelInstance(modelInstance); - duplicatedModelInstance.id = createOpaqueId("model-instance"); - return duplicatedModelInstance; -} - -function resolveDuplicatableBrushIds(selection) { - switch (selection.kind) { - case "brushes": - return selection.ids; - case "brushFace": - case "brushEdge": - case "brushVertex": - return [selection.brushId]; - default: - return null; - } -} - -function createDuplicateSelectionResult(currentDocument, selection) { - const duplicatableBrushIds = resolveDuplicatableBrushIds(selection); - - if (duplicatableBrushIds !== null) { - if (duplicatableBrushIds.length === 0) { - throw new Error("Select at least one whitebox solid to duplicate."); - } - - const duplicatedBrushes = duplicatableBrushIds.map((brushId) => { - const sourceBrush = currentDocument.brushes[brushId]; - - if (sourceBrush === undefined) { - throw new Error(`Box brush ${brushId} does not exist.`); - } - - if (sourceBrush.kind !== "box") { - throw new Error(`Brush ${brushId} is not a supported box brush.`); - } - - return duplicateBrush(sourceBrush); - }); - - return { - selection: { - kind: "brushes", - ids: duplicatedBrushes.map((brush) => brush.id) - }, - brushes: duplicatedBrushes, - entities: null, - modelInstances: null - }; - } - - if (selection.kind === "entities") { - if (selection.ids.length === 0) { - throw new Error("Select at least one entity to duplicate."); - } - - const duplicatedEntities = selection.ids.map((entityId) => { - const sourceEntity = currentDocument.entities[entityId]; - - if (sourceEntity === undefined) { - throw new Error(`Entity ${entityId} does not exist.`); - } - - return duplicateEntity(sourceEntity); - }); - - return { - selection: { - kind: "entities", - ids: duplicatedEntities.map((entity) => entity.id) - }, - brushes: null, - entities: duplicatedEntities, - modelInstances: null - }; - } - - if (selection.kind === "modelInstances") { - if (selection.ids.length === 0) { - throw new Error("Select at least one model instance to duplicate."); - } - - const duplicatedModelInstances = selection.ids.map((modelInstanceId) => { - const sourceModelInstance = currentDocument.modelInstances[modelInstanceId]; - - if (sourceModelInstance === undefined) { - throw new Error(`Model instance ${modelInstanceId} does not exist.`); - } - - return duplicateModelInstance(sourceModelInstance); - }); - - return { - selection: { - kind: "modelInstances", - ids: duplicatedModelInstances.map((modelInstance) => modelInstance.id) - }, - brushes: null, - entities: null, - modelInstances: duplicatedModelInstances - }; - } - - throw new Error("Selection must contain whitebox solids, entities, or model instances to duplicate."); -} - -export function createDuplicateSelectionCommand() { - let previousSelection = null; - let previousToolMode = null; - let duplicateSelectionResult = null; - - return { - id: createOpaqueId("command"), - label: "Duplicate selection", - execute(context) { - const currentDocument = context.getDocument(); - - if (previousSelection === null) { - previousSelection = cloneEditorSelection(context.getSelection()); - } - - if (previousToolMode === null) { - previousToolMode = context.getToolMode(); - } - - if (duplicateSelectionResult === null) { - duplicateSelectionResult = createDuplicateSelectionResult(currentDocument, context.getSelection()); - } - - if (duplicateSelectionResult.brushes !== null) { - context.setDocument({ - ...currentDocument, - brushes: { - ...currentDocument.brushes, - ...Object.fromEntries(duplicateSelectionResult.brushes.map((brush) => [brush.id, cloneBoxBrush(brush)])) - } - }); - } else if (duplicateSelectionResult.entities !== null) { - context.setDocument({ - ...currentDocument, - entities: { - ...currentDocument.entities, - ...Object.fromEntries(duplicateSelectionResult.entities.map((entity) => [entity.id, cloneEntityInstance(entity)])) - } - }); - } else if (duplicateSelectionResult.modelInstances !== null) { - context.setDocument({ - ...currentDocument, - modelInstances: { - ...currentDocument.modelInstances, - ...Object.fromEntries( - duplicateSelectionResult.modelInstances.map((modelInstance) => [modelInstance.id, cloneModelInstance(modelInstance)]) - ) - } - }); - } - - context.setSelection(cloneEditorSelection(duplicateSelectionResult.selection)); - context.setToolMode("select"); - }, - undo(context) { - if (duplicateSelectionResult === null) { - return; - } - - const currentDocument = context.getDocument(); - - if (duplicateSelectionResult.brushes !== null) { - const nextBrushes = { - ...currentDocument.brushes - }; - - for (const duplicatedBrush of duplicateSelectionResult.brushes) { - delete nextBrushes[duplicatedBrush.id]; - } - - context.setDocument({ - ...currentDocument, - brushes: nextBrushes - }); - } else if (duplicateSelectionResult.entities !== null) { - const nextEntities = { - ...currentDocument.entities - }; - - for (const duplicatedEntity of duplicateSelectionResult.entities) { - delete nextEntities[duplicatedEntity.id]; - } - - context.setDocument({ - ...currentDocument, - entities: nextEntities - }); - } else if (duplicateSelectionResult.modelInstances !== null) { - const nextModelInstances = { - ...currentDocument.modelInstances - }; - - for (const duplicatedModelInstance of duplicateSelectionResult.modelInstances) { - delete nextModelInstances[duplicatedModelInstance.id]; - } - - context.setDocument({ - ...currentDocument, - modelInstances: nextModelInstances - }); - } - - 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 deleted file mode 100644 index 8689c051..00000000 --- a/src/commands/import-audio-asset-command.js +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index fa2112cb..00000000 --- a/src/commands/import-background-image-asset-command.js +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 47393058..00000000 --- a/src/commands/import-model-asset-command.js +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 93fce2f3..00000000 --- a/src/commands/move-box-brush-command.js +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 1a288377..00000000 --- a/src/commands/resize-box-brush-command.js +++ /dev/null @@ -1,61 +0,0 @@ -import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid } from "../geometry/grid-snapping"; -import { createOpaqueId } from "../core/ids"; -import { cloneBoxBrushGeometry, scaleBoxBrushGeometryToSize } from "../document/brushes"; -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 previousGeometry = 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 - }; - previousGeometry = cloneBoxBrushGeometry(brush.geometry); - } - if (previousSelection === null) { - previousSelection = cloneSelectionForCommand(context.getSelection()); - } - if (previousToolMode === null) { - previousToolMode = context.getToolMode(); - } - const nextGeometry = scaleBoxBrushGeometryToSize(brush.geometry, resolvedSize); - context.setDocument(replaceBrush(currentDocument, { - ...brush, - size: { - ...resolvedSize - }, - geometry: nextGeometry - })); - context.setSelection(setSingleBrushSelection(options.brushId)); - context.setToolMode("select"); - }, - undo(context) { - if (previousSize === null || previousGeometry === null) { - return; - } - const currentDocument = context.getDocument(); - const brush = getBoxBrushOrThrow(currentDocument, options.brushId); - context.setDocument(replaceBrush(currentDocument, { - ...brush, - size: { - ...previousSize - }, - geometry: cloneBoxBrushGeometry(previousGeometry) - })); - 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 deleted file mode 100644 index f155266e..00000000 --- a/src/commands/rotate-box-brush-command.js +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 1952caf8..00000000 --- a/src/commands/set-box-brush-face-material-command.js +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index d3341e64..00000000 --- a/src/commands/set-box-brush-face-uv-state-command.js +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 530713e6..00000000 --- a/src/commands/set-box-brush-name-command.js +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 135242fd..00000000 --- a/src/commands/set-box-brush-transform-command.js +++ /dev/null @@ -1,95 +0,0 @@ -import { createOpaqueId } from "../core/ids"; -import { cloneBoxBrushGeometry, deriveBoxBrushSizeFromGeometry, scaleBoxBrushGeometryToSize } from "../document/brushes"; -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), - geometry: cloneBoxBrushGeometry(brush.geometry) - }; - } - if (previousSelection === null) { - previousSelection = cloneSelectionForCommand(context.getSelection()); - } - if (previousToolMode === null) { - previousToolMode = context.getToolMode(); - } - const nextGeometry = options.geometry === undefined ? scaleBoxBrushGeometryToSize(brush.geometry, options.size) : cloneBoxBrushGeometry(options.geometry); - const nextSize = deriveBoxBrushSizeFromGeometry(nextGeometry); - assertPositiveSize(nextSize); - context.setDocument(replaceBrush(currentDocument, { - ...brush, - center: cloneVec3(options.center), - rotationDegrees: cloneVec3(options.rotationDegrees), - size: nextSize, - geometry: nextGeometry - })); - 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), - geometry: cloneBoxBrushGeometry(previousSnapshot.geometry) - })); - if (previousSelection !== null) { - context.setSelection(previousSelection); - } - if (previousToolMode !== null) { - context.setToolMode(previousToolMode); - } - } - }; -} diff --git a/src/commands/set-box-brush-volume-settings-command.js b/src/commands/set-box-brush-volume-settings-command.js deleted file mode 100644 index 64851a3f..00000000 --- a/src/commands/set-box-brush-volume-settings-command.js +++ /dev/null @@ -1,33 +0,0 @@ -import { createOpaqueId } from "../core/ids"; -import { cloneBoxBrushVolumeSettings } from "../document/brushes"; -import { getBoxBrushOrThrow, replaceBrush } from "./brush-command-helpers"; -export function createSetBoxBrushVolumeSettingsCommand(options) { - const nextVolume = cloneBoxBrushVolumeSettings(options.volume); - let previousVolume = null; - return { - id: createOpaqueId("command"), - label: options.label ?? "Set box volume settings", - execute(context) { - const currentDocument = context.getDocument(); - const brush = getBoxBrushOrThrow(currentDocument, options.brushId); - if (previousVolume === null) { - previousVolume = cloneBoxBrushVolumeSettings(brush.volume); - } - context.setDocument(replaceBrush(currentDocument, { - ...brush, - volume: cloneBoxBrushVolumeSettings(nextVolume) - })); - }, - undo(context) { - if (previousVolume === null) { - return; - } - const currentDocument = context.getDocument(); - const brush = getBoxBrushOrThrow(currentDocument, options.brushId); - context.setDocument(replaceBrush(currentDocument, { - ...brush, - volume: cloneBoxBrushVolumeSettings(previousVolume) - })); - } - }; -} diff --git a/src/commands/set-entity-name-command.js b/src/commands/set-entity-name-command.js deleted file mode 100644 index 3b0d278c..00000000 --- a/src/commands/set-entity-name-command.js +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index ef31611e..00000000 --- a/src/commands/set-model-instance-name-command.js +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 008efafc..00000000 --- a/src/commands/set-player-start-command.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 5f293558..00000000 --- a/src/commands/set-scene-name-command.js +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 0518be99..00000000 --- a/src/commands/set-world-settings-command.js +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 7603c02d..00000000 --- a/src/commands/upsert-entity-command.js +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 914ad55f..00000000 --- a/src/commands/upsert-interaction-link-command.js +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 5e51dd80..00000000 --- a/src/commands/upsert-model-instance-command.js +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index dba09fe3..00000000 --- a/src/core/ids.js +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index a1d53a71..00000000 --- a/src/core/selection.js +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index cb0ff5c3..00000000 --- a/src/core/tool-mode.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/core/transform-session.js b/src/core/transform-session.js deleted file mode 100644 index c8186efc..00000000 --- a/src/core/transform-session.js +++ /dev/null @@ -1,596 +0,0 @@ -import { createOpaqueId } from "./ids"; -import { BOX_VERTEX_IDS, BOX_EDGE_LABELS, BOX_FACE_LABELS, BOX_VERTEX_LABELS, cloneBoxBrushGeometry } from "../document/brushes"; -import { cloneEntityInstance, getEntityKindLabel } from "../entities/entity-instances"; -import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances"; -function areBrushGeometriesEqual(left, right) { - return BOX_VERTEX_IDS.every((vertexId) => { - const leftVertex = left.vertices[vertexId]; - const rightVertex = right.vertices[vertexId]; - return areVec3Equal(leftVertex, rightVertex); - }); -} -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), - initialGeometry: cloneBoxBrushGeometry(target.initialGeometry) - }; - case "brushFace": - return { - kind: "brushFace", - brushId: target.brushId, - faceId: target.faceId, - initialCenter: cloneVec3(target.initialCenter), - initialRotationDegrees: cloneVec3(target.initialRotationDegrees), - initialSize: cloneVec3(target.initialSize), - initialGeometry: cloneBoxBrushGeometry(target.initialGeometry) - }; - case "brushEdge": - return { - kind: "brushEdge", - brushId: target.brushId, - edgeId: target.edgeId, - initialCenter: cloneVec3(target.initialCenter), - initialRotationDegrees: cloneVec3(target.initialRotationDegrees), - initialSize: cloneVec3(target.initialSize), - initialGeometry: cloneBoxBrushGeometry(target.initialGeometry) - }; - case "brushVertex": - return { - kind: "brushVertex", - brushId: target.brushId, - vertexId: target.vertexId, - initialCenter: cloneVec3(target.initialCenter), - initialRotationDegrees: cloneVec3(target.initialRotationDegrees), - initialSize: cloneVec3(target.initialSize), - initialGeometry: cloneBoxBrushGeometry(target.initialGeometry) - }; - 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), - geometry: cloneBoxBrushGeometry(preview.geometry) - }; - 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) && - areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)); - 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) && - areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)); - 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) && - areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)); - 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) && - areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)); - 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) && - areBrushGeometriesEqual(left.geometry, right.geometry)); - 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), - geometry: cloneBoxBrushGeometry(target.initialGeometry) - }; - 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) || - !areBrushGeometriesEqual(session.preview.geometry, session.target.initialGeometry))); - 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), - initialGeometry: cloneBoxBrushGeometry(brush.geometry) - }, - 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), - initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry) - }, - 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), - initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry) - }, - 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), - initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry) - }, - 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 deleted file mode 100644 index 50ee7a26..00000000 --- a/src/core/vector.js +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 7205ff8c..00000000 --- a/src/core/whitebox-selection-feedback.js +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 2b103100..00000000 --- a/src/core/whitebox-selection-mode.js +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index ab89963c..00000000 --- a/src/document/brushes.js +++ /dev/null @@ -1,395 +0,0 @@ -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_BRUSH_VOLUME_MODES = ["none", "water", "fog"]; -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 const DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT = 6; -export const MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT = 24; -const DEFAULT_BOX_BRUSH_WATER_SETTINGS = { - colorHex: "#4da6d9", - surfaceOpacity: 0.55, - waveStrength: 0.35, - foamContactLimit: DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, - surfaceDisplacementEnabled: false -}; -const DEFAULT_BOX_BRUSH_FOG_SETTINGS = { - colorHex: "#9cb7c7", - density: 0.08, - padding: 0.2 -}; -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 scaleBoxBrushGeometryToSize(geometry, size) { - const bounds = getBoxBrushGeometryLocalBounds(geometry); - const currentSize = deriveBoxBrushSizeFromGeometry(geometry); - if (!hasPositiveBoxSize(currentSize) || !hasPositiveBoxSize(size)) { - throw new Error("Box brush geometry size must remain positive on every axis."); - } - 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 scale = { - x: size.x / currentSize.x, - y: size.y / currentSize.y, - z: size.z / currentSize.z - }; - return { - vertices: { - negX_negY_negZ: { - x: center.x + (geometry.vertices.negX_negY_negZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.negX_negY_negZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.negX_negY_negZ.z - center.z) * scale.z - }, - posX_negY_negZ: { - x: center.x + (geometry.vertices.posX_negY_negZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.posX_negY_negZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.posX_negY_negZ.z - center.z) * scale.z - }, - negX_posY_negZ: { - x: center.x + (geometry.vertices.negX_posY_negZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.negX_posY_negZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.negX_posY_negZ.z - center.z) * scale.z - }, - posX_posY_negZ: { - x: center.x + (geometry.vertices.posX_posY_negZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.posX_posY_negZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.posX_posY_negZ.z - center.z) * scale.z - }, - negX_negY_posZ: { - x: center.x + (geometry.vertices.negX_negY_posZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.negX_negY_posZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.negX_negY_posZ.z - center.z) * scale.z - }, - posX_negY_posZ: { - x: center.x + (geometry.vertices.posX_negY_posZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.posX_negY_posZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.posX_negY_posZ.z - center.z) * scale.z - }, - negX_posY_posZ: { - x: center.x + (geometry.vertices.negX_posY_posZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.negX_posY_posZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.negX_posY_posZ.z - center.z) * scale.z - }, - posX_posY_posZ: { - x: center.x + (geometry.vertices.posX_posY_posZ.x - center.x) * scale.x, - y: center.y + (geometry.vertices.posX_posY_posZ.y - center.y) * scale.y, - z: center.z + (geometry.vertices.posX_posY_posZ.z - center.z) * scale.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 isBoxBrushVolumeMode(value) { - return typeof value === "string" && BOX_BRUSH_VOLUME_MODES.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 createDefaultBoxBrushWaterSettings() { - return { - colorHex: DEFAULT_BOX_BRUSH_WATER_SETTINGS.colorHex, - surfaceOpacity: DEFAULT_BOX_BRUSH_WATER_SETTINGS.surfaceOpacity, - waveStrength: DEFAULT_BOX_BRUSH_WATER_SETTINGS.waveStrength, - foamContactLimit: DEFAULT_BOX_BRUSH_WATER_SETTINGS.foamContactLimit, - surfaceDisplacementEnabled: DEFAULT_BOX_BRUSH_WATER_SETTINGS.surfaceDisplacementEnabled - }; -} -export function createDefaultBoxBrushFogSettings() { - return { - colorHex: DEFAULT_BOX_BRUSH_FOG_SETTINGS.colorHex, - density: DEFAULT_BOX_BRUSH_FOG_SETTINGS.density, - padding: DEFAULT_BOX_BRUSH_FOG_SETTINGS.padding - }; -} -export function createDefaultBoxBrushVolumeSettings() { - return { - mode: "none" - }; -} -export function cloneBoxBrushVolumeSettings(volume) { - switch (volume.mode) { - case "none": - return { - mode: "none" - }; - case "water": - return { - mode: "water", - water: { - colorHex: volume.water.colorHex, - surfaceOpacity: volume.water.surfaceOpacity, - waveStrength: volume.water.waveStrength, - foamContactLimit: volume.water.foamContactLimit, - surfaceDisplacementEnabled: volume.water.surfaceDisplacementEnabled - } - }; - case "fog": - return { - mode: "fog", - fog: { - colorHex: volume.fog.colorHex, - density: volume.fog.density, - padding: volume.fog.padding - } - }; - } -} -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), - volume: overrides.volume === undefined ? createDefaultBoxBrushVolumeSettings() : cloneBoxBrushVolumeSettings(overrides.volume), - 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 deleted file mode 100644 index 7a4ab605..00000000 --- a/src/document/migrate-scene-document.js +++ /dev/null @@ -1,1303 +0,0 @@ -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 { BOX_VERTEX_IDS, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, createBoxBrush, createDefaultBoxBrushGeometry, createDefaultBoxBrushFogSettings, createDefaultBoxBrushWaterSettings, createDefaultFaceUvState, DEFAULT_BOX_BRUSH_ROTATION_DEGREES, isBoxBrushVolumeMode, 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, WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION, WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION, WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION, WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION, WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION } from "./scene-document"; -import { createDefaultAdvancedRenderingSettings, isAdvancedRenderingWaterReflectionMode, isBoxVolumeRenderPath, 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 readOptionalPositiveIntegerWithMax(value, label, fallback, max) { - return Math.min(readOptionalPositiveInteger(value, label, fallback), max); -} -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); - const fogPath = readOptionalAllowedValue(value.fogPath, "world.advancedRendering.fogPath", defaults.fogPath, isBoxVolumeRenderPath); - const waterPath = readOptionalAllowedValue(value.waterPath, "world.advancedRendering.waterPath", defaults.waterPath, isBoxVolumeRenderPath); - const waterReflectionMode = readOptionalAllowedValue(value.waterReflectionMode, "world.advancedRendering.waterReflectionMode", defaults.waterReflectionMode, isAdvancedRenderingWaterReflectionMode); - 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) - }, - fogPath, - waterPath, - waterReflectionMode - }; -} -function readBoxBrushVolumeSettings(value, label) { - if (value === undefined) { - return { - mode: "none" - }; - } - if (!isRecord(value)) { - throw new Error(`${label} must be an object.`); - } - const mode = readOptionalAllowedValue(value.mode, `${label}.mode`, "none", isBoxBrushVolumeMode); - if (mode === "none") { - return { - mode: "none" - }; - } - if (mode === "water") { - const defaults = createDefaultBoxBrushWaterSettings(); - if (value.water !== undefined && !isRecord(value.water)) { - throw new Error(`${label}.water must be an object.`); - } - const water = value.water ?? {}; - return { - mode: "water", - water: { - colorHex: water.colorHex === undefined ? defaults.colorHex : expectHexColor(water.colorHex, `${label}.water.colorHex`), - surfaceOpacity: readOptionalNonNegativeFiniteNumber(water.surfaceOpacity, `${label}.water.surfaceOpacity`, defaults.surfaceOpacity), - waveStrength: readOptionalNonNegativeFiniteNumber(water.waveStrength, `${label}.water.waveStrength`, defaults.waveStrength), - foamContactLimit: readOptionalPositiveIntegerWithMax(water.foamContactLimit, `${label}.water.foamContactLimit`, defaults.foamContactLimit, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT), - surfaceDisplacementEnabled: readOptionalBoolean(water.surfaceDisplacementEnabled, `${label}.water.surfaceDisplacementEnabled`, defaults.surfaceDisplacementEnabled) - } - }; - } - const defaults = createDefaultBoxBrushFogSettings(); - if (value.fog !== undefined && !isRecord(value.fog)) { - throw new Error(`${label}.fog must be an object.`); - } - const fog = value.fog ?? {}; - return { - mode: "fog", - fog: { - colorHex: fog.colorHex === undefined ? defaults.colorHex : expectHexColor(fog.colorHex, `${label}.fog.colorHex`), - density: readOptionalNonNegativeFiniteNumber(fog.density, `${label}.fog.density`, defaults.density), - padding: readOptionalNonNegativeFiniteNumber(fog.padding, `${label}.fog.padding`, defaults.padding) - } - }; -} -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 readBoxBrushGeometry(value, label, size) { - if (value === undefined) { - return createDefaultBoxBrushGeometry(size); - } - if (!isRecord(value)) { - throw new Error(`${label} must be an object.`); - } - if (!isRecord(value.vertices)) { - throw new Error(`${label}.vertices must be an object.`); - } - const extraVertexKeys = Object.keys(value.vertices).filter((vertexId) => !BOX_VERTEX_IDS.includes(vertexId)); - if (extraVertexKeys.length > 0) { - throw new Error(`${label}.vertices contains unsupported vertex ids: ${extraVertexKeys.join(", ")}.`); - } - return { - vertices: { - negX_negY_negZ: readVec3(value.vertices.negX_negY_negZ, `${label}.vertices.negX_negY_negZ`), - posX_negY_negZ: readVec3(value.vertices.posX_negY_negZ, `${label}.vertices.posX_negY_negZ`), - negX_posY_negZ: readVec3(value.vertices.negX_posY_negZ, `${label}.vertices.negX_posY_negZ`), - posX_posY_negZ: readVec3(value.vertices.posX_posY_negZ, `${label}.vertices.posX_posY_negZ`), - negX_negY_posZ: readVec3(value.vertices.negX_negY_posZ, `${label}.vertices.negX_negY_posZ`), - posX_negY_posZ: readVec3(value.vertices.posX_negY_posZ, `${label}.vertices.posX_negY_posZ`), - negX_posY_posZ: readVec3(value.vertices.negX_posY_posZ, `${label}.vertices.negX_posY_posZ`), - posX_posY_posZ: readVec3(value.vertices.posX_posY_posZ, `${label}.vertices.posX_posY_posZ`) - } - }; -} -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, - geometry: readBoxBrushGeometry(brushValue.geometry, `brushes.${brushId}.geometry`, size), - faces: readBoxBrushFaces(brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState), - volume: readBoxBrushVolumeSettings(brushValue.volume, `brushes.${brushId}.volume`), - 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 !== WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_GEOMETRY_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 deleted file mode 100644 index 585ea403..00000000 --- a/src/document/scene-document-validation.js +++ /dev/null @@ -1,690 +0,0 @@ -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, BOX_VERTEX_IDS, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, hasPositiveBoxSize, isBoxBrushVolumeMode } from "./brushes"; -import { isAdvancedRenderingShadowMapSize, isAdvancedRenderingShadowType, isBoxVolumeRenderPath, 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 isPositiveIntegerInRange(value, max) { - return isPositiveInteger(value) && value <= max; -} -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")); - } - if (!isBoxVolumeRenderPath(advancedRendering.fogPath)) { - diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-fog-path", "Advanced rendering fog path must be performance or quality.", "world.advancedRendering.fogPath")); - } - if (!isBoxVolumeRenderPath(advancedRendering.waterPath)) { - diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-water-path", "Advanced rendering water path must be performance or quality.", "world.advancedRendering.waterPath")); - } - if (!["none", "world", "all"].includes(advancedRendering.waterReflectionMode)) { - diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-water-reflection-mode", "Advanced rendering water reflection mode must be none, world, or all.", "world.advancedRendering.waterReflectionMode")); - } -} -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 vertexId of BOX_VERTEX_IDS) { - if (!isFiniteVec3(brush.geometry.vertices[vertexId])) { - diagnostics.push(createDiagnostic("error", "invalid-box-geometry-vertex", "Box brush geometry vertices must remain finite on every axis.", `${path}.geometry.vertices.${vertexId}`)); - } - } - 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`)); - } - } - const volume = brush.volume; - if (!isBoxBrushVolumeMode(volume?.mode)) { - diagnostics.push(createDiagnostic("error", "invalid-box-volume-mode", "Box volume mode must be none, water, or fog.", `${path}.volume.mode`)); - continue; - } - if (volume.mode === "water") { - const water = volume.water; - if (water === undefined) { - diagnostics.push(createDiagnostic("error", "invalid-box-water-settings", "Water volumes must define water settings.", `${path}.volume.water`)); - } - else { - if (typeof water.colorHex !== "string" || !isHexColorString(water.colorHex)) { - diagnostics.push(createDiagnostic("error", "invalid-box-water-color", "Water volume color must use #RRGGBB format.", `${path}.volume.water.colorHex`)); - } - if (!isNonNegativeFiniteNumber(water.surfaceOpacity)) { - diagnostics.push(createDiagnostic("error", "invalid-box-water-surface-opacity", "Water surface opacity must be a non-negative finite number.", `${path}.volume.water.surfaceOpacity`)); - } - if (!isNonNegativeFiniteNumber(water.waveStrength)) { - diagnostics.push(createDiagnostic("error", "invalid-box-water-wave-strength", "Water wave strength must be a non-negative finite number.", `${path}.volume.water.waveStrength`)); - } - if (!isPositiveIntegerInRange(water.foamContactLimit, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT)) { - diagnostics.push(createDiagnostic("error", "invalid-box-water-foam-contact-limit", `Water foam contact limit must be a positive integer between 1 and ${MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT}.`, `${path}.volume.water.foamContactLimit`)); - } - if (typeof water.surfaceDisplacementEnabled !== "boolean") { - diagnostics.push(createDiagnostic("error", "invalid-box-water-surface-displacement-enabled", "Water surface displacement must be enabled or disabled explicitly.", `${path}.volume.water.surfaceDisplacementEnabled`)); - } - } - } - if (volume.mode === "fog") { - const fog = volume.fog; - if (fog === undefined) { - diagnostics.push(createDiagnostic("error", "invalid-box-fog-settings", "Fog volumes must define fog settings.", `${path}.volume.fog`)); - } - else { - if (typeof fog.colorHex !== "string" || !isHexColorString(fog.colorHex)) { - diagnostics.push(createDiagnostic("error", "invalid-box-fog-color", "Fog volume color must use #RRGGBB format.", `${path}.volume.fog.colorHex`)); - } - if (!isNonNegativeFiniteNumber(fog.density)) { - diagnostics.push(createDiagnostic("error", "invalid-box-fog-density", "Fog volume density must be a non-negative finite number.", `${path}.volume.fog.density`)); - } - if (!isNonNegativeFiniteNumber(fog.padding)) { - diagnostics.push(createDiagnostic("error", "invalid-box-fog-padding", "Fog volume padding must be a non-negative finite number.", `${path}.volume.fog.padding`)); - } - } - } - } - 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 deleted file mode 100644 index d86b03f2..00000000 --- a/src/document/scene-document.js +++ /dev/null @@ -1,36 +0,0 @@ -import { cloneMaterialRegistry, createStarterMaterialRegistry } from "../materials/starter-material-library"; -import { createDefaultWorldSettings } from "./world-settings"; -export const SCENE_DOCUMENT_VERSION = 21; -export const WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION = 21; -export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20; -export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19; -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 deleted file mode 100644 index 3b829bf8..00000000 --- a/src/document/world-settings.js +++ /dev/null @@ -1,254 +0,0 @@ -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"]; -export const BOX_VOLUME_RENDER_PATHS = ["performance", "quality"]; -export const ADVANCED_RENDERING_WATER_REFLECTION_MODES = ["none", "world", "all"]; -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; -const DEFAULT_BOX_VOLUME_RENDER_PATH = "performance"; -const DEFAULT_ADVANCED_RENDERING_WATER_REFLECTION_MODE = "none"; -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 isBoxVolumeRenderPath(value) { - return BOX_VOLUME_RENDER_PATHS.includes(value); -} -export function isAdvancedRenderingWaterReflectionMode(value) { - return ADVANCED_RENDERING_WATER_REFLECTION_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 - }, - fogPath: DEFAULT_BOX_VOLUME_RENDER_PATH, - waterPath: DEFAULT_BOX_VOLUME_RENDER_PATH, - waterReflectionMode: DEFAULT_ADVANCED_RENDERING_WATER_REFLECTION_MODE - }; -} -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 - }, - fogPath: settings.fogPath, - waterPath: settings.waterPath, - waterReflectionMode: settings.waterReflectionMode - }; -} -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 && - left.fogPath === right.fogPath && - left.waterPath === right.waterPath && - left.waterReflectionMode === right.waterReflectionMode); -} -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 deleted file mode 100644 index b73c1921..00000000 --- a/src/entities/entity-instances.js +++ /dev/null @@ -1,501 +0,0 @@ -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 deleted file mode 100644 index 0b679732..00000000 --- a/src/entities/entity-labels.js +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 961227af..00000000 --- a/src/geometry/box-brush-components.js +++ /dev/null @@ -1,168 +0,0 @@ -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 transformBoxBrushWorldPointToLocal(brush, worldPoint) { - const rotation = createBrushRotationEuler(brush); - const inverseRotation = new Quaternion().setFromEuler(rotation).invert(); - const localPoint = new Vector3(worldPoint.x - brush.center.x, worldPoint.y - brush.center.y, worldPoint.z - brush.center.z).applyQuaternion(inverseRotation); - return { - x: localPoint.x, - y: localPoint.y, - z: localPoint.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 deleted file mode 100644 index 8ddcfe75..00000000 --- a/src/geometry/box-brush-mesh.js +++ /dev/null @@ -1,401 +0,0 @@ -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"] -}; -const WATER_TOP_FACE_RENDER_SEGMENTS = 12; -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 }; -} -function lerpNumber(start, end, amount) { - return start + (end - start) * amount; -} -function lerpVec3(start, end, amount) { - return { - x: lerpNumber(start.x, end.x, amount), - y: lerpNumber(start.y, end.y, amount), - z: lerpNumber(start.z, end.z, amount) - }; -} -function interpolateQuadSurfaceVertex(corners, u, v) { - const topEdge = lerpVec3(corners[0], corners[1], u); - const bottomEdge = lerpVec3(corners[3], corners[2], u); - return lerpVec3(topEdge, bottomEdge, v); -} -function pushRenderedFaceVertex(positions, normals, uvs, indices, vertex, normal, faceId, faceBounds, uvSize, uvState) { - const projectedUv = projectLocalVertexToFaceUv(vertex, faceId, faceBounds); - const transformedUv = transformProjectedFaceUv(projectedUv, uvSize, uvState); - 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); -} -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 uvState = brush.faces[faceId].uv; - const indexStart = indices.length; - faceSurfaces.push({ - faceId, - vertexIds: faceVertexIds, - triangles, - normal - }); - const useSubdividedWaterTopFace = brush.volume.mode === "water" && faceId === "posY" && brush.volume.water.surfaceDisplacementEnabled; - if (useSubdividedWaterTopFace) { - const faceCorners = faceVertices; - for (let row = 0; row < WATER_TOP_FACE_RENDER_SEGMENTS; row += 1) { - const v0 = row / WATER_TOP_FACE_RENDER_SEGMENTS; - const v1 = (row + 1) / WATER_TOP_FACE_RENDER_SEGMENTS; - for (let column = 0; column < WATER_TOP_FACE_RENDER_SEGMENTS; column += 1) { - const u0 = column / WATER_TOP_FACE_RENDER_SEGMENTS; - const u1 = (column + 1) / WATER_TOP_FACE_RENDER_SEGMENTS; - const quadVertices = [ - interpolateQuadSurfaceVertex(faceCorners, u0, v0), - interpolateQuadSurfaceVertex(faceCorners, u1, v0), - interpolateQuadSurfaceVertex(faceCorners, u1, v1), - interpolateQuadSurfaceVertex(faceCorners, u0, v1) - ]; - for (const vertex of [quadVertices[0], quadVertices[1], quadVertices[2], quadVertices[0], quadVertices[2], quadVertices[3]]) { - pushRenderedFaceVertex(positions, normals, uvs, indices, vertex, normal, faceId, faceBounds, uvSize, uvState); - } - } - } - } - else { - for (const triangle of triangles) { - for (const vertexOffset of triangle) { - pushRenderedFaceVertex(positions, normals, uvs, indices, faceVertices[vertexOffset], normal, faceId, faceBounds, uvSize, uvState); - } - } - } - 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 deleted file mode 100644 index 789ed702..00000000 --- a/src/geometry/box-brush.js +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 784ce6b7..00000000 --- a/src/geometry/box-face-uvs.js +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index d76b5056..00000000 --- a/src/geometry/grid-snapping.js +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 3fa70018..00000000 --- a/src/geometry/model-instance-collider-debug-mesh.js +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 1fe2211e..00000000 --- a/src/geometry/model-instance-collider-generation.js +++ /dev/null @@ -1,419 +0,0 @@ -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 deleted file mode 100644 index d6c19a4e..00000000 --- a/src/interactions/interaction-links.js +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index 51553924..00000000 --- a/src/main.js +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 11c4cc58..00000000 --- a/src/materials/starter-material-library.js +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index be5ee7a2..00000000 --- a/src/materials/starter-material-textures.js +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 94d5e9e8..00000000 --- a/src/rendering/advanced-rendering.js +++ /dev/null @@ -1,136 +0,0 @@ -import { BasicShadowMap, DirectionalLight, HalfFloatType, Mesh, NoToneMapping, PCFShadowMap, PCFSoftShadowMap, PointLight, SpotLight, UnsignedByteType } from "three"; -import { BloomEffect, DepthOfFieldEffect, EffectComposer, EffectPass, NormalPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing"; -const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15; -const MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.02; -const MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.2; -const MIN_AMBIENT_OCCLUSION_SAMPLES = 12; -const COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.5; -const DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.75; -const DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE = 0.35; -const COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.45; -const DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.35; -export function resolveBoxVolumeRenderPaths(settings) { - if (!settings.enabled) { - return { - fog: "performance", - water: "performance" - }; - } - return { - fog: settings.fogPath, - water: settings.waterPath - }; -} -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; -} -function clampAmbientOcclusionEffectRadius(radius) { - return Math.min(Math.max(radius, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS), MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS); -} -function getAmbientOcclusionSampleCount(samples) { - return Math.max(samples, MIN_AMBIENT_OCCLUSION_SAMPLES); -} -export function createAdvancedRenderingComposer(renderer, scene, camera, settings) { - // The scene is always rendered into the composer's offscreen targets first, - // so those targets need depth for correct visibility even when no effect samples it. - const composer = new EffectComposer(renderer, { - depthBuffer: true, - stencilBuffer: false, - multisampling: 0, - frameBufferType: renderer.capabilities.isWebGL2 ? HalfFloatType : UnsignedByteType - }); - composer.addPass(new RenderPass(scene, camera)); - const effects = []; - if (settings.ambientOcclusion.enabled) { - // postprocessing's internal depth-downsampling path writes zero normals unless - // a real normal buffer is supplied, which turns SSAO into speckled noise. - const normalPass = new NormalPass(scene, camera); - composer.addPass(normalPass); - const ambientOcclusionRadius = clampAmbientOcclusionEffectRadius(settings.ambientOcclusion.radius); - const ambientOcclusionSamples = getAmbientOcclusionSampleCount(settings.ambientOcclusion.samples); - const detailAmbientOcclusionRadius = Math.max(ambientOcclusionRadius * DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS); - composer.addPass(new EffectPass(camera, new SSAOEffect(camera, normalPass.texture, { - depthAwareUpsampling: true, - luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, - resolutionScale: COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE, - samples: ambientOcclusionSamples, - radius: ambientOcclusionRadius, - intensity: settings.ambientOcclusion.intensity * COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE - }), new SSAOEffect(camera, normalPass.texture, { - depthAwareUpsampling: true, - luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, - resolutionScale: DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE, - samples: ambientOcclusionSamples, - radius: detailAmbientOcclusionRadius, - intensity: settings.ambientOcclusion.intensity * DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE - }))); - } - 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/rendering/fog-material.js b/src/rendering/fog-material.js deleted file mode 100644 index e3550805..00000000 --- a/src/rendering/fog-material.js +++ /dev/null @@ -1,202 +0,0 @@ -import { BackSide, Color, ShaderMaterial, UniformsLib, UniformsUtils, Vector3 } from "three"; -const MIN_FOG_HALF_SIZE = 0.05; -export function createFogQualityMaterial(options) { - const halfSize = new Vector3(Math.max(MIN_FOG_HALF_SIZE, options.halfSize.x), Math.max(MIN_FOG_HALF_SIZE, options.halfSize.y), Math.max(MIN_FOG_HALF_SIZE, options.halfSize.z)); - const minHalfExtent = Math.min(halfSize.x, halfSize.y, halfSize.z); - const padding = Math.max(0, Math.min(options.padding, minHalfExtent * 0.82)); - const animationUniform = { value: options.time }; - const uniforms = UniformsUtils.clone(UniformsLib.fog); - uniforms["time"] = animationUniform; - uniforms["volumeFogColor"] = { value: new Color(options.colorHex) }; - uniforms["volumeFogDensity"] = { value: Math.max(0, options.density) }; - uniforms["volumeHalfSize"] = { value: halfSize }; - uniforms["volumePadding"] = { value: padding }; - uniforms["opacityMultiplier"] = { value: Math.max(0.6, Math.min(1.5, options.opacityMultiplier ?? 1)) }; - uniforms["colorLift"] = { value: Math.max(0, Math.min(0.22, options.colorLift ?? 0)) }; - uniforms["localCameraPosition"] = { value: new Vector3() }; - const vertexShader = /* glsl */ ` - varying vec3 vLocalPosition; - #include - - void main() { - vLocalPosition = position; - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vec4 mvPosition = viewMatrix * worldPosition; - gl_Position = projectionMatrix * mvPosition; - #include - } - `; - const fragmentShader = /* glsl */ ` - uniform vec3 volumeFogColor; - uniform float volumeFogDensity; - uniform vec3 volumeHalfSize; - uniform float volumePadding; - uniform float opacityMultiplier; - uniform float colorLift; - uniform float time; - uniform vec3 localCameraPosition; - - varying vec3 vLocalPosition; - #include - - #define FOG_STEPS 10 - - float hash13(vec3 point) { - point = fract(point * 0.1031); - point += dot(point, point.yzx + 33.33); - return fract((point.x + point.y) * point.z); - } - - float noise3(vec3 point) { - vec3 cell = floor(point); - vec3 local = fract(point); - vec3 smoothLocal = local * local * (3.0 - 2.0 * local); - - float n000 = hash13(cell + vec3(0.0, 0.0, 0.0)); - float n100 = hash13(cell + vec3(1.0, 0.0, 0.0)); - float n010 = hash13(cell + vec3(0.0, 1.0, 0.0)); - float n110 = hash13(cell + vec3(1.0, 1.0, 0.0)); - float n001 = hash13(cell + vec3(0.0, 0.0, 1.0)); - float n101 = hash13(cell + vec3(1.0, 0.0, 1.0)); - float n011 = hash13(cell + vec3(0.0, 1.0, 1.0)); - float n111 = hash13(cell + vec3(1.0, 1.0, 1.0)); - - float nx00 = mix(n000, n100, smoothLocal.x); - float nx10 = mix(n010, n110, smoothLocal.x); - float nx01 = mix(n001, n101, smoothLocal.x); - float nx11 = mix(n011, n111, smoothLocal.x); - float nxy0 = mix(nx00, nx10, smoothLocal.y); - float nxy1 = mix(nx01, nx11, smoothLocal.y); - return mix(nxy0, nxy1, smoothLocal.z); - } - - float fbm(vec3 point) { - float value = 0.0; - float amplitude = 0.5; - - for (int octave = 0; octave < 3; octave += 1) { - value += amplitude * noise3(point); - point = point * 2.04 + vec3(17.1, 31.7, 9.2); - amplitude *= 0.5; - } - - return value; - } - - vec2 intersectBox(vec3 rayOrigin, vec3 rayDirection, vec3 halfSize) { - vec3 safeDirection = sign(rayDirection) * max(abs(rayDirection), vec3(1e-4)); - vec3 invDirection = 1.0 / safeDirection; - vec3 t0 = (-halfSize - rayOrigin) * invDirection; - vec3 t1 = (halfSize - rayOrigin) * invDirection; - vec3 tMin = min(t0, t1); - vec3 tMax = max(t0, t1); - float nearHit = max(max(tMin.x, tMin.y), tMin.z); - float farHit = min(min(tMax.x, tMax.y), tMax.z); - return vec2(nearHit, farHit); - } - - float sampleShape(vec3 samplePosition) { - float minHalfExtent = min(min(volumeHalfSize.x, volumeHalfSize.y), volumeHalfSize.z); - float edgeSoftness = max(0.08, min(volumePadding + minHalfExtent * 0.16, minHalfExtent * 0.72)); - vec3 innerHalfSize = max(volumeHalfSize - vec3(edgeSoftness), vec3(minHalfExtent * 0.18)); - vec3 distanceToCore = abs(samplePosition) - innerHalfSize; - float outsideDistance = length(max(distanceToCore, 0.0)); - float insideDistance = min(max(distanceToCore.x, max(distanceToCore.y, distanceToCore.z)), 0.0); - float roundedBoxDistance = outsideDistance + insideDistance; - float edgeMask = 1.0 - smoothstep(-edgeSoftness * 0.7, edgeSoftness * 1.35, roundedBoxDistance); - - vec3 ellipsoidPosition = samplePosition / max(volumeHalfSize - vec3(edgeSoftness * 0.18), vec3(1e-3)); - float roundedMask = 1.0 - smoothstep(0.54, 1.03, length(ellipsoidPosition * vec3(0.96, 1.08, 0.96))); - - return edgeMask * mix(0.42, 1.0, roundedMask); - } - - float sampleVolumeDensity(vec3 samplePosition) { - vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3)); - float shape = sampleShape(samplePosition); - - if (shape <= 1e-3) { - return 0.0; - } - - vec3 drift = vec3(time * 0.1, time * 0.04, -time * 0.065); - float primary = fbm(samplePosition * 0.58 + drift); - float secondary = fbm(samplePosition * 1.18 - drift * 1.45 + vec3(4.3, 9.7, 2.1)); - float wisps = noise3(samplePosition * 2.15 + vec3(0.0, time * 0.08, 0.0)); - float cloud = smoothstep(0.34, 0.92, primary * 0.68 + secondary * 0.24 + wisps * 0.08); - float centerBias = 1.0 - smoothstep(0.18, 1.08, length(normalizedPosition * vec3(1.05, 0.92, 1.05))); - float verticalBias = mix(0.9, 1.08, smoothstep(-0.75, 0.35, normalizedPosition.y)); - float carvedCloud = mix(0.42, 1.04, cloud) * mix(0.72, 1.0, centerBias); - - return volumeFogDensity * shape * carvedCloud * verticalBias; - } - - void main() { - vec3 rayDirection = normalize(vLocalPosition - localCameraPosition); - vec2 hitRange = intersectBox(localCameraPosition, rayDirection, volumeHalfSize); - float startDistance = max(hitRange.x, 0.0); - float endDistance = hitRange.y; - - if (endDistance <= startDistance) { - discard; - } - - float rayLength = endDistance - startDistance; - float stepLength = rayLength / float(FOG_STEPS); - float jitter = hash13(vLocalPosition * 1.73 + vec3(time * 0.17)) - 0.5; - float transmittance = 1.0; - vec3 accumulatedColor = vec3(0.0); - - for (int stepIndex = 0; stepIndex < FOG_STEPS; stepIndex += 1) { - float sampleDistance = startDistance + (float(stepIndex) + 0.5 + jitter * 0.35) * stepLength; - vec3 samplePosition = localCameraPosition + rayDirection * sampleDistance; - float sampleDensity = sampleVolumeDensity(samplePosition); - - if (sampleDensity <= 1e-4) { - continue; - } - - vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3)); - float forwardScatter = 1.0 - abs(dot(rayDirection, normalize(samplePosition + vec3(1e-3, 2e-3, -1e-3)))); - float topLight = smoothstep(-0.2, 0.95, normalizedPosition.y); - float coolShadow = smoothstep(0.18, 0.88, noise3(samplePosition * 0.88 - vec3(time * 0.08, 0.0, time * 0.05))); - vec3 sampleColor = mix(volumeFogColor * 0.76, vec3(1.0), 0.06 + topLight * 0.12 + forwardScatter * 0.12); - sampleColor = mix(sampleColor * 0.92, sampleColor, coolShadow); - - float extinction = sampleDensity * stepLength * 1.5; - float sampleAlpha = 1.0 - exp(-extinction); - accumulatedColor += transmittance * sampleColor * sampleAlpha; - transmittance *= 1.0 - sampleAlpha; - - if (transmittance < 0.03) { - break; - } - } - - float baseAlpha = 1.0 - transmittance; - float alpha = clamp(baseAlpha * opacityMultiplier, 0.0, 0.96); - - if (alpha <= 0.01) { - discard; - } - - vec3 color = accumulatedColor / max(baseAlpha, 1e-4); - color = mix(color, vec3(1.0), colorLift); - - gl_FragColor = vec4(color, alpha); - #include - } - `; - return { - material: new ShaderMaterial({ - vertexShader, - fragmentShader, - uniforms, - transparent: true, - depthWrite: false, - fog: true, - side: BackSide - }), - animationUniform - }; -} \ No newline at end of file diff --git a/src/rendering/planar-reflection.js b/src/rendering/planar-reflection.js deleted file mode 100644 index ee8869a7..00000000 --- a/src/rendering/planar-reflection.js +++ /dev/null @@ -1,50 +0,0 @@ -import { Euler, Matrix4, PerspectiveCamera, Plane, Quaternion, Vector3, Vector4 } from "three"; - -const SURFACE_UP = new Vector3(0, 1, 0); -const CAMERA_FORWARD = new Vector3(0, 0, -1); - -function createRotationQuaternion(rotationDegrees) { - return new Quaternion().setFromEuler(new Euler((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180, "XYZ")); -} - -export function updatePlanarReflectionCamera(surface, sourceCamera, reflectionCamera, reflectionMatrix, clipBias = 0.003) { - const rotation = createRotationQuaternion(surface.rotationDegrees); - const surfaceNormal = SURFACE_UP.clone().applyQuaternion(rotation).normalize(); - const surfaceCenter = new Vector3(surface.center.x, surface.center.y, surface.center.z).add(surfaceNormal.clone().multiplyScalar(surface.size.y * 0.5)); - const cameraWorldPosition = new Vector3().setFromMatrixPosition(sourceCamera.matrixWorld); - const sourceRotationMatrix = new Matrix4().extractRotation(sourceCamera.matrixWorld); - const lookAtPosition = CAMERA_FORWARD.clone().applyMatrix4(sourceRotationMatrix).add(cameraWorldPosition); - const reflectedViewPosition = surfaceCenter.clone().sub(cameraWorldPosition); - if (reflectedViewPosition.dot(surfaceNormal) > 0) { - return false; - } - reflectedViewPosition.reflect(surfaceNormal).negate(); - reflectedViewPosition.add(surfaceCenter); - const reflectedTarget = surfaceCenter.clone().sub(lookAtPosition); - reflectedTarget.reflect(surfaceNormal).negate(); - reflectedTarget.add(surfaceCenter); - reflectionCamera.position.copy(reflectedViewPosition); - reflectionCamera.up.set(0, 1, 0).applyMatrix4(sourceRotationMatrix).reflect(surfaceNormal); - reflectionCamera.near = sourceCamera.near; - reflectionCamera.far = sourceCamera.far; - reflectionCamera.aspect = sourceCamera.aspect; - reflectionCamera.projectionMatrix.copy(sourceCamera.projectionMatrix); - reflectionCamera.projectionMatrixInverse.copy(sourceCamera.projectionMatrixInverse); - reflectionCamera.lookAt(reflectedTarget); - reflectionCamera.updateMatrixWorld(); - reflectionCamera.matrixWorldInverse.copy(reflectionCamera.matrixWorld).invert(); - reflectionMatrix.set(0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0, 1); - reflectionMatrix.multiply(reflectionCamera.projectionMatrix); - reflectionMatrix.multiply(reflectionCamera.matrixWorldInverse); - const clipPlane = new Plane().setFromNormalAndCoplanarPoint(surfaceNormal, surfaceCenter).applyMatrix4(reflectionCamera.matrixWorldInverse); - const clipVector = new Vector4(clipPlane.normal.x, clipPlane.normal.y, clipPlane.normal.z, clipPlane.constant); - const projectionElements = reflectionCamera.projectionMatrix.elements; - const q = new Vector4((Math.sign(clipVector.x) + projectionElements[8]) / projectionElements[0], (Math.sign(clipVector.y) + projectionElements[9]) / projectionElements[5], -1, (1 + projectionElements[10]) / projectionElements[14]); - clipVector.multiplyScalar(2 / clipVector.dot(q)); - projectionElements[2] = clipVector.x; - projectionElements[6] = clipVector.y; - projectionElements[10] = clipVector.z + 1 - clipBias; - projectionElements[14] = clipVector.w; - reflectionCamera.projectionMatrixInverse.copy(reflectionCamera.projectionMatrix).invert(); - return true; -} \ No newline at end of file diff --git a/src/rendering/water-material.js b/src/rendering/water-material.js deleted file mode 100644 index b57284e3..00000000 --- a/src/rendering/water-material.js +++ /dev/null @@ -1,1059 +0,0 @@ -import { DoubleSide, Euler, Matrix4, MeshBasicMaterial, Quaternion, ShaderMaterial, UniformsLib, UniformsUtils, Vector2, Vector3, Vector4 } from "three"; -import { MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT } from "../document/brushes"; - -const MAX_WATER_CONTACT_PATCHES = MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT; -const WATER_CONTACT_EPSILON = 1e-4; - -function createBoundsCorners(bounds) { - return [ - new Vector3(bounds.min.x, bounds.min.y, bounds.min.z), - new Vector3(bounds.min.x, bounds.min.y, bounds.max.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.min.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.max.z), - new Vector3(bounds.max.x, bounds.min.y, bounds.min.z), - new Vector3(bounds.max.x, bounds.min.y, bounds.max.z), - new Vector3(bounds.max.x, bounds.max.y, bounds.min.z), - new Vector3(bounds.max.x, bounds.max.y, bounds.max.z) - ]; -} - -function createOrientedBoxCorners(box) { - const halfSize = { - x: box.size.x * 0.5, - y: box.size.y * 0.5, - z: box.size.z * 0.5 - }; - const rotation = new Quaternion().setFromEuler(new Euler((box.rotationDegrees.x * Math.PI) / 180, (box.rotationDegrees.y * Math.PI) / 180, (box.rotationDegrees.z * Math.PI) / 180, "XYZ")); - return [ - new Vector3(-halfSize.x, -halfSize.y, -halfSize.z), - new Vector3(-halfSize.x, -halfSize.y, halfSize.z), - new Vector3(-halfSize.x, halfSize.y, -halfSize.z), - new Vector3(-halfSize.x, halfSize.y, halfSize.z), - new Vector3(halfSize.x, -halfSize.y, -halfSize.z), - new Vector3(halfSize.x, -halfSize.y, halfSize.z), - new Vector3(halfSize.x, halfSize.y, -halfSize.z), - new Vector3(halfSize.x, halfSize.y, halfSize.z) - ].map((corner) => corner.applyQuaternion(rotation).add(new Vector3(box.center.x, box.center.y, box.center.z))); -} - -function createRotationQuaternion(rotationDegrees) { - return new Quaternion().setFromEuler(new Euler((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180, "XYZ")); -} - -function createInverseVolumeRotation(rotationDegrees) { - return createRotationQuaternion(rotationDegrees).invert(); -} - -function cross2d(origin, pointA, pointB) { - return (pointA.x - origin.x) * (pointB.y - origin.y) - (pointA.y - origin.y) * (pointB.x - origin.x); -} - -function buildConvexHull(points) { - const sortedPoints = [...points] - .map((point) => point.clone()) - .sort((left, right) => (left.x === right.x ? left.y - right.y : left.x - right.x)); - const uniquePoints = []; - for (const point of sortedPoints) { - const lastPoint = uniquePoints.at(-1); - if (lastPoint === undefined || Math.abs(point.x - lastPoint.x) > WATER_CONTACT_EPSILON || Math.abs(point.y - lastPoint.y) > WATER_CONTACT_EPSILON) { - uniquePoints.push(point); - } - } - if (uniquePoints.length <= 2) { - return uniquePoints; - } - const lowerHull = []; - for (const point of uniquePoints) { - while (lowerHull.length >= 2 && cross2d(lowerHull[lowerHull.length - 2], lowerHull[lowerHull.length - 1], point) <= WATER_CONTACT_EPSILON) { - lowerHull.pop(); - } - lowerHull.push(point); - } - const upperHull = []; - for (let index = uniquePoints.length - 1; index >= 0; index -= 1) { - const point = uniquePoints[index]; - if (point === undefined) { - continue; - } - while (upperHull.length >= 2 && cross2d(upperHull[upperHull.length - 2], upperHull[upperHull.length - 1], point) <= WATER_CONTACT_EPSILON) { - upperHull.pop(); - } - upperHull.push(point); - } - lowerHull.pop(); - upperHull.pop(); - return [...lowerHull, ...upperHull]; -} - -function clipPolygonAgainstVerticalBoundary(polygon, limit, keepGreater) { - if (polygon.length === 0) { - return []; - } - const clipped = []; - let previousPoint = polygon[polygon.length - 1] ?? null; - if (previousPoint === null) { - return []; - } - let previousInside = keepGreater ? previousPoint.x >= limit - WATER_CONTACT_EPSILON : previousPoint.x <= limit + WATER_CONTACT_EPSILON; - for (const point of polygon) { - const inside = keepGreater ? point.x >= limit - WATER_CONTACT_EPSILON : point.x <= limit + WATER_CONTACT_EPSILON; - if (inside !== previousInside) { - const deltaX = point.x - previousPoint.x; - if (Math.abs(deltaX) > WATER_CONTACT_EPSILON) { - const interpolation = (limit - previousPoint.x) / deltaX; - clipped.push(new Vector2(limit, previousPoint.y + (point.y - previousPoint.y) * interpolation)); - } - } - if (inside) { - clipped.push(point.clone()); - } - previousPoint = point; - previousInside = inside; - } - return clipped; -} - -function clipPolygonAgainstHorizontalBoundary(polygon, limit, keepGreater) { - if (polygon.length === 0) { - return []; - } - const clipped = []; - let previousPoint = polygon[polygon.length - 1] ?? null; - if (previousPoint === null) { - return []; - } - let previousInside = keepGreater ? previousPoint.y >= limit - WATER_CONTACT_EPSILON : previousPoint.y <= limit + WATER_CONTACT_EPSILON; - for (const point of polygon) { - const inside = keepGreater ? point.y >= limit - WATER_CONTACT_EPSILON : point.y <= limit + WATER_CONTACT_EPSILON; - if (inside !== previousInside) { - const deltaY = point.y - previousPoint.y; - if (Math.abs(deltaY) > WATER_CONTACT_EPSILON) { - const interpolation = (limit - previousPoint.y) / deltaY; - clipped.push(new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, limit)); - } - } - if (inside) { - clipped.push(point.clone()); - } - previousPoint = point; - previousInside = inside; - } - return clipped; -} - -function clipPolygonToRectangle(polygon, minX, maxX, minZ, maxZ) { - let clippedPolygon = polygon; - clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, minX, true); - clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, maxX, false); - clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, minZ, true); - clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, maxZ, false); - return clippedPolygon; -} - -function clipPolygonAgainstPlane3d(polygon, signedDistance) { - if (polygon.length === 0) { - return []; - } - const clipped = []; - let previousPoint = polygon[polygon.length - 1] ?? null; - if (previousPoint === null) { - return []; - } - let previousDistance = signedDistance(previousPoint); - let previousInside = previousDistance >= -WATER_CONTACT_EPSILON; - for (const point of polygon) { - const distance = signedDistance(point); - const inside = distance >= -WATER_CONTACT_EPSILON; - if (inside !== previousInside) { - const interpolation = previousDistance / (previousDistance - distance); - clipped.push(previousPoint.clone().lerp(point, interpolation)); - } - if (inside) { - clipped.push(point.clone()); - } - previousPoint = point; - previousDistance = distance; - previousInside = inside; - } - return clipped; -} - -function clipPolygonToContactVolume(polygon, halfX, minY, maxY, halfZ) { - let clippedPolygon = polygon; - clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => point.x + halfX); - clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => halfX - point.x); - clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => point.y - minY); - clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => maxY - point.y); - clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => point.z + halfZ); - clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => halfZ - point.z); - return clippedPolygon; -} - -function calculatePolygonArea(polygon) { - if (polygon.length < 3) { - return 0; - } - let doubledArea = 0; - for (let index = 0; index < polygon.length; index += 1) { - const point = polygon[index]; - const nextPoint = polygon[(index + 1) % polygon.length]; - if (point === undefined || nextPoint === undefined) { - continue; - } - doubledArea += point.x * nextPoint.y - nextPoint.x * point.y; - } - return Math.abs(doubledArea) * 0.5; -} - -function createPatchFromProjectedPoints(projectedPoints, preferredAxis, minimumThickness) { - const hull = buildConvexHull(projectedPoints); - if (hull.length === 0) { - return null; - } - const primaryAxis = preferredAxis !== null && preferredAxis.lengthSq() > WATER_CONTACT_EPSILON ? preferredAxis.clone().normalize() : new Vector2(1, 0); - if (preferredAxis === null || preferredAxis.lengthSq() <= WATER_CONTACT_EPSILON) { - let longestSegmentLength = 0; - for (let index = 0; index < hull.length; index += 1) { - const startPoint = hull[index]; - const endPoint = hull[(index + 1) % hull.length]; - if (startPoint === undefined || endPoint === undefined) { - continue; - } - const segment = endPoint.clone().sub(startPoint); - const segmentLength = segment.lengthSq(); - if (segmentLength > longestSegmentLength) { - longestSegmentLength = segmentLength; - primaryAxis.copy(segment.normalize()); - } - } - } - if (primaryAxis.lengthSq() <= WATER_CONTACT_EPSILON) { - return null; - } - const secondaryAxis = new Vector2(-primaryAxis.y, primaryAxis.x); - let minPrimary = Number.POSITIVE_INFINITY; - let maxPrimary = Number.NEGATIVE_INFINITY; - let minSecondary = Number.POSITIVE_INFINITY; - let maxSecondary = Number.NEGATIVE_INFINITY; - for (const point of hull) { - const primaryDistance = point.dot(primaryAxis); - const secondaryDistance = point.dot(secondaryAxis); - minPrimary = Math.min(minPrimary, primaryDistance); - maxPrimary = Math.max(maxPrimary, primaryDistance); - minSecondary = Math.min(minSecondary, secondaryDistance); - maxSecondary = Math.max(maxSecondary, secondaryDistance); - } - const halfWidth = (maxPrimary - minPrimary) * 0.5; - let halfDepth = (maxSecondary - minSecondary) * 0.5; - if (halfWidth <= WATER_CONTACT_EPSILON) { - return null; - } - if (halfDepth <= WATER_CONTACT_EPSILON || calculatePolygonArea(hull) <= WATER_CONTACT_EPSILON) { - halfDepth = Math.max(halfDepth, minimumThickness); - } - if (halfDepth <= WATER_CONTACT_EPSILON) { - return null; - } - const patchCenterPrimary = (minPrimary + maxPrimary) * 0.5; - const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5; - return { - shape: "box", - x: primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary, - z: primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary, - halfWidth, - halfDepth, - axisX: primaryAxis.x, - axisZ: primaryAxis.y - }; -} - -function computeTriangleNormal(pointA, pointB, pointC) { - const edgeAB = pointB.clone().sub(pointA); - const edgeAC = pointC.clone().sub(pointA); - const normal = edgeAB.cross(edgeAC); - if (normal.lengthSq() <= WATER_CONTACT_EPSILON) { - return null; - } - return normal.normalize(); -} - -function createSegmentPatchFromEndpoints(startPoint, endPoint, radius) { - const axis = endPoint.clone().sub(startPoint); - const length = axis.length(); - if (length <= WATER_CONTACT_EPSILON) { - return null; - } - axis.divideScalar(length); - const center = startPoint.clone().add(endPoint).multiplyScalar(0.5); - return { - shape: "segment", - x: center.x, - z: center.y, - halfWidth: length * 0.5, - halfDepth: Math.max(radius, WATER_CONTACT_EPSILON), - axisX: axis.x, - axisZ: axis.y - }; -} - -function addUniqueProjectedPoint(points, point) { - const alreadyExists = points.some((candidate) => Math.abs(candidate.x - point.x) <= WATER_CONTACT_EPSILON && Math.abs(candidate.y - point.y) <= WATER_CONTACT_EPSILON); - if (!alreadyExists) { - points.push(point); - } -} - -function createWaterlineSegmentFromPolygon(polygon, surfaceY) { - if (polygon.length < 2) { - return null; - } - const intersectionPoints = []; - let previousPoint = polygon[polygon.length - 1] ?? null; - if (previousPoint === null) { - return null; - } - for (const point of polygon) { - const previousDelta = previousPoint.y - surfaceY; - const delta = point.y - surfaceY; - const previousOnPlane = Math.abs(previousDelta) <= WATER_CONTACT_EPSILON; - const onPlane = Math.abs(delta) <= WATER_CONTACT_EPSILON; - if (previousOnPlane && onPlane) { - addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z)); - addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z)); - } - else if (previousOnPlane) { - addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z)); - } - else if (onPlane) { - addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z)); - } - else if ((previousDelta < 0 && delta > 0) || (previousDelta > 0 && delta < 0)) { - const interpolation = (surfaceY - previousPoint.y) / (point.y - previousPoint.y); - addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, previousPoint.z + (point.z - previousPoint.z) * interpolation)); - } - previousPoint = point; - } - if (intersectionPoints.length < 2) { - return null; - } - let startPoint = intersectionPoints[0] ?? null; - let endPoint = intersectionPoints[1] ?? null; - let longestDistanceSquared = -1; - for (let startIndex = 0; startIndex < intersectionPoints.length; startIndex += 1) { - for (let endIndex = startIndex + 1; endIndex < intersectionPoints.length; endIndex += 1) { - const candidateStart = intersectionPoints[startIndex]; - const candidateEnd = intersectionPoints[endIndex]; - if (candidateStart === undefined || candidateEnd === undefined) { - continue; - } - const distanceSquared = candidateStart.distanceToSquared(candidateEnd); - if (distanceSquared > longestDistanceSquared) { - longestDistanceSquared = distanceSquared; - startPoint = candidateStart; - endPoint = candidateEnd; - } - } - } - if (startPoint === null || endPoint === null || longestDistanceSquared <= WATER_CONTACT_EPSILON) { - return null; - } - return [startPoint.clone(), endPoint.clone()]; -} - -function createSegmentEndpoints(patch) { - const axis = new Vector2(patch.axisX, patch.axisZ); - if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { - axis.set(1, 0); - } - else { - axis.normalize(); - } - const center = new Vector2(patch.x, patch.z); - const offset = axis.clone().multiplyScalar(patch.halfWidth); - return [center.clone().sub(offset), center.clone().add(offset)]; -} - -function measureSegmentExtentsInBasis(points, radius, axis) { - const perpendicularAxis = new Vector2(-axis.y, axis.x); - let minPrimary = Number.POSITIVE_INFINITY; - let maxPrimary = Number.NEGATIVE_INFINITY; - let minSecondary = Number.POSITIVE_INFINITY; - let maxSecondary = Number.NEGATIVE_INFINITY; - for (const point of points) { - const primaryDistance = point.dot(axis); - const secondaryDistance = point.dot(perpendicularAxis); - minPrimary = Math.min(minPrimary, primaryDistance); - maxPrimary = Math.max(maxPrimary, primaryDistance); - minSecondary = Math.min(minSecondary, secondaryDistance); - maxSecondary = Math.max(maxSecondary, secondaryDistance); - } - return { - minPrimary, - maxPrimary, - minSecondary: minSecondary - radius, - maxSecondary: maxSecondary + radius - }; -} - -function createSegmentPatchFromCluster(cluster) { - const axis = cluster.axis.clone(); - if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { - axis.set(1, 0); - } - else { - axis.normalize(); - } - const perpendicularAxis = new Vector2(-axis.y, axis.x); - const halfWidth = (cluster.extents.maxPrimary - cluster.extents.minPrimary) * 0.5; - const halfDepth = Math.max(cluster.maxRadius, (cluster.extents.maxSecondary - cluster.extents.minSecondary) * 0.5); - if (halfWidth <= WATER_CONTACT_EPSILON || halfDepth <= WATER_CONTACT_EPSILON) { - return null; - } - const centerPrimary = (cluster.extents.minPrimary + cluster.extents.maxPrimary) * 0.5; - const centerSecondary = (cluster.extents.minSecondary + cluster.extents.maxSecondary) * 0.5; - return { - shape: "segment", - x: axis.x * centerPrimary + perpendicularAxis.x * centerSecondary, - z: axis.y * centerPrimary + perpendicularAxis.y * centerSecondary, - halfWidth, - halfDepth, - axisX: axis.x, - axisZ: axis.y - }; -} - -function createSignedDistanceToPatchRegion(point, center, axis, halfExtents) { - const patchPerpendicular = new Vector2(-axis.y, axis.x); - const patchLocalUv = new Vector2(point.clone().sub(center).dot(axis), point.clone().sub(center).dot(patchPerpendicular)); - const regionDelta = new Vector2(Math.abs(patchLocalUv.x) - halfExtents.x, Math.abs(patchLocalUv.y) - halfExtents.y); - const outsideDistance = new Vector2(Math.max(regionDelta.x, 0), Math.max(regionDelta.y, 0)).length(); - const insideDistance = Math.min(Math.max(regionDelta.x, regionDelta.y), 0); - return outsideDistance + insideDistance; -} - -function createBoxPatchFromSegmentLoop(segments, minimumThickness) { - if (segments.length < 3) { - return null; - } - const canonicalAxes = []; - for (const segment of segments) { - if (segment.shape !== "segment") { - return null; - } - const axis = new Vector2(segment.axisX, segment.axisZ); - if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { - continue; - } - axis.normalize(); - if (axis.x < 0 || (Math.abs(axis.x) <= WATER_CONTACT_EPSILON && axis.y < 0)) { - axis.multiplyScalar(-1); - } - const existingAxis = canonicalAxes.find((candidate) => Math.abs(candidate.dot(axis)) >= 0.94); - if (existingAxis === undefined) { - canonicalAxes.push(axis); - } - } - if (canonicalAxes.length < 2) { - return null; - } - let foundOrthogonalAxes = false; - for (let leftIndex = 0; leftIndex < canonicalAxes.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < canonicalAxes.length; rightIndex += 1) { - const leftAxis = canonicalAxes[leftIndex]; - const rightAxis = canonicalAxes[rightIndex]; - if (leftAxis === undefined || rightAxis === undefined) { - continue; - } - if (Math.abs(leftAxis.dot(rightAxis)) <= 0.35) { - foundOrthogonalAxes = true; - break; - } - } - if (foundOrthogonalAxes) { - break; - } - } - if (!foundOrthogonalAxes) { - return null; - } - const projectedPoints = []; - for (const segment of segments) { - const [startPoint, endPoint] = createSegmentEndpoints(segment); - projectedPoints.push(startPoint, endPoint); - } - const candidatePatch = createPatchFromProjectedPoints(projectedPoints, null, minimumThickness); - if (candidatePatch === null) { - return null; - } - const candidateAxis = new Vector2(candidatePatch.axisX, candidatePatch.axisZ); - if (candidateAxis.lengthSq() <= WATER_CONTACT_EPSILON) { - return null; - } - candidateAxis.normalize(); - const candidateCenter = new Vector2(candidatePatch.x, candidatePatch.z); - const candidateHalfExtents = new Vector2(candidatePatch.halfWidth, candidatePatch.halfDepth); - const acceptanceDistance = Math.max(minimumThickness * 2.2, 0.14); - for (const point of projectedPoints) { - const signedDistance = createSignedDistanceToPatchRegion(point, candidateCenter, candidateAxis, candidateHalfExtents); - if (Math.abs(signedDistance) > acceptanceDistance) { - return null; - } - } - return candidatePatch; -} - -function getTriangleMeshMergeSettings(mergeProfile, minimumThickness) { - if (mergeProfile === "aggressive") { - return { - axisAlignment: 0.88, - normalAlignment: 0.9, - minimumPrimaryGap: Math.max(0.26, minimumThickness * 2.8), - minimumSecondaryGap: Math.max(0.18, minimumThickness * 2.2), - primaryGapScale: 0.34, - secondaryGapScale: 0.55 - }; - } - return { - axisAlignment: 0.95, - normalAlignment: 0.97, - minimumPrimaryGap: Math.max(0.04, minimumThickness), - minimumSecondaryGap: Math.max(0.05, minimumThickness * 1.1), - primaryGapScale: 0.06, - secondaryGapScale: 0.12 - }; -} - -function mergeTriangleMeshContactPatches(rawPatches, minimumThickness, mergeProfile) { - const mergeSettings = getTriangleMeshMergeSettings(mergeProfile, minimumThickness); - const clusters = []; - for (const rawPatch of rawPatches) { - const patchAxis = new Vector2(rawPatch.patch.axisX, rawPatch.patch.axisZ); - if (patchAxis.lengthSq() <= WATER_CONTACT_EPSILON) { - patchAxis.set(1, 0); - } - else { - patchAxis.normalize(); - } - const patchEndpoints = createSegmentEndpoints(rawPatch.patch); - let merged = false; - for (const cluster of clusters) { - const alignment = Math.abs(cluster.axis.dot(patchAxis)); - if (alignment < mergeSettings.axisAlignment) { - continue; - } - const normalAlignment = Math.abs(cluster.normal.dot(rawPatch.normal)); - if (normalAlignment < mergeSettings.normalAlignment) { - continue; - } - const patchExtents = measureSegmentExtentsInBasis(patchEndpoints, rawPatch.patch.halfDepth, cluster.axis); - const primaryGap = Math.max(0, Math.max(cluster.extents.minPrimary - patchExtents.maxPrimary, patchExtents.minPrimary - cluster.extents.maxPrimary)); - const secondaryGap = Math.max(0, Math.max(cluster.extents.minSecondary - patchExtents.maxSecondary, patchExtents.minSecondary - cluster.extents.maxSecondary)); - const clusterPrimarySpan = cluster.extents.maxPrimary - cluster.extents.minPrimary; - const clusterSecondarySpan = cluster.extents.maxSecondary - cluster.extents.minSecondary; - const allowedPrimaryGap = Math.max(mergeSettings.minimumPrimaryGap, Math.max(rawPatch.patch.halfWidth, clusterPrimarySpan) * mergeSettings.primaryGapScale); - const allowedSecondaryGap = Math.max(mergeSettings.minimumSecondaryGap, Math.max(rawPatch.patch.halfDepth, clusterSecondarySpan) * mergeSettings.secondaryGapScale); - if (primaryGap > allowedPrimaryGap || secondaryGap > allowedSecondaryGap) { - continue; - } - cluster.endpoints.push(...patchEndpoints.map((point) => point.clone())); - cluster.maxRadius = Math.max(cluster.maxRadius, rawPatch.patch.halfDepth); - cluster.extents = measureSegmentExtentsInBasis(cluster.endpoints, cluster.maxRadius, cluster.axis); - merged = true; - break; - } - if (!merged) { - clusters.push({ - axis: patchAxis, - normal: rawPatch.normal.clone(), - endpoints: patchEndpoints.map((point) => point.clone()), - maxRadius: rawPatch.patch.halfDepth, - extents: measureSegmentExtentsInBasis(patchEndpoints, rawPatch.patch.halfDepth, patchAxis) - }); - } - } - return clusters - .map((cluster) => createSegmentPatchFromCluster(cluster)) - .filter((patch) => patch !== null); -} - -function appendTriangleMeshContactPatches(patches, source, volume, inverseRotation, halfX, surfaceY, surfaceBand, halfZ) { - const position = new Vector3(source.transform?.position.x ?? 0, source.transform?.position.y ?? 0, source.transform?.position.z ?? 0); - const rotation = source.transform !== undefined ? createRotationQuaternion(source.transform.rotationDegrees) : null; - const scale = new Vector3(source.transform?.scale.x ?? 1, source.transform?.scale.y ?? 1, source.transform?.scale.z ?? 1); - const bandMinimumThickness = Math.max(0.08, Math.min(0.22, surfaceBand * 0.45)); - const triangleVertices = [new Vector3(), new Vector3(), new Vector3()]; - const rawPatches = []; - for (let indexOffset = 0; indexOffset <= source.indices.length - 3; indexOffset += 3) { - const polygon = []; - for (let cornerIndex = 0; cornerIndex < 3; cornerIndex += 1) { - const vertexIndex = source.indices[indexOffset + cornerIndex] ?? 0; - const vertex = triangleVertices[cornerIndex] ?? new Vector3(); - vertex.set(source.vertices[vertexIndex * 3] ?? 0, source.vertices[vertexIndex * 3 + 1] ?? 0, source.vertices[vertexIndex * 3 + 2] ?? 0); - vertex.multiply(scale); - if (rotation !== null) { - vertex.applyQuaternion(rotation); - } - vertex.add(position); - vertex.x -= volume.center.x; - vertex.y -= volume.center.y; - vertex.z -= volume.center.z; - vertex.applyQuaternion(inverseRotation); - polygon.push(vertex.clone()); - } - const triangleNormal = computeTriangleNormal(polygon[0] ?? new Vector3(), polygon[1] ?? new Vector3(), polygon[2] ?? new Vector3()); - if (triangleNormal === null) { - continue; - } - const clippedPolygon = clipPolygonToContactVolume(polygon, halfX, surfaceY - surfaceBand, surfaceY + surfaceBand, halfZ); - const waterlineSegment = createWaterlineSegmentFromPolygon(clippedPolygon, surfaceY); - if (waterlineSegment === null) { - continue; - } - const preferredAxis = waterlineSegment[1].clone().sub(waterlineSegment[0]); - if (preferredAxis.lengthSq() <= WATER_CONTACT_EPSILON) { - continue; - } - const patch = createSegmentPatchFromEndpoints(waterlineSegment[0], waterlineSegment[1], bandMinimumThickness); - if (patch !== null) { - rawPatches.push({ - patch, - normal: triangleNormal - }); - } - } - const mergedPatches = mergeTriangleMeshContactPatches(rawPatches, bandMinimumThickness, source.mergeProfile); - const loopPatch = createBoxPatchFromSegmentLoop(mergedPatches, bandMinimumThickness); - if (loopPatch !== null) { - patches.push(loopPatch); - return; - } - patches.push(...mergedPatches); -} - -export function collectWaterContactPatches(volume, contactBounds, patchLimit = MAX_WATER_CONTACT_PATCHES) { - const inverseRotation = createInverseVolumeRotation(volume.rotationDegrees); - const halfX = Math.max(volume.size.x * 0.5, WATER_CONTACT_EPSILON); - const halfY = Math.max(volume.size.y * 0.5, WATER_CONTACT_EPSILON); - const halfZ = Math.max(volume.size.z * 0.5, WATER_CONTACT_EPSILON); - const surfaceY = halfY; - const surfaceBand = Math.max(0.18, Math.min(0.55, volume.size.y * 0.2)); - const localPoint = new Vector3(); - const patches = []; - - for (const source of contactBounds) { - if ("kind" in source && source.kind === "triangleMesh") { - appendTriangleMeshContactPatches(patches, source, volume, inverseRotation, halfX, surfaceY, surfaceBand, halfZ); - continue; - } - const corners = "kind" in source ? createOrientedBoxCorners(source) : createBoundsCorners(source); - const localCorners = []; - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let minZ = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let maxZ = Number.NEGATIVE_INFINITY; - - for (const corner of corners) { - localPoint.copy(corner); - localPoint.x -= volume.center.x; - localPoint.y -= volume.center.y; - localPoint.z -= volume.center.z; - localPoint.applyQuaternion(inverseRotation); - localCorners.push(localPoint.clone()); - minX = Math.min(minX, localPoint.x); - minY = Math.min(minY, localPoint.y); - minZ = Math.min(minZ, localPoint.z); - maxX = Math.max(maxX, localPoint.x); - maxY = Math.max(maxY, localPoint.y); - maxZ = Math.max(maxZ, localPoint.z); - } - - if (maxX < -halfX || minX > halfX || maxZ < -halfZ || minZ > halfZ) { - continue; - } - - if (maxY < surfaceY - surfaceBand || minY > surfaceY + surfaceBand) { - continue; - } - - const clippedFootprint = clipPolygonToRectangle(buildConvexHull(localCorners.map((corner) => new Vector2(corner.x, corner.z))), -halfX, halfX, -halfZ, halfZ); - - if (clippedFootprint.length < 2) { - continue; - } - - const verticalDistance = Math.min(Math.abs(surfaceY - minY), Math.abs(maxY - surfaceY)); - - if (1 - Math.min(verticalDistance / surfaceBand, 1) <= WATER_CONTACT_EPSILON) { - continue; - } - - let preferredAxis = null; - - if ("kind" in source) { - const sourceRotation = createRotationQuaternion(source.rotationDegrees); - const projectedSourceX = new Vector2(new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x, new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z); - const projectedSourceZ = new Vector2(new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x, new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z); - const nextPrimaryAxis = projectedSourceX.lengthSq() >= projectedSourceZ.lengthSq() ? projectedSourceX : projectedSourceZ; - - if (nextPrimaryAxis.lengthSq() > WATER_CONTACT_EPSILON) { - preferredAxis = nextPrimaryAxis.normalize(); - } - } - const patch = createPatchFromProjectedPoints(clippedFootprint, preferredAxis, Math.max(0.08, Math.min(0.18, surfaceBand * 0.4))); - if (patch !== null) { - patches.push(patch); - } - } - - const clampedPatchLimit = Math.max(1, Math.min(patchLimit, MAX_WATER_CONTACT_PATCHES)); - return patches - .sort((left, right) => right.halfWidth * right.halfDepth - left.halfWidth * left.halfDepth) - .slice(0, clampedPatchLimit); -} - -export function createWaterContactPatchUniformValue(contactPatches) { - return Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => { - const patch = contactPatches?.[index]; - return new Vector4(patch?.x ?? 0, patch?.z ?? 0, patch?.halfWidth ?? 0, patch?.halfDepth ?? 0); - }); -} - -export function createWaterContactPatchAxisUniformValue(contactPatches) { - return Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => { - const patch = contactPatches?.[index]; - return new Vector2(patch?.axisX ?? 1, patch?.axisZ ?? 0); - }); -} - -export function createWaterContactPatchShapeUniformValue(contactPatches) { - return Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => { - const patch = contactPatches?.[index]; - return patch?.shape === "segment" ? 1 : 0; - }); -} - -export function createWaterMaterial(options) { - if (options.wireframe) { - return { - material: new MeshBasicMaterial({ - color: options.colorHex, - wireframe: true, - transparent: true, - opacity: Math.min(1, options.opacity + 0.2), - depthWrite: false - }), - animationUniform: null, - contactPatchesUniform: null, - contactPatchAxesUniform: null, - contactPatchShapesUniform: null, - reflectionTextureUniform: null, - reflectionMatrixUniform: null, - reflectionEnabledUniform: null - }; - } - - if (!options.quality) { - return { - material: new MeshBasicMaterial({ - color: options.colorHex, - transparent: true, - opacity: options.opacity, - depthWrite: false - }), - animationUniform: null, - contactPatchesUniform: null, - contactPatchAxesUniform: null, - contactPatchShapesUniform: null, - reflectionTextureUniform: null, - reflectionMatrixUniform: null, - reflectionEnabledUniform: null - }; - } - - const animationUniform = { value: options.time }; - const halfSize = new Vector2(Math.max(options.halfSize.x, WATER_CONTACT_EPSILON), Math.max(options.halfSize.z, WATER_CONTACT_EPSILON)); - const contactPatchesUniform = { value: createWaterContactPatchUniformValue(options.contactPatches) }; - const contactPatchAxesUniform = { value: createWaterContactPatchAxisUniformValue(options.contactPatches) }; - const contactPatchShapesUniform = { value: createWaterContactPatchShapeUniformValue(options.contactPatches) }; - const reflectionTextureUniform = { value: options.reflection?.texture ?? null }; - const reflectionMatrixUniform = { value: new Matrix4() }; - const reflectionEnabledUniform = { - value: options.reflection?.enabled === true && options.reflection?.texture !== null - ? Math.max(0, Math.min(1, options.reflection?.strength ?? 0.36)) - : 0 - }; - const surfaceDisplacementEnabledUniform = { - value: options.surfaceDisplacementEnabled === true ? 1 : 0 - }; - const waveStrength = Math.max(0, options.waveStrength); - const clampedOpacity = Math.max(0.14, Math.min(1, options.opacity)); - const topFaceFlag = options.isTopFace ? 1 : 0; - const hex = options.colorHex.replace("#", ""); - const cr = parseInt(hex.substring(0, 2), 16) / 255; - const cg = parseInt(hex.substring(2, 4), 16) / 255; - const cb = parseInt(hex.substring(4, 6), 16) / 255; - - const vertexShader = /* glsl */ ` - uniform float time; - uniform float waveStrength; - uniform float isTopFace; - uniform float surfaceDisplacementEnabled; - uniform mat4 reflectionMatrix; - - varying vec2 vLocalSurfaceUv; - varying vec3 vWaveNormal; - varying vec3 vViewDir; - varying vec4 vReflectionCoord; - #include - - void main() { - vec3 transformedPosition = position; - vLocalSurfaceUv = position.xz; - vWaveNormal = vec3(0.0, 1.0, 0.0); - vReflectionCoord = vec4(0.0); - - if (isTopFace > 0.5) { - vec2 dirA = normalize(vec2(0.92, 0.38)); - vec2 dirB = normalize(vec2(-0.34, 0.94)); - vec2 dirC = normalize(vec2(0.58, -0.81)); - float phaseA = dot(vLocalSurfaceUv, dirA) / 2.3 + time * 0.92; - float phaseB = dot(vLocalSurfaceUv, dirB) / 1.45 - time * 1.08; - float phaseC = dot(vLocalSurfaceUv, dirC) / 0.82 + time * 1.42; - float waveA = sin(phaseA) * 0.55; - float waveB = sin(phaseB) * 0.30; - float waveC = sin(phaseC) * 0.15; - float combinedWave = waveA + waveB + waveC; - - vec2 slope = - dirA * (cos(phaseA) / 2.3) * 0.55 + - dirB * (cos(phaseB) / 1.45) * 0.30 + - dirC * (cos(phaseC) / 0.82) * 0.15; - vWaveNormal = normalize(vec3(-slope.x * (0.3 + waveStrength * 0.7), 1.0, -slope.y * (0.3 + waveStrength * 0.7))); - - if (surfaceDisplacementEnabled > 0.5) { - transformedPosition.y += combinedWave * (0.035 + waveStrength * 0.09); - } - } - - vec4 worldPos = modelMatrix * vec4(transformedPosition, 1.0); - vec4 mvPosition = viewMatrix * worldPos; - vViewDir = normalize(cameraPosition - worldPos.xyz); - vReflectionCoord = reflectionMatrix * worldPos; - gl_Position = projectionMatrix * mvPosition; - #include - } - `; - - const fragmentShader = /* glsl */ ` - precision highp float; - - uniform vec3 waterColor; - uniform float surfaceOpacity; - uniform float waveStrength; - uniform float time; - uniform float isTopFace; - uniform vec2 halfSize; - uniform vec4 contactPatches[${MAX_WATER_CONTACT_PATCHES}]; - uniform vec2 contactPatchAxes[${MAX_WATER_CONTACT_PATCHES}]; - uniform float contactPatchShapes[${MAX_WATER_CONTACT_PATCHES}]; - uniform sampler2D reflectionTexture; - uniform float reflectionEnabled; - - varying vec2 vLocalSurfaceUv; - varying vec3 vWaveNormal; - varying vec3 vViewDir; - varying vec4 vReflectionCoord; - #include - - float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); - } - - float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - vec2 u = f * f * (3.0 - 2.0 * f); - - return mix( - mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x), - mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), - u.y - ); - } - - float fbm(vec2 p) { - float value = 0.0; - float amplitude = 0.5; - - for (int octave = 0; octave < 4; octave += 1) { - value += noise(p) * amplitude; - p = p * 2.02 + vec2(17.1, 11.7); - amplitude *= 0.5; - } - - return value; - } - - float signedDistanceToRegion(vec2 point, vec2 center, vec2 axis, vec2 halfExtents) { - vec2 patchPerpendicular = vec2(-axis.y, axis.x); - vec2 patchLocalUv = vec2(dot(point - center, axis), dot(point - center, patchPerpendicular)); - vec2 regionDelta = abs(patchLocalUv) - halfExtents; - vec2 outsideDelta = max(regionDelta, 0.0); - float outsideDistance = length(outsideDelta); - float insideDistance = min(max(regionDelta.x, regionDelta.y), 0.0); - return outsideDistance + insideDistance; - } - - float distanceToSegmentBand(vec2 point, vec2 center, vec2 axis, float halfLength) { - float along = clamp(dot(point - center, axis), -halfLength, halfLength); - vec2 closestPoint = center + axis * along; - return distance(point, closestPoint); - } - - void main() { - vec3 normal = normalize(vWaveNormal); - vec3 viewDir = normalize(vViewDir); - float fresnel = pow(1.0 - clamp(dot(viewDir, normal), 0.0, 1.0), 2.8); - - float largeWave = fbm(vLocalSurfaceUv * 0.42 + vec2(time * 0.06, -time * 0.04)); - float mediumWave = fbm(vLocalSurfaceUv * 0.95 + normal.xz * 0.55 + vec2(-time * 0.11, time * 0.09)); - float microWave = noise(vLocalSurfaceUv * 3.6 + normal.xz * 1.6 + vec2(time * 0.24, -time * 0.19)); - float caustics = fbm(vLocalSurfaceUv * 1.8 + normal.xz * 1.2 + vec2(time * 0.16, -time * 0.14)); - caustics *= fbm(vLocalSurfaceUv * 2.7 - normal.xz * 1.4 + vec2(-time * 0.21, time * 0.18)); - - vec3 deepTint = waterColor * vec3(0.52, 0.66, 0.78); - vec3 shallowTint = mix(waterColor, vec3(0.72, 0.9, 1.0), 0.2 + fresnel * 0.24); - float contactFoam = 0.0; - float contactRipple = 0.0; - float contactSheen = 0.0; - float reflectionMask = 0.0; - vec3 reflectionColor = vec3(0.0); - vec2 foamDrift = vec2( - sin(time * 0.52 + vLocalSurfaceUv.y * 1.15), - cos(time * 0.46 + vLocalSurfaceUv.x * 1.08) - ) * (0.06 + waveStrength * 0.12); - vec2 foamUv = vLocalSurfaceUv + foamDrift + normal.xz * (0.08 + waveStrength * 0.14); - - float edgeDistance = min(halfSize.x - abs(vLocalSurfaceUv.x), halfSize.y - abs(vLocalSurfaceUv.y)); - float edgeBand = max(0.22, min(halfSize.x, halfSize.y) * 0.12); - float edgeFoam = isTopFace > 0.5 ? 1.0 - smoothstep(0.0, edgeBand, edgeDistance) : 0.0; - - if (isTopFace > 0.5) { - for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) { - vec4 patchData = contactPatches[patchIndex]; - if (patchData.z <= 0.0 || patchData.w <= 0.0) { - continue; - } - - vec2 patchAxis = contactPatchAxes[patchIndex]; - if (dot(patchAxis, patchAxis) <= 0.0) { - patchAxis = vec2(1.0, 0.0); - } - else { - patchAxis = normalize(patchAxis); - } - - float alongDistance = dot(foamUv - patchData.xy, patchAxis); - float contactBody = 0.0; - float ripple = 0.0; - float normalizedDistance = 1.0; - float tangentNoise = noise(vec2(alongDistance * 0.45 + float(patchIndex) * 7.13, time * 0.12)); - - if (contactPatchShapes[patchIndex] > 0.5) { - float segmentRadius = max(patchData.w * mix(0.82, 1.18, tangentNoise), 0.05); - float segmentDistance = distanceToSegmentBand(foamUv, patchData.xy, patchAxis, patchData.z); - normalizedDistance = segmentDistance / segmentRadius; - contactBody = 1.0 - smoothstep(0.0, 1.0, normalizedDistance); - ripple = (sin(normalizedDistance * 11.0 - time * 3.2 + alongDistance * 0.48) * 0.5 + 0.5) * exp(-normalizedDistance * 1.9); - } - else { - float boundaryScale = max(min(patchData.z, patchData.w), 0.18) * mix(0.86, 1.14, tangentNoise); - float signedDistance = signedDistanceToRegion(foamUv, patchData.xy, patchAxis, patchData.zw); - normalizedDistance = abs(signedDistance) / max(boundaryScale, 0.05); - contactBody = 1.0 - smoothstep(0.0, 1.0, normalizedDistance); - ripple = (sin(normalizedDistance * 13.0 - time * 3.2 + alongDistance * 0.35) * 0.5 + 0.5) * exp(-normalizedDistance * 2.6); - } - - float wakeNoise = noise(foamUv * 3.4 + vec2(time * 0.34, -time * 0.28)); - float foamFlow = fbm(foamUv * 1.95 + vec2(time * 0.22, -time * 0.18)); - float foamField = max(contactBody * (0.42 + foamFlow * 0.18), ripple * (0.68 + wakeNoise * 0.32)); - contactFoam = max(contactFoam, foamField); - contactRipple = max(contactRipple, ripple); - contactSheen = max(contactSheen, contactBody); - } - } - - float refraction = (largeWave - 0.5) * 0.18 + (mediumWave - 0.5) * 0.14 + (microWave - 0.5) * 0.08 + contactRipple * 0.06; - float glints = smoothstep(0.78, 0.97, fbm(vLocalSurfaceUv * 4.8 + normal.xz * 2.2 + vec2(time * 0.38, -time * 0.31))) * (0.14 + fresnel * 0.28); - vec3 color = mix(deepTint, shallowTint, clamp(0.46 + refraction + fresnel * 0.24 + caustics * 0.08, 0.05, 0.98)); - - if (isTopFace > 0.5 && reflectionEnabled > 0.0 && vReflectionCoord.w > 0.0) { - vec2 reflectionUv = vReflectionCoord.xy / vReflectionCoord.w; - reflectionUv += normal.xz * (0.01 + waveStrength * 0.012) + vec2((microWave - 0.5) * 0.018, (mediumWave - 0.5) * 0.015); - if (reflectionUv.x >= 0.0 && reflectionUv.x <= 1.0 && reflectionUv.y >= 0.0 && reflectionUv.y <= 1.0) { - vec4 reflectionSample = texture2D(reflectionTexture, clamp(reflectionUv, vec2(0.001), vec2(0.999))); - if (reflectionSample.a > 0.001) { - reflectionColor = mix(reflectionSample.rgb, shallowTint, 0.32); - reflectionMask = reflectionEnabled * reflectionSample.a * clamp(0.08 + fresnel * 0.72 + glints * 0.18, 0.0, 0.62); - } - } - } - - float foam = clamp(max(edgeFoam * 0.48, contactFoam) * (0.52 + waveStrength * 0.8) + caustics * 0.08 + glints * 0.06, 0.0, 0.84); - vec3 specular = vec3(pow(max(0.0, dot(reflect(-viewDir, normal), normalize(vec3(0.25, 0.88, 0.35)))), 18.0)) * (0.14 + fresnel * 0.56 + caustics * 0.14 + contactSheen * 0.12); - - color = mix(color, mix(reflectionColor, color, 0.42), reflectionMask); - color = mix(color, vec3(0.97, 0.99, 1.0), foam); - color += specular; - color += vec3(0.05, 0.08, 0.12) * fresnel; - color += vec3(0.02, 0.05, 0.08) * caustics; - - float alpha = isTopFace > 0.5 - ? clamp(surfaceOpacity + fresnel * 0.18 + foam * 0.16 + contactRipple * 0.08, 0.32, 0.92) - : clamp(surfaceOpacity * 0.72 + refraction * 0.08 + caustics * 0.04, 0.16, 0.7); - - gl_FragColor = vec4(color, alpha); - #include - } - `; - - const uniforms = UniformsUtils.clone(UniformsLib.fog); - Object.assign(uniforms, { - time: animationUniform, - waterColor: { value: [cr, cg, cb] }, - surfaceOpacity: { value: clampedOpacity }, - waveStrength: { value: waveStrength }, - isTopFace: { value: topFaceFlag }, - surfaceDisplacementEnabled: surfaceDisplacementEnabledUniform, - halfSize: { value: halfSize }, - contactPatches: contactPatchesUniform, - contactPatchAxes: contactPatchAxesUniform, - contactPatchShapes: contactPatchShapesUniform, - reflectionTexture: reflectionTextureUniform, - reflectionMatrix: reflectionMatrixUniform, - reflectionEnabled: reflectionEnabledUniform - }); - - const material = new ShaderMaterial({ - vertexShader, - fragmentShader, - uniforms, - transparent: true, - depthWrite: false, - fog: true, - side: DoubleSide - }); - - return { - material, - animationUniform, - contactPatchesUniform, - contactPatchAxesUniform, - contactPatchShapesUniform, - reflectionTextureUniform, - reflectionMatrixUniform, - reflectionEnabledUniform - }; -} \ No newline at end of file diff --git a/src/runner-web/RunnerCanvas.js b/src/runner-web/RunnerCanvas.js deleted file mode 100644 index 0735e335..00000000 --- a/src/runner-web/RunnerCanvas.js +++ /dev/null @@ -1,56 +0,0 @@ -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); - const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); - useEffect(() => { - const container = containerRef.current; - if (container === null) { - return; - } - try { - const runtimeHost = new RuntimeHost({ - enableRendering: true - }); - hostRef.current = runtimeHost; - runtimeHost.mount(container); - runtimeHost.setRuntimeMessageHandler(onRuntimeMessageChange); - runtimeHost.setFirstPersonTelemetryHandler((telemetry) => { - setFirstPersonTelemetry(telemetry); - onFirstPersonTelemetryChange(telemetry); - }); - runtimeHost.setInteractionPromptHandler((prompt) => { - setInteractionPrompt(prompt); - onInteractionPromptChange(prompt); - }); - setRunnerMessage(null); - return () => { - onInteractionPromptChange(null); - setFirstPersonTelemetry(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 ${navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? "runner-canvas--underwater" : ""}`, "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" && firstPersonTelemetry?.cameraSubmerged ? _jsx("div", { className: "runner-canvas__underwater", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" ? _jsx("div", { className: "runner-canvas__crosshair", "aria-hidden": "true" }) : null, 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 deleted file mode 100644 index e6e732e5..00000000 --- a/src/runtime-three/first-person-navigation-controller.js +++ /dev/null @@ -1,240 +0,0 @@ -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; - locomotionState = "flying"; - inWaterVolume = false; - inFogVolume = 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.locomotionState = "flying"; - this.inWaterVolume = false; - this.inFogVolume = 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 currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition); - 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 if (currentVolumeState.inWater) { - this.verticalVelocity = 0; - } - else { - this.verticalVelocity -= GRAVITY * dt; - } - const resolvedMotion = this.context.resolveFirstPersonMotion(this.feetPosition, { - x: horizontalX, - y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt, - z: horizontalZ - }, playerShape); - if (resolvedMotion === null) { - this.updateCameraTransform(); - this.publishTelemetry(); - return; - } - this.feetPosition = resolvedMotion.feetPosition; - const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition); - this.inWaterVolume = nextVolumeState.inWater; - this.inFogVolume = nextVolumeState.inFog; - this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded; - if (playerShape.mode === "none") { - this.locomotionState = "flying"; - } - else if (this.inWaterVolume) { - this.locomotionState = "swimming"; - } - else if (this.grounded) { - this.locomotionState = "grounded"; - } - else { - this.locomotionState = "flying"; - } - if (this.grounded && this.verticalVelocity < 0) { - this.verticalVelocity = 0; - } - else if (this.inWaterVolume) { - 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.locomotionState = "flying"; - this.inWaterVolume = false; - this.inFogVolume = 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; - } - const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)); - const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition); - this.context.setFirstPersonTelemetry({ - feetPosition: { - ...this.feetPosition - }, - eyePosition, - grounded: this.grounded, - locomotionState: this.locomotionState, - inWaterVolume: this.inWaterVolume, - cameraSubmerged: cameraVolumeState.inWater, - inFogVolume: this.inFogVolume, - 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 deleted file mode 100644 index cb0ff5c3..00000000 --- a/src/runtime-three/navigation-controller.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/runtime-three/orbit-visitor-navigation-controller.js b/src/runtime-three/orbit-visitor-navigation-controller.js deleted file mode 100644 index 9a3d5f44..00000000 --- a/src/runtime-three/orbit-visitor-navigation-controller.js +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index d0368fd9..00000000 --- a/src/runtime-three/player-collision.js +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 1cef7014..00000000 --- a/src/runtime-three/rapier-collision-world.js +++ /dev/null @@ -1,267 +0,0 @@ -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))); - world.createCollider(RAPIER.ColliderDesc.trimesh(collider.vertices, collider.indices), 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 deleted file mode 100644 index 5ceee803..00000000 --- a/src/runtime-three/runtime-audio-system.js +++ /dev/null @@ -1,289 +0,0 @@ -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 deleted file mode 100644 index c246a954..00000000 --- a/src/runtime-three/runtime-host.js +++ /dev/null @@ -1,991 +0,0 @@ -import { AmbientLight, AnimationClip, AnimationMixer, DirectionalLight, Euler, FogExp2, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, ShaderMaterial, Vector3, SpotLight, WebGLRenderTarget, WebGLRenderer } from "three"; -import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; -import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; -import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; -import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering"; -import { createFogQualityMaterial } from "../rendering/fog-material"; -import { collectWaterContactPatches, createWaterContactPatchAxisUniformValue, createWaterContactPatchShapeUniformValue, createWaterContactPatchUniformValue, createWaterMaterial } from "../rendering/water-material"; -import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; -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"; -import { resolveUnderwaterFogState } from "./underwater-fog"; -const FALLBACK_FACE_COLOR = 0xf2ece2; -const BOX_FACE_MATERIAL_COUNT = 6; -const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96; -export class RuntimeHost { - scene = new Scene(); - camera = new PerspectiveCamera(70, 1, 0.05, 1000); - cameraForward = new Vector3(); - volumeOffset = new Vector3(); - volumeInverseRotation = new Quaternion(); - fogLocalCameraPosition = new Vector3(); - domElement; - ambientLight = new AmbientLight(); - sunLight = new DirectionalLight(); - localLightGroup = new Group(); - brushGroup = new Group(); - modelGroup = new Group(); - waterReflectionCamera = new PerspectiveCamera(); - firstPersonController = new FirstPersonNavigationController(); - orbitVisitorController = new OrbitVisitorNavigationController(); - interactionSystem = new RuntimeInteractionSystem(); - audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null); - underwaterSceneFog = new FogExp2("#2c6f8d", 0.03); - brushMeshes = new Map(); - volumeTime = 0; - volumeAnimatedUniforms = []; - runtimeWaterContactUniforms = []; - 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.underwaterSceneFog.density = 0; - this.scene.fog = this.underwaterSceneFog; - this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, 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, - resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition), - setRuntimeMessage: (message) => { - if (message === this.currentRuntimeMessage) { - return; - } - this.currentRuntimeMessage = message; - this.runtimeMessageHandler?.(message); - }, - setFirstPersonTelemetry: (telemetry) => { - this.currentFirstPersonTelemetry = telemetry; - this.firstPersonTelemetryHandler?.(telemetry); - } - }; - } - resolvePlayerVolumeState(feetPosition) { - if (this.runtimeScene === null) { - return { - inWater: false, - inFog: false - }; - } - const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume)); - const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume)); - return { - inWater, - inFog - }; - } - isPointInsideOrientedVolume(point, volume) { - this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z); - this.volumeInverseRotation - .setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ")) - .invert(); - this.volumeOffset.applyQuaternion(this.volumeInverseRotation); - const halfX = volume.size.x * 0.5; - const halfY = volume.size.y * 0.5; - const halfZ = volume.size.z * 0.5; - return (Math.abs(this.volumeOffset.x) <= halfX && - Math.abs(this.volumeOffset.y) <= halfY && - Math.abs(this.volumeOffset.z) <= halfZ); - } - 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; - this.scene.fog = null; - if (this.renderer !== null) { - this.renderer.autoClear = true; - } - for (const cachedTexture of this.materialTextureCache.values()) { - cachedTexture.texture.dispose(); - } - this.materialTextureCache.clear(); - this.renderer?.forceContextLoss(); - 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(); - const volumeRenderPaths = this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering); - for (const brush of brushes) { - const geometry = buildBoxBrushDerivedMeshData(brush).geometry; - const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : []; - const contactPatches = brush.volume.mode === "water" - ? this.mergeRuntimeWaterContactPatches(staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush)) - : []; - const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? - [ - this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches) - ]; - 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.configureFogVolumeMesh(mesh, materials); - this.brushGroup.add(mesh); - this.brushMeshes.set(brush.id, mesh); - } - this.applyShadowState(); - } - createFogMaterialSet(brush, volumeRenderPaths) { - if (brush.volume.mode !== "fog") { - return null; - } - if (volumeRenderPaths.fog === "quality") { - const fogMaterial = createFogQualityMaterial({ - colorHex: brush.volume.fog.colorHex, - density: brush.volume.fog.density, - padding: brush.volume.fog.padding, - time: this.volumeTime, - halfSize: { - x: brush.size.x * 0.5, - y: brush.size.y * 0.5, - z: brush.size.z * 0.5 - } - }); - this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); - return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material); - } - const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); - const fogMaterial = new MeshBasicMaterial({ - color: brush.volume.fog.colorHex, - transparent: true, - opacity: densityOpacity, - depthWrite: false - }); - return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial); - } - configureFogVolumeMesh(mesh, materials) { - const fogMaterials = materials.filter((material) => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined); - if (fogMaterials.length === 0) { - return; - } - mesh.onBeforeRender = (_renderer, _scene, camera) => { - const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position)); - for (const material of fogMaterials) { - material.uniforms["localCameraPosition"].value.copy(localCameraPosition); - } - }; - } - 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(brush, faceId, material, volumeRenderPaths, contactPatches, staticContactPatches) { - if (brush.volume.mode === "water") { - const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity)); - const waterMaterial = createWaterMaterial({ - colorHex: brush.volume.water.colorHex, - surfaceOpacity: brush.volume.water.surfaceOpacity, - waveStrength: brush.volume.water.waveStrength, - surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled, - opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5, - quality: volumeRenderPaths.water === "quality", - wireframe: false, - isTopFace: faceId === "posY", - time: this.volumeTime, - halfSize: { - x: brush.size.x * 0.5, - z: brush.size.z * 0.5 - }, - contactPatches, - reflection: { - texture: null, - enabled: faceId === "posY" - } - }); - if (waterMaterial.animationUniform !== null) { - this.volumeAnimatedUniforms.push(waterMaterial.animationUniform); - } - if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) { - this.runtimeWaterContactUniforms.push({ - brush, - uniform: waterMaterial.contactPatchesUniform, - axisUniform: waterMaterial.contactPatchAxesUniform, - shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] }, - staticContactPatches, - reflectionTextureUniform: waterMaterial.reflectionTextureUniform, - reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform, - reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform, - reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null, - lastReflectionUpdateTime: Number.NEGATIVE_INFINITY - }); - } - return waterMaterial.material; - } - if (brush.volume.mode === "fog") { - if (volumeRenderPaths.fog === "quality") { - const fogMaterial = createFogQualityMaterial({ - colorHex: brush.volume.fog.colorHex, - density: brush.volume.fog.density, - padding: brush.volume.fog.padding, - time: this.volumeTime, - halfSize: { - x: brush.size.x * 0.5, - y: brush.size.y * 0.5, - z: brush.size.z * 0.5 - } - }); - this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); - return fogMaterial.material; - } - const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); - return new MeshBasicMaterial({ - color: brush.volume.fog.colorHex, - transparent: true, - opacity: densityOpacity, - depthWrite: false - }); - } - 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 - }); - } - const fogState = this.activeController === this.firstPersonController - ? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry) - : null; - if (fogState === null) { - this.underwaterSceneFog.density = 0; - return; - } - this.underwaterSceneFog.color.set(fogState.colorHex); - this.underwaterSceneFog.density = fogState.density; - } - getWaterReflectionMode() { - if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") { - return "none"; - } - return this.currentWorld.advancedRendering.waterReflectionMode; - } - createWaterReflectionRenderTarget() { - const canvasWidth = this.container?.clientWidth ?? this.domElement.width; - const canvasHeight = this.container?.clientHeight ?? this.domElement.height; - const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5)); - const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5)); - return new WebGLRenderTarget(width, height); - } - resizeWaterReflectionTargets() { - const canvasWidth = this.container?.clientWidth ?? this.domElement.width; - const canvasHeight = this.container?.clientHeight ?? this.domElement.height; - const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5)); - const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5)); - for (const binding of this.runtimeWaterContactUniforms) { - binding.reflectionRenderTarget?.setSize(width, height); - binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY; - } - } - updateRuntimeWaterReflections() { - if (this.renderer === null || this.runtimeScene === null) { - return; - } - const reflectionMode = this.getWaterReflectionMode(); - const now = performance.now(); - for (const binding of this.runtimeWaterContactUniforms) { - if (reflectionMode === "none" || - binding.reflectionTextureUniform === null || - binding.reflectionMatrixUniform === null || - binding.reflectionEnabledUniform === null) { - if (binding.reflectionEnabledUniform !== null) { - binding.reflectionEnabledUniform.value = 0; - } - continue; - } - if (binding.reflectionRenderTarget === null) { - binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget(); - } - const canRenderReflection = updatePlanarReflectionCamera(binding.brush, this.camera, this.waterReflectionCamera, binding.reflectionMatrixUniform.value); - if (!canRenderReflection || binding.reflectionRenderTarget === null) { - binding.reflectionEnabledUniform.value = 0; - continue; - } - if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) { - binding.reflectionEnabledUniform.value = 0.36; - continue; - } - const hiddenWaterMeshes = []; - for (const runtimeBrush of this.runtimeScene.brushes) { - if (runtimeBrush.volume.mode !== "water") { - continue; - } - const mesh = this.brushMeshes.get(runtimeBrush.id); - if (mesh === undefined) { - continue; - } - hiddenWaterMeshes.push({ mesh, visible: mesh.visible }); - mesh.visible = false; - } - const previousModelGroupVisibility = this.modelGroup.visible; - if (reflectionMode === "world") { - this.modelGroup.visible = false; - } - const previousAutoClear = this.renderer.autoClear; - const previousRenderTarget = this.renderer.getRenderTarget(); - const previousFogDensity = this.underwaterSceneFog.density; - const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({ - binding: waterBinding, - enabled: waterBinding.reflectionEnabledUniform?.value ?? 0, - texture: waterBinding.reflectionTextureUniform?.value ?? null - })); - try { - this.underwaterSceneFog.density = 0; - for (const state of previousReflectionStates) { - if (state.binding.reflectionEnabledUniform !== null) { - state.binding.reflectionEnabledUniform.value = 0; - } - } - binding.reflectionTextureUniform.value = null; - this.renderer.autoClear = true; - this.renderer.setRenderTarget(binding.reflectionRenderTarget); - this.renderer.clear(); - this.renderer.render(this.scene, this.waterReflectionCamera); - } - finally { - this.renderer.setRenderTarget(previousRenderTarget); - this.renderer.autoClear = previousAutoClear; - this.modelGroup.visible = previousModelGroupVisibility; - this.underwaterSceneFog.density = previousFogDensity; - for (const state of previousReflectionStates) { - if (state.binding.reflectionEnabledUniform !== null) { - state.binding.reflectionEnabledUniform.value = state.enabled; - } - if (state.binding.reflectionTextureUniform !== null) { - state.binding.reflectionTextureUniform.value = state.texture; - } - } - for (const hiddenWaterMesh of hiddenWaterMeshes) { - hiddenWaterMesh.mesh.visible = hiddenWaterMesh.visible; - } - } - binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture; - binding.reflectionEnabledUniform.value = 0.36; - binding.lastReflectionUpdateTime = now; - } - } - 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(); - this.disposeUniqueMaterials(mesh.material); - } - this.brushMeshes.clear(); - this.volumeAnimatedUniforms.length = 0; - for (const binding of this.runtimeWaterContactUniforms) { - binding.reflectionRenderTarget?.dispose(); - } - this.runtimeWaterContactUniforms.length = 0; - } - disposeUniqueMaterials(materials) { - for (const material of new Set(materials)) { - material.dispose(); - } - } - createPlayerWaterContactBounds() { - if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) { - return null; - } - const feetPosition = this.currentFirstPersonTelemetry.feetPosition; - const playerShape = this.runtimeScene.playerCollider; - switch (playerShape.mode) { - case "capsule": - return { - min: { - x: feetPosition.x - playerShape.radius, - y: feetPosition.y, - z: feetPosition.z - playerShape.radius - }, - max: { - x: feetPosition.x + playerShape.radius, - y: feetPosition.y + playerShape.height, - z: feetPosition.z + playerShape.radius - } - }; - case "box": - return { - min: { - x: feetPosition.x - playerShape.size.x * 0.5, - y: feetPosition.y, - z: feetPosition.z - playerShape.size.z * 0.5 - }, - max: { - x: feetPosition.x + playerShape.size.x * 0.5, - y: feetPosition.y + playerShape.size.y, - z: feetPosition.z + playerShape.size.z * 0.5 - } - }; - case "none": - return null; - } - } - collectRuntimeStaticWaterContactPatches(brush) { - const contactBounds = []; - const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush])); - for (const collider of this.runtimeScene?.colliders ?? []) { - if (collider.source === "brush") { - const otherBrush = runtimeBrushesById.get(collider.brushId); - if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") { - continue; - } - contactBounds.push({ - kind: "triangleMesh", - vertices: collider.vertices, - indices: collider.indices, - transform: { - position: collider.center, - rotationDegrees: collider.rotationDegrees, - scale: { - x: 1, - y: 1, - z: 1 - } - } - }); - continue; - } - if (collider.kind === "trimesh") { - contactBounds.push({ - kind: "triangleMesh", - vertices: collider.vertices, - indices: collider.indices, - mergeProfile: "aggressive", - transform: collider.transform - }); - continue; - } - contactBounds.push({ - min: collider.worldBounds.min, - max: collider.worldBounds.max - }); - } - return collectWaterContactPatches({ - center: brush.center, - rotationDegrees: brush.rotationDegrees, - size: brush.size - }, contactBounds, this.getRuntimeWaterFoamContactLimit(brush)); - } - collectRuntimePlayerWaterContactPatches(brush) { - const playerBounds = this.createPlayerWaterContactBounds(); - if (playerBounds === null) { - return []; - } - return collectWaterContactPatches({ - center: brush.center, - rotationDegrees: brush.rotationDegrees, - size: brush.size - }, [playerBounds], this.getRuntimeWaterFoamContactLimit(brush)); - } - getRuntimeWaterFoamContactLimit(brush) { - return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0; - } - mergeRuntimeWaterContactPatches(brush, staticContactPatches, dynamicContactPatches) { - return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush)); - } - updateRuntimeWaterContactUniforms() { - for (const binding of this.runtimeWaterContactUniforms) { - const mergedPatches = this.mergeRuntimeWaterContactPatches(binding.brush, binding.staticContactPatches, this.collectRuntimePlayerWaterContactPatches(binding.brush)); - binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches); - binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches); - binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches); - } - } - 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); - this.resizeWaterReflectionTargets(); - } - 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(); - this.volumeTime += dt; - for (const uniform of this.volumeAnimatedUniforms) { - uniform.value = this.volumeTime; - } - for (const mixer of this.animationMixers.values()) { - mixer.update(dt); - } - if (this.sceneReady && this.runtimeScene !== null && this.currentFirstPersonTelemetry !== null) { - this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher()); - this.setInteractionPrompt(this.resolveInteractionPrompt()); - } - else { - this.setInteractionPrompt(null); - } - if (this.runtimeWaterContactUniforms.length > 0) { - this.updateRuntimeWaterContactUniforms(); - this.updateRuntimeWaterReflections(); - } - this.updateUnderwaterSceneFog(); - if (this.advancedRenderingComposer !== null) { - this.advancedRenderingComposer.render(dt); - return; - } - this.renderer?.render(this.scene, this.camera); - }; - applyTeleportPlayerAction(target) { - if (this.activeController === this.thirdPersonController) { - this.thirdPersonController.teleportTo(target.position, target.yawDegrees); - return; - } - 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); - } - resolveInteractionPrompt() { - if (this.runtimeScene === null || - this.currentFirstPersonTelemetry === null || - (this.activeController !== this.firstPersonController && - this.activeController !== this.thirdPersonController)) { - return null; - } - this.camera.getWorldDirection(this.cameraForward); - const interactionOrigin = this.currentFirstPersonTelemetry.eyePosition; - const rayOrigin = this.activeController === this.thirdPersonController - ? { - x: this.camera.position.x, - y: this.camera.position.y, - z: this.camera.position.z - } - : interactionOrigin; - return this.interactionSystem.resolveClickInteractionPrompt(interactionOrigin, rayOrigin, { - x: this.cameraForward.x, - y: this.cameraForward.y, - z: this.cameraForward.z - }, this.runtimeScene); - } - handleRuntimeClick = () => { - if (!this.sceneReady || - this.runtimeScene === null || - (this.activeController !== this.firstPersonController && - this.activeController !== this.thirdPersonController) || - this.currentInteractionPrompt === null) { - return; - } - this.audioSystem.handleUserGesture(); - 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 deleted file mode 100644 index 2bd26662..00000000 --- a/src/runtime-three/runtime-interaction-system.js +++ /dev/null @@ -1,163 +0,0 @@ -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(interactionOrigin, rayOrigin, rayDirection, runtimeScene) { - const normalizedViewDirection = normalizeVec3(rayDirection); - 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(interactionOrigin, interactable.position); - if (distance > interactable.radius) { - continue; - } - const hitDistance = raySphereHitDistance(rayOrigin, 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 deleted file mode 100644 index 20935a4c..00000000 --- a/src/runtime-three/runtime-scene-build.js +++ /dev/null @@ -1,380 +0,0 @@ -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 { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; -import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation"; -import { cloneInteractionLink, getInteractionLinks } from "../interactions/interaction-links"; -import { cloneMaterialDef } from "../materials/starter-material-library"; -import { cloneBoxBrushGeometry, cloneBoxBrushVolumeSettings, 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), - geometry: cloneBoxBrushGeometry(brush.geometry), - volume: cloneBoxBrushVolumeSettings(brush.volume), - 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 buildRuntimeFogVolume(brush) { - if (brush.volume.mode !== "fog") { - throw new Error(`Cannot build fog volume from non-fog brush ${brush.id}.`); - } - return { - brushId: brush.id, - center: cloneVec3(brush.center), - rotationDegrees: cloneVec3(brush.rotationDegrees), - size: cloneVec3(brush.size), - colorHex: brush.volume.fog.colorHex, - density: brush.volume.fog.density, - padding: brush.volume.fog.padding - }; -} -function buildRuntimeWaterVolume(brush) { - if (brush.volume.mode !== "water") { - throw new Error(`Cannot build water volume from non-water brush ${brush.id}.`); - } - return { - brushId: brush.id, - center: cloneVec3(brush.center), - rotationDegrees: cloneVec3(brush.rotationDegrees), - size: cloneVec3(brush.size), - colorHex: brush.volume.water.colorHex, - surfaceOpacity: brush.volume.water.surfaceOpacity, - waveStrength: brush.volume.water.waveStrength - }; -} -function buildRuntimeCollider(brush) { - const bounds = getBoxBrushBounds(brush); - const derivedMesh = buildBoxBrushDerivedMeshData(brush); - return { - kind: "trimesh", - source: "brush", - brushId: brush.id, - center: cloneVec3(brush.center), - rotationDegrees: cloneVec3(brush.rotationDegrees), - vertices: derivedMesh.colliderVertices, - indices: derivedMesh.colliderIndices, - 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 = []; - const volumes = { - fog: [], - water: [] - }; - for (const brush of Object.values(document.brushes)) { - if (brush.volume.mode === "none") { - colliders.push(buildRuntimeCollider(brush)); - continue; - } - if (brush.volume.mode === "fog") { - volumes.fog.push(buildRuntimeFogVolume(brush)); - continue; - } - volumes.water.push(buildRuntimeWaterVolume(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, - volumes, - 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 deleted file mode 100644 index 6d108c52..00000000 --- a/src/runtime-three/runtime-scene-validation.js +++ /dev/null @@ -1,51 +0,0 @@ -import { getModelInstances } from "../assets/model-instances"; -import { assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary } from "../document/scene-document-validation"; -import { getPrimaryPlayerStartEntity } from "../entities/entity-instances"; -import { validateBoxBrushGeometry } from "../geometry/box-brush-mesh"; -import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation"; -function validateBrushGeometry(brush, path, diagnostics) { - for (const diagnostic of validateBoxBrushGeometry(brush)) { - diagnostics.push(createDiagnostic("error", diagnostic.code, diagnostic.message, `${path}.geometry`, "build")); - } -} -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 brush of Object.values(document.brushes)) { - validateBrushGeometry(brush, `brushes.${brush.id}`, diagnostics); - } - 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/runtime-three/underwater-fog.js b/src/runtime-three/underwater-fog.js deleted file mode 100644 index 7dd7a3d2..00000000 --- a/src/runtime-three/underwater-fog.js +++ /dev/null @@ -1,44 +0,0 @@ -import { Euler, Quaternion, Vector3 } from "three"; - -const MIN_UNDERWATER_FOG_DENSITY = 0.018; -const MAX_UNDERWATER_FOG_DENSITY = 0.12; - -function clampNumber(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function getWaterVolumeLocalPoint(point, volume) { - const offset = new Vector3(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z); - const inverseRotation = new Quaternion() - .setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ")) - .invert(); - offset.applyQuaternion(inverseRotation); - return offset; -} -function isPointInsideWaterVolume(point, volume) { - const offset = getWaterVolumeLocalPoint(point, volume); - return (Math.abs(offset.x) <= volume.size.x * 0.5 && - Math.abs(offset.y) <= volume.size.y * 0.5 && - Math.abs(offset.z) <= volume.size.z * 0.5); -} - -function resolveUnderwaterFogDensity(volume, point) { - const localPoint = getWaterVolumeLocalPoint(point, volume); - const halfHeight = Math.max(volume.size.y * 0.5, 0.0001); - const submersionDepth = clampNumber((halfHeight - localPoint.y) / (halfHeight * 2), 0, 1); - return clampNumber(0.045 + volume.surfaceOpacity * 0.035 + Math.max(volume.waveStrength, 0) * 0.015 + submersionDepth * 0.03, MIN_UNDERWATER_FOG_DENSITY, MAX_UNDERWATER_FOG_DENSITY); -} - -export function resolveUnderwaterFogState(runtimeScene, telemetry) { - if (runtimeScene === null || telemetry === null || telemetry.cameraSubmerged !== true) { - return null; - } - const containingVolume = runtimeScene.volumes.water.find((volume) => isPointInsideWaterVolume(telemetry.eyePosition, volume)); - if (containingVolume === undefined) { - return null; - } - return { - colorHex: containingVolume.colorHex, - density: resolveUnderwaterFogDensity(containingVolume, telemetry.eyePosition) - }; -} \ No newline at end of file diff --git a/src/serialization/local-draft-storage.js b/src/serialization/local-draft-storage.js deleted file mode 100644 index 0f345b6e..00000000 --- a/src/serialization/local-draft-storage.js +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index 8b6625ae..00000000 --- a/src/serialization/scene-document-json.js +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 01cdeb2a..00000000 --- a/src/shared-ui/HierarchicalMenu.js +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index f009e223..00000000 --- a/src/shared-ui/Panel.js +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index e305efbb..00000000 --- a/src/shared-ui/world-background-style.js +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index fd23c491..00000000 --- a/src/viewport-three/ViewportCanvas.js +++ /dev/null @@ -1,130 +0,0 @@ -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 shouldRenderPanel = layoutMode === "quad" || isActivePanel; - const [viewportMessage, setViewportMessage] = useState(null); - const [hoveredWhiteboxLabel, setHoveredWhiteboxLabel] = useState(null); - useEffect(() => { - const container = containerRef.current; - if (container === null) { - return; - } - try { - const viewportHost = new ViewportHost(); - hostRef.current = viewportHost; - viewportHost.setPanelId(panelId); - viewportHost.setRenderEnabled(shouldRenderPanel); - 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?.setRenderEnabled(shouldRenderPanel); - }, [shouldRenderPanel]); - 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 deleted file mode 100644 index 43b42ef3..00000000 --- a/src/viewport-three/ViewportPanel.js +++ /dev/null @@ -1,30 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { ViewportCanvas } from "./ViewportCanvas"; -import { - VIEWPORT_LAYOUT_MODES, - getViewportDisplayModeLabel, - getViewportLayoutModeLabel, - getViewportPanelLabel -} from "./viewport-layout"; -import { VIEWPORT_VIEW_MODES, getViewportViewModeLabel } from "./viewport-view-modes"; -import { - WHITEBOX_SELECTION_MODES, - getWhiteboxSelectionModeLabel -} from "../core/whitebox-selection-mode"; -const VIEWPORT_DISPLAY_MODES = ["normal", "authoring", "wireframe"]; -function getPanelScopedTestId(panelId, name) { - return `viewport-panel-${panelId}-${name}`; -} -function getSharedControlTestId(panelId, isActive, sharedId, fallbackId = sharedId) { - return isActive ? sharedId : getPanelScopedTestId(panelId, fallbackId); -} -export function ViewportPanel({ panelId, panelState, layoutMode, isActive, className, style, world, sceneDocument, projectAssets, loadedModelAssets, loadedImageAssets, whiteboxSelectionMode, whiteboxSnapEnabled, whiteboxSnapStepDraft, whiteboxSnapStep, viewportGridVisible, selection, toolMode, toolPreview, transformSession, canTranslateSelectedTarget, canRotateSelectedTarget, canScaleSelectedTarget, cameraState, focusRequestId, focusSelection, isAddMenuOpen, onActivatePanel, onOpenAddMenu, onSetViewportLayoutMode, onSetPanelViewMode, onSetPanelDisplayMode, onCommitCreation, onCameraStateChange, onToolPreviewChange, onBeginTransformOperation, onWhiteboxSelectionModeChange, onViewportGridToggle, onWhiteboxSnapToggle, onWhiteboxSnapStepDraftChange, onWhiteboxSnapStepBlur, onTransformSessionChange, onTransformCommit, onTransformCancel, onSelectionChange }) { - const shouldShow = layoutMode === "quad" || isActive; - const panelStyle = shouldShow ? style : { ...(style ?? {}), display: "none" }; - const transformButtonsDisabled = toolMode !== "select"; - 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: [_jsx(ViewportCanvas, { panelId: panelId, world: world, sceneDocument: sceneDocument, projectAssets: projectAssets, loadedModelAssets: loadedModelAssets, loadedImageAssets: loadedImageAssets, whiteboxSelectionMode: whiteboxSelectionMode, whiteboxSnapEnabled: whiteboxSnapEnabled, whiteboxSnapStep: whiteboxSnapStep, viewportGridVisible: viewportGridVisible, 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 }), _jsx("div", { className: "viewport-panel__overlay viewport-panel__overlay--top", children: _jsxs("div", { className: "viewport-panel__overlay-scroll", children: [_jsx("button", { className: "viewport-panel__button viewport-panel__button--accent", type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "outliner-add-button", "add-button"), "aria-haspopup": "menu", "aria-expanded": isAddMenuOpen, onClick: onOpenAddMenu, children: "Add" }), _jsx("div", { className: "viewport-panel__control-group", role: "group", "aria-label": "Viewport layout mode", children: VIEWPORT_LAYOUT_MODES.map((mode) => (_jsx("button", { className: `viewport-panel__button ${layoutMode === mode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, `viewport-layout-${mode}`, `layout-${mode}`), "aria-pressed": layoutMode === mode, onClick: () => onSetViewportLayoutMode(mode), children: getViewportLayoutModeLabel(mode) }, mode))) }), _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: VIEWPORT_DISPLAY_MODES.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))) }), _jsxs("div", { className: "viewport-panel__control-group", role: "group", "aria-label": "Whitebox snap settings", children: [_jsx("button", { className: `viewport-panel__button ${viewportGridVisible ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "viewport-grid-toggle"), "aria-pressed": viewportGridVisible, onClick: onViewportGridToggle, children: viewportGridVisible ? "Grid On" : "Grid Off" }), _jsx("button", { className: `viewport-panel__button ${whiteboxSnapEnabled ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "whitebox-snap-toggle"), "aria-pressed": whiteboxSnapEnabled, onClick: onWhiteboxSnapToggle, children: whiteboxSnapEnabled ? "Snap On" : "Snap Off" }), _jsxs("label", { className: "viewport-panel__inline-field", children: [_jsx("span", { className: "viewport-panel__inline-label", children: "Step" }), _jsx("input", { "data-testid": getSharedControlTestId(panelId, isActive, "whitebox-snap-step"), className: "text-input viewport-panel__inline-input", type: "number", min: "0.01", step: "0.1", value: whiteboxSnapStepDraft, onChange: (event) => onWhiteboxSnapStepDraftChange(event.currentTarget.value), onBlur: onWhiteboxSnapStepBlur, onKeyDown: (event) => { - if (event.key === "Enter") { - onWhiteboxSnapStepBlur(); - } - } })] })] })] }) }), _jsxs("div", { className: "viewport-panel__overlay viewport-panel__overlay--left", children: [_jsxs("div", { className: "viewport-panel__control-group viewport-panel__control-group--stack", role: "group", "aria-label": "Transform operations", children: [_jsx("button", { className: `viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "translate" ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "transform-translate-button"), "aria-pressed": transformSession.kind === "active" && transformSession.operation === "translate", disabled: transformButtonsDisabled || !canTranslateSelectedTarget, onClick: () => onBeginTransformOperation("translate"), children: "Move" }), _jsx("button", { className: `viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "rotate" ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "transform-rotate-button"), "aria-pressed": transformSession.kind === "active" && transformSession.operation === "rotate", disabled: transformButtonsDisabled || !canRotateSelectedTarget, onClick: () => onBeginTransformOperation("rotate"), children: "Rotate" }), _jsx("button", { className: `viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "scale" ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "transform-scale-button"), "aria-pressed": transformSession.kind === "active" && transformSession.operation === "scale", disabled: transformButtonsDisabled || !canScaleSelectedTarget, onClick: () => onBeginTransformOperation("scale"), children: "Scale" })] }), _jsx("div", { className: "viewport-panel__control-group viewport-panel__control-group--stack", role: "group", "aria-label": "Whitebox selection mode", children: WHITEBOX_SELECTION_MODES.map((mode) => (_jsx("button", { className: `viewport-panel__button ${whiteboxSelectionMode === mode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, `whitebox-selection-mode-${mode}`), "aria-pressed": whiteboxSelectionMode === mode, onClick: () => onWhiteboxSelectionModeChange(mode), children: getWhiteboxSelectionModeLabel(mode) }, mode))) })] })] })); -} diff --git a/src/viewport-three/viewport-entity-markers.js b/src/viewport-three/viewport-entity-markers.js deleted file mode 100644 index b83cc63b..00000000 --- a/src/viewport-three/viewport-entity-markers.js +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 4acf1739..00000000 --- a/src/viewport-three/viewport-focus.js +++ /dev/null @@ -1,294 +0,0 @@ -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 deleted file mode 100644 index 32e43406..00000000 --- a/src/viewport-three/viewport-host.js +++ /dev/null @@ -1,3356 +0,0 @@ -import { AmbientLight, AxesHelper, BufferGeometry, BoxGeometry, CapsuleGeometry, ConeGeometry, CylinderGeometry, DirectionalLight, EdgesGeometry, GridHelper, Group, Line, LineBasicMaterial, LineSegments, Mesh, MeshBasicMaterial, MeshStandardMaterial, OrthographicCamera, Plane, PerspectiveCamera, PointLight, Quaternion, Raycaster, Scene, ShaderMaterial, SphereGeometry, Spherical, TorusGeometry, SpotLight, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three"; -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, cloneBoxBrushGeometry, deriveBoxBrushSizeFromGeometry, scaleBoxBrushGeometryToSize, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes"; -import { getBoxBrushEdgeAxis, getBoxBrushEdgeTransformMeta, getBoxBrushEdgeWorldSegment, getBoxBrushFaceAxis, getBoxBrushFaceTransformMeta, getBoxBrushFaceWorldCenter, getBoxBrushVertexWorldPosition, transformBoxBrushWorldPointToLocal, transformBoxBrushWorldVectorToLocal } from "../geometry/box-brush-components"; -import { buildBoxBrushDerivedMeshData, getBoxBrushEdgeVertexIds, getBoxBrushFaceVertexIds, getBoxBrushLocalVertexPosition } from "../geometry/box-brush-mesh"; -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, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering"; -import { createFogQualityMaterial } from "../rendering/fog-material"; -import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; -import { collectWaterContactPatches, createWaterMaterial } from "../rendering/water-material"; -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 = 0xf2ece2; -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; -const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96; -export class ViewportHost { - scene = new Scene(); - axesHelper = new AxesHelper(2); - perspectiveCamera = new PerspectiveCamera(60, 1, 0.1, 1000); - orthographicCamera = new OrthographicCamera(-10, 10, 10, -10, 0.1, 1000); - renderer = new WebGLRenderer({ antialias: false, alpha: true }); - cameraTarget = new Vector3(0, 0, 0); - cameraOffset = new Vector3(); - cameraForward = new Vector3(); - cameraRight = new Vector3(); - cameraUp = new Vector3(); - fogLocalCameraPosition = 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(); - waterReflectionCamera = new PerspectiveCamera(); - 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 = {}; - volumeTime = 0; - previousFrameTime = 0; - volumeAnimatedUniforms = []; - viewportWaterSurfaceBindings = []; - 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 - this.configureFogVolumeMesh(mesh, materials); - })); - resizeObserver = null; - animationFrame = 0; - renderEnabled = false; - 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(); - 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(this.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); - if (this.renderEnabled) { - this.render(); - } - } - setRenderEnabled(enabled) { - if (this.renderEnabled === enabled) { - return; - } - this.renderEnabled = enabled; - if (!enabled) { - if (this.animationFrame !== 0) { - cancelAnimationFrame(this.animationFrame); - this.animationFrame = 0; - } - this.previousFrameTime = 0; - return; - } - if (this.container !== null && this.animationFrame === 0) { - 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.forceContextLoss(); - 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 - }, - geometry: cloneBoxBrushGeometry(session.preview.geometry) - }; - } - 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 - }, - geometry: cloneBoxBrushGeometry(session.target.initialGeometry) - }; - } - 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 - }, - geometry: cloneBoxBrushGeometry(session.target.initialGeometry) - }; - } - 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, - geometry: scaleBoxBrushGeometryToSize(session.target.initialGeometry, 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 - }, - geometry: cloneBoxBrushGeometry(session.target.initialGeometry) - }; - } - createBrushPreviewFromGeometry(brush, geometry) { - const nextGeometry = cloneBoxBrushGeometry(geometry); - return { - kind: "brush", - center: { - ...brush.center - }, - rotationDegrees: { - ...brush.rotationDegrees - }, - size: deriveBoxBrushSizeFromGeometry(nextGeometry), - geometry: nextGeometry - }; - } - getComponentTargetVertexIds(target) { - switch (target.kind) { - case "brushFace": - return [...getBoxBrushFaceVertexIds(target.faceId)]; - case "brushEdge": { - const [start, end] = getBoxBrushEdgeVertexIds(target.edgeId); - return [start, end]; - } - case "brushVertex": - return [target.vertexId]; - default: - return []; - } - } - applyDeltaToVertices(brush, vertexIds, delta) { - const nextGeometry = cloneBoxBrushGeometry(brush.geometry); - for (const vertexId of vertexIds) { - const vertex = nextGeometry.vertices[vertexId]; - vertex.x = this.snapWhiteboxPositionValue(vertex.x + delta.x); - vertex.y = this.snapWhiteboxPositionValue(vertex.y + delta.y); - vertex.z = this.snapWhiteboxPositionValue(vertex.z + delta.z); - } - return nextGeometry; - } - 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 }, - geometry: cloneBoxBrushGeometry(initialBrush.geometry) - } - }); - 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 vertexIds = this.getComponentTargetVertexIds(session.target); - const nextGeometry = this.applyDeltaToVertices(initialBrush, vertexIds, localDelta); - return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry); - } - 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 pivotWorld = this.getTransformPivotPosition({ - ...session, - preview: { - kind: "brush", - center: { ...initialBrush.center }, - rotationDegrees: { ...initialBrush.rotationDegrees }, - size: { ...initialBrush.size }, - geometry: cloneBoxBrushGeometry(initialBrush.geometry) - } - }); - const pivotLocal = transformBoxBrushWorldPointToLocal(initialBrush, pivotWorld); - const rotationAxis = this.axisVector(effectiveAxis).normalize(); - const vertexIds = this.getComponentTargetVertexIds(session.target); - const nextGeometry = cloneBoxBrushGeometry(initialBrush.geometry); - for (const vertexId of vertexIds) { - const vertex = getBoxBrushLocalVertexPosition(initialBrush, vertexId); - const next = new Vector3(vertex.x - pivotLocal.x, vertex.y - pivotLocal.y, vertex.z - pivotLocal.z) - .applyAxisAngle(rotationAxis, (pointerDeltaDegrees * Math.PI) / 180) - .add(new Vector3(pivotLocal.x, pivotLocal.y, pivotLocal.z)); - nextGeometry.vertices[vertexId] = { - x: this.snapWhiteboxPositionValue(next.x), - y: this.snapWhiteboxPositionValue(next.y), - z: this.snapWhiteboxPositionValue(next.z) - }; - } - return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry); - } - 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 pivotWorld = this.getTransformPivotPosition({ - ...session, - preview: { - kind: "brush", - center: { ...initialBrush.center }, - rotationDegrees: { ...initialBrush.rotationDegrees }, - size: { ...initialBrush.size }, - geometry: cloneBoxBrushGeometry(initialBrush.geometry) - } - }); - const pivotLocal = transformBoxBrushWorldPointToLocal(initialBrush, pivotWorld); - const nextGeometry = cloneBoxBrushGeometry(initialBrush.geometry); - const vertexIds = this.getComponentTargetVertexIds(session.target); - if (session.target.kind === "brushFace") { - const meta = getBoxBrushFaceTransformMeta(session.target.faceId); - const axis = axisConstraint ?? meta.axis; - const scaleFactor = 1 + this.getAxisMovementDistance(axis, pivotWorld, origin, current) * 0.45; - for (const vertexId of vertexIds) { - const vertex = nextGeometry.vertices[vertexId]; - vertex[axis] = this.snapWhiteboxPositionValue(pivotLocal[axis] + (vertex[axis] - pivotLocal[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)); - for (const axis of affectedAxes) { - const scaleFactor = 1 + this.getAxisMovementDistance(axis, pivotWorld, origin, current) * 0.45; - for (const vertexId of vertexIds) { - const vertex = nextGeometry.vertices[vertexId]; - vertex[axis] = this.snapWhiteboxPositionValue(pivotLocal[axis] + (vertex[axis] - pivotLocal[axis]) * scaleFactor); - } - } - } - return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry); - } - updateBrushRenderObjectGeometry(brush) { - const renderObjects = this.brushRenderObjects.get(brush.id); - if (renderObjects === undefined) { - return; - } - const nextGeometry = buildBoxBrushDerivedMeshData(brush).geometry; - renderObjects.mesh.geometry.dispose(); - renderObjects.mesh.geometry = nextGeometry; - renderObjects.edges.geometry.dispose(); - renderObjects.edges.geometry = new EdgesGeometry(nextGeometry); - for (const edgeHelper of renderObjects.edgeHelpers) { - const segment = getBoxBrushEdgeWorldSegment(brush, edgeHelper.id); - const nextEdgeGeometry = 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) - ]); - edgeHelper.line.geometry.dispose(); - edgeHelper.line.geometry = nextEdgeGeometry; - } - for (const vertexHelper of renderObjects.vertexHelpers) { - const vertex = getBoxBrushVertexWorldPosition(brush, vertexHelper.id); - vertexHelper.mesh.position.set(vertex.x, vertex.y, vertex.z); - } - } - applyBrushRenderObjectTransform(brushId, center, rotationDegrees) { - const renderObjects = this.brushRenderObjects.get(brushId); - if (renderObjects === 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(1, 1, 1); - 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(1, 1, 1); - } - 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; - } - } - applyLocalLightRenderObjectTransform(entity) { - const renderObjects = this.localLightRenderObjects.get(entity.id); - if (renderObjects === undefined) { - return; - } - switch (entity.kind) { - case "pointLight": - 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; - default: - 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.updateBrushRenderObjectGeometry(brush); - this.applyBrushRenderObjectTransform(brush.id, brush.center, brush.rotationDegrees); - } - for (const entity of getEntityInstances(this.currentDocument.entities)) { - this.applyEntityRenderObjectTransform(entity); - this.applyLocalLightRenderObjectTransform(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") { - const previewBrush = this.createPreviewBrushForSession(this.currentTransformSession); - if (previewBrush !== null) { - this.updateBrushRenderObjectGeometry(previewBrush); - } - this.applyBrushRenderObjectTransform(this.currentTransformSession.target.brushId, this.currentTransformSession.preview.center, this.currentTransformSession.preview.rotationDegrees); - } - 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 - }); - this.applyLocalLightRenderObjectTransform({ - ...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 - }); - this.applyLocalLightRenderObjectTransform({ - ...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 - }); - this.applyLocalLightRenderObjectTransform({ - ...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(); - const volumeRenderPaths = resolveBoxVolumeRenderPaths(document.world.advancedRendering); - for (const brush of Object.values(document.brushes)) { - const geometry = buildBoxBrushDerivedMeshData(brush).geometry; - const contactPatches = brush.volume.mode === "water" ? this.collectViewportWaterContactPatches(document, brush) : []; - const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? BOX_FACE_IDS.map((faceId) => this.createFaceMaterial(brush, faceId, document.materials[brush.faces[faceId].materialId ?? ""], this.getFaceHighlightState(brush.id, faceId), volumeRenderPaths, contactPatches)); - 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); - } - this.refreshBrushPresentation(); - this.applyShadowState(); - } - configureFogVolumeMesh(mesh, materials) { - const fogMaterials = Array.from(new Set(materials.filter((material) => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined))); - if (fogMaterials.length === 0) { - return; - } - mesh.onBeforeRender = (_renderer, _scene, camera) => { - const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position)); - for (const material of fogMaterials) { - material.uniforms["localCameraPosition"].value.copy(localCameraPosition); - } - }; - } - createFogMaterialSet(brush, volumeRenderPaths) { - if (brush.volume.mode !== "fog" || this.displayMode === "wireframe" || this.displayMode === "authoring") { - return null; - } - const highlightStates = BOX_FACE_IDS.map((faceId) => this.getFaceHighlightState(brush.id, faceId)); - const selectedFace = highlightStates.includes("selected"); - const hoveredFace = !selectedFace && highlightStates.includes("hovered"); - const quality = volumeRenderPaths.fog === "quality"; - const densityBoost = selectedFace ? 1.08 : hoveredFace ? 1.04 : 1; - const opacityBoost = selectedFace ? 0.08 : hoveredFace ? 0.04 : 0; - if (quality) { - const fogMaterial = createFogQualityMaterial({ - colorHex: brush.volume.fog.colorHex, - density: brush.volume.fog.density * densityBoost, - padding: brush.volume.fog.padding, - time: this.volumeTime, - halfSize: { - x: brush.size.x * 0.5, - y: brush.size.y * 0.5, - z: brush.size.z * 0.5 - }, - opacityMultiplier: densityBoost, - colorLift: selectedFace ? 0.08 : hoveredFace ? 0.04 : 0 - }); - this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); - return BOX_FACE_IDS.map(() => fogMaterial.material); - } - const baseOpacity = Math.max(0.08, Math.min(0.82, brush.volume.fog.density * 0.9 + 0.1)); - const fogMaterial = new MeshStandardMaterial({ - color: brush.volume.fog.colorHex, - emissive: brush.volume.fog.colorHex, - emissiveIntensity: 0.04, - roughness: 1, - metalness: 0, - transparent: true, - opacity: Math.min(0.92, baseOpacity + opacityBoost), - depthWrite: false - }); - return BOX_FACE_IDS.map(() => fogMaterial); - } - 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, volumeRenderPaths, contactPatches) { - const face = brush.faces[faceId]; - const selectedFace = highlightState === "selected"; - const hoveredFace = highlightState === "hovered"; - const emphasizedFace = selectedFace || hoveredFace; - if (brush.volume.mode === "water") { - const quality = volumeRenderPaths.water === "quality"; - const baseOpacity = Math.max(0.08, Math.min(1, brush.volume.water.surfaceOpacity)); - const opacityBoost = faceId === "posY" ? 0.16 : 0; - const opacity = Math.min(1, baseOpacity + opacityBoost + (selectedFace ? 0.08 : hoveredFace ? 0.04 : 0)); - const waterMaterial = createWaterMaterial({ - colorHex: brush.volume.water.colorHex, - surfaceOpacity: brush.volume.water.surfaceOpacity, - waveStrength: brush.volume.water.waveStrength, - surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled, - opacity, - quality, - wireframe: this.displayMode === "wireframe", - isTopFace: faceId === "posY", - time: this.volumeTime, - halfSize: { - x: brush.size.x * 0.5, - z: brush.size.z * 0.5 - }, - contactPatches, - reflection: { - texture: null, - enabled: faceId === "posY" - } - }); - if (waterMaterial.animationUniform !== null) { - this.volumeAnimatedUniforms.push(waterMaterial.animationUniform); - } - if (faceId === "posY" && waterMaterial.reflectionMatrixUniform !== null && waterMaterial.reflectionEnabledUniform !== null) { - this.viewportWaterSurfaceBindings.push({ - brush, - reflectionTextureUniform: waterMaterial.reflectionTextureUniform, - reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform, - reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform, - reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null, - lastReflectionUpdateTime: Number.NEGATIVE_INFINITY - }); - } - return waterMaterial.material; - } - if (brush.volume.mode === "fog") { - const quality = volumeRenderPaths.fog === "quality"; - const baseOpacity = Math.max(0.08, Math.min(0.82, brush.volume.fog.density * (quality ? 0.65 : 0.9) + 0.1)); - const opacity = Math.min(0.92, baseOpacity + (selectedFace ? 0.08 : hoveredFace ? 0.04 : 0)); - if (this.displayMode === "wireframe") { - return new MeshBasicMaterial({ - color: brush.volume.fog.colorHex, - wireframe: true, - transparent: true, - opacity: Math.min(1, opacity + 0.16), - depthWrite: false - }); - } - if (this.displayMode === "authoring") { - return new MeshBasicMaterial({ - color: brush.volume.fog.colorHex, - transparent: true, - opacity - }); - } - if (quality) { - const fogMaterial = createFogQualityMaterial({ - colorHex: brush.volume.fog.colorHex, - density: brush.volume.fog.density * (selectedFace ? 1.12 : hoveredFace ? 1.06 : 1), - padding: brush.volume.fog.padding, - time: this.volumeTime, - halfSize: { - x: brush.size.x * 0.5, - y: brush.size.y * 0.5, - z: brush.size.z * 0.5 - }, - opacityMultiplier: selectedFace ? 1.12 : hoveredFace ? 1.06 : 1, - colorLift: selectedFace ? 0.08 : hoveredFace ? 0.04 : 0 - }); - this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); - return fogMaterial.material; - } - return new MeshStandardMaterial({ - color: brush.volume.fog.colorHex, - emissive: brush.volume.fog.colorHex, - emissiveIntensity: quality ? 0.08 : 0.04, - roughness: 1, - metalness: 0, - transparent: true, - opacity, - depthWrite: false - }); - } - 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 - }); - } - getWaterReflectionMode() { - if (this.currentWorld === null || - !this.currentWorld.advancedRendering.enabled || - this.currentWorld.advancedRendering.waterPath !== "quality" || - this.displayMode !== "normal" || - this.viewMode !== "perspective") { - return "none"; - } - return this.currentWorld.advancedRendering.waterReflectionMode; - } - createWaterReflectionRenderTarget() { - const canvasWidth = this.container?.clientWidth ?? this.renderer.domElement.width; - const canvasHeight = this.container?.clientHeight ?? this.renderer.domElement.height; - const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5)); - const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5)); - return new WebGLRenderTarget(width, height); - } - resizeWaterReflectionTargets() { - const canvasWidth = this.container?.clientWidth ?? this.renderer.domElement.width; - const canvasHeight = this.container?.clientHeight ?? this.renderer.domElement.height; - const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5)); - const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5)); - for (const binding of this.viewportWaterSurfaceBindings) { - binding.reflectionRenderTarget?.setSize(width, height); - binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY; - } - } - updateViewportWaterReflections() { - const activeCamera = this.getActiveCamera(); - if (!(activeCamera instanceof PerspectiveCamera)) { - for (const binding of this.viewportWaterSurfaceBindings) { - if (binding.reflectionEnabledUniform !== null) { - binding.reflectionEnabledUniform.value = 0; - } - } - return; - } - const reflectionMode = this.getWaterReflectionMode(); - const now = performance.now(); - for (const binding of this.viewportWaterSurfaceBindings) { - if (reflectionMode === "none" || - binding.reflectionTextureUniform === null || - binding.reflectionMatrixUniform === null || - binding.reflectionEnabledUniform === null) { - if (binding.reflectionEnabledUniform !== null) { - binding.reflectionEnabledUniform.value = 0; - } - continue; - } - if (binding.reflectionRenderTarget === null) { - binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget(); - } - const canRenderReflection = updatePlanarReflectionCamera(binding.brush, activeCamera, this.waterReflectionCamera, binding.reflectionMatrixUniform.value); - if (!canRenderReflection || binding.reflectionRenderTarget === null) { - binding.reflectionEnabledUniform.value = 0; - continue; - } - if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) { - binding.reflectionEnabledUniform.value = 0.36; - continue; - } - const hiddenObjects = []; - const hideObject = (object) => { - if (object === null || object === undefined) { - return; - } - hiddenObjects.push({ object, visible: object.visible }); - object.visible = false; - }; - for (const waterBinding of this.viewportWaterSurfaceBindings) { - const renderObjects = this.brushRenderObjects.get(waterBinding.brush.id); - if (renderObjects !== undefined) { - hideObject(renderObjects.mesh); - } - } - for (const renderObjects of this.brushRenderObjects.values()) { - hideObject(renderObjects.edges); - for (const edgeHelper of renderObjects.edgeHelpers) { - hideObject(edgeHelper.line); - } - for (const vertexHelper of renderObjects.vertexHelpers) { - hideObject(vertexHelper.mesh); - } - } - hideObject(this.axesHelper); - hideObject(this.gridHelpers.xz); - hideObject(this.gridHelpers.xy); - hideObject(this.gridHelpers.yz); - hideObject(this.entityGroup); - hideObject(this.transformGizmoGroup); - hideObject(this.boxCreatePreviewMesh); - hideObject(this.boxCreatePreviewEdges); - hideObject(this.creationPreviewObject); - if (reflectionMode === "world") { - hideObject(this.modelGroup); - } - const previousAutoClear = this.renderer.autoClear; - const previousRenderTarget = this.renderer.getRenderTarget(); - const previousReflectionStates = this.viewportWaterSurfaceBindings.map((waterBinding) => ({ - binding: waterBinding, - enabled: waterBinding.reflectionEnabledUniform?.value ?? 0, - texture: waterBinding.reflectionTextureUniform?.value ?? null - })); - try { - for (const state of previousReflectionStates) { - if (state.binding.reflectionEnabledUniform !== null) { - state.binding.reflectionEnabledUniform.value = 0; - } - } - binding.reflectionTextureUniform.value = null; - this.renderer.autoClear = true; - this.renderer.setRenderTarget(binding.reflectionRenderTarget); - this.renderer.clear(); - this.renderer.render(this.scene, this.waterReflectionCamera); - } - finally { - this.renderer.setRenderTarget(previousRenderTarget); - this.renderer.autoClear = previousAutoClear; - for (const state of previousReflectionStates) { - if (state.binding.reflectionEnabledUniform !== null) { - state.binding.reflectionEnabledUniform.value = state.enabled; - } - if (state.binding.reflectionTextureUniform !== null) { - state.binding.reflectionTextureUniform.value = state.texture; - } - } - for (const hiddenObject of hiddenObjects) { - hiddenObject.object.visible = hiddenObject.visible; - } - } - binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture; - binding.reflectionEnabledUniform.value = 0.36; - binding.lastReflectionUpdateTime = now; - } - } - 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; - } - collectViewportWaterContactPatches(document, waterBrush) { - const contactBounds = []; - for (const brush of Object.values(document.brushes)) { - if (brush.id === waterBrush.id || brush.volume.mode !== "none") { - continue; - } - const derivedMesh = buildBoxBrushDerivedMeshData(brush); - contactBounds.push({ - kind: "triangleMesh", - vertices: derivedMesh.colliderVertices, - indices: derivedMesh.colliderIndices, - transform: { - position: brush.center, - rotationDegrees: brush.rotationDegrees, - scale: { - x: 1, - y: 1, - z: 1 - } - } - }); - } - for (const modelInstance of getModelInstances(document.modelInstances)) { - if (modelInstance.collision.mode === "none") { - continue; - } - const asset = this.projectAssets[modelInstance.assetId]; - if (asset?.kind !== "model") { - continue; - } - try { - const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, this.loadedModelAssets[modelInstance.assetId]); - if (generatedCollider !== null) { - if (generatedCollider.kind === "trimesh") { - contactBounds.push({ - kind: "triangleMesh", - vertices: generatedCollider.vertices, - indices: generatedCollider.indices, - mergeProfile: "aggressive", - transform: generatedCollider.transform - }); - } - else { - contactBounds.push(generatedCollider.worldBounds); - } - } - } - catch { - } - } - return collectWaterContactPatches({ - center: waterBrush.center, - rotationDegrees: waterBrush.rotationDegrees, - size: waterBrush.size - }, contactBounds, this.getViewportWaterFoamContactLimit(waterBrush)); - } - getViewportWaterFoamContactLimit(brush) { - return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0; - } - 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; - } - const volumeRenderPaths = resolveBoxVolumeRenderPaths(this.currentDocument.world.advancedRendering); - 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; - const contactPatches = brush.volume.mode === "water" ? this.collectViewportWaterContactPatches(this.currentDocument, brush) : []; - renderObjects.mesh.material = this.createFogMaterialSet(brush, volumeRenderPaths) ?? BOX_FACE_IDS.map((faceId) => this.createFaceMaterial(brush, faceId, this.currentDocument?.materials[brush.faces[faceId].materialId ?? ""], this.getFaceHighlightState(brush.id, faceId), volumeRenderPaths, contactPatches)); - this.configureFogVolumeMesh(renderObjects.mesh, renderObjects.mesh.material); - this.disposeUniqueMaterials(previousMaterials); - 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; - } - } - } - disposeUniqueMaterials(materials) { - for (const material of new Set(materials)) { - material.dispose(); - } - } - 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(); - this.disposeUniqueMaterials(renderObjects.mesh.material); - renderObjects.edges.geometry.dispose(); - renderObjects.edges.material.dispose(); - } - this.brushRenderObjects.clear(); - this.volumeAnimatedUniforms.length = 0; - for (const binding of this.viewportWaterSurfaceBindings) { - binding.reflectionRenderTarget?.dispose(); - } - this.viewportWaterSurfaceBindings.length = 0; - } - 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); - this.resizeWaterReflectionTargets(); - } - 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 = () => { - if (!this.renderEnabled) { - this.animationFrame = 0; - return; - } - this.animationFrame = window.requestAnimationFrame(this.render); - this.updateTransformGizmoPose(); - const now = performance.now(); - const dt = this.previousFrameTime === 0 ? 0 : Math.min((now - this.previousFrameTime) / 1000, 1 / 20); - this.previousFrameTime = now; - this.volumeTime += dt; - for (const uniform of this.volumeAnimatedUniforms) { - uniform.value = this.volumeTime; - } - if (this.viewportWaterSurfaceBindings.length > 0) { - this.updateViewportWaterReflections(); - } - 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 deleted file mode 100644 index 79344ef5..00000000 --- a/src/viewport-three/viewport-layout.js +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index a1a75bca..00000000 --- a/src/viewport-three/viewport-transient-state.js +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 54890f61..00000000 --- a/src/viewport-three/viewport-view-modes.js +++ /dev/null @@ -1,89 +0,0 @@ -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/vite.config.js b/vite.config.js deleted file mode 100644 index 6cb65227..00000000 --- a/vite.config.js +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], - resolve: { - // Prefer TypeScript source over compiled JS mirrors when both exist - extensions: [".mjs", ".mts", ".ts", ".tsx", ".jsx", ".js", ".json"] - }, - server: { - host: "0.0.0.0", - port: 5173 - } -}); diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index d16b002d..00000000 --- a/vitest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -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"] - } -}));