@pascal-app/editor 0.9.1 → 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.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -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
+ }