@vgai/editor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +35 -0
- package/dist/a/b/c/test-nested.json +1 -0
- package/dist/assets/CCDIKSolver-Dz-ysxZF.js +1 -0
- package/dist/assets/SelectionBox-BXsv_6aH.js +1 -0
- package/dist/assets/browser-project-scripts-DOru4L1x.js +2 -0
- package/dist/assets/browserAll-CEkWYlFk.js +1 -0
- package/dist/assets/collision-world-DniMB0qq.glb +0 -0
- package/dist/assets/create-runtime-DYIWr3va.js +1 -0
- package/dist/assets/data-asset-Bb9sIM7i.js +5 -0
- package/dist/assets/esbuild-BHljloGq.wasm +0 -0
- package/dist/assets/fake-three-BMTQ9rl_.js +1 -0
- package/dist/assets/game-8SDZPPgH.js +1 -0
- package/dist/assets/game-BI111o5f.js +10 -0
- package/dist/assets/game-CevorxyV.js +1 -0
- package/dist/assets/game-Di1H0gFj.js +1 -0
- package/dist/assets/game-Di7XlywD.js +1 -0
- package/dist/assets/game-DjnCaw8R.js +1 -0
- package/dist/assets/game-Q9A0aXSV.js +1 -0
- package/dist/assets/game-pt_1lQLv.js +1 -0
- package/dist/assets/game-source-CvidN7AB.js +1 -0
- package/dist/assets/game-xsOho_wg.js +83 -0
- package/dist/assets/index-Bp5cecD6.js +12 -0
- package/dist/assets/index-CPVV6pjw.js +1 -0
- package/dist/assets/index-CpOnYyeD.js +213 -0
- package/dist/assets/index-Cs8xwv3e.js +1 -0
- package/dist/assets/index-DL7RcQ0n.js +27 -0
- package/dist/assets/index-Dr8H_6Rl.js +1 -0
- package/dist/assets/index-_bCaipUk.js +13650 -0
- package/dist/assets/ingest-siblings-Dz8eEqrV.js +1 -0
- package/dist/assets/mount-manifest-Bhk0ohsl.js +1 -0
- package/dist/assets/recast-navigation.wasm-compat-CvP4UGK6.js +1 -0
- package/dist/assets/scene-query-BivAHhJu.js +1 -0
- package/dist/assets/stats.module--VATS4Kh.js +1 -0
- package/dist/assets/webworkerAll-BLrH0F6F.js +1 -0
- package/dist/assets/xstate-animation-binding-1NGLmw6y.js +1 -0
- package/dist/assets/xstate.esm-BgUD33nl.js +3 -0
- package/dist/index.html +37 -0
- package/dist/ingest/racing-game/UPSTREAM.md +68 -0
- package/dist/ingest/racing-game/a/Inter-Black.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Black.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-BlackItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-BlackItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Bold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Bold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-BoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-BoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLight.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLight.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Italic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Italic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Light.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Light.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-LightItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-LightItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Medium.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Medium.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-MediumItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-MediumItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Regular.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Regular.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Thin.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Thin.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ThinItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ThinItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-italic.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-roman.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/index.css +1 -0
- package/dist/ingest/racing-game/images/bronze.png +0 -0
- package/dist/ingest/racing-game/images/gold.png +0 -0
- package/dist/ingest/racing-game/images/silver.png +0 -0
- package/dist/ingest/racing-game/index.html +15 -0
- package/dist/ingest/racing-game/models/chassis-draco.glb +0 -0
- package/dist/ingest/racing-game/models/track-draco.glb +0 -0
- package/dist/ingest/racing-game/models/wheel-draco.glb +0 -0
- package/dist/ingest/racing-game/rg-entry.js +1 -0
- package/dist/ingest/racing-game/sounds/accelerate.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/boost.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/crash.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/engine.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/honk.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/tire-brake.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/train.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/water.mp3 +0 -0
- package/dist/ingest/racing-game/textures/cursor.svg +3 -0
- package/dist/ingest/racing-game/textures/dikhololo_night_1k.hdr +0 -0
- package/dist/ingest/racing-game/textures/heightmap_1024.png +0 -0
- package/dist/ingest/racing-game/textures/mask.svg +11 -0
- package/dist/ingest/racing-game/textures/no_sound.png +0 -0
- package/dist/ingest/racing-game/three-r139.module.js +50617 -0
- package/dist/ingest/racing-game/vendor.js +179 -0
- package/dist/ingest/racing-game/vgai.game.json +20 -0
- package/dist/ingest/simcity/UPSTREAM.md +65 -0
- package/dist/ingest/simcity/favicon.ico +0 -0
- package/dist/ingest/simcity/fonts/Bebas-Regular.otf +0 -0
- package/dist/ingest/simcity/fonts/Digitalt.otf +0 -0
- package/dist/ingest/simcity/fonts/Stanberry.ttf +0 -0
- package/dist/ingest/simcity/icons/bulldozer-color.png +0 -0
- package/dist/ingest/simcity/icons/bulldozer.png +0 -0
- package/dist/ingest/simcity/icons/calendar.png +0 -0
- package/dist/ingest/simcity/icons/factory-color.png +0 -0
- package/dist/ingest/simcity/icons/factory.png +0 -0
- package/dist/ingest/simcity/icons/house-color.png +0 -0
- package/dist/ingest/simcity/icons/house.png +0 -0
- package/dist/ingest/simcity/icons/job.png +0 -0
- package/dist/ingest/simcity/icons/office.png +0 -0
- package/dist/ingest/simcity/icons/pause-color.png +0 -0
- package/dist/ingest/simcity/icons/pause.png +0 -0
- package/dist/ingest/simcity/icons/person.png +0 -0
- package/dist/ingest/simcity/icons/play-color.png +0 -0
- package/dist/ingest/simcity/icons/play.png +0 -0
- package/dist/ingest/simcity/icons/power-color.png +0 -0
- package/dist/ingest/simcity/icons/power-line-color.png +0 -0
- package/dist/ingest/simcity/icons/power.png +0 -0
- package/dist/ingest/simcity/icons/road-color.png +0 -0
- package/dist/ingest/simcity/icons/road.png +0 -0
- package/dist/ingest/simcity/icons/select-color.png +0 -0
- package/dist/ingest/simcity/icons/select.png +0 -0
- package/dist/ingest/simcity/icons/store-color.png +0 -0
- package/dist/ingest/simcity/index.html +94 -0
- package/dist/ingest/simcity/index.js +58 -0
- package/dist/ingest/simcity/main.css +233 -0
- package/dist/ingest/simcity/models/armored-truck.glb +0 -0
- package/dist/ingest/simcity/models/building-block-4floor-corner.glb +0 -0
- package/dist/ingest/simcity/models/building-block-4floor-short.glb +0 -0
- package/dist/ingest/simcity/models/building-block-5floor.glb +0 -0
- package/dist/ingest/simcity/models/building-burger-joint.glb +0 -0
- package/dist/ingest/simcity/models/building-cafe.glb +0 -0
- package/dist/ingest/simcity/models/building-casino.glb +0 -0
- package/dist/ingest/simcity/models/building-cinema.glb +0 -0
- package/dist/ingest/simcity/models/building-house-block-big.glb +0 -0
- package/dist/ingest/simcity/models/building-house-family-large.glb +0 -0
- package/dist/ingest/simcity/models/building-house-family-small.glb +0 -0
- package/dist/ingest/simcity/models/building-office-balcony.glb +0 -0
- package/dist/ingest/simcity/models/building-office-big.glb +0 -0
- package/dist/ingest/simcity/models/building-office-pyramid.glb +0 -0
- package/dist/ingest/simcity/models/building-office-tall.glb +0 -0
- package/dist/ingest/simcity/models/building-office.glb +0 -0
- package/dist/ingest/simcity/models/building-restaurant.glb +0 -0
- package/dist/ingest/simcity/models/building-skyscraper.glb +0 -0
- package/dist/ingest/simcity/models/car-ambulance-pickup.glb +0 -0
- package/dist/ingest/simcity/models/car-baywatch.glb +0 -0
- package/dist/ingest/simcity/models/car-hippie-van.glb +0 -0
- package/dist/ingest/simcity/models/car-passenger-race.glb +0 -0
- package/dist/ingest/simcity/models/car-passenger.glb +0 -0
- package/dist/ingest/simcity/models/car-police.glb +0 -0
- package/dist/ingest/simcity/models/car-taxi.glb +0 -0
- package/dist/ingest/simcity/models/car-tow-truck.glb +0 -0
- package/dist/ingest/simcity/models/car-truck-dump.glb +0 -0
- package/dist/ingest/simcity/models/car-veteran.glb +0 -0
- package/dist/ingest/simcity/models/construction-small.glb +0 -0
- package/dist/ingest/simcity/models/data-center.glb +0 -0
- package/dist/ingest/simcity/models/industry-factory-old.glb +0 -0
- package/dist/ingest/simcity/models/industry-factory.glb +0 -0
- package/dist/ingest/simcity/models/industry-refinery.glb +0 -0
- package/dist/ingest/simcity/models/industry-warehouse.glb +0 -0
- package/dist/ingest/simcity/models/power_line_pole_modified.glb +0 -0
- package/dist/ingest/simcity/models/tile-plain_grass.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-curve.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-end.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-intersection-t.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-intersection.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-straight.glb +0 -0
- package/dist/ingest/simcity/models/truck.glb +0 -0
- package/dist/ingest/simcity/statusIcons/no-power.png +0 -0
- package/dist/ingest/simcity/statusIcons/no-road-access.png +0 -0
- package/dist/ingest/simcity/textures/base.png +0 -0
- package/dist/ingest/simcity/textures/grid.png +0 -0
- package/dist/ingest/simcity/textures/specular.png +0 -0
- package/dist/ingest/simcity/three-r155.module.js +51466 -0
- package/dist/ingest/simcity/vgai.game.json +20 -0
- package/dist/ingest/tanks/UPSTREAM.md +69 -0
- package/dist/ingest/tanks/index.html +214 -0
- package/dist/ingest/tanks/models/pixelTank.fbx +0 -0
- package/dist/ingest/tanks/tanks-entry.js +92 -0
- package/dist/ingest/tanks/three-r170.module.js +54571 -0
- package/dist/ingest/tanks/vgai.game.json +20 -0
- package/dist/inputmaps/default.inputmap.json +41 -0
- package/dist/jsm/libs/draco/gltf/draco_decoder.js +33 -0
- package/dist/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
- package/dist/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
- package/dist/scenes/untitled.vscn.json +29 -0
- package/dist/textures/waternormals.jpg +0 -0
- package/dist-server/packaged.mjs +18155 -0
- package/package.json +36 -0
- package/server/asset-library-routes.ts +833 -0
- package/server/canonical-path.ts +50 -0
- package/server/cloud-asset-catalog.ts +101 -0
- package/server/dev.ts +350 -0
- package/server/editor-server.ts +1537 -0
- package/server/editor-sse.ts +73 -0
- package/server/game-globals-shadow.ts +58 -0
- package/server/local-asset-catalog.ts +163 -0
- package/server/packaged.ts +571 -0
- package/server/prod.ts +75 -0
- package/server/project-optimize-deps-entries.ts +149 -0
- package/server/project-view.ts +65 -0
- package/server/server-utils.ts +430 -0
- package/server/session-registry.ts +93 -0
- package/src/EditorContext.tsx +87 -0
- package/src/achieved-tier.ts +126 -0
- package/src/action-registry.ts +268 -0
- package/src/adapter-resolver.ts +1858 -0
- package/src/asset-events.ts +100 -0
- package/src/authoring/active-adapter.ts +98 -0
- package/src/authoring/active-systems.ts +50 -0
- package/src/authoring/boundary-authoring-adapter.ts +178 -0
- package/src/authoring/composite-authoring-adapter.ts +891 -0
- package/src/authoring/design-time-layers.ts +776 -0
- package/src/authoring/edit-mode-authoring.ts +643 -0
- package/src/authoring/embed-only-adapter.ts +57 -0
- package/src/authoring/ephemeral-persistence.ts +26 -0
- package/src/authoring/eyedropper-session.ts +69 -0
- package/src/authoring/first-party-hierarchy-menu.ts +74 -0
- package/src/authoring/first-party-inspector/AddSectionMenu.tsx +118 -0
- package/src/authoring/first-party-inspector/EnvironmentSection.tsx +550 -0
- package/src/authoring/first-party-inspector/HeaderSection.tsx +329 -0
- package/src/authoring/first-party-inspector/is-first-party-selection.ts +44 -0
- package/src/authoring/first-party-inspector/register.tsx +108 -0
- package/src/authoring/ingest-authoring-adapter.ts +475 -0
- package/src/authoring/ingest-capture.ts +94 -0
- package/src/authoring/ingest-game-adapter.ts +291 -0
- package/src/authoring/ingest-iframe-adapter.ts +38 -0
- package/src/authoring/ingest-iframe-reachable-adapter.ts +401 -0
- package/src/authoring/ingest-overlay.ts +193 -0
- package/src/authoring/layered-pick.ts +87 -0
- package/src/authoring/mount-failure-report.ts +112 -0
- package/src/authoring/overlay-report.ts +52 -0
- package/src/authoring/project-scenes.ts +111 -0
- package/src/authoring/react-dom-authoring-adapter.ts +795 -0
- package/src/authoring/react-inspector-section.tsx +1041 -0
- package/src/authoring/react-world-authoring-adapter.ts +2400 -0
- package/src/authoring/root-activation.ts +61 -0
- package/src/authoring/scene-ui-layer.ts +485 -0
- package/src/authoring/story-overlay.ts +48 -0
- package/src/authoring/ui-authoring-adapter.ts +325 -0
- package/src/authoring/ui-hierarchy-menu.ts +40 -0
- package/src/authoring/ui-inspector-sections.tsx +156 -0
- package/src/authoring/undo-timeline.ts +145 -0
- package/src/authoring/vgai-scene-authoring-adapter.ts +746 -0
- package/src/authoring/viewport-pick-context.ts +31 -0
- package/src/authoring/viewport-raycast.ts +80 -0
- package/src/authoring/world-overlay.ts +76 -0
- package/src/authoring/world-pan-state.ts +115 -0
- package/src/authoring/world-session-state.ts +102 -0
- package/src/authoring/world2d-authoring-adapter.ts +313 -0
- package/src/browser-external-watch.ts +98 -0
- package/src/browser-play-entry.ts +112 -0
- package/src/browser-project-scripts.ts +112 -0
- package/src/browser-transpile.ts +246 -0
- package/src/browser-watch-reload.ts +100 -0
- package/src/command-listener.ts +326 -0
- package/src/components/AlignToolbar.tsx +189 -0
- package/src/components/AppRoot.tsx +73 -0
- package/src/components/AssetBrowser.tsx +502 -0
- package/src/components/BottomPanel.tsx +326 -0
- package/src/components/BuildPanel.tsx +455 -0
- package/src/components/CameraInfo.tsx +56 -0
- package/src/components/CommandPalette.tsx +265 -0
- package/src/components/ConsolePanel.tsx +272 -0
- package/src/components/DataPanel.tsx +1268 -0
- package/src/components/DefaultEditorLayout.tsx +509 -0
- package/src/components/FolderTree.tsx +179 -0
- package/src/components/GameHierarchy.tsx +1349 -0
- package/src/components/Inspector.tsx +671 -0
- package/src/components/InspectorToolSection.tsx +74 -0
- package/src/components/MountFailureBanner.tsx +110 -0
- package/src/components/NetworkSection.tsx +109 -0
- package/src/components/OnlineAssetBrowser.tsx +448 -0
- package/src/components/OverlayReportBanner.tsx +122 -0
- package/src/components/PlayBar.tsx +356 -0
- package/src/components/PrefabSaveDialog.tsx +201 -0
- package/src/components/ProjectHeader.tsx +174 -0
- package/src/components/ProjectScreen.tsx +1004 -0
- package/src/components/RightPanel.tsx +166 -0
- package/src/components/RootActivateDialog.tsx +105 -0
- package/src/components/SaveDialog.tsx +197 -0
- package/src/components/SaveStatus.tsx +69 -0
- package/src/components/StatsOverlay.tsx +35 -0
- package/src/components/ToolHost.tsx +80 -0
- package/src/components/Toolbar.tsx +307 -0
- package/src/components/ViewportOverlay.tsx +234 -0
- package/src/components/ViewportPanel.tsx +369 -0
- package/src/components/WorldContextMenu.tsx +257 -0
- package/src/components/WorldSelectionOverlay.tsx +2003 -0
- package/src/components/WorldTextEditor.tsx +86 -0
- package/src/components/asset-viewers/AssetViewer.tsx +38 -0
- package/src/components/asset-viewers/AudioViewer.tsx +149 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +77 -0
- package/src/components/asset-viewers/ImageViewer.tsx +65 -0
- package/src/components/asset-viewers/InputMapViewer.tsx +206 -0
- package/src/components/asset-viewers/ModelViewer.tsx +180 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +236 -0
- package/src/components/data-csv.ts +301 -0
- package/src/components/entity-name-commit.ts +18 -0
- package/src/components/inspector-property-grouping.ts +65 -0
- package/src/components/inspector-widgets/AlignmentGrid.tsx +188 -0
- package/src/components/inspector-widgets/BorderEditor.tsx +305 -0
- package/src/components/inspector-widgets/ColorPicker.tsx +548 -0
- package/src/components/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/components/inspector-widgets/FontPicker.tsx +196 -0
- package/src/components/inspector-widgets/GradientEditor.tsx +277 -0
- package/src/components/inspector-widgets/ScrubbableInput.tsx +152 -0
- package/src/components/inspector-widgets/ShadowEditor.tsx +332 -0
- package/src/components/inspector-widgets/color-utils.ts +202 -0
- package/src/components/inspector-widgets/gallery-main.tsx +167 -0
- package/src/components/inspector-widgets/index.ts +22 -0
- package/src/components/inspector-widgets/shared.tsx +135 -0
- package/src/components/inspectors/AnimationSection.tsx +143 -0
- package/src/components/inspectors/AudioSection.tsx +215 -0
- package/src/components/inspectors/BoneAttachmentSection.tsx +82 -0
- package/src/components/inspectors/CameraSection.tsx +105 -0
- package/src/components/inspectors/ComponentsSection.tsx +358 -0
- package/src/components/inspectors/JointsSection.tsx +255 -0
- package/src/components/inspectors/LightSection.tsx +116 -0
- package/src/components/inspectors/MaterialSection.tsx +1189 -0
- package/src/components/inspectors/MeshSection.tsx +99 -0
- package/src/components/inspectors/NavigationSection.tsx +58 -0
- package/src/components/inspectors/ParticleSection.tsx +681 -0
- package/src/components/inspectors/PhysicsSection.tsx +326 -0
- package/src/components/inspectors/PostProcessingSection.tsx +1290 -0
- package/src/components/inspectors/RenderingSection.tsx +109 -0
- package/src/components/inspectors/ShadowSection.tsx +84 -0
- package/src/components/inspectors/SplineSection.tsx +191 -0
- package/src/components/inspectors/TransformSection.tsx +191 -0
- package/src/components/inspectors/json-schema-fields.ts +281 -0
- package/src/components/inspectors/multi-edit-utils.ts +49 -0
- package/src/components/inspectors/zod-fields.ts +184 -0
- package/src/components/primitives/ColorInput.tsx +109 -0
- package/src/components/primitives/JsonInput.tsx +98 -0
- package/src/components/primitives/NumberInput.tsx +164 -0
- package/src/components/primitives/Panel.tsx +68 -0
- package/src/components/primitives/SectionHeader.tsx +45 -0
- package/src/components/primitives/Tooltip.tsx +82 -0
- package/src/components/primitives/Vec3Input.tsx +62 -0
- package/src/components/world-overlay-gestures.ts +1438 -0
- package/src/doctor/data-checks.ts +285 -0
- package/src/doctor/report.ts +386 -0
- package/src/editor-api.ts +700 -0
- package/src/editor-console.ts +194 -0
- package/src/editor-hotkeys.ts +441 -0
- package/src/editor-layers.ts +2 -0
- package/src/editor-mode.ts +28 -0
- package/src/editor-store.ts +2225 -0
- package/src/editor-viewport.ts +1924 -0
- package/src/entity-factory.ts +221 -0
- package/src/game-globals-prelude.ts +27 -0
- package/src/gated-globals.ts +131 -0
- package/src/generated/setup-by-example.generated.ts +53 -0
- package/src/gizmo-registry.ts +608 -0
- package/src/handle-system.ts +221 -0
- package/src/hierarchy-menu-registry.ts +70 -0
- package/src/hierarchy-rows.ts +73 -0
- package/src/hosted-example.ts +160 -0
- package/src/hotkeys.ts +142 -0
- package/src/ingest/discovery-react.ts +132 -0
- package/src/ingest/discovery.ts +205 -0
- package/src/ingest/discovery2d.ts +204 -0
- package/src/ingest/esm-entry-resolution.ts +102 -0
- package/src/ingest/games/bloom-composer/game.js +99 -0
- package/src/ingest/games/bloom-composer/vgai.game.json +21 -0
- package/src/ingest/games/embed-bundle/embed.html +10 -0
- package/src/ingest/games/embed-bundle/vgai.game.json +21 -0
- package/src/ingest/games/games-fps/assets/collision-world.glb +0 -0
- package/src/ingest/games/games-fps/game.js +479 -0
- package/src/ingest/games/games-fps/vgai.game.json +24 -0
- package/src/ingest/games/iframe-reachable/game-source.js +58 -0
- package/src/ingest/games/iframe-reachable/vgai.game.json +21 -0
- package/src/ingest/games/interactive-cubes/game.js +139 -0
- package/src/ingest/games/interactive-cubes/vgai.game.json +21 -0
- package/src/ingest/games/mismatched-three/fake-three.js +38 -0
- package/src/ingest/games/mismatched-three/game.js +10 -0
- package/src/ingest/games/mismatched-three/vgai.game.json +22 -0
- package/src/ingest/games/own-three-bundled/game.js +43 -0
- package/src/ingest/games/own-three-bundled/vgai.game.json +21 -0
- package/src/ingest/games/pointerlock-maze/game.js +288 -0
- package/src/ingest/games/pointerlock-maze/vgai.game.json +22 -0
- package/src/ingest/games-2d/_fixtures/first-party-scene.scn2d.json +124 -0
- package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +25 -0
- package/src/ingest/games-2d/bunnymark/game.ts +88 -0
- package/src/ingest/games-2d/bunnymark/vgai.game.json +21 -0
- package/src/ingest/games-2d/flappy/game.ts +148 -0
- package/src/ingest/games-2d/flappy/vgai.game.json +21 -0
- package/src/ingest/games-2d/flappy-pixi/vgai.game.json +25 -0
- package/src/ingest/games-2d/puzzling-potions/vgai.game.json +25 -0
- package/src/ingest/games-2d/tilemap-camera/game.ts +115 -0
- package/src/ingest/games-2d/tilemap-camera/vgai.game.json +21 -0
- package/src/ingest/games-react/react-rpg/entry.tsx +185 -0
- package/src/ingest/games-react/react-rpg/shims.d.ts +26 -0
- package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +129 -0
- package/src/ingest/games-react/react-rpg/vgai.game.json +21 -0
- package/src/ingest/host-namespace-registry.ts +196 -0
- package/src/ingest/types.ts +127 -0
- package/src/ingest-hierarchy-rows.ts +207 -0
- package/src/ingest-mode.ts +2092 -0
- package/src/ingest-siblings.ts +466 -0
- package/src/initial-scene.ts +194 -0
- package/src/inspector-section-registry.ts +117 -0
- package/src/interactive-edit-scope.ts +34 -0
- package/src/main.tsx +23 -0
- package/src/manifest-project.ts +43 -0
- package/src/model-thumbnail.ts +156 -0
- package/src/module-mode.ts +513 -0
- package/src/navmesh-handler.ts +94 -0
- package/src/packaged-runtime.ts +49 -0
- package/src/play-mode.ts +1148 -0
- package/src/playground/main.ts +206 -0
- package/src/project-manager.ts +173 -0
- package/src/scene-diff.ts +112 -0
- package/src/scene-serializer.ts +144 -0
- package/src/scene-sync.ts +587 -0
- package/src/scene-ui-mode/active-scene-ui.ts +58 -0
- package/src/scene-ui-mode/scene-ui-palette-panel.tsx +152 -0
- package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +29 -0
- package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +74 -0
- package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +49 -0
- package/src/scene-ui-mode/ui-prefab-io.ts +76 -0
- package/src/script-loader.ts +143 -0
- package/src/storage/fsa-storage.ts +55 -0
- package/src/storage/handle-storage.ts +150 -0
- package/src/storage/handle-store.ts +116 -0
- package/src/storage/http-storage.ts +137 -0
- package/src/storage/index.ts +147 -0
- package/src/storage/mem-storage.ts +135 -0
- package/src/storage/opfs-storage.ts +25 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/seed.ts +106 -0
- package/src/storage/types.ts +110 -0
- package/src/tool-loader.ts +291 -0
- package/src/ui-editor/inspector.tsx +563 -0
- package/src/ui-editor/node-ops.ts +266 -0
- package/src/ui-editor/overlay.tsx +380 -0
- package/src/ui-editor/palette.tsx +114 -0
- package/src/ui-editor/react-store.ts +78 -0
- package/src/ui-editor/timeline-panel.tsx +443 -0
- package/src/ui-editor/ui-edit-store.ts +552 -0
- package/src/ui-editor/ui-prefab.ts +95 -0
- package/src/ui-source/inspect.ts +544 -0
- package/src/ui-source/oid-transform.ts +133 -0
- package/src/ui-source/source-write-backend.ts +203 -0
- package/src/ui-source/tailwind-map.ts +174 -0
- package/src/ui-source/writer.ts +1175 -0
- package/src/widgets/index.ts +73 -0
- package/template/.claude/skills/editor/SKILL.md +252 -0
- package/template/CLAUDE.md +512 -0
- package/template/LICENSE +30 -0
- package/template/emit-schemas.ts +42 -0
- package/template/index.html +17 -0
- package/template/package.json +53 -0
- package/template/project.json +9 -0
- package/template/public/audio/ambient-arena.wav +0 -0
- package/template/public/audio/ambient-music.wav +0 -0
- package/template/public/audio/ambient-nature.wav +0 -0
- package/template/public/audio/ambient-wind.wav +0 -0
- package/template/public/inputmaps/default.inputmap.json +41 -0
- package/template/public/jsm/libs/draco/gltf/draco_decoder.js +33 -0
- package/template/public/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
- package/template/public/jsm/libs/draco/gltf/draco_encoder.js +33 -0
- package/template/public/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
- package/template/public/scenes/default.vscn.json +43 -0
- package/template/public/scenes/untitled.vscn.json +29 -0
- package/template/public/textures/waternormals.jpg +0 -0
- package/template/server/colyseus-setup.ts +85 -0
- package/template/server/main.ts +34 -0
- package/template/server/rooms/arena-room.ts +420 -0
- package/template/server/rooms/game-room.ts +162 -0
- package/template/server/rooms.ts +15 -0
- package/template/server/tsconfig.json +19 -0
- package/template/src/data/README.md +40 -0
- package/template/src/data/assets.ts +31 -0
- package/template/src/data/tuning.data.json +5 -0
- package/template/src/data/tuning.schema.json +21 -0
- package/template/src/data/tuning.schema.ts +34 -0
- package/template/src/data/tuning.ts +24 -0
- package/template/src/main.ts +157 -0
- package/template/src/scripts/components/data-spinner.ts +45 -0
- package/template/src/scripts/components/scene-camera.ts +40 -0
- package/template/src/scripts/main.ts +94 -0
- package/template/src/scripts/registry.ts +46 -0
- package/template/src/tools/README.md +14 -0
- package/template/src/tools/example.tool.tsx +91 -0
- package/template/src/tools/spin.tool.tsx +101 -0
- package/template/src/ui/Button.tsx +27 -0
- package/template/src/ui/DefaultHud.tsx +40 -0
- package/template/src/ui/KeyHint.tsx +25 -0
- package/template/src/ui/Panel.tsx +52 -0
- package/template/src/ui/RebindPanel.tsx +129 -0
- package/template/src/ui/Slider.tsx +28 -0
- package/template/src/ui/StatusDot.tsx +22 -0
- package/template/src/ui/Toggle.tsx +19 -0
- package/template/src/ui/game-state.tsx +37 -0
- package/template/src/ui/index.ts +7 -0
- package/template/src/ui/mount-default-hud.tsx +19 -0
- package/template/src/ui/mount-ui.ts +15 -0
- package/template/tsconfig.json +36 -0
- package/template/validate-asset-content.ts +78 -0
- package/template/validate-manifest.ts +55 -0
- package/template/validate-scenes.ts +211 -0
- package/template/vgai.game.json +21 -0
- package/template/vite.config.ts +128 -0
- package/vite-plugin-example-assets.ts +60 -0
- package/vite-plugin-game-globals.ts +46 -0
- package/vite-plugin-game-static.ts +71 -0
- package/vite-plugin-project-game-static.ts +84 -0
- package/vite-plugin-react-game-static.ts +98 -0
- package/vite-plugin-react-world-provider.ts +81 -0
- package/vite-plugin-react-world-runtime.ts +76 -0
- package/vite-plugin-ui-oid.ts +561 -0
|
@@ -0,0 +1,2225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditorStore — central editor state backed by the Three.js scene graph.
|
|
3
|
+
*
|
|
4
|
+
* The scene graph (THREE.Scene) is the single source of truth for entity data.
|
|
5
|
+
* Each entity Object3D carries its full SceneEntity descriptor in
|
|
6
|
+
* `userData['entity']`. The store reads from and writes to these descriptors,
|
|
7
|
+
* and triggers per-entity Three.js rebuilds when properties change.
|
|
8
|
+
*
|
|
9
|
+
* Full scene rebuilds (clearPreview + buildPreview) only happen on:
|
|
10
|
+
* - Scene load
|
|
11
|
+
* - Undo / redo
|
|
12
|
+
* - Prefab resolution
|
|
13
|
+
* - Asset path rename
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PersistenceProvider } from '@engine/adapter';
|
|
17
|
+
import { applyAssetRename } from '@engine/scene/asset-paths';
|
|
18
|
+
import {
|
|
19
|
+
mergePrefabInstance,
|
|
20
|
+
type PrefabFile,
|
|
21
|
+
type SceneEntity,
|
|
22
|
+
type SceneEnvironment,
|
|
23
|
+
type SceneFile,
|
|
24
|
+
} from '@engine/scene/scene-types';
|
|
25
|
+
import { getUserData, setUserData } from '@engine/scene/user-data';
|
|
26
|
+
import * as THREE from 'three';
|
|
27
|
+
import type { BatchedRenderer } from 'three.quarks';
|
|
28
|
+
import {
|
|
29
|
+
activeOverrideIsEditMode,
|
|
30
|
+
getActiveAuthoring,
|
|
31
|
+
hasAuthoringOverride,
|
|
32
|
+
} from './authoring/active-adapter';
|
|
33
|
+
import { notifyUndoableOp, registerUndoAdapter } from './authoring/undo-timeline';
|
|
34
|
+
import { loadEditorState, saveEditorState, saveFile, saveThumbnail } from './editor-api';
|
|
35
|
+
import { createEntityObject } from './entity-factory';
|
|
36
|
+
import { diffScene } from './scene-diff';
|
|
37
|
+
import type { SceneMetadata } from './scene-serializer';
|
|
38
|
+
import { cleanDocument, serializeScene } from './scene-serializer';
|
|
39
|
+
import {
|
|
40
|
+
applyEnvironment,
|
|
41
|
+
applyShadingOverride,
|
|
42
|
+
buildPreview,
|
|
43
|
+
registerParticles,
|
|
44
|
+
unregisterParticles,
|
|
45
|
+
updateEntityPreview,
|
|
46
|
+
} from './scene-sync';
|
|
47
|
+
|
|
48
|
+
/** Like Partial but explicitly allows setting fields to undefined (to clear them). */
|
|
49
|
+
export type EntityUpdate = { [K in keyof SceneEntity]?: SceneEntity[K] | undefined };
|
|
50
|
+
|
|
51
|
+
export type TransformMode = 'translate' | 'rotate' | 'scale';
|
|
52
|
+
export type TransformSpace = 'world' | 'local';
|
|
53
|
+
export type PivotMode = 'active-element' | 'median-point' | 'individual-origins';
|
|
54
|
+
|
|
55
|
+
export type ShadingMode = 'solid' | 'wireframe' | 'unlit';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which persistence regime the CURRENT play session's edits are under —
|
|
59
|
+
* a pure readout of the decision `play-mode.ts` already makes to pick a
|
|
60
|
+
* provider (docs/MULTI-WORLD-DESIGN.md §1.F / MASTER-ARCHITECTURE-REVIEW §7.4
|
|
61
|
+
* gap 4): `'ephemeral'` for a single-world session (edits discarded on stop,
|
|
62
|
+
* `createEphemeralPersistence`), `'overlay'` for a multi-world (≥2) session
|
|
63
|
+
* (composite of per-world overlay-persisted adapters, edits survive the
|
|
64
|
+
* session). `null` while not playing. Never derive this independently of the
|
|
65
|
+
* real provider decision — see `play-mode.ts`'s `enterPlayMode`, which sets it
|
|
66
|
+
* from the SAME `_isMultiWorldSession` boolean that selects
|
|
67
|
+
* `installMultiWorldAuthoring` vs `installPlayAuthoring`.
|
|
68
|
+
*/
|
|
69
|
+
export type PlayEditRegime = 'ephemeral' | 'overlay' | null;
|
|
70
|
+
|
|
71
|
+
export type AssetKind =
|
|
72
|
+
| 'model'
|
|
73
|
+
| 'image'
|
|
74
|
+
| 'audio'
|
|
75
|
+
| 'json'
|
|
76
|
+
| 'scene'
|
|
77
|
+
| 'prefab'
|
|
78
|
+
| 'material'
|
|
79
|
+
| 'source';
|
|
80
|
+
|
|
81
|
+
export interface OnlineAssetInfo {
|
|
82
|
+
source: 'polyhaven' | 'ambientcg' | 'local';
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
thumbnailUrl: string;
|
|
86
|
+
type: string;
|
|
87
|
+
tags: string[];
|
|
88
|
+
license?: string;
|
|
89
|
+
author?: string;
|
|
90
|
+
sourceUrl?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AssetTab {
|
|
94
|
+
key: string;
|
|
95
|
+
label: string;
|
|
96
|
+
assetPath: string;
|
|
97
|
+
kind: AssetKind;
|
|
98
|
+
/** Present when this tab represents an online (not-yet-downloaded) asset. */
|
|
99
|
+
online?: OnlineAssetInfo;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface HelperVisibility {
|
|
103
|
+
lights: boolean;
|
|
104
|
+
cameras: boolean;
|
|
105
|
+
colliders: boolean;
|
|
106
|
+
audio: boolean;
|
|
107
|
+
splines: boolean;
|
|
108
|
+
navmesh: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type ViewportAction =
|
|
112
|
+
| { type: 'focus-entity'; id: string }
|
|
113
|
+
| { type: 'focus-selection' }
|
|
114
|
+
| { type: 'set-view-preset'; preset: 'top' | 'front' | 'right' | 'perspective' }
|
|
115
|
+
| {
|
|
116
|
+
type: 'set-camera-pose';
|
|
117
|
+
position: { x: number; y: number; z: number };
|
|
118
|
+
target: { x: number; y: number; z: number };
|
|
119
|
+
fov?: number;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const MAX_UNDO = 50;
|
|
123
|
+
/** Cooldown (ms) to prevent tight feedback loops between auto-save and external updates. */
|
|
124
|
+
const SYNC_COOLDOWN_MS = 500;
|
|
125
|
+
|
|
126
|
+
function deepClone<T>(obj: T): T {
|
|
127
|
+
return JSON.parse(JSON.stringify(obj)) as T;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Ensure every entity has a UUID and a children array. Mutates in place. */
|
|
131
|
+
export function assignMissingIds(entities: SceneEntity[]): void {
|
|
132
|
+
const walk = (list: SceneEntity[]) => {
|
|
133
|
+
for (const e of list) {
|
|
134
|
+
if (!e.id) e.id = crypto.randomUUID();
|
|
135
|
+
if (!e.children) e.children = [];
|
|
136
|
+
walk(e.children);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
walk(entities);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Walk entity Object3Ds in the scene graph and call fn on each. */
|
|
143
|
+
function walkEntityObjects(
|
|
144
|
+
parent: THREE.Object3D,
|
|
145
|
+
fn: (obj: THREE.Object3D, entity: SceneEntity) => void,
|
|
146
|
+
): void {
|
|
147
|
+
for (const child of parent.children) {
|
|
148
|
+
if (getUserData(child, 'entityId') === undefined) continue;
|
|
149
|
+
if (getUserData(child, 'editorHelper')) continue;
|
|
150
|
+
const entity = getUserData(child, 'entity') as SceneEntity | undefined;
|
|
151
|
+
if (!entity) continue;
|
|
152
|
+
fn(child, entity);
|
|
153
|
+
walkEntityObjects(child, fn);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Walk a SceneEntity tree (JSON). */
|
|
158
|
+
function walkEntities(entities: SceneEntity[], fn: (e: SceneEntity) => void): void {
|
|
159
|
+
for (const e of entities) {
|
|
160
|
+
fn(e);
|
|
161
|
+
if (e.children) walkEntities(e.children, fn);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class EditorStore {
|
|
166
|
+
/**
|
|
167
|
+
* Register with the editor-global undo TIMELINE (T3.2 slice 2) once, for the
|
|
168
|
+
* lifetime of this store — first-party never "disposes" mid-session, so unlike
|
|
169
|
+
* ingest/UI sessions there is no matching `unregisterUndoAdapter` call. Safe for
|
|
170
|
+
* headless test instances too: the timeline only ever calls INTO
|
|
171
|
+
* `this.undo()`/`this.redo()` when notified, never the reverse.
|
|
172
|
+
*/
|
|
173
|
+
constructor() {
|
|
174
|
+
registerUndoAdapter(this);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Scene graph references (set via bindScene) ---
|
|
178
|
+
private _scene: THREE.Scene | null = null;
|
|
179
|
+
private _editorScene: THREE.Scene | null = null; // stored while game scene is active
|
|
180
|
+
private _editorObjectMap: Map<string, THREE.Object3D> | null = null; // stored during play
|
|
181
|
+
/** Pre-play snapshot of `_sceneMeta`, restored by exitPlayScene (see enterPlayScene). */
|
|
182
|
+
private _editorSceneMeta: SceneMetadata | null = null;
|
|
183
|
+
private _objectMap = new Map<string, THREE.Object3D>();
|
|
184
|
+
private _renderer: THREE.WebGLRenderer | null = null;
|
|
185
|
+
private _camera: THREE.Camera | null = null;
|
|
186
|
+
private _batchedRenderer: BatchedRenderer | null = null;
|
|
187
|
+
private _lastThumbnailTime = 0;
|
|
188
|
+
private _lastEditorStateSaveTime = 0;
|
|
189
|
+
private _orbitTarget: THREE.Vector3 | null = null;
|
|
190
|
+
|
|
191
|
+
/** Scene-level metadata (version, name, environment). Not per-entity. */
|
|
192
|
+
private _sceneMeta: SceneMetadata = { version: 1, name: 'Untitled' };
|
|
193
|
+
|
|
194
|
+
// --- UI state (not stored in scene graph) ---
|
|
195
|
+
private _selectedEntityIds = new Set<string>();
|
|
196
|
+
private _clipboard: SceneEntity[] = [];
|
|
197
|
+
private _transformMode: TransformMode = 'translate';
|
|
198
|
+
private _transformSpace: TransformSpace = 'world';
|
|
199
|
+
private _snapEnabled = false;
|
|
200
|
+
private _snapValues = { translate: 1, rotate: 15, scale: 0.25 };
|
|
201
|
+
private _snapToSurface = false;
|
|
202
|
+
private _pivotMode: PivotMode = 'active-element';
|
|
203
|
+
private _showGrid = true;
|
|
204
|
+
private _showHelpers = true;
|
|
205
|
+
private _helperVisibility: HelperVisibility = {
|
|
206
|
+
lights: true,
|
|
207
|
+
cameras: true,
|
|
208
|
+
colliders: true,
|
|
209
|
+
audio: true,
|
|
210
|
+
splines: true,
|
|
211
|
+
navmesh: true,
|
|
212
|
+
};
|
|
213
|
+
private _showStats = false;
|
|
214
|
+
private _shadingMode: ShadingMode = 'solid';
|
|
215
|
+
private _isDirty = false;
|
|
216
|
+
/**
|
|
217
|
+
* Bumped on every document mutation that sets `_isDirty`. A save captures
|
|
218
|
+
* the epoch when it serializes; its completion callback only clears
|
|
219
|
+
* `_isDirty` if the epoch is unchanged — a save resolving AFTER a newer
|
|
220
|
+
* mutation (e.g. an in-flight autosave racing an undo) must not mark the
|
|
221
|
+
* newer, still-unpersisted state as saved.
|
|
222
|
+
*/
|
|
223
|
+
private _docEpoch = 0;
|
|
224
|
+
/**
|
|
225
|
+
* Exact content of the last write we issued, for own-echo detection: the
|
|
226
|
+
* file watcher reports our own saves back via SSE, and when that echo
|
|
227
|
+
* arrives after `_ignoreExternalUntil` (slow watcher under load) it must
|
|
228
|
+
* not be applied as an "external" update — the document may have moved on
|
|
229
|
+
* (undo/redo), and applying the echo would clobber the newer state.
|
|
230
|
+
*/
|
|
231
|
+
private _lastSavedContent: string | null = null;
|
|
232
|
+
/** True when the most recent auto-save / manual save attempt failed. */
|
|
233
|
+
private _lastSaveFailed = false;
|
|
234
|
+
/** True while a coalesced interactive edit gesture is in progress. */
|
|
235
|
+
private _interactiveActive = false;
|
|
236
|
+
private _navMeshBaked = false;
|
|
237
|
+
private _savePath: string | null = null;
|
|
238
|
+
private _undoStack: string[] = [];
|
|
239
|
+
private _redoStack: string[] = [];
|
|
240
|
+
private _activeTabKey = '__inspector__';
|
|
241
|
+
private _assetTabs: AssetTab[] = [];
|
|
242
|
+
private _documentType: 'scene' | 'prefab' = 'scene';
|
|
243
|
+
private _playState: 'stopped' | 'playing' | 'paused' = 'stopped';
|
|
244
|
+
/** See {@link PlayEditRegime} — set by play-mode.ts on play-enter, cleared on play-exit. */
|
|
245
|
+
private _playEditRegime: PlayEditRegime = null;
|
|
246
|
+
private _activeViewportTab: 'scene' | 'game' = 'scene';
|
|
247
|
+
private _commandPaletteOpen = false;
|
|
248
|
+
private _vertexSnapActive = false;
|
|
249
|
+
private _listeners = new Set<() => void>();
|
|
250
|
+
private _viewportActionListeners = new Set<(action: ViewportAction) => void>();
|
|
251
|
+
private _version = 0;
|
|
252
|
+
/**
|
|
253
|
+
* Bumped only when post-processing-relevant state changes (environment / scene
|
|
254
|
+
* structure / play-stop scene swap). The viewport rebuilds its EffectComposer
|
|
255
|
+
* only when this changes — NOT on every notify — so a transform drag/scrub (which
|
|
256
|
+
* notifies per frame but never touches these) no longer recompiles shaders 60×/s.
|
|
257
|
+
*/
|
|
258
|
+
private _composerVersion = 0;
|
|
259
|
+
private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
260
|
+
/** Timestamp after which external updates are accepted (cooldown after auto-save). */
|
|
261
|
+
private _ignoreExternalUntil = 0;
|
|
262
|
+
/** Timestamp after which auto-save is allowed (cooldown after external update). */
|
|
263
|
+
private _suppressAutoSaveUntil = 0;
|
|
264
|
+
/** Rolling counter for sync cycles — trips a circuit breaker if too many in a short window. */
|
|
265
|
+
private _syncCycleTimestamps: number[] = [];
|
|
266
|
+
/**
|
|
267
|
+
* Deferred file-watcher/asset-move inputs received while play/ingest is
|
|
268
|
+
* active (latest wins per kind). Applied once play/ingest stops — see
|
|
269
|
+
* `setPlayState`/`_applyPendingExternalMutations`, `applyExternalUpdate`,
|
|
270
|
+
* `applyAssetMove`.
|
|
271
|
+
*/
|
|
272
|
+
private _pendingExternalUpdate: SceneFile | null = null;
|
|
273
|
+
private _pendingAssetMove: { oldPath: string; newPath: string } | null = null;
|
|
274
|
+
|
|
275
|
+
// --- Bind to scene graph (call once at editor init) ---
|
|
276
|
+
|
|
277
|
+
bindScene(
|
|
278
|
+
scene: THREE.Scene,
|
|
279
|
+
renderer?: THREE.WebGLRenderer,
|
|
280
|
+
batchedRenderer?: BatchedRenderer | null,
|
|
281
|
+
camera?: THREE.Camera,
|
|
282
|
+
): void {
|
|
283
|
+
this._scene = scene;
|
|
284
|
+
this._renderer = renderer ?? null;
|
|
285
|
+
this._batchedRenderer = batchedRenderer ?? null;
|
|
286
|
+
if (camera) this._camera = camera;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Set the orbit controls target reference for camera state persistence. */
|
|
290
|
+
setOrbitTarget(target: THREE.Vector3): void {
|
|
291
|
+
this._orbitTarget = target;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- React useSyncExternalStore integration ---
|
|
295
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
296
|
+
this._listeners.add(listener);
|
|
297
|
+
return () => this._listeners.delete(listener);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
getSnapshot = (): number => this._version;
|
|
301
|
+
|
|
302
|
+
private _notify(): void {
|
|
303
|
+
this._version++;
|
|
304
|
+
for (const fn of this._listeners) fn();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Viewport action dispatch ---
|
|
308
|
+
// Panels call these methods; ViewportPanel subscribes and executes them.
|
|
309
|
+
|
|
310
|
+
onViewportAction = (fn: (action: ViewportAction) => void): (() => void) => {
|
|
311
|
+
this._viewportActionListeners.add(fn);
|
|
312
|
+
return () => this._viewportActionListeners.delete(fn);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
private _emitViewportAction(action: ViewportAction): void {
|
|
316
|
+
for (const fn of this._viewportActionListeners) fn(action);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Focus the viewport camera on a specific entity. */
|
|
320
|
+
focusOnEntity(id: string): void {
|
|
321
|
+
this._emitViewportAction({ type: 'focus-entity', id });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Focus the viewport camera on the current selection. */
|
|
325
|
+
focusOnSelection(): void {
|
|
326
|
+
this._emitViewportAction({ type: 'focus-selection' });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Switch the viewport camera to a preset view (top, front, right, perspective). */
|
|
330
|
+
setViewPreset(preset: 'top' | 'front' | 'right' | 'perspective'): void {
|
|
331
|
+
this._emitViewportAction({ type: 'set-view-preset', preset });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Move the viewport camera to an arbitrary position/target/fov pose. */
|
|
335
|
+
setCameraPose(
|
|
336
|
+
position: { x: number; y: number; z: number },
|
|
337
|
+
target: { x: number; y: number; z: number },
|
|
338
|
+
fov?: number,
|
|
339
|
+
): void {
|
|
340
|
+
this._emitViewportAction({
|
|
341
|
+
type: 'set-camera-pose',
|
|
342
|
+
position,
|
|
343
|
+
target,
|
|
344
|
+
...(fov !== undefined ? { fov } : {}),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Live viewport camera pose, read straight from the bound Three.js camera +
|
|
350
|
+
* orbit target (`bindScene`/`setOrbitTarget` — wired by `ViewportPanel.tsx`).
|
|
351
|
+
* Null only when nothing has bound a camera yet (e.g. a headless store in a
|
|
352
|
+
* unit test that never mounted `ViewportPanel`).
|
|
353
|
+
*/
|
|
354
|
+
get cameraPose(): { position: THREE.Vector3; target: THREE.Vector3; fov: number } | null {
|
|
355
|
+
if (!this._camera) return null;
|
|
356
|
+
const fov = (this._camera as THREE.PerspectiveCamera).fov;
|
|
357
|
+
return {
|
|
358
|
+
position: this._camera.position.clone(),
|
|
359
|
+
target: this._orbitTarget ? this._orbitTarget.clone() : new THREE.Vector3(),
|
|
360
|
+
fov: typeof fov === 'number' ? fov : 0,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- Scene graph accessors ---
|
|
365
|
+
|
|
366
|
+
/** The object map: entity ID → Three.js Object3D. */
|
|
367
|
+
get objectMap(): Map<string, THREE.Object3D> {
|
|
368
|
+
return this._objectMap;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The bound Three.js scene. */
|
|
372
|
+
get scene(): THREE.Scene | null {
|
|
373
|
+
return this._scene;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Monotonic counter bumped when post-processing inputs change (see field doc). */
|
|
377
|
+
get composerVersion(): number {
|
|
378
|
+
return this._composerVersion;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Build the entity tree from the live scene graph.
|
|
383
|
+
* Returns a nested SceneEntity[] matching the Three.js hierarchy.
|
|
384
|
+
* Used by SceneHierarchy and any code that needs the full tree.
|
|
385
|
+
*/
|
|
386
|
+
get entities(): SceneEntity[] {
|
|
387
|
+
if (!this._scene) return [];
|
|
388
|
+
return this._buildEntityTree(this._scene);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Flattened id/name/childIds rows for the whole entity tree — the wire
|
|
393
|
+
* shape `editor.hierarchy.inspect` reports (see
|
|
394
|
+
* `command-listener.ts`'s `collectState`). Kept here (not in
|
|
395
|
+
* command-listener.ts) so that file — outside the `SceneEntity` allowlist
|
|
396
|
+
* (`test/authoring-inversion-guard.test.ts`) — never needs to import
|
|
397
|
+
* `SceneEntity` itself; this is the one place that walks the real tree.
|
|
398
|
+
* Entities missing an `id` (should not happen post `assignMissingIds`, but
|
|
399
|
+
* the type is optional) are skipped — there is no stable key to report.
|
|
400
|
+
*/
|
|
401
|
+
hierarchyRows(): { id: string; name: string; childIds: string[] }[] {
|
|
402
|
+
const rows: { id: string; name: string; childIds: string[] }[] = [];
|
|
403
|
+
const walkTree = (list: SceneEntity[]) => {
|
|
404
|
+
for (const e of list) {
|
|
405
|
+
if (!e.id) continue;
|
|
406
|
+
rows.push({
|
|
407
|
+
id: e.id,
|
|
408
|
+
name: e.name,
|
|
409
|
+
childIds: (e.children ?? []).map((c) => c.id).filter((id): id is string => Boolean(id)),
|
|
410
|
+
});
|
|
411
|
+
if (e.children) walkTree(e.children);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
walkTree(this.entities);
|
|
415
|
+
return rows;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Scene-level environment settings. */
|
|
419
|
+
get environment(): SceneEnvironment | undefined {
|
|
420
|
+
return this._sceneMeta.environment;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The scene's UI roots (the `ui` field) — read by UI-edit mode (K5). */
|
|
424
|
+
getSceneUI(): import('@engine/scene/schema').UIRoot[] {
|
|
425
|
+
return this._sceneMeta.ui ?? [];
|
|
426
|
+
}
|
|
427
|
+
/** Replace the scene's UI roots (UI-edit commit), mark dirty + autosave (C9). */
|
|
428
|
+
setSceneUI(roots: import('@engine/scene/schema').UIRoot[]): void {
|
|
429
|
+
this._sceneMeta.ui = roots;
|
|
430
|
+
this._isDirty = true;
|
|
431
|
+
this._docEpoch++;
|
|
432
|
+
this._autoSave();
|
|
433
|
+
this._notify();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Scene name. */
|
|
437
|
+
get sceneName(): string {
|
|
438
|
+
return this._sceneMeta.name;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
setSceneName(name: string): void {
|
|
442
|
+
this._sceneMeta.name = name;
|
|
443
|
+
// No undo — name changes are lightweight, same as old behavior
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- Selection ---
|
|
447
|
+
get selectedEntityId(): string | null {
|
|
448
|
+
if (this._selectedEntityIds.size === 0) return null;
|
|
449
|
+
return [...this._selectedEntityIds].at(-1)!;
|
|
450
|
+
}
|
|
451
|
+
get selectedEntityIds(): ReadonlySet<string> {
|
|
452
|
+
return this._selectedEntityIds;
|
|
453
|
+
}
|
|
454
|
+
get selectedEntity(): SceneEntity | null {
|
|
455
|
+
const id = this.selectedEntityId;
|
|
456
|
+
return id ? this.getEntityById(id) : null;
|
|
457
|
+
}
|
|
458
|
+
get selectedEntities(): SceneEntity[] {
|
|
459
|
+
return [...this._selectedEntityIds]
|
|
460
|
+
.map((id) => this.getEntityById(id))
|
|
461
|
+
.filter((e): e is SceneEntity => e !== null);
|
|
462
|
+
}
|
|
463
|
+
get transformMode(): TransformMode {
|
|
464
|
+
return this._transformMode;
|
|
465
|
+
}
|
|
466
|
+
get transformSpace(): TransformSpace {
|
|
467
|
+
return this._transformSpace;
|
|
468
|
+
}
|
|
469
|
+
get snapEnabled(): boolean {
|
|
470
|
+
return this._snapEnabled;
|
|
471
|
+
}
|
|
472
|
+
get snapValues() {
|
|
473
|
+
return this._snapValues;
|
|
474
|
+
}
|
|
475
|
+
get snapToSurface(): boolean {
|
|
476
|
+
return this._snapToSurface;
|
|
477
|
+
}
|
|
478
|
+
get pivotMode(): PivotMode {
|
|
479
|
+
return this._pivotMode;
|
|
480
|
+
}
|
|
481
|
+
get isDirty(): boolean {
|
|
482
|
+
return this._isDirty;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Coarse save status for UI affordances:
|
|
486
|
+
* 'failed' — last save attempt failed; changes are NOT on disk.
|
|
487
|
+
* 'unsaved' — there are dirty changes not yet persisted.
|
|
488
|
+
* 'saved' — in sync with disk.
|
|
489
|
+
*/
|
|
490
|
+
get saveState(): 'saved' | 'unsaved' | 'failed' {
|
|
491
|
+
if (this._lastSaveFailed) return 'failed';
|
|
492
|
+
if (this._isDirty) return 'unsaved';
|
|
493
|
+
return 'saved';
|
|
494
|
+
}
|
|
495
|
+
get canUndo(): boolean {
|
|
496
|
+
return this._undoStack.length > 0;
|
|
497
|
+
}
|
|
498
|
+
get canRedo(): boolean {
|
|
499
|
+
return this._redoStack.length > 0;
|
|
500
|
+
}
|
|
501
|
+
get showGrid(): boolean {
|
|
502
|
+
return this._showGrid;
|
|
503
|
+
}
|
|
504
|
+
get showHelpers(): boolean {
|
|
505
|
+
return this._showHelpers;
|
|
506
|
+
}
|
|
507
|
+
get helperVisibility(): Readonly<HelperVisibility> {
|
|
508
|
+
return this._helperVisibility;
|
|
509
|
+
}
|
|
510
|
+
get showStats(): boolean {
|
|
511
|
+
return this._showStats;
|
|
512
|
+
}
|
|
513
|
+
get shadingMode(): ShadingMode {
|
|
514
|
+
return this._shadingMode;
|
|
515
|
+
}
|
|
516
|
+
get hasClipboard(): boolean {
|
|
517
|
+
return this._clipboard.length > 0;
|
|
518
|
+
}
|
|
519
|
+
get savePath(): string | null {
|
|
520
|
+
return this._savePath;
|
|
521
|
+
}
|
|
522
|
+
setSavePath(path: string | null): void {
|
|
523
|
+
this._savePath = path;
|
|
524
|
+
this._notify();
|
|
525
|
+
}
|
|
526
|
+
get documentType(): 'scene' | 'prefab' {
|
|
527
|
+
return this._documentType;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Notify subscribers after an ingest adapter mutated a live foreign object
|
|
531
|
+
* (material/visibility). Runtime-only — no dirty/autosave (edits live on the
|
|
532
|
+
* Object3D, not a .vscn descriptor). */
|
|
533
|
+
notifyIngestEdit(): void {
|
|
534
|
+
this._notify();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// --- Play mode scene swap ---
|
|
538
|
+
|
|
539
|
+
/** Callback to sync transform edits to game ECS. Set by play-mode, cleared on exit. */
|
|
540
|
+
private _ecsSyncTransform: ((obj: THREE.Object3D) => void) | null = null;
|
|
541
|
+
|
|
542
|
+
/** Set a callback that syncs editor transform changes to the game's ECS state. */
|
|
543
|
+
setEcsSyncTransform(fn: ((obj: THREE.Object3D) => void) | null): void {
|
|
544
|
+
this._ecsSyncTransform = fn;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Swap the store's active scene to the game scene. Editor scene is preserved.
|
|
549
|
+
*
|
|
550
|
+
* Also snapshots `_sceneMeta` (env/name/ui — see `getDocument`/`updateEnvironment`/
|
|
551
|
+
* `setSceneName`/`setSceneUI`). Those setters keep mutating the SAME live
|
|
552
|
+
* `_sceneMeta` object during play so the environment inspector / scene name
|
|
553
|
+
* field keep working and `getDocument()` stays consistent while playing —
|
|
554
|
+
* but any such edit must be discarded when play stops (probe: F5 in the
|
|
555
|
+
* round-1 review — `updateEnvironment` during play silently persisted with
|
|
556
|
+
* `isDirty` false). `exitPlayScene` restores this snapshot.
|
|
557
|
+
*/
|
|
558
|
+
enterPlayScene(gameScene: THREE.Scene, opts: { liveHierarchy?: boolean } = {}): void {
|
|
559
|
+
if (!this._scene) return;
|
|
560
|
+
this._editorScene = this._scene;
|
|
561
|
+
this._editorObjectMap = this._objectMap;
|
|
562
|
+
this._editorSceneMeta = deepClone(this._sceneMeta);
|
|
563
|
+
this._scene = gameScene;
|
|
564
|
+
|
|
565
|
+
// In play mode the hierarchy is a LIVE view of the running scene graph, not
|
|
566
|
+
// the authored document — so surface every object, not just the ones that
|
|
567
|
+
// came from the .vscn. Objects the scene-loader tagged keep their rich
|
|
568
|
+
// descriptor (full inspector); procedurally-created / runtime-spawned
|
|
569
|
+
// objects (the ship hull, instanced asteroids, spawned bullets…) get a
|
|
570
|
+
// synthetic descriptor so they appear in the tree, are selectable, and can
|
|
571
|
+
// be nudged via the gizmo to test the game. These edits are ephemeral —
|
|
572
|
+
// nothing is written back to the .vscn (play mode never serializes).
|
|
573
|
+
//
|
|
574
|
+
// Synthesis is OPT-IN (`liveHierarchy: true` — first-party play mode only).
|
|
575
|
+
// Ingest/module mounts adopt a FOREIGN scene through this same method, and
|
|
576
|
+
// the anti-shim rule forbids fabricating a SceneEntity descriptor onto an
|
|
577
|
+
// object we don't own: their hierarchies come from the AuthoringAdapter
|
|
578
|
+
// seam (OID-derived), never from synthesized userData. Untagged foreign
|
|
579
|
+
// objects therefore stay out of the objectMap entirely by default.
|
|
580
|
+
const gameMap = new Map<string, THREE.Object3D>();
|
|
581
|
+
gameScene.traverse((obj) => {
|
|
582
|
+
if (obj === gameScene) return;
|
|
583
|
+
if (getUserData(obj, 'editorHelper')) return;
|
|
584
|
+
// Engine-owned infra (particle renderer, debug-draw) is not a game entity.
|
|
585
|
+
if (getUserData(obj, 'engineInternal')) return;
|
|
586
|
+
let eid = getUserData(obj, 'entityId') as string | undefined;
|
|
587
|
+
if (eid === undefined) {
|
|
588
|
+
if (!opts.liveHierarchy) return;
|
|
589
|
+
eid = obj.uuid;
|
|
590
|
+
setUserData(obj, 'entityId', eid);
|
|
591
|
+
setUserData(obj, 'entity', { id: eid, name: obj.name || obj.type } as SceneEntity);
|
|
592
|
+
setUserData(obj, 'liveObject', true); // marks a non-authored runtime object
|
|
593
|
+
}
|
|
594
|
+
gameMap.set(eid, obj);
|
|
595
|
+
});
|
|
596
|
+
this._objectMap = gameMap;
|
|
597
|
+
|
|
598
|
+
this._selectedEntityIds.clear();
|
|
599
|
+
this._composerVersion++; // scene swapped → composer must rebuild against the game scene
|
|
600
|
+
this._notify();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Restore the editor scene after play mode ends (see enterPlayScene). */
|
|
604
|
+
exitPlayScene(): void {
|
|
605
|
+
if (!this._editorScene || !this._editorObjectMap) return;
|
|
606
|
+
this._scene = this._editorScene;
|
|
607
|
+
this._objectMap = this._editorObjectMap;
|
|
608
|
+
this._editorScene = null;
|
|
609
|
+
this._editorObjectMap = null;
|
|
610
|
+
// Discard any play-time env/name/ui edits — restore the pre-play snapshot.
|
|
611
|
+
if (this._editorSceneMeta) {
|
|
612
|
+
this._sceneMeta = this._editorSceneMeta;
|
|
613
|
+
this._editorSceneMeta = null;
|
|
614
|
+
}
|
|
615
|
+
this._selectedEntityIds.clear();
|
|
616
|
+
this._composerVersion++; // scene swapped back → composer must rebuild against the editor scene
|
|
617
|
+
this._notify();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- Play mode state ---
|
|
621
|
+
get playState(): 'stopped' | 'playing' | 'paused' {
|
|
622
|
+
return this._playState;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Guard structural/history mutations while a game is running. During play the
|
|
627
|
+
* store's `_scene` is the LIVE game scene (see enterPlayScene), so undo/redo
|
|
628
|
+
* (which call clearPreview + buildPreview and rebuild the whole scene) or
|
|
629
|
+
* delete/duplicate (which splice the scene graph and objectMap) would tear the
|
|
630
|
+
* scene out from under the running GameSession. Returns true (and warns) when
|
|
631
|
+
* the named op must be blocked. The intended live-edit path during play is the
|
|
632
|
+
* incremental ECS-sync transform path, not these full-scene mutations.
|
|
633
|
+
*/
|
|
634
|
+
private _blockedDuringPlay(op: string): boolean {
|
|
635
|
+
if (this._playState === 'stopped') return false;
|
|
636
|
+
console.warn(`EditorStore: "${op}" is disabled during play mode — stop the game first.`);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
setPlayState(state: 'stopped' | 'playing' | 'paused'): void {
|
|
640
|
+
const wasActive = this._playState !== 'stopped';
|
|
641
|
+
this._playState = state;
|
|
642
|
+
if (state === 'playing' && this._activeViewportTab === 'scene') {
|
|
643
|
+
this._activeViewportTab = 'game';
|
|
644
|
+
}
|
|
645
|
+
if (state === 'stopped') {
|
|
646
|
+
this._activeViewportTab = 'scene';
|
|
647
|
+
// Apply anything deferred while play/ingest was active. play-mode.ts /
|
|
648
|
+
// ingest-mode.ts call exitPlayScene() (restoring the editor scene) BEFORE
|
|
649
|
+
// this — so by the time we get here `_scene` is already the editor scene,
|
|
650
|
+
// never the just-torn-down game scene (see applyExternalUpdate/applyAssetMove).
|
|
651
|
+
if (wasActive) this._applyPendingExternalMutations();
|
|
652
|
+
}
|
|
653
|
+
this._notify();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** See {@link PlayEditRegime}. Null while not playing. */
|
|
657
|
+
get playEditRegime(): PlayEditRegime {
|
|
658
|
+
return this._playEditRegime;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Set by play-mode.ts's enterPlayMode/exitPlayMode — see {@link PlayEditRegime}. */
|
|
662
|
+
setPlayEditRegime(regime: PlayEditRegime): void {
|
|
663
|
+
this._playEditRegime = regime;
|
|
664
|
+
this._notify();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Apply deferred external-update/asset-move inputs after play/ingest stops. */
|
|
668
|
+
private _applyPendingExternalMutations(): void {
|
|
669
|
+
if (this._pendingExternalUpdate) {
|
|
670
|
+
const doc = this._pendingExternalUpdate;
|
|
671
|
+
this._pendingExternalUpdate = null;
|
|
672
|
+
this.applyExternalUpdate(doc);
|
|
673
|
+
}
|
|
674
|
+
if (this._pendingAssetMove) {
|
|
675
|
+
const { oldPath, newPath } = this._pendingAssetMove;
|
|
676
|
+
this._pendingAssetMove = null;
|
|
677
|
+
this.applyAssetMove(oldPath, newPath);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// --- Viewport tab ---
|
|
682
|
+
get activeViewportTab(): 'scene' | 'game' {
|
|
683
|
+
return this._activeViewportTab;
|
|
684
|
+
}
|
|
685
|
+
setActiveViewportTab(tab: 'scene' | 'game'): void {
|
|
686
|
+
this._activeViewportTab = tab;
|
|
687
|
+
this._notify();
|
|
688
|
+
}
|
|
689
|
+
// --- Command palette ---
|
|
690
|
+
get commandPaletteOpen(): boolean {
|
|
691
|
+
return this._commandPaletteOpen;
|
|
692
|
+
}
|
|
693
|
+
toggleCommandPalette(): void {
|
|
694
|
+
this._commandPaletteOpen = !this._commandPaletteOpen;
|
|
695
|
+
this._notify();
|
|
696
|
+
}
|
|
697
|
+
closeCommandPalette(): void {
|
|
698
|
+
if (!this._commandPaletteOpen) return;
|
|
699
|
+
this._commandPaletteOpen = false;
|
|
700
|
+
this._notify();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// --- Vertex snap ---
|
|
704
|
+
get vertexSnapActive(): boolean {
|
|
705
|
+
return this._vertexSnapActive;
|
|
706
|
+
}
|
|
707
|
+
setVertexSnapActive(active: boolean): void {
|
|
708
|
+
this._vertexSnapActive = active;
|
|
709
|
+
this._notify();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
get activeTabKey(): string {
|
|
713
|
+
return this._activeTabKey;
|
|
714
|
+
}
|
|
715
|
+
get assetTabs(): readonly AssetTab[] {
|
|
716
|
+
return this._assetTabs;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** True if any entities have a navigation role. */
|
|
720
|
+
hasNavigationEntities(): boolean {
|
|
721
|
+
if (!this._scene) return false;
|
|
722
|
+
let found = false;
|
|
723
|
+
walkEntityObjects(this._scene, (_, entity) => {
|
|
724
|
+
if (entity.navigation?.role) found = true;
|
|
725
|
+
});
|
|
726
|
+
return found;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
get navMeshStale(): boolean {
|
|
730
|
+
return !this._navMeshBaked && this.hasNavigationEntities();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
markNavMeshBaked(): void {
|
|
734
|
+
this._navMeshBaked = true;
|
|
735
|
+
this._notify();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// --- Selection ---
|
|
739
|
+
select(id: string | null): void {
|
|
740
|
+
this._selectedEntityIds.clear();
|
|
741
|
+
if (id) this._selectedEntityIds.add(id);
|
|
742
|
+
this._notify();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
selectMultiple(ids: string[]): void {
|
|
746
|
+
this._selectedEntityIds = new Set(ids);
|
|
747
|
+
this._notify();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
addToSelection(id: string): void {
|
|
751
|
+
this._selectedEntityIds.add(id);
|
|
752
|
+
this._notify();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
toggleSelection(id: string): void {
|
|
756
|
+
if (this._selectedEntityIds.has(id)) {
|
|
757
|
+
this._selectedEntityIds.delete(id);
|
|
758
|
+
} else {
|
|
759
|
+
this._selectedEntityIds.add(id);
|
|
760
|
+
}
|
|
761
|
+
this._notify();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
selectAll(): void {
|
|
765
|
+
const all = this.allEntities();
|
|
766
|
+
this._selectedEntityIds = new Set(all.map((e) => e.id!).filter(Boolean));
|
|
767
|
+
this._notify();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// --- Entity lookup ---
|
|
771
|
+
|
|
772
|
+
getEntityById(id: string): SceneEntity | null {
|
|
773
|
+
const obj = this._objectMap.get(id);
|
|
774
|
+
return (getUserData(obj, 'entity') as SceneEntity) ?? null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Flat list of all entities in scene. */
|
|
778
|
+
allEntities(): SceneEntity[] {
|
|
779
|
+
if (!this._scene) return [];
|
|
780
|
+
const result: SceneEntity[] = [];
|
|
781
|
+
walkEntityObjects(this._scene, (_, entity) => result.push(entity));
|
|
782
|
+
return result;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// --- Undo stack ---
|
|
786
|
+
|
|
787
|
+
/** Public: push a snapshot before a drag begins (called by viewport). */
|
|
788
|
+
pushUndoSnapshot(): void {
|
|
789
|
+
this._pushUndo();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private _pushUndo(): void {
|
|
793
|
+
if (!this._scene) return;
|
|
794
|
+
// Play-mode guard (T1.6): while playing/paused, `_scene`/`_objectMap` point
|
|
795
|
+
// at the LIVE game scene (see enterPlayScene) — a fresh object graph loaded
|
|
796
|
+
// from a snapshot of the document, whose entity descriptors are runtime-only
|
|
797
|
+
// copies discarded on exitPlayScene. Pushing a snapshot here would serialize
|
|
798
|
+
// that runtime state onto the REAL undo stack: a later edit's snapshot could
|
|
799
|
+
// capture an intermediate runtime pose, and a post-stop Ctrl+Z would then
|
|
800
|
+
// restore that runtime pose into the restored editor scene. Marking dirty /
|
|
801
|
+
// scheduling autosave would similarly persist runtime state over the on-disk
|
|
802
|
+
// `.vscn`. Skip all three; the property mutation itself (already applied by
|
|
803
|
+
// the caller to the live game-scene descriptor/Object3D) still takes effect
|
|
804
|
+
// for the running game — it's just never recorded in editor history or saved.
|
|
805
|
+
if (this._playState !== 'stopped') return;
|
|
806
|
+
// During an interactive gesture (a NumberInput scrub or color-picker drag),
|
|
807
|
+
// beginInteractiveEdit() already captured ONE pre-gesture snapshot. Skip the
|
|
808
|
+
// per-event snapshot so the whole gesture collapses to a single undo step,
|
|
809
|
+
// but still mark dirty + schedule autosave so the live value persists.
|
|
810
|
+
if (!this._interactiveActive) {
|
|
811
|
+
const doc = serializeScene(this._scene, this._sceneMeta);
|
|
812
|
+
this._undoStack.push(JSON.stringify(doc));
|
|
813
|
+
if (this._undoStack.length > MAX_UNDO) this._undoStack.shift();
|
|
814
|
+
this._redoStack.length = 0;
|
|
815
|
+
// T3.2 slice 2: tag this op on the editor-global undo timeline too (only
|
|
816
|
+
// when a snapshot was actually pushed — the interactive-gesture branch
|
|
817
|
+
// above collapses a whole drag into the ONE snapshot already notified by
|
|
818
|
+
// beginInteractiveEdit's own _pushUndo call).
|
|
819
|
+
notifyUndoableOp(this);
|
|
820
|
+
}
|
|
821
|
+
this._isDirty = true;
|
|
822
|
+
this._docEpoch++;
|
|
823
|
+
this._navMeshBaked = false;
|
|
824
|
+
this._scheduleAutoSave();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Begin a coalesced interactive edit (continuous input gesture). Pushes exactly
|
|
829
|
+
* ONE undo snapshot for the whole gesture; intermediate mutations during the
|
|
830
|
+
* gesture do not push more. Idempotent — safe to call on every pointermove /
|
|
831
|
+
* onChange. Pair with endInteractiveEdit(). Mirrors the viewport gizmo's live
|
|
832
|
+
* drag (pushUndoSnapshot at start, no-undo live updates, commit at end).
|
|
833
|
+
*/
|
|
834
|
+
beginInteractiveEdit(): void {
|
|
835
|
+
if (this._interactiveActive) return;
|
|
836
|
+
this._pushUndo();
|
|
837
|
+
this._interactiveActive = true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** End a coalesced interactive edit gesture. */
|
|
841
|
+
endInteractiveEdit(): void {
|
|
842
|
+
this._interactiveActive = false;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private _scheduleAutoSave(): void {
|
|
846
|
+
if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer);
|
|
847
|
+
this._autoSaveTimer = setTimeout(() => this._autoSave(), 300);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Returns true if too many sync cycles happened recently (circuit breaker). */
|
|
851
|
+
private _isSyncLooping(): boolean {
|
|
852
|
+
const now = Date.now();
|
|
853
|
+
this._syncCycleTimestamps.push(now);
|
|
854
|
+
// Keep only timestamps from the last 5 seconds
|
|
855
|
+
while (this._syncCycleTimestamps.length > 0 && this._syncCycleTimestamps[0]! < now - 5000) {
|
|
856
|
+
this._syncCycleTimestamps.shift();
|
|
857
|
+
}
|
|
858
|
+
if (this._syncCycleTimestamps.length > 20) {
|
|
859
|
+
console.warn(
|
|
860
|
+
'[EditorStore] Sync loop detected — too many save/update cycles. Pausing sync for 5s.',
|
|
861
|
+
);
|
|
862
|
+
this._ignoreExternalUntil = now + 5000;
|
|
863
|
+
this._suppressAutoSaveUntil = now + 5000;
|
|
864
|
+
this._syncCycleTimestamps.length = 0;
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Whether and where an edit persists is decided by the ACTIVE authoring
|
|
872
|
+
* adapter's `persistence` provider — one place, not a store-wide flag per
|
|
873
|
+
* session kind (design `docs/PERSISTENCE-PROVIDER-DESIGN.md` §2). Absent
|
|
874
|
+
* provider, or an adapter that declares `capabilities.persist === false`
|
|
875
|
+
* (e.g. world2d, embed-only), means "nothing to autosave" here.
|
|
876
|
+
*/
|
|
877
|
+
private _activePersistence(): PersistenceProvider | null {
|
|
878
|
+
const adapter = getActiveAuthoring(this);
|
|
879
|
+
if (!adapter.persistence || !adapter.capabilities.persist) return null;
|
|
880
|
+
return adapter.persistence;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private _autoSave(): void {
|
|
884
|
+
this._autoSaveTimer = null;
|
|
885
|
+
// Play-mode guard (T1.6, belt-and-braces): `_scene` is the live game scene
|
|
886
|
+
// while playing/paused (see enterPlayScene). `_pushUndo`/`commitLiveTransform`
|
|
887
|
+
// already skip scheduling autosave during play, and play-mode.ts installs an
|
|
888
|
+
// ephemeral persistence provider (isDirty() always false) via
|
|
889
|
+
// `setActiveAuthoring` for the duration — so this check is now a backstop
|
|
890
|
+
// against a timer that was already in flight the instant play started, not
|
|
891
|
+
// the structural decision (design §2/§7 slice 1).
|
|
892
|
+
if (this._playState !== 'stopped') return;
|
|
893
|
+
const provider = this._activePersistence();
|
|
894
|
+
if (!provider) return;
|
|
895
|
+
if (!this._scene) return;
|
|
896
|
+
// Suppressed (sync cooldown / circuit breaker): RESCHEDULE, don't drop —
|
|
897
|
+
// a dropped autosave silently strands dirty changes off-disk. The retry
|
|
898
|
+
// hits the `_suppressAutoSaveUntil` check first, so it does not feed the
|
|
899
|
+
// `_isSyncLooping` counter while waiting out the window.
|
|
900
|
+
if (Date.now() < this._suppressAutoSaveUntil) {
|
|
901
|
+
this._scheduleAutoSave();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (this._isSyncLooping()) {
|
|
905
|
+
this._scheduleAutoSave();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Delegate the actual write to the provider (first-party's `save()` calls
|
|
909
|
+
// back into `saveNow()` below — the store's existing write path/epoch
|
|
910
|
+
// discipline; an ingest/other provider writes its own destination). Failure
|
|
911
|
+
// handling for the first-party path already lives inside `saveNow()`; this
|
|
912
|
+
// catch only guards a provider whose `save()` itself throws.
|
|
913
|
+
void provider.save().catch((err) => {
|
|
914
|
+
this._lastSaveFailed = true;
|
|
915
|
+
console.warn('[EditorStore] Auto-save threw — changes are NOT saved to disk.', err);
|
|
916
|
+
this._notify();
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Guard manual save (Ctrl/Cmd+S — `saveNow()`, which unconditionally writes
|
|
922
|
+
* `this.getDocument()` to `this._savePath`) against play mode AND any other
|
|
923
|
+
* authoring session (ingest, world2d, UI-edit, …).
|
|
924
|
+
*
|
|
925
|
+
* `_autoSave()` routes through the active adapter's persistence provider (see
|
|
926
|
+
* `_activePersistence()`) so it never reaches this method's write path for a
|
|
927
|
+
* non-first-party session. `saveNow()` itself is still the FIRST-PARTY write
|
|
928
|
+
* (the first-party provider's `save()` calls back into it) — bypassing the
|
|
929
|
+
* debounce entirely — so it needs its own guard: `hasAuthoringOverride()` is
|
|
930
|
+
* true whenever a session installed a DIFFERENT adapter via
|
|
931
|
+
* `setActiveAuthoring` (ingest/world2d/UI-edit/play), meaning `this._scene`
|
|
932
|
+
* and `this.getDocument()` no longer describe the first-party `.vscn` (or, for
|
|
933
|
+
* play, must route through the ephemeral provider instead — see design
|
|
934
|
+
* `docs/PERSISTENCE-PROVIDER-DESIGN.md` §2). During ingest specifically, an
|
|
935
|
+
* ingested foreign object carries only `userData.entityId`, never the
|
|
936
|
+
* `userData['entity']` descriptor `serializeScene` requires, so an unguarded
|
|
937
|
+
* write here would silently overwrite the previously-open first-party scene
|
|
938
|
+
* with an empty entity list (probe: F1b in the round-1 review).
|
|
939
|
+
*/
|
|
940
|
+
private _blockedForSave(): boolean {
|
|
941
|
+
if (this._playState !== 'stopped') {
|
|
942
|
+
console.warn('EditorStore: "save" is disabled during play mode — stop the game first.');
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
// A1: the edit-mode composite is NOT a foreign override — its focused
|
|
946
|
+
// child IS the store's own first-party `.vscn` document, writing through
|
|
947
|
+
// this very path. Blocking it would silently strand the focused world's
|
|
948
|
+
// edits off-disk (isDirty stuck true forever). Only a TRUE foreign
|
|
949
|
+
// override (ingest/world2d/UI-edit/play) blocks here.
|
|
950
|
+
if (hasAuthoringOverride() && !activeOverrideIsEditMode()) {
|
|
951
|
+
console.warn(
|
|
952
|
+
'EditorStore: "save" is disabled while another authoring adapter is active — exit it first.',
|
|
953
|
+
);
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Force an immediate save (e.g. Ctrl/Cmd+S), bypassing the debounce. Resolves
|
|
961
|
+
* to whether the write succeeded and surfaces failures via saveState.
|
|
962
|
+
*/
|
|
963
|
+
async saveNow(): Promise<boolean> {
|
|
964
|
+
if (this._blockedForSave()) return false;
|
|
965
|
+
if (this._autoSaveTimer) {
|
|
966
|
+
clearTimeout(this._autoSaveTimer);
|
|
967
|
+
this._autoSaveTimer = null;
|
|
968
|
+
}
|
|
969
|
+
if (!this._savePath || !this._scene) return false;
|
|
970
|
+
const content = JSON.stringify(this.getDocument(), null, 2);
|
|
971
|
+
const epoch = this._docEpoch;
|
|
972
|
+
this._ignoreExternalUntil = Date.now() + SYNC_COOLDOWN_MS;
|
|
973
|
+
this._lastSavedContent = content;
|
|
974
|
+
try {
|
|
975
|
+
const ok = await saveFile(this._savePath, content);
|
|
976
|
+
// Same epoch discipline as `_autoSave`: don't mark a newer, unpersisted
|
|
977
|
+
// mutation clean because an older save resolved.
|
|
978
|
+
if (ok && this._docEpoch === epoch) this._isDirty = false;
|
|
979
|
+
else if (!ok) this._isDirty = true;
|
|
980
|
+
this._lastSaveFailed = !ok;
|
|
981
|
+
this._notify();
|
|
982
|
+
if (ok) {
|
|
983
|
+
this._captureThumbnail();
|
|
984
|
+
this._saveEditorStateIfDue();
|
|
985
|
+
} else {
|
|
986
|
+
console.warn('[EditorStore] Save failed — changes are NOT saved to disk.');
|
|
987
|
+
}
|
|
988
|
+
return ok;
|
|
989
|
+
} catch (err) {
|
|
990
|
+
this._lastSaveFailed = true;
|
|
991
|
+
this._notify();
|
|
992
|
+
console.warn('[EditorStore] Save threw — changes are NOT saved to disk.', err);
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private static readonly THUMBNAIL_INTERVAL_MS = 60_000;
|
|
998
|
+
private static readonly THUMBNAIL_SIZE = 256;
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Render the bound camera's current view to a PNG data URL at `size`x`size`
|
|
1002
|
+
* (game content only — editor-layer grid/helpers/gizmos excluded, same as
|
|
1003
|
+
* the thumbnail path). Shared by the throttled autosave thumbnail
|
|
1004
|
+
* (`_captureThumbnail`) and the on-demand `captureViewportImage` (backs the
|
|
1005
|
+
* `capture-viewport` relay command / `editor.viewport.screenshot.capture`'s
|
|
1006
|
+
* fresh-capture mode) — same rendering technique (offscreen render target +
|
|
1007
|
+
* `readRenderTargetPixels`), so it needs no `preserveDrawingBuffer` on the
|
|
1008
|
+
* live canvas's WebGL context. Returns null if no renderer/scene/camera is
|
|
1009
|
+
* bound yet.
|
|
1010
|
+
*/
|
|
1011
|
+
private _renderViewportImage(size: number): string | null {
|
|
1012
|
+
if (!this._renderer || !this._scene || !this._camera) return null;
|
|
1013
|
+
|
|
1014
|
+
const renderSize = size * 2; // render at 2x then downscale for crisp result
|
|
1015
|
+
const renderer = this._renderer;
|
|
1016
|
+
const renderTarget = new THREE.WebGLRenderTarget(renderSize, renderSize);
|
|
1017
|
+
const prevTarget = renderer.getRenderTarget();
|
|
1018
|
+
const prevPixelRatio = renderer.getPixelRatio();
|
|
1019
|
+
|
|
1020
|
+
// Render without editor-layer objects (grid, helpers, gizmos)
|
|
1021
|
+
const camera = this._camera as THREE.PerspectiveCamera;
|
|
1022
|
+
const prevLayers = camera.layers.mask;
|
|
1023
|
+
camera.layers.disableAll();
|
|
1024
|
+
camera.layers.enable(0); // game content only
|
|
1025
|
+
|
|
1026
|
+
renderer.setPixelRatio(1);
|
|
1027
|
+
renderer.setRenderTarget(renderTarget);
|
|
1028
|
+
renderer.render(this._scene, camera);
|
|
1029
|
+
renderer.setRenderTarget(prevTarget);
|
|
1030
|
+
renderer.setPixelRatio(prevPixelRatio);
|
|
1031
|
+
|
|
1032
|
+
camera.layers.mask = prevLayers;
|
|
1033
|
+
|
|
1034
|
+
// Read pixels from the render target
|
|
1035
|
+
const pixels = new Uint8Array(renderSize * renderSize * 4);
|
|
1036
|
+
renderer.readRenderTargetPixels(renderTarget, 0, 0, renderSize, renderSize, pixels);
|
|
1037
|
+
renderTarget.dispose();
|
|
1038
|
+
|
|
1039
|
+
// Flip vertically (WebGL reads bottom-up) and write to a full-size canvas
|
|
1040
|
+
const fullCanvas = document.createElement('canvas');
|
|
1041
|
+
fullCanvas.width = renderSize;
|
|
1042
|
+
fullCanvas.height = renderSize;
|
|
1043
|
+
const fullCtx = fullCanvas.getContext('2d')!;
|
|
1044
|
+
const imageData = fullCtx.createImageData(renderSize, renderSize);
|
|
1045
|
+
for (let y = 0; y < renderSize; y++) {
|
|
1046
|
+
const srcRow = (renderSize - 1 - y) * renderSize * 4;
|
|
1047
|
+
const dstRow = y * renderSize * 4;
|
|
1048
|
+
imageData.data.set(pixels.subarray(srcRow, srcRow + renderSize * 4), dstRow);
|
|
1049
|
+
}
|
|
1050
|
+
fullCtx.putImageData(imageData, 0, 0);
|
|
1051
|
+
|
|
1052
|
+
// Downscale to final thumbnail size
|
|
1053
|
+
const canvas = document.createElement('canvas');
|
|
1054
|
+
canvas.width = size;
|
|
1055
|
+
canvas.height = size;
|
|
1056
|
+
const ctx2d = canvas.getContext('2d')!;
|
|
1057
|
+
ctx2d.drawImage(fullCanvas, 0, 0, size, size);
|
|
1058
|
+
|
|
1059
|
+
return canvas.toDataURL('image/png');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private _captureThumbnail(): void {
|
|
1063
|
+
const now = Date.now();
|
|
1064
|
+
if (now - this._lastThumbnailTime < EditorStore.THUMBNAIL_INTERVAL_MS) return;
|
|
1065
|
+
this._lastThumbnailTime = now;
|
|
1066
|
+
const dataUrl = this._renderViewportImage(EditorStore.THUMBNAIL_SIZE);
|
|
1067
|
+
if (dataUrl) saveThumbnail(dataUrl);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Fresh, on-demand viewport capture (unthrottled, unlike the periodic
|
|
1072
|
+
* autosave thumbnail above) — backs the `capture-viewport` relay command.
|
|
1073
|
+
* Returns null when no renderer/scene/camera is bound yet (e.g. before
|
|
1074
|
+
* `ViewportPanel` has mounted).
|
|
1075
|
+
*/
|
|
1076
|
+
captureViewportImage(size = EditorStore.THUMBNAIL_SIZE): string | null {
|
|
1077
|
+
return this._renderViewportImage(size);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// --- Editor state persistence ---
|
|
1081
|
+
|
|
1082
|
+
private static readonly EDITOR_STATE_INTERVAL_MS = 60_000;
|
|
1083
|
+
|
|
1084
|
+
private _saveEditorStateIfDue(): void {
|
|
1085
|
+
const now = Date.now();
|
|
1086
|
+
if (now - this._lastEditorStateSaveTime < EditorStore.EDITOR_STATE_INTERVAL_MS) return;
|
|
1087
|
+
this._lastEditorStateSaveTime = now;
|
|
1088
|
+
|
|
1089
|
+
const state: Record<string, unknown> = {
|
|
1090
|
+
transformMode: this._transformMode,
|
|
1091
|
+
transformSpace: this._transformSpace,
|
|
1092
|
+
snapEnabled: this._snapEnabled,
|
|
1093
|
+
snapValues: this._snapValues,
|
|
1094
|
+
pivotMode: this._pivotMode,
|
|
1095
|
+
showGrid: this._showGrid,
|
|
1096
|
+
showHelpers: this._showHelpers,
|
|
1097
|
+
helperVisibility: { ...this._helperVisibility },
|
|
1098
|
+
showStats: this._showStats,
|
|
1099
|
+
shadingMode: this._shadingMode,
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
if (this._savePath) {
|
|
1103
|
+
state['lastScene'] = this._savePath;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (this._camera) {
|
|
1107
|
+
const cam = this._camera as THREE.PerspectiveCamera;
|
|
1108
|
+
state['camera'] = {
|
|
1109
|
+
position: cam.position.toArray(),
|
|
1110
|
+
target: this._orbitTarget ? this._orbitTarget.toArray() : [0, 0, 0],
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
saveEditorState(state);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** Restore editor state from server. Call after bindScene + loadInitialScene. */
|
|
1118
|
+
async restoreEditorState(): Promise<void> {
|
|
1119
|
+
const state = await loadEditorState();
|
|
1120
|
+
if (!state || Object.keys(state).length === 0) return;
|
|
1121
|
+
|
|
1122
|
+
if (typeof state['transformMode'] === 'string') {
|
|
1123
|
+
this._transformMode = state['transformMode'] as TransformMode;
|
|
1124
|
+
}
|
|
1125
|
+
if (typeof state['transformSpace'] === 'string') {
|
|
1126
|
+
this._transformSpace = state['transformSpace'] as TransformSpace;
|
|
1127
|
+
}
|
|
1128
|
+
if (typeof state['snapEnabled'] === 'boolean') {
|
|
1129
|
+
this._snapEnabled = state['snapEnabled'];
|
|
1130
|
+
}
|
|
1131
|
+
if (state['snapValues'] && typeof state['snapValues'] === 'object') {
|
|
1132
|
+
Object.assign(this._snapValues, state['snapValues']);
|
|
1133
|
+
}
|
|
1134
|
+
if (typeof state['pivotMode'] === 'string') {
|
|
1135
|
+
this._pivotMode = state['pivotMode'] as PivotMode;
|
|
1136
|
+
}
|
|
1137
|
+
if (typeof state['showGrid'] === 'boolean') {
|
|
1138
|
+
this._showGrid = state['showGrid'];
|
|
1139
|
+
}
|
|
1140
|
+
if (typeof state['showHelpers'] === 'boolean') {
|
|
1141
|
+
this._showHelpers = state['showHelpers'];
|
|
1142
|
+
}
|
|
1143
|
+
if (state['helperVisibility'] && typeof state['helperVisibility'] === 'object') {
|
|
1144
|
+
Object.assign(this._helperVisibility, state['helperVisibility']);
|
|
1145
|
+
}
|
|
1146
|
+
if (typeof state['showStats'] === 'boolean') {
|
|
1147
|
+
this._showStats = state['showStats'];
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof state['shadingMode'] === 'string') {
|
|
1150
|
+
this._shadingMode = state['shadingMode'] as ShadingMode;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Restore camera
|
|
1154
|
+
if (state['camera'] && typeof state['camera'] === 'object') {
|
|
1155
|
+
const cam = state['camera'] as { position?: number[]; target?: number[] };
|
|
1156
|
+
if (this._camera && cam.position && cam.position.length === 3) {
|
|
1157
|
+
(this._camera as THREE.PerspectiveCamera).position.set(
|
|
1158
|
+
cam.position[0]!,
|
|
1159
|
+
cam.position[1]!,
|
|
1160
|
+
cam.position[2]!,
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
if (this._orbitTarget && cam.target && cam.target.length === 3) {
|
|
1164
|
+
this._orbitTarget.set(cam.target[0]!, cam.target[1]!, cam.target[2]!);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
this._notify();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// --- Mutations ---
|
|
1172
|
+
|
|
1173
|
+
updateEntity(id: string, partial: EntityUpdate): void {
|
|
1174
|
+
const obj = this._objectMap.get(id);
|
|
1175
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1176
|
+
if (!entity || !obj) return;
|
|
1177
|
+
this._pushUndo();
|
|
1178
|
+
Object.assign(entity, partial);
|
|
1179
|
+
this._rebuildEntity(id);
|
|
1180
|
+
this._notify();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
updateTransform(
|
|
1184
|
+
id: string,
|
|
1185
|
+
pos: [number, number, number],
|
|
1186
|
+
rot: [number, number, number, number],
|
|
1187
|
+
scl: [number, number, number],
|
|
1188
|
+
): void {
|
|
1189
|
+
const obj = this._objectMap.get(id);
|
|
1190
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1191
|
+
if (!entity || !obj) return;
|
|
1192
|
+
this._pushUndo();
|
|
1193
|
+
entity.transform = { position: pos, rotation: rot, scale: scl };
|
|
1194
|
+
obj.position.set(pos[0], pos[1], pos[2]);
|
|
1195
|
+
obj.quaternion.set(rot[0], rot[1], rot[2], rot[3]);
|
|
1196
|
+
obj.scale.set(scl[0], scl[1], scl[2]);
|
|
1197
|
+
this._notify();
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Update transform without pushing undo — used for live gizmo drag preview. */
|
|
1201
|
+
updateTransformLive(
|
|
1202
|
+
id: string,
|
|
1203
|
+
pos: [number, number, number],
|
|
1204
|
+
rot: [number, number, number, number],
|
|
1205
|
+
scl: [number, number, number],
|
|
1206
|
+
): void {
|
|
1207
|
+
const obj = this._objectMap.get(id);
|
|
1208
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1209
|
+
if (!entity || !obj) return;
|
|
1210
|
+
// The gizmo has already moved the Object3D — just sync the descriptor
|
|
1211
|
+
entity.transform = { position: pos, rotation: rot, scale: scl };
|
|
1212
|
+
// During play, also sync to ECS so the game loop doesn't overwrite
|
|
1213
|
+
if (this._ecsSyncTransform) this._ecsSyncTransform(obj);
|
|
1214
|
+
this._notify();
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Rebuild an entity's Three.js preview (for handle drags where the descriptor is already mutated). */
|
|
1218
|
+
rebuildEntityLive(id: string): void {
|
|
1219
|
+
this._rebuildEntity(id);
|
|
1220
|
+
this._notify();
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Commit a completed live drag (gizmo or handle) (ED2).
|
|
1225
|
+
*
|
|
1226
|
+
* During a drag only `updateTransformLive`/`rebuildEntityLive` run, which
|
|
1227
|
+
* mutate the descriptor but never mark dirty or schedule a save. The undo
|
|
1228
|
+
* snapshot was already pushed at drag start, so we must NOT push another
|
|
1229
|
+
* here — but we must persist the FINAL transform: mark dirty and (re)schedule
|
|
1230
|
+
* the autosave so the last position is written, even if a mid-drag autosave
|
|
1231
|
+
* already fired and cleared the dirty flag.
|
|
1232
|
+
*
|
|
1233
|
+
* Play-mode guard (T1.6): while playing/paused, the drag only ever moved the
|
|
1234
|
+
* live GAME scene's Object3D (via `_ecsSyncTransform`) — there is nothing of
|
|
1235
|
+
* the editor's own document to persist, so skip marking dirty / scheduling
|
|
1236
|
+
* autosave entirely. Still notify so any subscribed UI refreshes.
|
|
1237
|
+
*/
|
|
1238
|
+
commitLiveTransform(): void {
|
|
1239
|
+
if (this._playState === 'stopped') {
|
|
1240
|
+
this._isDirty = true;
|
|
1241
|
+
this._docEpoch++;
|
|
1242
|
+
this._navMeshBaked = false;
|
|
1243
|
+
this._scheduleAutoSave();
|
|
1244
|
+
}
|
|
1245
|
+
this._notify();
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
addEntity(parentId: string | null, entity: SceneEntity, index?: number): void {
|
|
1249
|
+
if (this._blockedDuringPlay('add entity')) return;
|
|
1250
|
+
if (!this._scene) return;
|
|
1251
|
+
if (!entity.id) entity.id = crypto.randomUUID();
|
|
1252
|
+
assignMissingIds(entity.children ?? []);
|
|
1253
|
+
this._pushUndo();
|
|
1254
|
+
|
|
1255
|
+
const obj = createEntityObject(entity);
|
|
1256
|
+
obj.visible = entity.visible !== false;
|
|
1257
|
+
|
|
1258
|
+
const parent = parentId ? this._objectMap.get(parentId) : null;
|
|
1259
|
+
const target = parent ?? this._scene;
|
|
1260
|
+
|
|
1261
|
+
if (index !== undefined) {
|
|
1262
|
+
// Insert at specific index among entity children
|
|
1263
|
+
const entityChildren = target.children.filter(
|
|
1264
|
+
(c) => getUserData(c, 'entityId') !== undefined && !getUserData(c, 'editorHelper'),
|
|
1265
|
+
);
|
|
1266
|
+
const clampedIndex = Math.min(index, entityChildren.length);
|
|
1267
|
+
const insertBefore = entityChildren[clampedIndex];
|
|
1268
|
+
const threeIndex = insertBefore
|
|
1269
|
+
? target.children.indexOf(insertBefore)
|
|
1270
|
+
: target.children.length;
|
|
1271
|
+
target.children.splice(threeIndex, 0, obj);
|
|
1272
|
+
obj.parent = target;
|
|
1273
|
+
} else {
|
|
1274
|
+
target.add(obj);
|
|
1275
|
+
}
|
|
1276
|
+
this._objectMap.set(entity.id, obj);
|
|
1277
|
+
registerParticles(obj, this._batchedRenderer);
|
|
1278
|
+
|
|
1279
|
+
// If entity has children, add them recursively
|
|
1280
|
+
if (entity.children) {
|
|
1281
|
+
this._addChildEntities(entity.children, obj);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
this._selectedEntityIds.clear();
|
|
1285
|
+
this._selectedEntityIds.add(entity.id);
|
|
1286
|
+
this._notify();
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Recursively create child entity Object3Ds and add to parent. */
|
|
1290
|
+
private _addChildEntities(children: SceneEntity[], parent: THREE.Object3D): void {
|
|
1291
|
+
for (const child of children) {
|
|
1292
|
+
if (!child.id) child.id = crypto.randomUUID();
|
|
1293
|
+
const childObj = createEntityObject(child);
|
|
1294
|
+
childObj.visible = child.visible !== false;
|
|
1295
|
+
parent.add(childObj);
|
|
1296
|
+
this._objectMap.set(child.id, childObj);
|
|
1297
|
+
registerParticles(childObj, this._batchedRenderer);
|
|
1298
|
+
if (child.children) {
|
|
1299
|
+
this._addChildEntities(child.children, childObj);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
removeEntity(id: string): void {
|
|
1305
|
+
if (this._blockedDuringPlay('delete entity')) return;
|
|
1306
|
+
const obj = this._objectMap.get(id);
|
|
1307
|
+
if (!obj) return;
|
|
1308
|
+
this._pushUndo();
|
|
1309
|
+
this._removeObjectFromScene(obj);
|
|
1310
|
+
this._selectedEntityIds.delete(id);
|
|
1311
|
+
this._notify();
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** Remove an Object3D and all its entity children from the scene + objectMap. */
|
|
1315
|
+
private _removeObjectFromScene(obj: THREE.Object3D): void {
|
|
1316
|
+
// Recursively remove entity children from objectMap
|
|
1317
|
+
for (const child of [...obj.children]) {
|
|
1318
|
+
if (getUserData(child, 'entityId') !== undefined && !getUserData(child, 'editorHelper')) {
|
|
1319
|
+
this._removeObjectFromScene(child);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
unregisterParticles(obj, this._batchedRenderer);
|
|
1323
|
+
const eid = getUserData(obj, 'entityId') as string | undefined;
|
|
1324
|
+
if (eid) this._objectMap.delete(eid);
|
|
1325
|
+
obj.removeFromParent();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
duplicateEntity(id: string): void {
|
|
1329
|
+
if (this._blockedDuringPlay('duplicate entity')) return;
|
|
1330
|
+
if (!this._scene) return;
|
|
1331
|
+
const obj = this._objectMap.get(id);
|
|
1332
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1333
|
+
if (!entity || !obj) return;
|
|
1334
|
+
|
|
1335
|
+
// Deep clone the entity descriptor with full subtree
|
|
1336
|
+
const cloneEntity = this._serializeSubtree(obj);
|
|
1337
|
+
const reassign = (e: SceneEntity) => {
|
|
1338
|
+
e.id = crypto.randomUUID();
|
|
1339
|
+
if (e.children) e.children.forEach(reassign);
|
|
1340
|
+
};
|
|
1341
|
+
reassign(cloneEntity);
|
|
1342
|
+
cloneEntity.name = `${entity.name} (copy)`;
|
|
1343
|
+
|
|
1344
|
+
this._pushUndo();
|
|
1345
|
+
|
|
1346
|
+
// Create object and insert after original
|
|
1347
|
+
const cloneObj = createEntityObject(cloneEntity);
|
|
1348
|
+
cloneObj.visible = cloneEntity.visible !== false;
|
|
1349
|
+
const parent = obj.parent ?? this._scene;
|
|
1350
|
+
const index = parent.children.indexOf(obj);
|
|
1351
|
+
if (index >= 0) {
|
|
1352
|
+
parent.children.splice(index + 1, 0, cloneObj);
|
|
1353
|
+
cloneObj.parent = parent;
|
|
1354
|
+
} else {
|
|
1355
|
+
parent.add(cloneObj);
|
|
1356
|
+
}
|
|
1357
|
+
this._objectMap.set(cloneEntity.id!, cloneObj);
|
|
1358
|
+
registerParticles(cloneObj, this._batchedRenderer);
|
|
1359
|
+
|
|
1360
|
+
// Recursively add child entities
|
|
1361
|
+
if (cloneEntity.children) {
|
|
1362
|
+
this._addChildEntities(cloneEntity.children, cloneObj);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
this._selectedEntityIds.clear();
|
|
1366
|
+
this._selectedEntityIds.add(cloneEntity.id!);
|
|
1367
|
+
this._notify();
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Serialize an entity + its scene-graph children into a nested SceneEntity. */
|
|
1371
|
+
private _serializeSubtree(obj: THREE.Object3D): SceneEntity {
|
|
1372
|
+
const stored = getUserData(obj, 'entity') as SceneEntity;
|
|
1373
|
+
const entity = deepClone(stored);
|
|
1374
|
+
const children: SceneEntity[] = [];
|
|
1375
|
+
for (const child of obj.children) {
|
|
1376
|
+
if (getUserData(child, 'entityId') !== undefined && !getUserData(child, 'editorHelper')) {
|
|
1377
|
+
children.push(this._serializeSubtree(child));
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (children.length > 0) {
|
|
1381
|
+
entity.children = children;
|
|
1382
|
+
} else {
|
|
1383
|
+
delete entity.children;
|
|
1384
|
+
}
|
|
1385
|
+
return entity;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// --- Reparent / reorder (react-arborist onMove) ---
|
|
1389
|
+
moveEntity(dragIds: string[], parentId: string | null, index: number): void {
|
|
1390
|
+
if (this._blockedDuringPlay('move entity')) return;
|
|
1391
|
+
if (!this._scene) return;
|
|
1392
|
+
|
|
1393
|
+
// Validate before pushing undo — a rejected move must not leave a
|
|
1394
|
+
// no-op undo entry on the stack (T1.12c: complete or roll back, no
|
|
1395
|
+
// undo-then-bail).
|
|
1396
|
+
const targetParent = parentId ? this._objectMap.get(parentId) : this._scene;
|
|
1397
|
+
if (!targetParent) return;
|
|
1398
|
+
|
|
1399
|
+
this._pushUndo();
|
|
1400
|
+
|
|
1401
|
+
// Collect objects and remove from old locations
|
|
1402
|
+
const objects: THREE.Object3D[] = [];
|
|
1403
|
+
for (const id of dragIds) {
|
|
1404
|
+
const obj = this._objectMap.get(id);
|
|
1405
|
+
if (!obj) continue;
|
|
1406
|
+
obj.removeFromParent();
|
|
1407
|
+
objects.push(obj);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Find the insertion point among entity children of the target
|
|
1411
|
+
const entityChildren = targetParent.children.filter(
|
|
1412
|
+
(c) => getUserData(c, 'entityId') !== undefined && !getUserData(c, 'editorHelper'),
|
|
1413
|
+
);
|
|
1414
|
+
const clampedIndex = Math.min(index, entityChildren.length);
|
|
1415
|
+
const insertBefore = entityChildren[clampedIndex];
|
|
1416
|
+
const threeIndex = insertBefore
|
|
1417
|
+
? targetParent.children.indexOf(insertBefore)
|
|
1418
|
+
: targetParent.children.length;
|
|
1419
|
+
|
|
1420
|
+
// Insert at the computed position
|
|
1421
|
+
for (let i = 0; i < objects.length; i++) {
|
|
1422
|
+
const obj = objects[i]!;
|
|
1423
|
+
targetParent.children.splice(threeIndex + i, 0, obj);
|
|
1424
|
+
obj.parent = targetParent;
|
|
1425
|
+
}
|
|
1426
|
+
this._notify();
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// --- Rename ---
|
|
1430
|
+
renameEntity(id: string, name: string): void {
|
|
1431
|
+
const obj = this._objectMap.get(id);
|
|
1432
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1433
|
+
if (!entity || !obj) return;
|
|
1434
|
+
this._pushUndo();
|
|
1435
|
+
entity.name = name;
|
|
1436
|
+
obj.name = name;
|
|
1437
|
+
this._notify();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// --- Visibility ---
|
|
1441
|
+
toggleVisibility(id: string): void {
|
|
1442
|
+
const obj = this._objectMap.get(id);
|
|
1443
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1444
|
+
if (!entity || !obj) return;
|
|
1445
|
+
this._pushUndo();
|
|
1446
|
+
if (entity.visible === false) {
|
|
1447
|
+
delete entity.visible;
|
|
1448
|
+
obj.visible = true;
|
|
1449
|
+
} else {
|
|
1450
|
+
entity.visible = false;
|
|
1451
|
+
obj.visible = false;
|
|
1452
|
+
}
|
|
1453
|
+
this._notify();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// --- Locking (viewport pickability) ---
|
|
1457
|
+
toggleLocked(id: string): void {
|
|
1458
|
+
const obj = this._objectMap.get(id);
|
|
1459
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1460
|
+
if (!entity) return;
|
|
1461
|
+
this._pushUndo();
|
|
1462
|
+
if (entity.locked) {
|
|
1463
|
+
delete entity.locked;
|
|
1464
|
+
} else {
|
|
1465
|
+
entity.locked = true;
|
|
1466
|
+
}
|
|
1467
|
+
this._notify();
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Check if an entity or any ancestor is locked. */
|
|
1471
|
+
isEntityLocked(id: string): boolean {
|
|
1472
|
+
const obj = this._objectMap.get(id);
|
|
1473
|
+
if (!obj) return false;
|
|
1474
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1475
|
+
if (!entity) return false;
|
|
1476
|
+
if (entity.locked) return true;
|
|
1477
|
+
|
|
1478
|
+
// Walk up the Three.js parent chain
|
|
1479
|
+
let parent = obj.parent;
|
|
1480
|
+
while (parent) {
|
|
1481
|
+
const parentEntity = getUserData(parent, 'entity') as SceneEntity | undefined;
|
|
1482
|
+
if (parentEntity?.locked) return true;
|
|
1483
|
+
parent = parent.parent;
|
|
1484
|
+
}
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// --- Bulk delete ---
|
|
1489
|
+
deleteEntities(ids: string[]): void {
|
|
1490
|
+
if (this._blockedDuringPlay('delete entities')) return;
|
|
1491
|
+
this._pushUndo();
|
|
1492
|
+
for (const id of ids) {
|
|
1493
|
+
const obj = this._objectMap.get(id);
|
|
1494
|
+
if (!obj) continue;
|
|
1495
|
+
this._removeObjectFromScene(obj);
|
|
1496
|
+
this._selectedEntityIds.delete(id);
|
|
1497
|
+
}
|
|
1498
|
+
this._notify();
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// --- Transform mode ---
|
|
1502
|
+
setTransformMode(mode: TransformMode): void {
|
|
1503
|
+
this._transformMode = mode;
|
|
1504
|
+
this._notify();
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
setTransformSpace(space: TransformSpace): void {
|
|
1508
|
+
this._transformSpace = space;
|
|
1509
|
+
this._notify();
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
toggleSnap(): void {
|
|
1513
|
+
this._snapEnabled = !this._snapEnabled;
|
|
1514
|
+
this._notify();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
setSnapValues(values: Partial<{ translate: number; rotate: number; scale: number }>): void {
|
|
1518
|
+
this._snapValues = { ...this._snapValues, ...values };
|
|
1519
|
+
this._notify();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
toggleSnapToSurface(): void {
|
|
1523
|
+
this._snapToSurface = !this._snapToSurface;
|
|
1524
|
+
this._notify();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
setPivotMode(mode: PivotMode): void {
|
|
1528
|
+
this._pivotMode = mode;
|
|
1529
|
+
this._notify();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
updatePivot(id: string, pivot: [number, number, number]): void {
|
|
1533
|
+
const obj = this._objectMap.get(id);
|
|
1534
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1535
|
+
if (!entity) return;
|
|
1536
|
+
this._pushUndo();
|
|
1537
|
+
const isZero = pivot[0] === 0 && pivot[1] === 0 && pivot[2] === 0;
|
|
1538
|
+
if (isZero) {
|
|
1539
|
+
delete entity.pivot;
|
|
1540
|
+
} else {
|
|
1541
|
+
entity.pivot = pivot;
|
|
1542
|
+
}
|
|
1543
|
+
this._rebuildEntity(id);
|
|
1544
|
+
this._notify();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
toggleGrid(): void {
|
|
1548
|
+
this._showGrid = !this._showGrid;
|
|
1549
|
+
this._notify();
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
toggleHelpers(): void {
|
|
1553
|
+
const anyOn = Object.values(this._helperVisibility).some((v) => v);
|
|
1554
|
+
const newVal = !anyOn;
|
|
1555
|
+
this._showHelpers = newVal;
|
|
1556
|
+
for (const key of Object.keys(this._helperVisibility) as (keyof HelperVisibility)[]) {
|
|
1557
|
+
this._helperVisibility[key] = newVal;
|
|
1558
|
+
}
|
|
1559
|
+
this._notify();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
toggleHelperType(type: keyof HelperVisibility): void {
|
|
1563
|
+
this._helperVisibility[type] = !this._helperVisibility[type];
|
|
1564
|
+
this._showHelpers = Object.values(this._helperVisibility).some((v) => v);
|
|
1565
|
+
this._notify();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
toggleStats(): void {
|
|
1569
|
+
this._showStats = !this._showStats;
|
|
1570
|
+
this._notify();
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
setShadingMode(mode: ShadingMode): void {
|
|
1574
|
+
this._shadingMode = mode;
|
|
1575
|
+
// Re-shade live meshes immediately (ED1) — don't wait for a full rebuild.
|
|
1576
|
+
if (this._scene) applyShadingOverride(this._scene, mode);
|
|
1577
|
+
this._notify();
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// --- Clipboard ---
|
|
1581
|
+
copyEntities(): void {
|
|
1582
|
+
// Blocked during play for a subtler reason than its siblings: copying a
|
|
1583
|
+
// live, runtime-mutated descriptor into the clipboard lets a post-stop
|
|
1584
|
+
// paste insert that runtime snapshot into the real document (the Ctrl+C
|
|
1585
|
+
// variant of the cut-during-play hazard).
|
|
1586
|
+
if (this._blockedDuringPlay('copy entities')) return;
|
|
1587
|
+
this._clipboard = this.selectedEntities.map((e) => deepClone(e));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
cutEntities(): void {
|
|
1591
|
+
if (this._blockedDuringPlay('cut entities')) return;
|
|
1592
|
+
this.copyEntities();
|
|
1593
|
+
const ids = [...this._selectedEntityIds];
|
|
1594
|
+
this._pushUndo();
|
|
1595
|
+
for (const id of ids) {
|
|
1596
|
+
const obj = this._objectMap.get(id);
|
|
1597
|
+
if (!obj) continue;
|
|
1598
|
+
this._removeObjectFromScene(obj);
|
|
1599
|
+
}
|
|
1600
|
+
this._selectedEntityIds.clear();
|
|
1601
|
+
this._notify();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
pasteEntities(): void {
|
|
1605
|
+
if (this._blockedDuringPlay('paste')) return;
|
|
1606
|
+
if (this._clipboard.length === 0 || !this._scene) return;
|
|
1607
|
+
this._pushUndo();
|
|
1608
|
+
const newIds: string[] = [];
|
|
1609
|
+
for (const template of this._clipboard) {
|
|
1610
|
+
const clone = deepClone(template);
|
|
1611
|
+
const reassign = (e: SceneEntity) => {
|
|
1612
|
+
e.id = crypto.randomUUID();
|
|
1613
|
+
if (e.children) e.children.forEach(reassign);
|
|
1614
|
+
};
|
|
1615
|
+
reassign(clone);
|
|
1616
|
+
clone.name = `${clone.name} (copy)`;
|
|
1617
|
+
|
|
1618
|
+
const obj = createEntityObject(clone);
|
|
1619
|
+
obj.visible = clone.visible !== false;
|
|
1620
|
+
this._scene.add(obj);
|
|
1621
|
+
this._objectMap.set(clone.id!, obj);
|
|
1622
|
+
registerParticles(obj, this._batchedRenderer);
|
|
1623
|
+
if (clone.children) {
|
|
1624
|
+
this._addChildEntities(clone.children, obj);
|
|
1625
|
+
}
|
|
1626
|
+
newIds.push(clone.id!);
|
|
1627
|
+
}
|
|
1628
|
+
this._selectedEntityIds = new Set(newIds);
|
|
1629
|
+
this._notify();
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// --- Tags ---
|
|
1633
|
+
addTag(id: string, tag: string): void {
|
|
1634
|
+
const entity = this.getEntityById(id);
|
|
1635
|
+
if (!entity) return;
|
|
1636
|
+
const trimmed = tag.trim();
|
|
1637
|
+
if (!trimmed) return;
|
|
1638
|
+
if (entity.tags?.includes(trimmed)) return;
|
|
1639
|
+
this._pushUndo();
|
|
1640
|
+
if (!entity.tags) entity.tags = [];
|
|
1641
|
+
entity.tags.push(trimmed);
|
|
1642
|
+
this._notify();
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
removeTag(id: string, tag: string): void {
|
|
1646
|
+
const entity = this.getEntityById(id);
|
|
1647
|
+
if (!entity?.tags) return;
|
|
1648
|
+
const idx = entity.tags.indexOf(tag);
|
|
1649
|
+
if (idx < 0) return;
|
|
1650
|
+
this._pushUndo();
|
|
1651
|
+
entity.tags.splice(idx, 1);
|
|
1652
|
+
if (entity.tags.length === 0) delete entity.tags;
|
|
1653
|
+
this._notify();
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Collect all unique tags used in the scene. */
|
|
1657
|
+
allTags(): string[] {
|
|
1658
|
+
const tags = new Set<string>();
|
|
1659
|
+
if (this._scene) {
|
|
1660
|
+
walkEntityObjects(this._scene, (_, entity) => {
|
|
1661
|
+
if (entity.tags) for (const t of entity.tags) tags.add(t);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
return [...tags].sort();
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// --- Batch mutations (multi-entity editing) ---
|
|
1668
|
+
/** Push undo once, then mutate each entity by id and rebuild. */
|
|
1669
|
+
batchMutate(ids: string[], mutator: (entity: SceneEntity) => void): void {
|
|
1670
|
+
this._pushUndo();
|
|
1671
|
+
for (const id of ids) {
|
|
1672
|
+
const obj = this._objectMap.get(id);
|
|
1673
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1674
|
+
if (entity) {
|
|
1675
|
+
mutator(entity);
|
|
1676
|
+
this._rebuildEntity(id);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
this._notify();
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// --- Environment ---
|
|
1683
|
+
updateEnvironment(partial: Partial<SceneEnvironment>): void {
|
|
1684
|
+
this._pushUndo();
|
|
1685
|
+
this._sceneMeta.environment = { ...this._sceneMeta.environment, ...partial };
|
|
1686
|
+
this._applyEnvironment();
|
|
1687
|
+
this._notify();
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** Replace the entire environment object (for removing keys). */
|
|
1691
|
+
setEnvironment(env: SceneEnvironment): void {
|
|
1692
|
+
this._pushUndo();
|
|
1693
|
+
this._sceneMeta.environment = env;
|
|
1694
|
+
this._applyEnvironment();
|
|
1695
|
+
this._notify();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// --- External update (live sync from disk) ---
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Apply an external SceneFile update (e.g. from a file watcher) without
|
|
1702
|
+
* pushing undo or triggering auto-save. Diffs against current state and
|
|
1703
|
+
* applies only the changes, preserving selection, undo history, and camera.
|
|
1704
|
+
*
|
|
1705
|
+
* `rawContent`, when provided (the watcher SSE carries the file's exact
|
|
1706
|
+
* bytes), enables own-echo detection: an event whose content is exactly
|
|
1707
|
+
* what we last wrote is our own save reflected back, and is skipped even
|
|
1708
|
+
* if it arrives after `_ignoreExternalUntil` — the document may have moved
|
|
1709
|
+
* on (undo/redo) and applying the echo would clobber the newer state.
|
|
1710
|
+
*/
|
|
1711
|
+
applyExternalUpdate(incoming: SceneFile, rawContent?: string): void {
|
|
1712
|
+
if (rawContent !== undefined && rawContent === this._lastSavedContent) return;
|
|
1713
|
+
// Play-mode / ingest guard: `_scene` is the LIVE game/ingested scene while
|
|
1714
|
+
// playing (see enterPlayScene) — diffing and patching it here would rebuild
|
|
1715
|
+
// the RUNNING scene out from under the game/ingested adapter, and the disk
|
|
1716
|
+
// change would be silently lost the moment exitPlayScene restores the real
|
|
1717
|
+
// editor scene (probe: F6 in the round-1 review). Defer: keep only the
|
|
1718
|
+
// latest incoming doc and apply it once play/ingest stops (see setPlayState).
|
|
1719
|
+
if (this._playState !== 'stopped') {
|
|
1720
|
+
this._pendingExternalUpdate = incoming;
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (!this._scene) return;
|
|
1724
|
+
if (Date.now() < this._ignoreExternalUntil) return;
|
|
1725
|
+
assignMissingIds(incoming.entities);
|
|
1726
|
+
const current = this.getDocument();
|
|
1727
|
+
const diff = diffScene(current, incoming);
|
|
1728
|
+
|
|
1729
|
+
// No-op polls must not count toward the circuit breaker (ED6) — only a
|
|
1730
|
+
// poll that actually changes something is a real sync cycle.
|
|
1731
|
+
if (diff.ops.length === 0 && !diff.environmentChanged && !diff.metaChanged) return;
|
|
1732
|
+
if (this._isSyncLooping()) return;
|
|
1733
|
+
|
|
1734
|
+
this._suppressAutoSaveUntil = Date.now() + SYNC_COOLDOWN_MS;
|
|
1735
|
+
|
|
1736
|
+
// Process removals first (deepest-first ordering is handled by diffScene)
|
|
1737
|
+
for (const op of diff.ops) {
|
|
1738
|
+
if (op.type !== 'remove') continue;
|
|
1739
|
+
const obj = this._objectMap.get(op.id);
|
|
1740
|
+
if (!obj) continue;
|
|
1741
|
+
this._removeObjectFromScene(obj);
|
|
1742
|
+
this._selectedEntityIds.delete(op.id);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Process additions
|
|
1746
|
+
for (const op of diff.ops) {
|
|
1747
|
+
if (op.type !== 'add') continue;
|
|
1748
|
+
const entity = { ...op.entity };
|
|
1749
|
+
if (!entity.id) entity.id = crypto.randomUUID();
|
|
1750
|
+
const obj = createEntityObject(entity);
|
|
1751
|
+
obj.visible = entity.visible !== false;
|
|
1752
|
+
const parent = op.parentId ? this._objectMap.get(op.parentId) : this._scene;
|
|
1753
|
+
if (!parent) continue;
|
|
1754
|
+
parent.add(obj);
|
|
1755
|
+
this._objectMap.set(entity.id, obj);
|
|
1756
|
+
registerParticles(obj, this._batchedRenderer);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Process updates (property changes)
|
|
1760
|
+
for (const op of diff.ops) {
|
|
1761
|
+
if (op.type !== 'update') continue;
|
|
1762
|
+
const obj = this._objectMap.get(op.id);
|
|
1763
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1764
|
+
if (!entity || !obj) continue;
|
|
1765
|
+
// Replace properties (preserve id and children). Fields absent from the
|
|
1766
|
+
// incoming entity must be deleted, not left stale (ED3) — Object.assign
|
|
1767
|
+
// alone would keep removed material/physics/etc.
|
|
1768
|
+
const { id: _, children: __, ...fields } = op.entity;
|
|
1769
|
+
for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
|
|
1770
|
+
if (key === 'id' || key === 'children') continue;
|
|
1771
|
+
if (!(key in fields)) delete entity[key];
|
|
1772
|
+
}
|
|
1773
|
+
Object.assign(entity, fields);
|
|
1774
|
+
// Sync transform to Three.js object
|
|
1775
|
+
if (entity.transform) {
|
|
1776
|
+
const t = entity.transform;
|
|
1777
|
+
if (t.position) obj.position.set(t.position[0], t.position[1], t.position[2]);
|
|
1778
|
+
if (t.rotation)
|
|
1779
|
+
obj.quaternion.set(t.rotation[0], t.rotation[1], t.rotation[2], t.rotation[3]);
|
|
1780
|
+
if (t.scale) obj.scale.set(t.scale[0], t.scale[1], t.scale[2]);
|
|
1781
|
+
}
|
|
1782
|
+
this._rebuildEntity(op.id);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// Process moves (reparent/reorder). Incremental splices against a mutating
|
|
1786
|
+
// sibling list can land multi-sibling reorders wrong (ED7). Instead:
|
|
1787
|
+
// 1) reparent every moved object (append for now), then
|
|
1788
|
+
// 2) sort each affected parent's entity children to match the FINAL target
|
|
1789
|
+
// sibling order from the incoming document.
|
|
1790
|
+
const movedParents = new Set<THREE.Object3D>();
|
|
1791
|
+
for (const op of diff.ops) {
|
|
1792
|
+
if (op.type !== 'move') continue;
|
|
1793
|
+
const obj = this._objectMap.get(op.id);
|
|
1794
|
+
if (!obj) continue;
|
|
1795
|
+
obj.removeFromParent();
|
|
1796
|
+
const newParent = op.parentId ? this._objectMap.get(op.parentId) : this._scene;
|
|
1797
|
+
if (!newParent) continue;
|
|
1798
|
+
newParent.add(obj);
|
|
1799
|
+
movedParents.add(newParent);
|
|
1800
|
+
}
|
|
1801
|
+
if (movedParents.size > 0) {
|
|
1802
|
+
// Build target sibling order (parentId | null) → [childId...] from incoming.
|
|
1803
|
+
const targetOrder = new Map<string | null, string[]>();
|
|
1804
|
+
const collectOrder = (list: SceneEntity[], pid: string | null): void => {
|
|
1805
|
+
targetOrder.set(
|
|
1806
|
+
pid,
|
|
1807
|
+
list.map((e) => e.id).filter((id): id is string => Boolean(id)),
|
|
1808
|
+
);
|
|
1809
|
+
for (const e of list) if (e.children) collectOrder(e.children, e.id ?? null);
|
|
1810
|
+
};
|
|
1811
|
+
collectOrder(incoming.entities, null);
|
|
1812
|
+
|
|
1813
|
+
for (const parent of movedParents) {
|
|
1814
|
+
const pid =
|
|
1815
|
+
parent === this._scene
|
|
1816
|
+
? null
|
|
1817
|
+
: ((getUserData(parent, 'entityId') as string | undefined) ?? null);
|
|
1818
|
+
const order = targetOrder.get(pid);
|
|
1819
|
+
if (!order) continue;
|
|
1820
|
+
const rankOf = (id: string | undefined): number => {
|
|
1821
|
+
const i = id ? order.indexOf(id) : -1;
|
|
1822
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
1823
|
+
};
|
|
1824
|
+
// Pull out entity children, sort to target order, re-append.
|
|
1825
|
+
const entityChildren = parent.children.filter(
|
|
1826
|
+
(c) => getUserData(c, 'entityId') !== undefined && !getUserData(c, 'editorHelper'),
|
|
1827
|
+
);
|
|
1828
|
+
for (const c of entityChildren) {
|
|
1829
|
+
parent.children.splice(parent.children.indexOf(c), 1);
|
|
1830
|
+
}
|
|
1831
|
+
entityChildren.sort(
|
|
1832
|
+
(a, b) =>
|
|
1833
|
+
rankOf(getUserData(a, 'entityId') as string | undefined) -
|
|
1834
|
+
rankOf(getUserData(b, 'entityId') as string | undefined),
|
|
1835
|
+
);
|
|
1836
|
+
for (const c of entityChildren) {
|
|
1837
|
+
parent.children.push(c);
|
|
1838
|
+
c.parent = parent;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// Environment
|
|
1844
|
+
if (diff.environmentChanged) {
|
|
1845
|
+
if (diff.newEnvironment) {
|
|
1846
|
+
this._sceneMeta.environment = diff.newEnvironment;
|
|
1847
|
+
} else {
|
|
1848
|
+
delete this._sceneMeta.environment;
|
|
1849
|
+
}
|
|
1850
|
+
this._applyEnvironment();
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// Metadata
|
|
1854
|
+
if (diff.metaChanged && diff.newName) {
|
|
1855
|
+
this._sceneMeta.name = diff.newName;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
this._notify();
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// --- Undo / Redo ---
|
|
1862
|
+
undo(): boolean {
|
|
1863
|
+
// `false` tells the undo timeline "couldn't act — put the entry back"
|
|
1864
|
+
// (see UndoAdapter in authoring/undo-timeline.ts).
|
|
1865
|
+
if (this._blockedDuringPlay('undo')) return false;
|
|
1866
|
+
if (this._undoStack.length === 0 || !this._scene) return false;
|
|
1867
|
+
const currentDoc = serializeScene(this._scene, this._sceneMeta);
|
|
1868
|
+
this._redoStack.push(JSON.stringify(currentDoc));
|
|
1869
|
+
const doc = JSON.parse(this._undoStack.pop()!) as SceneFile;
|
|
1870
|
+
this._loadFromDoc(doc);
|
|
1871
|
+
// The restored state is a document mutation like any other: mark dirty and
|
|
1872
|
+
// schedule autosave. Without this, an undo after a completed autosave left
|
|
1873
|
+
// the store claiming "saved" while the pre-undo state stayed on disk.
|
|
1874
|
+
this._isDirty = true;
|
|
1875
|
+
this._docEpoch++;
|
|
1876
|
+
this._navMeshBaked = false;
|
|
1877
|
+
this._scheduleAutoSave();
|
|
1878
|
+
// Re-validate selection
|
|
1879
|
+
for (const id of [...this._selectedEntityIds]) {
|
|
1880
|
+
if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
|
|
1881
|
+
}
|
|
1882
|
+
this._notify();
|
|
1883
|
+
return true;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
redo(): boolean {
|
|
1887
|
+
if (this._blockedDuringPlay('redo')) return false;
|
|
1888
|
+
if (this._redoStack.length === 0 || !this._scene) return false;
|
|
1889
|
+
const currentDoc = serializeScene(this._scene, this._sceneMeta);
|
|
1890
|
+
this._undoStack.push(JSON.stringify(currentDoc));
|
|
1891
|
+
const doc = JSON.parse(this._redoStack.pop()!) as SceneFile;
|
|
1892
|
+
this._loadFromDoc(doc);
|
|
1893
|
+
// Same as undo(): the restored state must be persisted.
|
|
1894
|
+
this._isDirty = true;
|
|
1895
|
+
this._docEpoch++;
|
|
1896
|
+
this._navMeshBaked = false;
|
|
1897
|
+
this._scheduleAutoSave();
|
|
1898
|
+
for (const id of [...this._selectedEntityIds]) {
|
|
1899
|
+
if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
|
|
1900
|
+
}
|
|
1901
|
+
this._notify();
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// --- Document load/save ---
|
|
1906
|
+
|
|
1907
|
+
getDocument(): SceneFile {
|
|
1908
|
+
if (!this._scene) {
|
|
1909
|
+
return { version: this._sceneMeta.version, name: this._sceneMeta.name, entities: [] };
|
|
1910
|
+
}
|
|
1911
|
+
const doc = serializeScene(this._scene, this._sceneMeta);
|
|
1912
|
+
return cleanDocument(doc);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
loadDocument(doc: SceneFile, savePath?: string): void {
|
|
1916
|
+
if (this._blockedDuringPlay('load document')) return;
|
|
1917
|
+
assignMissingIds(doc.entities);
|
|
1918
|
+
this._sceneMeta = { version: doc.version, name: doc.name };
|
|
1919
|
+
if (doc.environment) this._sceneMeta.environment = doc.environment;
|
|
1920
|
+
if (doc.ui) this._sceneMeta.ui = doc.ui; // preserve scene-graph UI (A1/C9)
|
|
1921
|
+
this._documentType = 'scene';
|
|
1922
|
+
this._selectedEntityIds.clear();
|
|
1923
|
+
this._undoStack.length = 0;
|
|
1924
|
+
this._redoStack.length = 0;
|
|
1925
|
+
this._isDirty = false;
|
|
1926
|
+
this._savePath = savePath ?? null;
|
|
1927
|
+
|
|
1928
|
+
if (this._scene) {
|
|
1929
|
+
this._rebuildScene(doc);
|
|
1930
|
+
}
|
|
1931
|
+
this._notify();
|
|
1932
|
+
|
|
1933
|
+
// Capture a thumbnail after assets have had time to load
|
|
1934
|
+
if (savePath) {
|
|
1935
|
+
setTimeout(() => this._captureThumbnail(), 2000);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
markClean(): void {
|
|
1940
|
+
this._isDirty = false;
|
|
1941
|
+
this._notify();
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// --- Prefab operations ---
|
|
1945
|
+
|
|
1946
|
+
extractAsPrefab(id: string): PrefabFile | null {
|
|
1947
|
+
const obj = this._objectMap.get(id);
|
|
1948
|
+
if (!obj) return null;
|
|
1949
|
+
const root = this._serializeSubtree(obj);
|
|
1950
|
+
// Strip instance-specific fields from the prefab root
|
|
1951
|
+
delete root.id;
|
|
1952
|
+
delete root.transform;
|
|
1953
|
+
delete root.prefab;
|
|
1954
|
+
// Strip IDs from children too
|
|
1955
|
+
walkEntities(root.children ?? [], (e) => {
|
|
1956
|
+
delete e.id;
|
|
1957
|
+
});
|
|
1958
|
+
walkEntities([root], (e) => {
|
|
1959
|
+
if (e.children && e.children.length === 0) delete e.children;
|
|
1960
|
+
});
|
|
1961
|
+
return { version: 1, name: root.name, root };
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
convertToPrefabInstance(id: string, prefabPath: string): void {
|
|
1965
|
+
const obj = this._objectMap.get(id);
|
|
1966
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1967
|
+
if (!entity) return;
|
|
1968
|
+
this._pushUndo();
|
|
1969
|
+
const keepName = entity.name;
|
|
1970
|
+
const keepId = entity.id;
|
|
1971
|
+
const keepTransform = entity.transform;
|
|
1972
|
+
// Clear all properties
|
|
1973
|
+
for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
|
|
1974
|
+
delete entity[key];
|
|
1975
|
+
}
|
|
1976
|
+
// Set instance properties
|
|
1977
|
+
entity.name = keepName;
|
|
1978
|
+
if (keepId) entity.id = keepId;
|
|
1979
|
+
entity.prefab = prefabPath;
|
|
1980
|
+
if (keepTransform) entity.transform = keepTransform;
|
|
1981
|
+
this._rebuildEntity(id);
|
|
1982
|
+
this._notify();
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
unpackPrefab(id: string, prefabRoot: SceneEntity): void {
|
|
1986
|
+
const obj = this._objectMap.get(id);
|
|
1987
|
+
const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
|
|
1988
|
+
if (!entity || !entity.prefab) return;
|
|
1989
|
+
// Merge BEFORE pushing undo: since T1.10 an invalid prefab+instance combo
|
|
1990
|
+
// throws (schema re-validation of the merged entity), and pushing first
|
|
1991
|
+
// would leave a no-op undo entry (the undo-then-bail defect class — see
|
|
1992
|
+
// moveEntity/T1.12).
|
|
1993
|
+
let merged: SceneEntity;
|
|
1994
|
+
try {
|
|
1995
|
+
merged = mergePrefabInstance(prefabRoot, entity);
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
console.error(`EditorStore: cannot unpack invalid prefab instance "${entity.name}":`, err);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
this._pushUndo();
|
|
2001
|
+
for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
|
|
2002
|
+
delete entity[key];
|
|
2003
|
+
}
|
|
2004
|
+
Object.assign(entity, merged);
|
|
2005
|
+
assignMissingIds(entity.children ?? []);
|
|
2006
|
+
this._rebuildEntity(id);
|
|
2007
|
+
this._notify();
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
loadPrefab(prefab: PrefabFile, savePath?: string): void {
|
|
2011
|
+
if (this._blockedDuringPlay('load prefab')) return;
|
|
2012
|
+
const root = deepClone(prefab.root);
|
|
2013
|
+
const doc: SceneFile = {
|
|
2014
|
+
version: prefab.version,
|
|
2015
|
+
name: prefab.name,
|
|
2016
|
+
entities: [root],
|
|
2017
|
+
};
|
|
2018
|
+
assignMissingIds(doc.entities);
|
|
2019
|
+
this._sceneMeta = {
|
|
2020
|
+
version: prefab.version,
|
|
2021
|
+
name: prefab.name,
|
|
2022
|
+
};
|
|
2023
|
+
this._documentType = 'prefab';
|
|
2024
|
+
this._selectedEntityIds.clear();
|
|
2025
|
+
this._undoStack.length = 0;
|
|
2026
|
+
this._redoStack.length = 0;
|
|
2027
|
+
this._isDirty = false;
|
|
2028
|
+
this._savePath = savePath ?? null;
|
|
2029
|
+
|
|
2030
|
+
if (this._scene) {
|
|
2031
|
+
this._rebuildScene(doc);
|
|
2032
|
+
}
|
|
2033
|
+
this._notify();
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
getPrefabDocument(): PrefabFile | null {
|
|
2037
|
+
if (this._documentType !== 'prefab') return null;
|
|
2038
|
+
const doc = this.getDocument();
|
|
2039
|
+
const root = doc.entities[0];
|
|
2040
|
+
if (!root) return null;
|
|
2041
|
+
const cleanRoot = deepClone(root);
|
|
2042
|
+
walkEntities([cleanRoot], (e) => {
|
|
2043
|
+
delete e.id;
|
|
2044
|
+
if (e.children && e.children.length === 0) delete e.children;
|
|
2045
|
+
});
|
|
2046
|
+
return { version: doc.version, name: doc.name, root: cleanRoot };
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/** Update all references to an asset path across the live scene. */
|
|
2050
|
+
applyAssetMove(oldPath: string, newPath: string): void {
|
|
2051
|
+
// Play-mode / ingest guard: same rationale as applyExternalUpdate — this
|
|
2052
|
+
// does a full serialize+rebuild of `_scene`, which is the LIVE game/ingested
|
|
2053
|
+
// scene while playing (probe: F6b in the round-1 review — the running
|
|
2054
|
+
// game's scene was torn down and rebuilt from under it). Defer to stop.
|
|
2055
|
+
if (this._playState !== 'stopped') {
|
|
2056
|
+
this._pendingAssetMove = { oldPath, newPath };
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
if (!this._scene) return;
|
|
2060
|
+
this._pushUndo();
|
|
2061
|
+
// Serialize, rename, rebuild
|
|
2062
|
+
const doc = serializeScene(this._scene, this._sceneMeta);
|
|
2063
|
+
const renamed = applyAssetRename(doc, oldPath, newPath);
|
|
2064
|
+
this._sceneMeta = { version: renamed.version, name: renamed.name };
|
|
2065
|
+
if (renamed.environment) this._sceneMeta.environment = renamed.environment;
|
|
2066
|
+
if (renamed.ui) this._sceneMeta.ui = renamed.ui; // preserve scene-graph UI (T1.5)
|
|
2067
|
+
this._rebuildScene(renamed);
|
|
2068
|
+
this._notify();
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// --- Asset tabs ---
|
|
2072
|
+
|
|
2073
|
+
setActiveTab(key: string): void {
|
|
2074
|
+
this._activeTabKey = key;
|
|
2075
|
+
this._notify();
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
openAssetTab(assetPath: string, kind: AssetKind): void {
|
|
2079
|
+
const existing = this._assetTabs.find((t) => t.key === assetPath);
|
|
2080
|
+
if (existing) {
|
|
2081
|
+
this._activeTabKey = existing.key;
|
|
2082
|
+
this._notify();
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const label = assetPath.split('/').pop() ?? assetPath;
|
|
2086
|
+
const tab: AssetTab = { key: assetPath, label, assetPath, kind };
|
|
2087
|
+
this._assetTabs.push(tab);
|
|
2088
|
+
this._activeTabKey = tab.key;
|
|
2089
|
+
this._notify();
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
openOnlineAssetTab(online: OnlineAssetInfo): void {
|
|
2093
|
+
const key = `online:${online.source}:${online.id}`;
|
|
2094
|
+
const existing = this._assetTabs.find((t) => t.key === key);
|
|
2095
|
+
if (existing) {
|
|
2096
|
+
this._activeTabKey = existing.key;
|
|
2097
|
+
this._notify();
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
const kind: AssetKind =
|
|
2101
|
+
online.type === 'model'
|
|
2102
|
+
? 'model'
|
|
2103
|
+
: online.type === 'animation' || online.type === 'source'
|
|
2104
|
+
? 'source'
|
|
2105
|
+
: 'image';
|
|
2106
|
+
const tab: AssetTab = { key, label: online.name, assetPath: '', kind, online };
|
|
2107
|
+
this._assetTabs.push(tab);
|
|
2108
|
+
this._activeTabKey = tab.key;
|
|
2109
|
+
this._notify();
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Update an online asset tab to point at a local path after download. */
|
|
2113
|
+
resolveOnlineAssetTab(onlineKey: string, localPath: string): void {
|
|
2114
|
+
const tab = this._assetTabs.find((t) => t.key === onlineKey);
|
|
2115
|
+
if (!tab) return;
|
|
2116
|
+
tab.assetPath = localPath;
|
|
2117
|
+
delete tab.online;
|
|
2118
|
+
this._notify();
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
closeAssetTab(key: string): void {
|
|
2122
|
+
const idx = this._assetTabs.findIndex((t) => t.key === key);
|
|
2123
|
+
if (idx < 0) return;
|
|
2124
|
+
this._assetTabs.splice(idx, 1);
|
|
2125
|
+
if (this._activeTabKey === key) {
|
|
2126
|
+
this._activeTabKey = '__inspector__';
|
|
2127
|
+
}
|
|
2128
|
+
this._notify();
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// --- Internal helpers ---
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* Rebuild a single entity's Three.js representation after its descriptor
|
|
2135
|
+
* changed. This is the single choke point for every property edit that
|
|
2136
|
+
* requires tearing down and recreating the entity's Object3D (mesh/material/
|
|
2137
|
+
* physics/component shape changes — see updateEntity, batchMutate,
|
|
2138
|
+
* updatePivot, convertToPrefabInstance, unpackPrefab).
|
|
2139
|
+
*
|
|
2140
|
+
* Play-mode guard: while playing/paused, `_scene`/`_objectMap` point at the
|
|
2141
|
+
* LIVE game scene (see enterPlayScene). `updateEntityPreview` disposes the
|
|
2142
|
+
* old Object3D (geometry/material) and swaps in a fresh, component-less
|
|
2143
|
+
* preview object — which would rip a running GameComponent's Object3D out
|
|
2144
|
+
* from under it and orphan any physics body/writer targeting it (probe:
|
|
2145
|
+
* F4 in the round-1 review). The descriptor mutation the caller already
|
|
2146
|
+
* applied still stands (and is discarded on exitPlayScene, same as any
|
|
2147
|
+
* other play-time edit) — only the destructive rebuild is skipped.
|
|
2148
|
+
*/
|
|
2149
|
+
private _rebuildEntity(id: string): void {
|
|
2150
|
+
if (!this._scene) return;
|
|
2151
|
+
if (this._playState !== 'stopped') {
|
|
2152
|
+
console.warn(
|
|
2153
|
+
'EditorStore: property change requiring a rebuild is disabled during play mode — stop the game first.',
|
|
2154
|
+
);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
// (Ingested unmodified-game objects are edited LIVE through the
|
|
2158
|
+
// IngestAuthoringAdapter — they never flow through this first-party
|
|
2159
|
+
// descriptor-rebuild path.)
|
|
2160
|
+
const entity = this.getEntityById(id);
|
|
2161
|
+
if (!entity) return;
|
|
2162
|
+
updateEntityPreview(entity, this._objectMap, this._scene, this._batchedRenderer);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** Full scene rebuild from a SceneFile (for load, undo, redo). */
|
|
2166
|
+
private _rebuildScene(doc: SceneFile): void {
|
|
2167
|
+
if (!this._scene) return;
|
|
2168
|
+
this._objectMap = buildPreview(
|
|
2169
|
+
doc,
|
|
2170
|
+
this._scene,
|
|
2171
|
+
this._shadingMode,
|
|
2172
|
+
this._renderer ?? undefined,
|
|
2173
|
+
this._batchedRenderer,
|
|
2174
|
+
);
|
|
2175
|
+
// Structure (and thus composer inputs: tagged objects / selective effects) changed.
|
|
2176
|
+
this._composerVersion++;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/** Apply environment from sceneMeta to the live scene. */
|
|
2180
|
+
private _applyEnvironment(): void {
|
|
2181
|
+
if (!this._scene) return;
|
|
2182
|
+
// Remove existing environment objects
|
|
2183
|
+
const envObjs: THREE.Object3D[] = [];
|
|
2184
|
+
this._scene.traverse((obj) => {
|
|
2185
|
+
if (getUserData(obj, 'envObject')) envObjs.push(obj);
|
|
2186
|
+
});
|
|
2187
|
+
for (const obj of envObjs) obj.removeFromParent();
|
|
2188
|
+
this._scene.fog = null;
|
|
2189
|
+
this._scene.environment = null;
|
|
2190
|
+
|
|
2191
|
+
applyEnvironment(this._sceneMeta.environment, this._scene, this._renderer ?? undefined);
|
|
2192
|
+
this._composerVersion++;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** Load a SceneFile into the scene without changing undo/selection state. */
|
|
2196
|
+
private _loadFromDoc(doc: SceneFile): void {
|
|
2197
|
+
this._sceneMeta = { version: doc.version, name: doc.name };
|
|
2198
|
+
if (doc.environment) this._sceneMeta.environment = doc.environment;
|
|
2199
|
+
if (doc.ui) this._sceneMeta.ui = doc.ui; // preserve scene-graph UI through undo/redo (T1.5)
|
|
2200
|
+
this._rebuildScene(doc);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/** Build nested SceneEntity[] from the Three.js hierarchy (for react-arborist). */
|
|
2204
|
+
private _buildEntityTree(parent: THREE.Object3D): SceneEntity[] {
|
|
2205
|
+
const result: SceneEntity[] = [];
|
|
2206
|
+
for (const child of parent.children) {
|
|
2207
|
+
if (getUserData(child, 'entityId') === undefined) continue;
|
|
2208
|
+
if (getUserData(child, 'editorHelper')) continue;
|
|
2209
|
+
const stored = getUserData(child, 'entity') as SceneEntity | undefined;
|
|
2210
|
+
if (!stored) continue;
|
|
2211
|
+
|
|
2212
|
+
const childEntities = this._buildEntityTree(child);
|
|
2213
|
+
if (childEntities.length > 0) {
|
|
2214
|
+
result.push({ ...stored, children: childEntities });
|
|
2215
|
+
} else if (stored.children && stored.children.length > 0) {
|
|
2216
|
+
// Entity descriptor has stale children — strip them
|
|
2217
|
+
const { children: _, ...rest } = stored;
|
|
2218
|
+
result.push(rest as SceneEntity);
|
|
2219
|
+
} else {
|
|
2220
|
+
result.push(stored);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
return result;
|
|
2224
|
+
}
|
|
2225
|
+
}
|