@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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