@pascal-app/editor 0.8.0 → 0.9.2
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/package.json +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +7 -7
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +332 -0
- package/src/lib/scene.ts +40 -8
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +39 -0
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
bakePolicyOf,
|
|
4
|
+
type DoorNode,
|
|
5
|
+
emitter,
|
|
6
|
+
getLevelDisplayName,
|
|
7
|
+
isOperationDoorType,
|
|
8
|
+
itemClipRegistry,
|
|
9
|
+
type LevelNode,
|
|
10
|
+
nodeRegistry,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
type WindowNode,
|
|
13
|
+
type ZoneNode,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import {
|
|
16
|
+
poseDoorMovingParts,
|
|
17
|
+
poseWindowMovingParts,
|
|
18
|
+
SCENE_LAYER,
|
|
19
|
+
snapLevelsToTruePositions,
|
|
20
|
+
} from '@pascal-app/viewer'
|
|
21
|
+
import type { Object3D } from 'three'
|
|
22
|
+
import * as THREE from 'three'
|
|
23
|
+
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
24
|
+
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Two TRS samples (closed vs open) differing by less than this are treated as
|
|
28
|
+
* stationary, so only genuinely moving parts get an animation track.
|
|
29
|
+
*/
|
|
30
|
+
const POSE_EPSILON = 1e-5
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Marker stamped on a door's swing-leaf group by the door system. `axis` is the
|
|
34
|
+
* hinge axis and `openRotationY` is the fully-open angle (radians). The export
|
|
35
|
+
* reads it to bake an open clip from a single closed pose; see `door-system`.
|
|
36
|
+
*/
|
|
37
|
+
type SwingLeafMarker = { axis: 'y'; openRotationY: number }
|
|
38
|
+
|
|
39
|
+
export type GlbExport = {
|
|
40
|
+
scene: THREE.Object3D
|
|
41
|
+
animations: THREE.AnimationClip[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
45
|
+
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
|
|
46
|
+
* before the exporter clones the scene graph. Callers must set
|
|
47
|
+
* `useViewer.setExporting(true)` first and reset it after the export. */
|
|
48
|
+
export function nextFrames(): Promise<void> {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function exportSceneToGlb(
|
|
55
|
+
sceneGroup: Object3D,
|
|
56
|
+
nodes: Record<string, AnyNode>,
|
|
57
|
+
): Promise<ArrayBuffer> {
|
|
58
|
+
emitter.emit('thumbnail:before-capture', undefined)
|
|
59
|
+
// Snap levels to their true stacked positions (like thumbnail capture) so the
|
|
60
|
+
// export always reflects the clean stacked building, regardless of the live
|
|
61
|
+
// levelMode (exploded/solo) or an unsettled level lerp that could otherwise
|
|
62
|
+
// bake a level at a stray offset.
|
|
63
|
+
const restoreLevels = snapLevelsToTruePositions()
|
|
64
|
+
let prepared: ReturnType<typeof prepareSceneForExport>
|
|
65
|
+
try {
|
|
66
|
+
prepared = prepareSceneForExport(sceneGroup, nodes)
|
|
67
|
+
} finally {
|
|
68
|
+
restoreLevels()
|
|
69
|
+
emitter.emit('thumbnail:after-capture', undefined)
|
|
70
|
+
}
|
|
71
|
+
const { scene: exportScene, animations } = prepared
|
|
72
|
+
|
|
73
|
+
const exporter = new GLTFExporter()
|
|
74
|
+
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
|
|
75
|
+
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
|
|
76
|
+
// offscreen renderer (passing the live renderer would resize/draw over the
|
|
77
|
+
// editor canvas), letting the exporter embed standard textures.
|
|
78
|
+
exporter.setTextureUtils(WebGPUTextureUtils)
|
|
79
|
+
|
|
80
|
+
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
81
|
+
exporter.parse(
|
|
82
|
+
exportScene,
|
|
83
|
+
(gltf) => {
|
|
84
|
+
resolve(gltf as ArrayBuffer)
|
|
85
|
+
},
|
|
86
|
+
(error) => {
|
|
87
|
+
reject(error)
|
|
88
|
+
},
|
|
89
|
+
{ binary: true, animations },
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build an engine-agnostic export tree from the live scene graph. The result is
|
|
96
|
+
* a standalone three.js scene plus glTF animation clips, ready for
|
|
97
|
+
* `GLTFExporter` — it carries no Pascal runtime dependency.
|
|
98
|
+
*
|
|
99
|
+
* - Clones the source so live objects are never mutated.
|
|
100
|
+
* - Converts WebGPU NodeMaterials to classic glTF-standard materials.
|
|
101
|
+
* `GLTFExporter` only recognises `isMeshStandardMaterial` /
|
|
102
|
+
* `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
|
|
103
|
+
* `isNodeMaterial` instead, so without this every surface exports as a blank
|
|
104
|
+
* default material.
|
|
105
|
+
* - Bakes open motions into glTF animation clips via kind-owned registry
|
|
106
|
+
* hooks, plus the legacy door/window build-once + pose-at-t primitives
|
|
107
|
+
* (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows).
|
|
108
|
+
* - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
|
|
109
|
+
* survive the bake with no in-memory registry, and strips all other userData
|
|
110
|
+
* so editor/runtime ephemera never leak into glTF extras.
|
|
111
|
+
*/
|
|
112
|
+
export function prepareSceneForExport(
|
|
113
|
+
source: THREE.Object3D,
|
|
114
|
+
nodes: Record<string, AnyNode>,
|
|
115
|
+
): GlbExport {
|
|
116
|
+
const scene = source.clone(true)
|
|
117
|
+
const cloneByOriginal = pairClones(source, scene)
|
|
118
|
+
|
|
119
|
+
// Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy
|
|
120
|
+
// reference assets stored elsewhere and aren't part of the compiled building.
|
|
121
|
+
// Drop them from the artifact entirely — `/viewer` re-adds them from the scene
|
|
122
|
+
// graph, gated by the project's public-visibility flags, so they never bloat
|
|
123
|
+
// the shared GLB nor slip past those flags into a static public file.
|
|
124
|
+
// (`'replace'` kinds are *kept*: static for portability, the viewer swaps them
|
|
125
|
+
// for a live render.)
|
|
126
|
+
for (const [id, original] of sceneRegistry.nodes) {
|
|
127
|
+
const node = nodes[id]
|
|
128
|
+
if (node && bakePolicyOf(node.type) === 'strip') {
|
|
129
|
+
cloneByOriginal.get(original)?.removeFromParent()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Object3Ds that carry node identity — never strip these even when they sit on
|
|
134
|
+
// a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
|
|
135
|
+
// are stripped, but its identity node stays to carry the polygon that /viewer
|
|
136
|
+
// reconstructs the room from.
|
|
137
|
+
const identityNodes = new Set<THREE.Object3D>()
|
|
138
|
+
for (const original of sceneRegistry.nodes.values()) {
|
|
139
|
+
const clone = cloneByOriginal.get(original)
|
|
140
|
+
if (clone) identityNodes.add(clone)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pruneNonRenderableMeshes(scene, identityNodes)
|
|
144
|
+
sanitizeMaterialGroups(scene, identityNodes)
|
|
145
|
+
convertMaterials(scene)
|
|
146
|
+
|
|
147
|
+
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
|
|
148
|
+
|
|
149
|
+
stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
|
|
150
|
+
|
|
151
|
+
return { scene, animations: clips }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Pair each original Object3D with its clone. `clone(true)` builds children in
|
|
156
|
+
* source order, so parallel pre-order traversals line up 1:1 — this is how we
|
|
157
|
+
* map `sceneRegistry`'s live refs onto the export tree without mutating either.
|
|
158
|
+
*/
|
|
159
|
+
function pairClones(
|
|
160
|
+
source: THREE.Object3D,
|
|
161
|
+
clone: THREE.Object3D,
|
|
162
|
+
): Map<THREE.Object3D, THREE.Object3D> {
|
|
163
|
+
const originals: THREE.Object3D[] = []
|
|
164
|
+
const clones: THREE.Object3D[] = []
|
|
165
|
+
source.traverse((object) => originals.push(object))
|
|
166
|
+
clone.traverse((object) => clones.push(object))
|
|
167
|
+
|
|
168
|
+
const map = new Map<THREE.Object3D, THREE.Object3D>()
|
|
169
|
+
for (let i = 0; i < originals.length; i++) {
|
|
170
|
+
const target = clones[i]
|
|
171
|
+
if (target) map.set(originals[i]!, target)
|
|
172
|
+
}
|
|
173
|
+
return map
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A single empty geometry shared by every container mesh we neutralise below —
|
|
177
|
+
// it has no attributes, so GLTFExporter's processMesh returns null and emits a
|
|
178
|
+
// plain transform node instead of a primitive.
|
|
179
|
+
const EMPTY_GEOMETRY = new THREE.BufferGeometry()
|
|
180
|
+
|
|
181
|
+
// Hidden placeholder for a neutralised renderable that has no material: a valid
|
|
182
|
+
// material keeps GLTFExporter from crashing on `material.isShaderMaterial`, while
|
|
183
|
+
// EMPTY_GEOMETRY makes it emit a transform node instead of a primitive.
|
|
184
|
+
const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Strip everything that must not bake into the model:
|
|
188
|
+
* - Editor overlays on non-scene layers (gizmos, selection handles, ground
|
|
189
|
+
* grid, zone fills). The editor camera shows them via extra layers; a
|
|
190
|
+
* thumbnail/bake is layer 0 only. Scene-layer affordances that can't be
|
|
191
|
+
* layer-filtered (ceiling/site brackets) are hidden by the caller's
|
|
192
|
+
* `thumbnail:before-capture` emit before the clone instead.
|
|
193
|
+
* - Selection hitboxes, whose invisibility lives on `material.visible = false`
|
|
194
|
+
* (which GLTFExporter's `onlyVisible` does not catch). A door/window's hitbox
|
|
195
|
+
* root is a box spanning the wall opening — left in, it plugs the cutout.
|
|
196
|
+
* With children (it parents the visible frame + leaf) it keeps its node but
|
|
197
|
+
* loses its geometry; childless ones are removed outright.
|
|
198
|
+
*/
|
|
199
|
+
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
|
200
|
+
const toRemove: THREE.Object3D[] = []
|
|
201
|
+
root.traverse((object) => {
|
|
202
|
+
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
|
203
|
+
// live off the scene layer; the editor camera shows them via extra layers
|
|
204
|
+
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree —
|
|
205
|
+
// except identity nodes, which we keep (their off-layer mesh children are
|
|
206
|
+
// still pruned as the traversal continues).
|
|
207
|
+
if (!object.layers.isEnabled(SCENE_LAYER)) {
|
|
208
|
+
if (identityNodes.has(object)) return
|
|
209
|
+
toRemove.push(object)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
// A renderable (Mesh / Line / Points) with no material can't produce valid
|
|
213
|
+
// glTF and crashes GLTFExporter, which reads `material.isShaderMaterial`
|
|
214
|
+
// unconditionally — e.g. an imported sub-model that left a mesh material-less.
|
|
215
|
+
// Non-Mesh renderables also slip past the `isMesh` checks below and the
|
|
216
|
+
// material conversion. Neutralise it: keep the node (so children survive) but
|
|
217
|
+
// strip its geometry + give it the hidden placeholder, or drop it if a leaf.
|
|
218
|
+
const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
|
|
219
|
+
if (
|
|
220
|
+
(renderable.isMesh === true || renderable.isLine === true || renderable.isPoints === true) &&
|
|
221
|
+
renderable.material == null
|
|
222
|
+
) {
|
|
223
|
+
if (object.children.length > 0) {
|
|
224
|
+
renderable.geometry = EMPTY_GEOMETRY
|
|
225
|
+
renderable.material = PLACEHOLDER_MATERIAL
|
|
226
|
+
} else {
|
|
227
|
+
toRemove.push(object)
|
|
228
|
+
}
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const mesh = object as THREE.Mesh
|
|
232
|
+
if (!mesh.isMesh || isRenderableMesh(mesh)) return
|
|
233
|
+
if (mesh.children.length > 0) {
|
|
234
|
+
mesh.geometry = EMPTY_GEOMETRY
|
|
235
|
+
} else {
|
|
236
|
+
toRemove.push(mesh)
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
for (const object of toRemove) {
|
|
240
|
+
object.removeFromParent()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Repair meshes whose geometry groups don't line up with their material array —
|
|
246
|
+
* GLTFExporter reads `materials[group.materialIndex]` per group and crashes on
|
|
247
|
+
* undefined (`reading 'isShaderMaterial'`). The known producer is a roof
|
|
248
|
+
* placeholder (BoxGeometry's 6 groups vs the 4 roof materials), but any
|
|
249
|
+
* system/CSG output can end up here, so repair generically:
|
|
250
|
+
* - groups indexing past the array (or drawing zero triangles) are dropped;
|
|
251
|
+
* - null slots referenced by surviving groups get the hidden placeholder;
|
|
252
|
+
* - a mesh left with no drawable group — including an array-material mesh
|
|
253
|
+
* with no groups at all (three draws nothing for those, e.g. the roof
|
|
254
|
+
* system's degenerate placeholder) — is neutralised like other
|
|
255
|
+
* non-renderables (kept as a bare transform node, or removed if a leaf
|
|
256
|
+
* that carries no node identity).
|
|
257
|
+
* Geometry/material refs are shared with the live scene (`clone(true)` is
|
|
258
|
+
* shallow for both), so repairs swap refs instead of mutating in place.
|
|
259
|
+
*/
|
|
260
|
+
function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
|
261
|
+
const toRemove: THREE.Object3D[] = []
|
|
262
|
+
root.traverse((object) => {
|
|
263
|
+
const mesh = object as THREE.Mesh
|
|
264
|
+
if (!mesh.isMesh || !Array.isArray(mesh.material)) return
|
|
265
|
+
const materials = mesh.material
|
|
266
|
+
const groups = mesh.geometry.groups
|
|
267
|
+
const broken =
|
|
268
|
+
groups.length === 0 ||
|
|
269
|
+
groups.some((g) => (g.materialIndex ?? 0) >= materials.length || g.count === 0) ||
|
|
270
|
+
materials.some((m) => m == null)
|
|
271
|
+
if (!broken) return
|
|
272
|
+
|
|
273
|
+
const validGroups = groups.filter(
|
|
274
|
+
(g) => (g.materialIndex ?? 0) < materials.length && g.count !== 0,
|
|
275
|
+
)
|
|
276
|
+
if (validGroups.length === 0) {
|
|
277
|
+
if (mesh.children.length > 0 || identityNodes.has(mesh)) {
|
|
278
|
+
mesh.geometry = EMPTY_GEOMETRY
|
|
279
|
+
mesh.material = PLACEHOLDER_MATERIAL
|
|
280
|
+
} else {
|
|
281
|
+
toRemove.push(mesh)
|
|
282
|
+
}
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
// Only the group list needs repair — share the attribute/index refs
|
|
286
|
+
// instead of geometry.clone(), which deep-copies every vertex buffer.
|
|
287
|
+
if (validGroups.length !== groups.length) {
|
|
288
|
+
const geometry = new THREE.BufferGeometry()
|
|
289
|
+
geometry.index = mesh.geometry.index
|
|
290
|
+
for (const [name, attribute] of Object.entries(mesh.geometry.attributes)) {
|
|
291
|
+
geometry.setAttribute(name, attribute)
|
|
292
|
+
}
|
|
293
|
+
geometry.morphAttributes = mesh.geometry.morphAttributes
|
|
294
|
+
geometry.morphTargetsRelative = mesh.geometry.morphTargetsRelative
|
|
295
|
+
geometry.setDrawRange(mesh.geometry.drawRange.start, mesh.geometry.drawRange.count)
|
|
296
|
+
geometry.groups = validGroups.map((g) => ({ ...g }))
|
|
297
|
+
mesh.geometry = geometry
|
|
298
|
+
}
|
|
299
|
+
mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL)
|
|
300
|
+
})
|
|
301
|
+
for (const object of toRemove) {
|
|
302
|
+
object.removeFromParent()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isRenderableMesh(mesh: THREE.Mesh): boolean {
|
|
307
|
+
const position = mesh.geometry?.getAttribute('position')
|
|
308
|
+
if (!position || position.count === 0) return false
|
|
309
|
+
const material = mesh.material
|
|
310
|
+
// `colorWrite: false` is how raycast-only colliders (e.g. instanced plants'
|
|
311
|
+
// proxy boxes) hide on the GPU — glTF has no equivalent, so exporting one
|
|
312
|
+
// yields an opaque white box. Treat it as non-renderable.
|
|
313
|
+
const renders = (m: THREE.Material | null | undefined) =>
|
|
314
|
+
m?.visible !== false && m?.colorWrite !== false
|
|
315
|
+
return Array.isArray(material) ? material.some(renders) : renders(material)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Material conversion -------------------------------------------------
|
|
319
|
+
|
|
320
|
+
const STANDARD_MAP_SLOTS = [
|
|
321
|
+
'map',
|
|
322
|
+
'normalMap',
|
|
323
|
+
'roughnessMap',
|
|
324
|
+
'metalnessMap',
|
|
325
|
+
'aoMap',
|
|
326
|
+
'emissiveMap',
|
|
327
|
+
'alphaMap',
|
|
328
|
+
'lightMap',
|
|
329
|
+
'displacementMap',
|
|
330
|
+
'bumpMap',
|
|
331
|
+
] as const
|
|
332
|
+
|
|
333
|
+
function convertMaterials(root: THREE.Object3D) {
|
|
334
|
+
const cache = new Map<THREE.Material, THREE.Material>()
|
|
335
|
+
root.traverse((object) => {
|
|
336
|
+
const mesh = object as THREE.Mesh
|
|
337
|
+
if (!mesh.isMesh) return
|
|
338
|
+
const material = mesh.material
|
|
339
|
+
if (Array.isArray(material)) {
|
|
340
|
+
mesh.material = material.map((m) => convertMaterial(m, cache))
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
// glTF has no BackSide — GLTFExporter renders the *front* face for any
|
|
344
|
+
// non-DoubleSide material, which inverts a BackSide surface (e.g. the
|
|
345
|
+
// ceiling underside, meant to be seen from the room). Flip the mesh winding
|
|
346
|
+
// so the intended face shows with the FrontSide material convertMaterial
|
|
347
|
+
// produces. Per-mesh geometry clone keeps shared geometry untouched.
|
|
348
|
+
if (
|
|
349
|
+
(material as { isNodeMaterial?: boolean }).isNodeMaterial &&
|
|
350
|
+
material.side === THREE.BackSide
|
|
351
|
+
) {
|
|
352
|
+
mesh.geometry = flipGeometryWinding(mesh.geometry)
|
|
353
|
+
}
|
|
354
|
+
mesh.material = convertMaterial(material, cache)
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Reverse triangle winding and negate normals so a surface authored for
|
|
360
|
+
* `BackSide` reads correctly once exported as `FrontSide` (glTF can't express
|
|
361
|
+
* back-face-only rendering).
|
|
362
|
+
*/
|
|
363
|
+
function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
|
|
364
|
+
const flipped = geometry.clone()
|
|
365
|
+
const index = flipped.getIndex()
|
|
366
|
+
if (index) {
|
|
367
|
+
const a = index.array
|
|
368
|
+
for (let i = 0; i < a.length; i += 3) {
|
|
369
|
+
const tmp = a[i]!
|
|
370
|
+
a[i] = a[i + 2]!
|
|
371
|
+
a[i + 2] = tmp
|
|
372
|
+
}
|
|
373
|
+
index.needsUpdate = true
|
|
374
|
+
} else {
|
|
375
|
+
for (const attribute of Object.values(flipped.attributes)) {
|
|
376
|
+
const { array, itemSize } = attribute
|
|
377
|
+
for (let i = 0; i < array.length; i += itemSize * 3) {
|
|
378
|
+
for (let k = 0; k < itemSize; k++) {
|
|
379
|
+
const tmp = array[i + k]!
|
|
380
|
+
array[i + k] = array[i + 2 * itemSize + k]!
|
|
381
|
+
array[i + 2 * itemSize + k] = tmp
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
attribute.needsUpdate = true
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const normal = flipped.getAttribute('normal')
|
|
388
|
+
if (normal) {
|
|
389
|
+
for (let i = 0; i < normal.array.length; i++) normal.array[i] = -normal.array[i]!
|
|
390
|
+
normal.needsUpdate = true
|
|
391
|
+
}
|
|
392
|
+
return flipped
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
|
|
397
|
+
* glTF exporter understands. Classic materials pass through untouched, and the
|
|
398
|
+
* cache preserves material sharing (one source instance -> one target), so the
|
|
399
|
+
* exporter still dedups shared surfaces.
|
|
400
|
+
*/
|
|
401
|
+
function convertMaterial(
|
|
402
|
+
material: THREE.Material,
|
|
403
|
+
cache: Map<THREE.Material, THREE.Material>,
|
|
404
|
+
): THREE.Material {
|
|
405
|
+
if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material
|
|
406
|
+
|
|
407
|
+
const cached = cache.get(material)
|
|
408
|
+
if (cached) return cached
|
|
409
|
+
|
|
410
|
+
const src = material as THREE.Material & Record<string, unknown>
|
|
411
|
+
const target = new THREE.MeshStandardMaterial()
|
|
412
|
+
|
|
413
|
+
target.name = material.name
|
|
414
|
+
if (src.color instanceof THREE.Color) target.color.copy(src.color)
|
|
415
|
+
if (src.emissive instanceof THREE.Color) target.emissive.copy(src.emissive)
|
|
416
|
+
if (typeof src.emissiveIntensity === 'number') target.emissiveIntensity = src.emissiveIntensity
|
|
417
|
+
// Lambert (solid-shading / glass) node materials carry no PBR scalars; a fully
|
|
418
|
+
// rough, non-metallic surface is the faithful lit fallback.
|
|
419
|
+
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
|
|
420
|
+
target.metalness = typeof src.metalness === 'number' ? src.metalness : 0
|
|
421
|
+
// Only genuinely see-through surfaces stay transparent. Several viewer
|
|
422
|
+
// materials set `transparent: true` while fully opaque (opacity 1); exporting
|
|
423
|
+
// those as alphaMode=BLEND makes them render see-through with no depth write
|
|
424
|
+
// (e.g. the ceiling looked semi-transparent). Glass (opacity < 1) is kept.
|
|
425
|
+
target.transparent = material.transparent && material.opacity < 1
|
|
426
|
+
target.opacity = material.opacity
|
|
427
|
+
// BackSide is flipped to FrontSide (with the mesh winding reversed in
|
|
428
|
+
// convertMaterials) because glTF has no back-face-only mode.
|
|
429
|
+
target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
|
|
430
|
+
target.alphaTest = material.alphaTest
|
|
431
|
+
target.depthWrite = material.depthWrite
|
|
432
|
+
target.depthTest = material.depthTest
|
|
433
|
+
target.vertexColors = material.vertexColors
|
|
434
|
+
target.toneMapped = material.toneMapped
|
|
435
|
+
if (src.normalScale instanceof THREE.Vector2) target.normalScale.copy(src.normalScale)
|
|
436
|
+
if (typeof src.aoMapIntensity === 'number') target.aoMapIntensity = src.aoMapIntensity
|
|
437
|
+
if (typeof src.displacementScale === 'number') target.displacementScale = src.displacementScale
|
|
438
|
+
|
|
439
|
+
for (const slot of STANDARD_MAP_SLOTS) {
|
|
440
|
+
const texture = src[slot]
|
|
441
|
+
if (texture instanceof THREE.Texture) {
|
|
442
|
+
;(target as unknown as Record<string, THREE.Texture>)[slot] = texture
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
cache.set(material, target)
|
|
447
|
+
return target
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- Animation clip baking ----------------------------------------------
|
|
451
|
+
|
|
452
|
+
function bakeAnimationClips(
|
|
453
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
454
|
+
nodes: Record<string, AnyNode>,
|
|
455
|
+
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
|
|
456
|
+
const clips: THREE.AnimationClip[] = []
|
|
457
|
+
const clipNamesByNode = new Map<string, string[]>()
|
|
458
|
+
|
|
459
|
+
for (const [id, original] of sceneRegistry.nodes) {
|
|
460
|
+
const node = nodes[id]
|
|
461
|
+
const target = cloneByOriginal.get(original)
|
|
462
|
+
if (!node || !target) continue
|
|
463
|
+
|
|
464
|
+
const clip =
|
|
465
|
+
bakeRegistryAnimationClips(node, target) ??
|
|
466
|
+
(node.type === 'door'
|
|
467
|
+
? bakeDoorClip(id, node, target)
|
|
468
|
+
: node.type === 'window'
|
|
469
|
+
? bakeWindowClip(id, node as WindowNode, target)
|
|
470
|
+
: node.type === 'item'
|
|
471
|
+
? bakeItemClip(id, target)
|
|
472
|
+
: null)
|
|
473
|
+
|
|
474
|
+
if (clip) {
|
|
475
|
+
const nodeClips = Array.isArray(clip) ? clip : [clip]
|
|
476
|
+
clips.push(...nodeClips)
|
|
477
|
+
clipNamesByNode.set(
|
|
478
|
+
id,
|
|
479
|
+
nodeClips.map((c) => c.name),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return { clips, clipNamesByNode }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function bakeRegistryAnimationClips(
|
|
488
|
+
node: AnyNode,
|
|
489
|
+
object: THREE.Object3D,
|
|
490
|
+
): THREE.AnimationClip | THREE.AnimationClip[] | null | undefined {
|
|
491
|
+
return nodeRegistry.get(node.type)?.exportAnimation?.({ node, object })
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked
|
|
496
|
+
* subtree. The source clip targets the item GLB's nodes by name (`lamp_018`);
|
|
497
|
+
* since every fan shares those names, we rebind each track to the specific
|
|
498
|
+
* cloned node's uuid so multiple fans animate independently. The clip is named
|
|
499
|
+
* per node (`<id>: loop`) so the baked viewer can drive each one on its own.
|
|
500
|
+
*/
|
|
501
|
+
function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null {
|
|
502
|
+
const entry = itemClipRegistry.get(id)
|
|
503
|
+
if (!entry) return null
|
|
504
|
+
|
|
505
|
+
const tracks: THREE.KeyframeTrack[] = []
|
|
506
|
+
// The catalog node names (e.g. "lamp_018") repeat across every instance of the
|
|
507
|
+
// item, and the glTF export→import roundtrip rebinds clip tracks by node name —
|
|
508
|
+
// so a shared name would make all fans share one clip. Uniquify the targeted
|
|
509
|
+
// node's name per item once, then bind tracks by its (stable) uuid.
|
|
510
|
+
const renamed = new Map<string, THREE.Object3D>()
|
|
511
|
+
for (const track of entry.clip.tracks) {
|
|
512
|
+
const dot = track.name.lastIndexOf('.')
|
|
513
|
+
if (dot < 0) continue
|
|
514
|
+
const targetName = track.name.slice(0, dot)
|
|
515
|
+
const property = track.name.slice(dot + 1)
|
|
516
|
+
let targetNode = renamed.get(targetName)
|
|
517
|
+
if (!targetNode) {
|
|
518
|
+
const found = itemObject.getObjectByName(targetName)
|
|
519
|
+
if (!found) continue
|
|
520
|
+
found.name = `${id}__${targetName}`
|
|
521
|
+
renamed.set(targetName, found)
|
|
522
|
+
targetNode = found
|
|
523
|
+
}
|
|
524
|
+
const retargeted = track.clone()
|
|
525
|
+
retargeted.name = `${targetNode.uuid}.${property}`
|
|
526
|
+
tracks.push(retargeted)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (tracks.length === 0) return null
|
|
530
|
+
const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks)
|
|
531
|
+
clip.userData = { loop: entry.loop }
|
|
532
|
+
return clip
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Bake a door's open motion. Swing doors (hinged/double/french) carry a
|
|
537
|
+
* `pascalSwingLeaf` marker and bake a single quaternion track per leaf;
|
|
538
|
+
* operation doors (sliding/pocket/barn/folding/garage-*) build their moving
|
|
539
|
+
* parts in named groups posed by `poseDoorMovingParts`, sampled here into
|
|
540
|
+
* keyframes (their motion is non-linear, e.g. the sectional's overhead curve).
|
|
541
|
+
*/
|
|
542
|
+
function bakeDoorClip(
|
|
543
|
+
id: string,
|
|
544
|
+
node: AnyNode,
|
|
545
|
+
doorObject: THREE.Object3D,
|
|
546
|
+
): THREE.AnimationClip | null {
|
|
547
|
+
if (node.type === 'door' && isOperationDoorType((node as DoorNode).doorType)) {
|
|
548
|
+
return bakeOperationDoorClip(id, node as DoorNode, doorObject)
|
|
549
|
+
}
|
|
550
|
+
return bakeSwingDoorClip(id, node, doorObject)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Number of keyframes sampled across an operation door's 0→1 open motion. */
|
|
554
|
+
const OPERATION_DOOR_SAMPLES = 16
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Sample an operation door's open motion into keyframe tracks by posing the
|
|
558
|
+
* export clone with `poseDoorMovingParts` at evenly-spaced fractions. Only the
|
|
559
|
+
* named moving groups change (their children are rigid), so a track is emitted
|
|
560
|
+
* per group whose position / rotation / scale actually moves. The clone is left
|
|
561
|
+
* posed closed so the GLB's rest state is shut.
|
|
562
|
+
*/
|
|
563
|
+
function bakeOperationDoorClip(
|
|
564
|
+
id: string,
|
|
565
|
+
node: DoorNode,
|
|
566
|
+
doorObject: THREE.Object3D,
|
|
567
|
+
): THREE.AnimationClip | null {
|
|
568
|
+
if (!poseDoorMovingParts(node, doorObject, 0)) return null
|
|
569
|
+
|
|
570
|
+
const objects: THREE.Object3D[] = []
|
|
571
|
+
doorObject.traverse((object) => objects.push(object))
|
|
572
|
+
const basePoses = objects.map((object) => ({
|
|
573
|
+
position: object.position.clone(),
|
|
574
|
+
quaternion: object.quaternion.clone(),
|
|
575
|
+
scale: object.scale.clone(),
|
|
576
|
+
}))
|
|
577
|
+
|
|
578
|
+
const times: number[] = []
|
|
579
|
+
const positionSamples = objects.map(() => [] as number[])
|
|
580
|
+
const quaternionSamples = objects.map(() => [] as number[])
|
|
581
|
+
const scaleSamples = objects.map(() => [] as number[])
|
|
582
|
+
|
|
583
|
+
for (let step = 0; step <= OPERATION_DOOR_SAMPLES; step++) {
|
|
584
|
+
const t = step / OPERATION_DOOR_SAMPLES
|
|
585
|
+
times.push(t)
|
|
586
|
+
poseDoorMovingParts(node, doorObject, t)
|
|
587
|
+
for (let i = 0; i < objects.length; i++) {
|
|
588
|
+
const object = objects[i]!
|
|
589
|
+
positionSamples[i]!.push(...object.position.toArray())
|
|
590
|
+
quaternionSamples[i]!.push(...object.quaternion.toArray())
|
|
591
|
+
scaleSamples[i]!.push(...object.scale.toArray())
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const tracks: THREE.KeyframeTrack[] = []
|
|
596
|
+
for (let i = 0; i < objects.length; i++) {
|
|
597
|
+
const object = objects[i]!
|
|
598
|
+
const base = basePoses[i]!
|
|
599
|
+
if (samplesMovePosition(positionSamples[i]!, base.position)) {
|
|
600
|
+
tracks.push(
|
|
601
|
+
new THREE.VectorKeyframeTrack(`${object.uuid}.position`, times, positionSamples[i]!),
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
if (samplesMoveQuaternion(quaternionSamples[i]!, base.quaternion)) {
|
|
605
|
+
tracks.push(
|
|
606
|
+
new THREE.QuaternionKeyframeTrack(
|
|
607
|
+
`${object.uuid}.quaternion`,
|
|
608
|
+
times,
|
|
609
|
+
quaternionSamples[i]!,
|
|
610
|
+
),
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
if (samplesMoveScale(scaleSamples[i]!, base.scale)) {
|
|
614
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.scale`, times, scaleSamples[i]!))
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
poseDoorMovingParts(node, doorObject, 0)
|
|
619
|
+
|
|
620
|
+
if (tracks.length === 0) return null
|
|
621
|
+
return openClip(id, tracks)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function samplesMovePosition(flat: number[], base: THREE.Vector3): boolean {
|
|
625
|
+
const point = new THREE.Vector3()
|
|
626
|
+
for (let i = 0; i < flat.length; i += 3) {
|
|
627
|
+
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
|
|
628
|
+
if (point.distanceToSquared(base) > POSE_EPSILON) return true
|
|
629
|
+
}
|
|
630
|
+
return false
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function samplesMoveQuaternion(flat: number[], base: THREE.Quaternion): boolean {
|
|
634
|
+
const quaternion = new THREE.Quaternion()
|
|
635
|
+
for (let i = 0; i < flat.length; i += 4) {
|
|
636
|
+
quaternion.set(flat[i]!, flat[i + 1]!, flat[i + 2]!, flat[i + 3]!)
|
|
637
|
+
if (base.angleTo(quaternion) > POSE_EPSILON) return true
|
|
638
|
+
}
|
|
639
|
+
return false
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function samplesMoveScale(flat: number[], base: THREE.Vector3): boolean {
|
|
643
|
+
const point = new THREE.Vector3()
|
|
644
|
+
for (let i = 0; i < flat.length; i += 3) {
|
|
645
|
+
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
|
|
646
|
+
if (point.distanceToSquared(base) > POSE_EPSILON) return true
|
|
647
|
+
}
|
|
648
|
+
return false
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Bake a swing door's open motion. Each marked leaf is rotated from closed
|
|
653
|
+
* (rest pose) to its fully-open angle and emitted as a 1-second quaternion
|
|
654
|
+
* track; the leaf is left at the closed pose so the GLB's rest state is shut.
|
|
655
|
+
*/
|
|
656
|
+
function bakeSwingDoorClip(
|
|
657
|
+
id: string,
|
|
658
|
+
node: AnyNode,
|
|
659
|
+
doorObject: THREE.Object3D,
|
|
660
|
+
): THREE.AnimationClip | null {
|
|
661
|
+
const tracks: THREE.KeyframeTrack[] = []
|
|
662
|
+
|
|
663
|
+
doorObject.traverse((object) => {
|
|
664
|
+
const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined
|
|
665
|
+
if (marker?.axis !== 'y') return
|
|
666
|
+
|
|
667
|
+
object.rotation.y = 0
|
|
668
|
+
const closed = object.quaternion.clone()
|
|
669
|
+
object.rotation.y = marker.openRotationY
|
|
670
|
+
const open = object.quaternion.clone()
|
|
671
|
+
object.rotation.y = 0
|
|
672
|
+
|
|
673
|
+
tracks.push(
|
|
674
|
+
new THREE.QuaternionKeyframeTrack(
|
|
675
|
+
`${object.uuid}.quaternion`,
|
|
676
|
+
[0, 1],
|
|
677
|
+
[...closed.toArray(), ...open.toArray()],
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
if (tracks.length === 0) return null
|
|
683
|
+
return openClip(id, tracks)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Wrap an open motion in a named 1-second clip. The name is keyed by the node id
|
|
688
|
+
* (`<id>: open`), NOT the node's display name: clip names must be unique because
|
|
689
|
+
* the baked viewer drives playback by clip name (`useAnimations` maps name →
|
|
690
|
+
* action), so two same-named openables (e.g. several "Window 1"s) would collapse
|
|
691
|
+
* to a single action and a trigger on one would animate another. The
|
|
692
|
+
* human-readable name lives in `extras.label` instead. glTF has no core loop
|
|
693
|
+
* flag — the player decides — so we stamp `extras.loop = false` (via the clip's
|
|
694
|
+
* userData, which `GLTFExporter` serialises onto the animation): Pascal's
|
|
695
|
+
* `/viewer` and any extras-aware consumer play it once and hold the open pose; a
|
|
696
|
+
* dumb glTF player still loops. Consumers map a clip back to its node by walking
|
|
697
|
+
* up from a channel's target to the nearest ancestor carrying `extras.pascalId`.
|
|
698
|
+
*/
|
|
699
|
+
function openClip(id: string, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
|
|
700
|
+
const clip = new THREE.AnimationClip(`${id}: open`, 1, tracks)
|
|
701
|
+
clip.userData = { loop: false }
|
|
702
|
+
return clip
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Bake a window's open motion generically: snapshot every part's pose closed,
|
|
707
|
+
* pose the subtree open, and emit a track for whichever parts actually moved
|
|
708
|
+
* (translation for sliding/hung sashes, rotation for casement/awning/louvre).
|
|
709
|
+
* Reusing the live `poseWindowMovingParts` keeps one source of truth for window
|
|
710
|
+
* kinematics. The subtree is left posed closed as the GLB's rest state.
|
|
711
|
+
*/
|
|
712
|
+
function bakeWindowClip(
|
|
713
|
+
id: string,
|
|
714
|
+
node: WindowNode,
|
|
715
|
+
windowObject: THREE.Object3D,
|
|
716
|
+
): THREE.AnimationClip | null {
|
|
717
|
+
poseWindowMovingParts(node, windowObject, 0)
|
|
718
|
+
|
|
719
|
+
const closedPoses = new Map<
|
|
720
|
+
THREE.Object3D,
|
|
721
|
+
{ position: THREE.Vector3; quaternion: THREE.Quaternion }
|
|
722
|
+
>()
|
|
723
|
+
windowObject.traverse((object) => {
|
|
724
|
+
closedPoses.set(object, {
|
|
725
|
+
position: object.position.clone(),
|
|
726
|
+
quaternion: object.quaternion.clone(),
|
|
727
|
+
})
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
if (!poseWindowMovingParts(node, windowObject, 1)) return null
|
|
731
|
+
|
|
732
|
+
const tracks: THREE.KeyframeTrack[] = []
|
|
733
|
+
windowObject.traverse((object) => {
|
|
734
|
+
const closed = closedPoses.get(object)
|
|
735
|
+
if (!closed) return
|
|
736
|
+
|
|
737
|
+
if (object.position.distanceToSquared(closed.position) > POSE_EPSILON) {
|
|
738
|
+
tracks.push(
|
|
739
|
+
new THREE.VectorKeyframeTrack(
|
|
740
|
+
`${object.uuid}.position`,
|
|
741
|
+
[0, 1],
|
|
742
|
+
[...closed.position.toArray(), ...object.position.toArray()],
|
|
743
|
+
),
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
if (closed.quaternion.angleTo(object.quaternion) > POSE_EPSILON) {
|
|
747
|
+
tracks.push(
|
|
748
|
+
new THREE.QuaternionKeyframeTrack(
|
|
749
|
+
`${object.uuid}.quaternion`,
|
|
750
|
+
[0, 1],
|
|
751
|
+
[...closed.quaternion.toArray(), ...object.quaternion.toArray()],
|
|
752
|
+
),
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
poseWindowMovingParts(node, windowObject, 0)
|
|
758
|
+
|
|
759
|
+
if (tracks.length === 0) return null
|
|
760
|
+
return openClip(id, tracks)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// --- Identity stamping ---------------------------------------------------
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Replace every clone's userData with `{}`, then stamp identity onto the nodes
|
|
767
|
+
* that `sceneRegistry` tracks. Wiping first guarantees no editor/runtime marker
|
|
768
|
+
* (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
|
|
769
|
+
* file describes itself with exactly the fields a consumer needs.
|
|
770
|
+
*/
|
|
771
|
+
/**
|
|
772
|
+
* Human-readable label for a baked node, mirroring the viewer's `getNodeName`:
|
|
773
|
+
* an explicit name wins, items fall back to their catalog asset name, other
|
|
774
|
+
* kinds to a capitalized type. Levels override this with their display name.
|
|
775
|
+
*/
|
|
776
|
+
function nodeDisplayLabel(node: AnyNode): string {
|
|
777
|
+
if (node.name) return node.name
|
|
778
|
+
switch (node.type) {
|
|
779
|
+
case 'item':
|
|
780
|
+
return (node as { asset?: { name?: string } }).asset?.name || 'Item'
|
|
781
|
+
case 'wall':
|
|
782
|
+
return 'Wall'
|
|
783
|
+
case 'door':
|
|
784
|
+
return 'Door'
|
|
785
|
+
case 'window':
|
|
786
|
+
return 'Window'
|
|
787
|
+
case 'cabinet':
|
|
788
|
+
case 'cabinet-module':
|
|
789
|
+
return 'Cabinet'
|
|
790
|
+
case 'slab':
|
|
791
|
+
return 'Slab'
|
|
792
|
+
case 'ceiling':
|
|
793
|
+
return 'Ceiling'
|
|
794
|
+
case 'roof':
|
|
795
|
+
return 'Roof'
|
|
796
|
+
case 'fence':
|
|
797
|
+
return 'Fence'
|
|
798
|
+
case 'column':
|
|
799
|
+
return 'Column'
|
|
800
|
+
case 'stair':
|
|
801
|
+
return 'Stairs'
|
|
802
|
+
default:
|
|
803
|
+
return node.type
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function stampIdentity(
|
|
808
|
+
scene: THREE.Object3D,
|
|
809
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
810
|
+
nodes: Record<string, AnyNode>,
|
|
811
|
+
clipNamesByNode: Map<string, string[]>,
|
|
812
|
+
) {
|
|
813
|
+
scene.traverse((object) => {
|
|
814
|
+
object.userData = {}
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
for (const [id, original] of sceneRegistry.nodes) {
|
|
818
|
+
const node = nodes[id]
|
|
819
|
+
const target = cloneByOriginal.get(original)
|
|
820
|
+
if (!node || !target) continue
|
|
821
|
+
|
|
822
|
+
target.name = id
|
|
823
|
+
const extras: Record<string, unknown> = { pascalId: id, kind: node.type }
|
|
824
|
+
// Stamp a human label for every node (catalog name for items, a type label
|
|
825
|
+
// otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds.
|
|
826
|
+
extras.label = nodeDisplayLabel(node)
|
|
827
|
+
// Camera bookmarks ride on the identity node (any kind can carry one) so the
|
|
828
|
+
// baked viewer flies to a saved pose on selection without a side file.
|
|
829
|
+
if (node.camera) extras.camera = node.camera
|
|
830
|
+
// Levels carry no stored name; stamp the editor's display name ("Level 1")
|
|
831
|
+
// so the baked viewer's level/breadcrumb UI reads the same labels. Force the
|
|
832
|
+
// node visible: the bake must capture every floor regardless of the editor's
|
|
833
|
+
// current level mode (solo/hidden floors would otherwise be dropped by
|
|
834
|
+
// GLTFExporter's `onlyVisible`).
|
|
835
|
+
if (node.type === 'level') {
|
|
836
|
+
extras.label = getLevelDisplayName(node as LevelNode)
|
|
837
|
+
target.visible = true
|
|
838
|
+
}
|
|
839
|
+
// Only nodes that actually baked an open clip are openable. A cased opening
|
|
840
|
+
// (no leaf), fixed window, or static cabinet produces no clip, so it stays
|
|
841
|
+
// unflagged — the file never claims a part opens when nothing moves.
|
|
842
|
+
if (clipNamesByNode.get(id)?.some((name) => name.endsWith(': open'))) {
|
|
843
|
+
const clipNames = clipNamesByNode.get(id)
|
|
844
|
+
if (clipNames?.length) {
|
|
845
|
+
extras.openable = true
|
|
846
|
+
extras.clips = clipNames
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// Items with a baked ambient clip (a fan's spin) carry the clip name but no
|
|
850
|
+
// `openable` flag — nothing opens; the clip just loops.
|
|
851
|
+
if (node.type === 'item') {
|
|
852
|
+
const clipNames = clipNamesByNode.get(id)
|
|
853
|
+
if (clipNames?.length) extras.clips = clipNames
|
|
854
|
+
}
|
|
855
|
+
if (node.type === 'zone') {
|
|
856
|
+
// Zone fills are stripped from the bake; /viewer rebuilds the room from
|
|
857
|
+
// this polygon. Force the identity node visible so GLTFExporter's
|
|
858
|
+
// `onlyVisible` keeps it even when the editor had zones hidden at export.
|
|
859
|
+
const zone = node as ZoneNode
|
|
860
|
+
extras.polygon = zone.polygon
|
|
861
|
+
extras.color = zone.color
|
|
862
|
+
target.visible = true
|
|
863
|
+
}
|
|
864
|
+
if (node.type === 'spawn') {
|
|
865
|
+
// The spawn marker's visible mesh lives on a non-scene overlay layer (and
|
|
866
|
+
// is pruned), so this identity node is an empty transform. Keep it + force
|
|
867
|
+
// visible so the baked walkthrough can read its world position/yaw and
|
|
868
|
+
// start the player there (`extras.rotation` mirrors the node's yaw).
|
|
869
|
+
extras.rotation = (node as { rotation?: number }).rotation ?? 0
|
|
870
|
+
target.visible = true
|
|
871
|
+
}
|
|
872
|
+
target.userData = extras
|
|
873
|
+
}
|
|
874
|
+
}
|