@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,556 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
3
+ import { buildDoorPreviewMesh } from '@pascal-app/viewer'
4
+ import * as THREE from 'three'
5
+ import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
6
+ import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export'
7
+
8
+ // The reference module reads the storage origin lazily on first use, so
9
+ // setting the env here (before any validation call) pins it for the file.
10
+ process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co'
11
+ const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
12
+
13
+ afterEach(() => {
14
+ sceneRegistry.clear()
15
+ })
16
+
17
+ function nodeMaterial(overrides: Record<string, unknown> = {}) {
18
+ // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial:
19
+ // the exporter keys off `isNodeMaterial` and reads plain PBR props.
20
+ return {
21
+ isNodeMaterial: true,
22
+ name: 'painted',
23
+ color: new THREE.Color('#cc3300'),
24
+ roughness: 0.3,
25
+ metalness: 0.7,
26
+ transparent: false,
27
+ opacity: 1,
28
+ side: THREE.FrontSide,
29
+ alphaTest: 0,
30
+ depthWrite: true,
31
+ depthTest: true,
32
+ vertexColors: false,
33
+ toneMapped: true,
34
+ ...overrides,
35
+ } as unknown as THREE.Material
36
+ }
37
+
38
+ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
39
+ const geometry = new THREE.BoxGeometry(1, 1, 1)
40
+ return new THREE.Mesh(geometry, material)
41
+ }
42
+
43
+ describe('prepareSceneForExport', () => {
44
+ test('converts NodeMaterials to classic glTF-standard materials', () => {
45
+ const root = new THREE.Group()
46
+ root.name = 'scene-renderer'
47
+ const mesh = meshWithNodeMaterial(nodeMaterial())
48
+ root.add(mesh)
49
+
50
+ const { scene } = prepareSceneForExport(root, {})
51
+
52
+ const exported = scene.children[0] as THREE.Mesh
53
+ const material = exported.material as THREE.MeshStandardMaterial
54
+ expect(material.isMeshStandardMaterial).toBe(true)
55
+ expect(material.roughness).toBeCloseTo(0.3)
56
+ expect(material.metalness).toBeCloseTo(0.7)
57
+ expect(material.color.getHexString()).toBe('cc3300')
58
+ })
59
+
60
+ test('shared NodeMaterial instances convert to a single shared material', () => {
61
+ const root = new THREE.Group()
62
+ const shared = nodeMaterial()
63
+ root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared))
64
+
65
+ const { scene } = prepareSceneForExport(root, {})
66
+
67
+ const meshes = scene.children as THREE.Mesh[]
68
+ expect(meshes[0]!.material).toBe(meshes[1]!.material)
69
+ })
70
+
71
+ test('reference mode swaps stamped compressed textures and leaves unstamped textures embedded', () => {
72
+ const root = new THREE.Group()
73
+ const stamped = new THREE.CompressedTexture([], 4, 4)
74
+ stamped.wrapS = THREE.MirroredRepeatWrapping
75
+ stamped.wrapT = THREE.ClampToEdgeWrapping
76
+ stamped.repeat.set(2, 3)
77
+ stamped.offset.set(0.25, 0.5)
78
+ stamped.center.set(0.5, 0.5)
79
+ stamped.rotation = 0.75
80
+ stamped.flipY = false
81
+ stamped.colorSpace = THREE.SRGBColorSpace
82
+ stamped.updateMatrix()
83
+ stamped.userData.pascalTextureRef = {
84
+ v: 1,
85
+ kind: 'library-material',
86
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_basecolor_512.ktx2`,
87
+ map: 'basecolor',
88
+ colorSpace: 'srgb',
89
+ }
90
+ const unstamped = new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1)
91
+ root.add(
92
+ meshWithNodeMaterial(nodeMaterial({ map: stamped, normalMap: unstamped })),
93
+ meshWithNodeMaterial(nodeMaterial({ map: stamped })),
94
+ )
95
+
96
+ const { scene } = prepareSceneForExport(root, {}, { textures: 'reference' })
97
+
98
+ const material = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
99
+ const placeholder = material.map as THREE.DataTexture
100
+ expect(placeholder).not.toBe(stamped)
101
+ expect(placeholder.isDataTexture).toBe(true)
102
+ expect(
103
+ (placeholder as THREE.DataTexture & { isCompressedTexture?: boolean }).isCompressedTexture,
104
+ ).toBeUndefined()
105
+ expect(placeholder.image.width).toBe(1)
106
+ expect(placeholder.image.height).toBe(1)
107
+ expect(Array.from(placeholder.image.data!)).toEqual([255, 255, 255, 255])
108
+ expect(placeholder.wrapS).toBe(stamped.wrapS)
109
+ expect(placeholder.wrapT).toBe(stamped.wrapT)
110
+ expect(placeholder.repeat.toArray()).toEqual(stamped.repeat.toArray())
111
+ expect(placeholder.offset.toArray()).toEqual(stamped.offset.toArray())
112
+ expect(placeholder.center.toArray()).toEqual(stamped.center.toArray())
113
+ expect(placeholder.rotation).toBe(stamped.rotation)
114
+ expect(placeholder.flipY).toBe(stamped.flipY)
115
+ expect(placeholder.colorSpace).toBe(stamped.colorSpace)
116
+ expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
117
+ expect(material.normalMap).toBe(unstamped)
118
+ const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
119
+ expect(sharedMaterial.map).toBe(placeholder)
120
+ })
121
+
122
+ test('writes identical texture-reference extras to the texture and image definitions', () => {
123
+ const texture = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1)
124
+ const ref = {
125
+ v: 1,
126
+ kind: 'item-glb',
127
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/models/chair.glb`,
128
+ imageIndex: 3,
129
+ map: 'normal',
130
+ colorSpace: 'linear',
131
+ }
132
+ texture.userData.pascalTextureRef = ref
133
+ const imageDef: { extras?: Record<string, unknown> } = {}
134
+ const textureDef: { source: number; extras?: Record<string, unknown> } = { source: 0 }
135
+ const writer = { json: { images: [imageDef] } } as unknown as GLTFWriter
136
+
137
+ writeTextureReferenceExtras(writer, texture, textureDef)
138
+
139
+ expect(textureDef.extras?.pascalTextureRef).toEqual(ref)
140
+ expect(imageDef.extras?.pascalTextureRef).toEqual(ref)
141
+ expect(textureDef.extras?.pascalTextureRef).toEqual(imageDef.extras?.pascalTextureRef)
142
+ })
143
+
144
+ test('strips editor overlays that live off the scene layer', () => {
145
+ const root = new THREE.Group()
146
+ const realMesh = meshWithNodeMaterial(nodeMaterial())
147
+ const overlay = meshWithNodeMaterial(nodeMaterial())
148
+ overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0
149
+ root.add(realMesh, overlay)
150
+
151
+ const { scene } = prepareSceneForExport(root, {})
152
+
153
+ const meshes: THREE.Mesh[] = []
154
+ scene.traverse((o) => {
155
+ if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh)
156
+ })
157
+ expect(meshes).toHaveLength(1)
158
+ })
159
+
160
+ test('strips presentation-only geometry marked by its renderer', () => {
161
+ const root = new THREE.Group()
162
+ const siteGround = meshWithNodeMaterial(nodeMaterial())
163
+ const horizonDisc = meshWithNodeMaterial(nodeMaterial())
164
+ horizonDisc.userData.pascalExport = 'strip'
165
+ root.add(siteGround, horizonDisc)
166
+
167
+ const { scene } = prepareSceneForExport(root, {})
168
+
169
+ const meshes: THREE.Mesh[] = []
170
+ scene.traverse((object) => {
171
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
172
+ })
173
+ expect(meshes).toHaveLength(1)
174
+ })
175
+
176
+ test('neutralises an invisible hitbox root but keeps its visible children', () => {
177
+ // Door/window roots are selection hitboxes: a box geometry with an invisible
178
+ // material (object stays visible). Left intact it would plug the wall opening.
179
+ const root = new THREE.Group()
180
+ const hitbox = new THREE.Mesh(
181
+ new THREE.BoxGeometry(1, 2, 0.2),
182
+ new THREE.MeshBasicMaterial({ visible: false }),
183
+ )
184
+ const leaf = meshWithNodeMaterial(nodeMaterial())
185
+ hitbox.add(leaf)
186
+ root.add(hitbox)
187
+
188
+ const doorId = 'door_hitbox'
189
+ sceneRegistry.nodes.set(doorId, hitbox)
190
+ const nodes: Record<string, AnyNode> = {
191
+ [doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode,
192
+ }
193
+
194
+ const { scene } = prepareSceneForExport(root, nodes)
195
+
196
+ const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh
197
+ expect(exported).toBeDefined()
198
+ // Geometry emptied -> GLTFExporter emits a plain node, no solid block.
199
+ expect(exported.geometry.getAttribute('position')).toBeUndefined()
200
+ // The visible leaf survives as a child.
201
+ const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh)
202
+ expect(visibleChildren).toHaveLength(1)
203
+ })
204
+
205
+ test('fills undefined slots in an array material so no undefined survives the prune', () => {
206
+ // Multi-material / group geometry where one slot was never assigned:
207
+ // `mesh.material = [validMat, undefined]`. The scalar-null guard doesn't
208
+ // catch this (an array is never `== null`), so the undefined slot used to
209
+ // reach GLTFExporter and crash on `material.isShaderMaterial`.
210
+ const root = new THREE.Group()
211
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
212
+ mesh.material = [nodeMaterial(), undefined as unknown as THREE.Material]
213
+ root.add(mesh)
214
+
215
+ const { scene } = prepareSceneForExport(root, {})
216
+
217
+ const exported = scene.children[0] as THREE.Mesh
218
+ const materials = exported.material as THREE.Material[]
219
+ expect(Array.isArray(materials)).toBe(true)
220
+ expect(materials).toHaveLength(2)
221
+ // No undefined/null slot survives; every slot is a real material.
222
+ expect(materials.every((m) => m != null)).toBe(true)
223
+ })
224
+
225
+ test('stamps identity from the scene registry and strips other userData', () => {
226
+ const root = new THREE.Group()
227
+ const doorGroup = new THREE.Group()
228
+ const leaf = new THREE.Group()
229
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
230
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
231
+ doorGroup.add(leaf)
232
+ root.add(doorGroup)
233
+
234
+ const doorId = 'door_test'
235
+ sceneRegistry.nodes.set(doorId, doorGroup)
236
+ const nodes: Record<string, AnyNode> = {
237
+ [doorId]: {
238
+ object: 'node',
239
+ id: doorId,
240
+ type: 'door',
241
+ name: 'Front door',
242
+ } as unknown as AnyNode,
243
+ }
244
+
245
+ const { scene } = prepareSceneForExport(root, nodes)
246
+
247
+ const exportedDoor = scene.getObjectByProperty('name', doorId)
248
+ expect(exportedDoor).toBeDefined()
249
+ expect(exportedDoor?.userData).toEqual({
250
+ pascalId: doorId,
251
+ kind: 'door',
252
+ label: 'Front door',
253
+ openable: true,
254
+ clips: ['door_test: open'],
255
+ })
256
+
257
+ // The swing-leaf marker must not survive into glTF extras.
258
+ let leafMarkerSurvived = false
259
+ scene.traverse((object) => {
260
+ if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true
261
+ })
262
+ expect(leafMarkerSurvived).toBe(false)
263
+ })
264
+
265
+ test('does not flag a door/window openable when no open clip bakes', () => {
266
+ // A cased opening (no swing leaf) / fixed window (no operable sash) builds
267
+ // no movable part, so no clip bakes and the node must not claim openable.
268
+ const root = new THREE.Group()
269
+ const openingGroup = new THREE.Group()
270
+ openingGroup.add(meshWithNodeMaterial(nodeMaterial()))
271
+ root.add(openingGroup)
272
+
273
+ const openingId = 'door_opening'
274
+ sceneRegistry.nodes.set(openingId, openingGroup)
275
+ const nodes: Record<string, AnyNode> = {
276
+ [openingId]: {
277
+ object: 'node',
278
+ id: openingId,
279
+ type: 'door',
280
+ name: 'Cased opening',
281
+ } as unknown as AnyNode,
282
+ }
283
+
284
+ const { scene, animations } = prepareSceneForExport(root, nodes)
285
+
286
+ expect(animations).toHaveLength(0)
287
+ const exported = scene.getObjectByProperty('name', openingId)
288
+ expect(exported?.userData).toEqual({
289
+ pascalId: openingId,
290
+ kind: 'door',
291
+ label: 'Cased opening',
292
+ })
293
+ expect(exported?.userData.openable).toBeUndefined()
294
+ expect(exported?.userData.clips).toBeUndefined()
295
+ })
296
+
297
+ test('keeps the zone identity node with its polygon and strips the fill mesh', () => {
298
+ const root = new THREE.Group()
299
+ const zoneGroup = new THREE.Group()
300
+ const fill = meshWithNodeMaterial(nodeMaterial())
301
+ fill.layers.set(2) // ZONE_LAYER
302
+ zoneGroup.add(fill)
303
+ zoneGroup.visible = false // the editor often hides zones at export time
304
+ root.add(zoneGroup)
305
+
306
+ const zoneId = 'zone_living'
307
+ const polygon: [number, number][] = [
308
+ [0, 0],
309
+ [4, 0],
310
+ [4, 3],
311
+ ]
312
+ sceneRegistry.nodes.set(zoneId, zoneGroup)
313
+ const nodes: Record<string, AnyNode> = {
314
+ [zoneId]: {
315
+ object: 'node',
316
+ id: zoneId,
317
+ type: 'zone',
318
+ name: 'Living Room',
319
+ polygon,
320
+ color: '#ff0000',
321
+ } as unknown as AnyNode,
322
+ }
323
+
324
+ const { scene } = prepareSceneForExport(root, nodes)
325
+
326
+ const exported = scene.getObjectByProperty('name', zoneId)
327
+ expect(exported).toBeDefined()
328
+ // Forced visible so GLTFExporter's onlyVisible keeps the metadata node.
329
+ expect(exported?.visible).toBe(true)
330
+ expect(exported?.userData).toEqual({
331
+ pascalId: zoneId,
332
+ kind: 'zone',
333
+ label: 'Living Room',
334
+ polygon,
335
+ color: '#ff0000',
336
+ })
337
+ // The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead).
338
+ let hasMesh = false
339
+ exported?.traverse((o) => {
340
+ if ((o as THREE.Mesh).isMesh) hasMesh = true
341
+ })
342
+ expect(hasMesh).toBe(false)
343
+ })
344
+
345
+ test('bakes a swing door into an open quaternion clip', () => {
346
+ const root = new THREE.Group()
347
+ const doorGroup = new THREE.Group()
348
+ const leaf = new THREE.Group()
349
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
350
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
351
+ doorGroup.add(leaf)
352
+ root.add(doorGroup)
353
+
354
+ const doorId = 'door_swing'
355
+ sceneRegistry.nodes.set(doorId, doorGroup)
356
+ const nodes: Record<string, AnyNode> = {
357
+ [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
358
+ }
359
+
360
+ const { scene, animations } = prepareSceneForExport(root, nodes)
361
+
362
+ expect(animations).toHaveLength(1)
363
+ const clip = animations[0]!
364
+ expect(clip.name).toBe('door_swing: open')
365
+ expect(clip.duration).toBe(1)
366
+ // Playback intent carried in extras so consumers can play once and hold.
367
+ expect(clip.userData).toEqual({ loop: false })
368
+
369
+ const track = clip.tracks[0]!
370
+ expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack)
371
+ expect(track.name.endsWith('.quaternion')).toBe(true)
372
+ expect(Array.from(track.times)).toEqual([0, 1])
373
+
374
+ // The track must target an object that exists in the exported tree.
375
+ const targetUuid = track.name.replace('.quaternion', '')
376
+ const target = scene.getObjectByProperty('uuid', targetUuid)
377
+ expect(target).toBeDefined()
378
+
379
+ // Rest pose is closed: the first keyframe is the identity rotation.
380
+ const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
381
+ expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
382
+ })
383
+
384
+ test('bakes registry-owned open clips and stamps the node openable', () => {
385
+ const root = new THREE.Group()
386
+ const nodeGroup = new THREE.Group()
387
+ const movingPart = new THREE.Group()
388
+ movingPart.add(meshWithNodeMaterial(nodeMaterial()))
389
+ nodeGroup.add(movingPart)
390
+ root.add(nodeGroup)
391
+
392
+ const kind = `test-openable-${crypto.randomUUID()}`
393
+ const nodeId = 'registry_openable'
394
+ registerNode({
395
+ kind,
396
+ schemaVersion: 1,
397
+ category: 'fixtures',
398
+ defaults: () => ({}),
399
+ capabilities: {},
400
+ exportAnimation: ({ node, object }: { node: AnyNode; object: THREE.Object3D }) => {
401
+ const target = object.children[0]!
402
+ const clip = new THREE.AnimationClip(`${node.id}: open`, 1, [
403
+ new THREE.VectorKeyframeTrack(`${target.uuid}.position`, [0, 1], [0, 0, 0, 0, 0, 1]),
404
+ ])
405
+ clip.userData = { loop: false }
406
+ return clip
407
+ },
408
+ } as never)
409
+ sceneRegistry.nodes.set(nodeId, nodeGroup)
410
+
411
+ const { scene, animations } = prepareSceneForExport(root, {
412
+ [nodeId]: {
413
+ object: 'node',
414
+ id: nodeId,
415
+ type: kind,
416
+ name: 'Custom openable',
417
+ } as unknown as AnyNode,
418
+ })
419
+
420
+ expect(animations).toHaveLength(1)
421
+ expect(animations[0]!.name).toBe('registry_openable: open')
422
+ const exported = scene.getObjectByProperty('name', nodeId)
423
+ expect(exported?.userData).toMatchObject({
424
+ pascalId: nodeId,
425
+ kind,
426
+ label: 'Custom openable',
427
+ openable: true,
428
+ clips: ['registry_openable: open'],
429
+ })
430
+ })
431
+
432
+ test('bakes a sliding door into a sampled position clip', () => {
433
+ // Operation doors build their moving parts in a named group posed by
434
+ // `poseDoorMovingParts`; the exporter samples it into keyframes. The active
435
+ // panel group slides along x.
436
+ const root = new THREE.Group()
437
+ const doorGroup = new THREE.Group()
438
+ const activePanel = new THREE.Group()
439
+ activePanel.name = 'door-sliding-active'
440
+ activePanel.add(meshWithNodeMaterial(nodeMaterial()))
441
+ doorGroup.add(activePanel)
442
+ root.add(doorGroup)
443
+
444
+ const doorId = 'door_sliding'
445
+ sceneRegistry.nodes.set(doorId, doorGroup)
446
+ const nodes: Record<string, AnyNode> = {
447
+ [doorId]: {
448
+ object: 'node',
449
+ id: doorId,
450
+ type: 'door',
451
+ name: 'Slider',
452
+ doorType: 'sliding',
453
+ slideDirection: 'left',
454
+ width: 1,
455
+ height: 2.1,
456
+ frameThickness: 0.05,
457
+ } as unknown as AnyNode,
458
+ }
459
+
460
+ const { scene, animations } = prepareSceneForExport(root, nodes)
461
+
462
+ expect(animations).toHaveLength(1)
463
+ const clip = animations[0]!
464
+ expect(clip.name).toBe('door_sliding: open')
465
+ expect(clip.duration).toBe(1)
466
+ expect(clip.userData).toEqual({ loop: false })
467
+
468
+ const track = clip.tracks[0]!
469
+ expect(track).toBeInstanceOf(THREE.VectorKeyframeTrack)
470
+ expect(track.name.endsWith('.position')).toBe(true)
471
+ // 16 segments -> 17 keyframes, evenly spaced over the 1s clip.
472
+ expect(track.times.length).toBe(17)
473
+ expect(track.times[0]).toBeCloseTo(0)
474
+ expect(track.times[track.times.length - 1]!).toBeCloseTo(1)
475
+
476
+ // Rest pose is closed (first keyframe centred); the panel slides off-centre.
477
+ expect(track.values[0]!).toBeCloseTo(0)
478
+ expect(track.values[1]!).toBeCloseTo(0)
479
+ expect(track.values[2]!).toBeCloseTo(0)
480
+ const lastX = track.values[track.values.length - 3]!
481
+ expect(Math.abs(lastX)).toBeGreaterThan(0.1)
482
+
483
+ const target = scene.getObjectByProperty('uuid', track.name.replace('.position', ''))
484
+ expect(target).toBeDefined()
485
+
486
+ const exported = scene.getObjectByProperty('name', doorId)
487
+ expect(exported?.userData.openable).toBe(true)
488
+ expect(exported?.userData.clips).toEqual(['door_sliding: open'])
489
+ })
490
+
491
+ test('bakes a roll-up curtain into a sampled scale clip', () => {
492
+ // Roll-up geometry can't vanish in a glTF clip, so the bake scales the
493
+ // curtain group up into the lintel instead.
494
+ const root = new THREE.Group()
495
+ const doorGroup = new THREE.Group()
496
+ const curtain = new THREE.Group()
497
+ curtain.name = 'door-rollup-curtain'
498
+ curtain.add(meshWithNodeMaterial(nodeMaterial()))
499
+ doorGroup.add(curtain)
500
+ root.add(doorGroup)
501
+
502
+ const doorId = 'door_rollup'
503
+ sceneRegistry.nodes.set(doorId, doorGroup)
504
+ const nodes: Record<string, AnyNode> = {
505
+ [doorId]: {
506
+ object: 'node',
507
+ id: doorId,
508
+ type: 'door',
509
+ name: 'Roll-up',
510
+ doorType: 'garage-rollup',
511
+ width: 2.4,
512
+ height: 2.2,
513
+ frameThickness: 0.05,
514
+ } as unknown as AnyNode,
515
+ }
516
+
517
+ const { animations } = prepareSceneForExport(root, nodes)
518
+
519
+ expect(animations).toHaveLength(1)
520
+ const scaleTrack = animations[0]!.tracks.find((t) => t.name.endsWith('.scale'))
521
+ expect(scaleTrack).toBeInstanceOf(THREE.VectorKeyframeTrack)
522
+ // Rest pose is closed (full curtain, scale 1); it shrinks toward the header.
523
+ expect(Array.from(scaleTrack!.values).slice(0, 3)).toEqual([1, 1, 1])
524
+ const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
525
+ expect(lastScaleY).toBeLessThan(0.1)
526
+ })
527
+
528
+ // Regression: a folding door saved in an open state (|fold angle| > π/2) used
529
+ // to bake a 180°-flipped rest pose. The export clones + decomposes the door
530
+ // matrix, which re-derives a gimbal-flipped euler (x=z=π) for the wide Y
531
+ // rotation; the pose reset must zero the full euler triple, not just `.y`.
532
+ test('bakes an identity rest pose for an open folding door', () => {
533
+ const node = DoorNode.parse({
534
+ id: 'door_folding',
535
+ doorType: 'folding',
536
+ leafCount: 4,
537
+ operationState: 0.65,
538
+ })
539
+ const mesh = buildDoorPreviewMesh(node)
540
+ const root = new THREE.Group()
541
+ root.add(mesh)
542
+ sceneRegistry.nodes.set(node.id, mesh)
543
+
544
+ const { scene, animations } = prepareSceneForExport(root, {
545
+ [node.id]: node as unknown as AnyNode,
546
+ })
547
+
548
+ expect(animations).toHaveLength(1)
549
+ for (let index = 0; index < 4; index++) {
550
+ const panel = scene.getObjectByName(`door-fold-${index}`)
551
+ expect(panel).toBeDefined()
552
+ // Rest quaternion must be identity — no residual π on any axis.
553
+ expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
554
+ }
555
+ })
556
+ })