@pascal-app/editor 1.0.6 → 1.0.7

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 (317) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -15,11 +15,14 @@ import {
15
15
  SiteNode,
16
16
  sceneRegistry,
17
17
  useScene,
18
+ WindowNode,
18
19
  } from '@pascal-app/core'
19
20
  import { evaluateRecipe, ProceduralItemNode, parseRecipe } from '@pascal-app/core/procedural-items'
20
21
  import {
21
22
  buildDoorPreviewMesh,
23
+ buildWindowPreviewMesh,
22
24
  markViewerPresentationTextureBorrowed,
25
+ poseWindowMovingParts,
23
26
  type ViewerPresentationContribution,
24
27
  viewerPresentationRegistry,
25
28
  } from '@pascal-app/viewer'
@@ -1164,6 +1167,210 @@ describe('prepareSceneForExport', () => {
1164
1167
  expect(hasMesh).toBe(false)
1165
1168
  })
1166
1169
 
1170
+ test.each(['tilt', 'turn'] as const)(
1171
+ 'bakes tilt-turn %s with attached hardware and grids at 0, 0.5 and 1',
1172
+ (tiltTurnMode) => {
1173
+ const node = WindowNode.parse({
1174
+ windowType: 'tilt-turn',
1175
+ tiltTurnMode,
1176
+ hingesSide: 'right',
1177
+ operationState: 0.6,
1178
+ sill: false,
1179
+ sashGrid: true,
1180
+ columnRatios: [1, 1],
1181
+ rollerShutter: 'box',
1182
+ rollerShutterOpen: 0.5,
1183
+ })
1184
+ const mesh = buildWindowPreviewMesh(node)
1185
+ const original = mesh.getObjectByName('tilt-turn-window-sash')!.quaternion.clone()
1186
+ const root = new THREE.Group().add(mesh)
1187
+ sceneRegistry.nodes.set(node.id, mesh)
1188
+ const prepared = prepareSceneForExport(root, { [node.id]: node })
1189
+ expect(prepared.animations).toHaveLength(2)
1190
+ const clip = prepared.animations.find((clip) => clip.name === `${node.id}: open`)!
1191
+ expect(clip.tracks).toHaveLength(1)
1192
+ const target = prepared.scene.getObjectByProperty(
1193
+ 'uuid',
1194
+ clip.tracks[0]!.name.replace('.quaternion', ''),
1195
+ )!
1196
+ expect(target.getObjectByName('window-handle')).toBeDefined()
1197
+ expect(target.getObjectByName('window-sash-grid')).toBeDefined()
1198
+ const mixer = new THREE.AnimationMixer(prepared.scene)
1199
+ const action = mixer.clipAction(clip)
1200
+ action.setLoop(THREE.LoopOnce, 1)
1201
+ action.clampWhenFinished = true
1202
+ action.play()
1203
+ for (const value of [0, 0.5, 1]) {
1204
+ mixer.setTime(value)
1205
+ const live = buildWindowPreviewMesh({ ...node, operationState: value })
1206
+ const liveSash = live.getObjectByName('tilt-turn-window-sash')!
1207
+ expect(target.quaternion.clone().normalize().angleTo(liveSash.quaternion)).toBeLessThan(
1208
+ 1e-6,
1209
+ )
1210
+ }
1211
+ expect(
1212
+ mesh.getObjectByName('tilt-turn-window-sash')!.quaternion.angleTo(original),
1213
+ ).toBeCloseTo(0)
1214
+ mixer.stopAllAction()
1215
+ prepared.dispose()
1216
+ },
1217
+ )
1218
+
1219
+ test.each(
1220
+ (['box', 'concealed'] as const).flatMap((rollerShutter) =>
1221
+ [0, 0.5, 1].map((shutterOpen) => ({ rollerShutter, shutterOpen })),
1222
+ ),
1223
+ )(
1224
+ 'bakes saved bifold shutters and $rollerShutter roller at $shutterOpen without changing live previews',
1225
+ ({ shutterOpen, rollerShutter }) => {
1226
+ const node = WindowNode.parse({
1227
+ windowType: 'casement',
1228
+ shutters: 'panel',
1229
+ shutterOpen,
1230
+ width: 2,
1231
+ rollerShutter,
1232
+ rollerShutterOpen: shutterOpen,
1233
+ })
1234
+ const mesh = buildWindowPreviewMesh(node)
1235
+ const saved = mesh.getObjectByName('shutter-left')!.quaternion.clone()
1236
+ poseWindowMovingParts(node, mesh, 0, 1 - shutterOpen)
1237
+ const preview = mesh.getObjectByName('shutter-left')!.quaternion.clone()
1238
+ // Simulate a live roller preview whose topology differs from the saved node.
1239
+ mesh.getObjectByName('window-roller-curtain')!.clear()
1240
+ sceneRegistry.nodes.set(node.id, mesh)
1241
+ const prepared = prepareSceneForExport(new THREE.Group().add(mesh), { [node.id]: node })
1242
+ expect(prepared.animations.map((clip) => clip.name)).toEqual([
1243
+ `${node.id}: open`,
1244
+ `${node.id}: shutters`,
1245
+ ])
1246
+ expect(prepared.animations[0]!.tracks).toHaveLength(1)
1247
+ const extras = prepared.scene.getObjectByName(node.id)!.userData
1248
+ expect(extras.shutters).toBe(true)
1249
+ expect(extras.shuttersOpen).toBe(shutterOpen)
1250
+ expect(extras.openable).toBe(true)
1251
+ expect(extras.clips).toEqual(prepared.animations.map((clip) => clip.name))
1252
+ const shutters = prepared.animations[1]!
1253
+ expect(shutters.duration).toBe(1)
1254
+ const leaves: THREE.Object3D[] = []
1255
+ prepared.scene.traverse((part) => {
1256
+ if (['shutter-left', 'shutter-right', 'shutter-outer'].includes(part.name))
1257
+ leaves.push(part)
1258
+ })
1259
+ for (const leaf of leaves)
1260
+ expect(shutters.tracks.some((track) => track.name === `${leaf.uuid}.quaternion`)).toBe(true)
1261
+ const baked = prepared.scene.getObjectByName('shutter-left')!
1262
+ const outer = baked.getObjectByName('shutter-outer')!
1263
+ const expectedOuter = buildWindowPreviewMesh(node).getObjectByName('shutter-outer')!
1264
+ expect(outer.quaternion.angleTo(expectedOuter.quaternion)).toBeCloseTo(0)
1265
+ expect(baked.quaternion.angleTo(saved)).toBeCloseTo(0)
1266
+ expect(mesh.getObjectByName('shutter-left')!.quaternion.angleTo(preview)).toBeCloseTo(0)
1267
+ const curtain = prepared.scene.getObjectByName('window-roller-curtain')!
1268
+ expect(curtain.children).toHaveLength(1)
1269
+ expect(curtain.position.y).toBe(node.height / 2)
1270
+ expect(curtain.scale.y).toBeCloseTo(1 - 0.98 * shutterOpen)
1271
+ const panel = curtain.children[0] as THREE.Mesh
1272
+ expect(panel.geometry.index!.count).toBe(Math.floor(node.height / 0.04) * 36)
1273
+ const track = shutters.tracks.find((track) => track.name === `${curtain.uuid}.scale`)!
1274
+ expect(Array.from(track.values)).toEqual([1, 1, 1, 1, expect.closeTo(0.02), 1])
1275
+ expect(extras.rollerShutterAnimation).toBe('top-pivot-scale-y')
1276
+ const mixer = new THREE.AnimationMixer(prepared.scene)
1277
+ const action = mixer.clipAction(shutters).setLoop(THREE.LoopOnce, 1)
1278
+ action.clampWhenFinished = true
1279
+ action.play()
1280
+ for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
1281
+ mixer.setTime(fraction)
1282
+ const expected = buildWindowPreviewMesh({ ...node, shutterOpen: fraction })
1283
+ expect(
1284
+ baked.quaternion
1285
+ .clone()
1286
+ .normalize()
1287
+ .angleTo(expected.getObjectByName('shutter-left')!.quaternion),
1288
+ ).toBeLessThan(1e-6)
1289
+ expect(
1290
+ outer.quaternion
1291
+ .clone()
1292
+ .normalize()
1293
+ .angleTo(expected.getObjectByName('shutter-outer')!.quaternion),
1294
+ ).toBeLessThan(1e-6)
1295
+ }
1296
+ mixer.stopAllAction()
1297
+ prepared.dispose()
1298
+ },
1299
+ )
1300
+
1301
+ test('a shuttered casement exports independent sash and leaf rotations at its saved pose', () => {
1302
+ const node = WindowNode.parse({
1303
+ windowType: 'casement',
1304
+ width: 1,
1305
+ shutters: 'panel',
1306
+ shutterOpen: 0.25,
1307
+ rollerShutter: 'box',
1308
+ rollerShutterOpen: 0.75,
1309
+ })
1310
+ const mesh = buildWindowPreviewMesh(node)
1311
+ sceneRegistry.nodes.set(node.id, mesh)
1312
+ const prepared = prepareSceneForExport(new THREE.Group().add(mesh), { [node.id]: node })
1313
+ expect(prepared.animations.map((clip) => clip.name)).toEqual([
1314
+ `${node.id}: open`,
1315
+ `${node.id}: shutters`,
1316
+ ])
1317
+ const window = prepared.scene.getObjectByName(node.id)!
1318
+ expect(window.userData.shuttersOpen).toBe(0.25)
1319
+ expect(window.userData.rollerShutterOpen).toBe(0.75)
1320
+ const curtain = window.getObjectByName('window-roller-curtain')!
1321
+ expect(curtain.scale.y).toBeCloseTo(1 - 0.98 * 0.75)
1322
+ const top = new THREE.Box3().setFromObject(curtain).max.y
1323
+ const leaves = ['shutter-left', 'shutter-right'].map((name) => window.getObjectByName(name)!)
1324
+ const clip = prepared.animations[1]!
1325
+ expect(clip.tracks.map((track) => track.name)).toEqual([
1326
+ ...leaves.map((leaf) => `${leaf.uuid}.quaternion`),
1327
+ `${curtain.uuid}.scale`,
1328
+ ])
1329
+ for (const leaf of leaves)
1330
+ expect(leaf.quaternion.angleTo(mesh.getObjectByName(leaf.name)!.quaternion)).toBeCloseTo(0)
1331
+ const mixer = new THREE.AnimationMixer(prepared.scene)
1332
+ const action = mixer.clipAction(clip).setLoop(THREE.LoopOnce, 1)
1333
+ action.clampWhenFinished = true
1334
+ action.play()
1335
+ for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
1336
+ mixer.setTime(fraction)
1337
+ const expected = buildWindowPreviewMesh({ ...node, shutterOpen: fraction })
1338
+ for (const leaf of leaves)
1339
+ expect(
1340
+ leaf.quaternion
1341
+ .clone()
1342
+ .normalize()
1343
+ .angleTo(expected.getObjectByName(leaf.name)!.quaternion),
1344
+ ).toBeLessThan(1e-6)
1345
+ expect(new THREE.Box3().setFromObject(curtain).max.y).toBeCloseTo(top)
1346
+ }
1347
+ mixer.stopAllAction()
1348
+ prepared.dispose()
1349
+ })
1350
+
1351
+ test.each(['panel', 'roller'] as const)(
1352
+ 'a fixed window with %s shutters exports only its shutters clip',
1353
+ (kind) => {
1354
+ const node = WindowNode.parse({
1355
+ windowType: 'fixed',
1356
+ ...(kind === 'panel'
1357
+ ? { shutters: 'panel', shutterOpen: 0.3 }
1358
+ : { rollerShutter: 'box', rollerShutterOpen: 0.3 }),
1359
+ })
1360
+ const mesh = buildWindowPreviewMesh(node)
1361
+ sceneRegistry.nodes.set(node.id, mesh)
1362
+ const prepared = prepareSceneForExport(new THREE.Group().add(mesh), { [node.id]: node })
1363
+ expect(prepared.animations.map((clip) => clip.name)).toEqual([`${node.id}: shutters`])
1364
+ expect(mesh.userData.openable).toBeUndefined()
1365
+ const extras = prepared.scene.getObjectByName(node.id)!.userData
1366
+ expect(extras.openable).toBeUndefined()
1367
+ expect(extras.shutters).toBe(true)
1368
+ expect(extras.shuttersOpen).toBe(0.3)
1369
+ expect(extras.clips).toEqual([`${node.id}: shutters`])
1370
+ prepared.dispose()
1371
+ },
1372
+ )
1373
+
1167
1374
  test('bakes a swing door into an open quaternion clip', () => {
1168
1375
  const root = new THREE.Group()
1169
1376
  const doorGroup = new THREE.Group()
@@ -1203,6 +1410,64 @@ describe('prepareSceneForExport', () => {
1203
1410
  expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
1204
1411
  })
1205
1412
 
1413
+ for (const doorType of ['hinged', 'double', 'french'] as const) {
1414
+ test(`bakes ${doorType} hardware with the leaf through closed/45/90° poses`, () => {
1415
+ const node = DoorNode.parse({
1416
+ id: `door_hardware_${doorType}`,
1417
+ doorType,
1418
+ swingAngle: Math.PI / 3,
1419
+ position: [2, 1.05, 3],
1420
+ rotation: [0, 0.4, 0],
1421
+ })
1422
+ const mesh = buildDoorPreviewMesh(node)
1423
+ const root = new THREE.Group()
1424
+ root.add(mesh)
1425
+ sceneRegistry.nodes.set(node.id, mesh)
1426
+ const { scene, animations } = prepareSceneForExport(root, { [node.id]: node })
1427
+ expect(animations).toHaveLength(1)
1428
+ const clip = animations[0]!
1429
+ expect(clip.tracks).toHaveLength(doorType === 'hinged' ? 1 : 2)
1430
+ const mixer = new THREE.AnimationMixer(scene)
1431
+ const action = mixer.clipAction(clip)
1432
+ action.setLoop(THREE.LoopOnce, 1)
1433
+ action.clampWhenFinished = true
1434
+ action.play()
1435
+ for (const amount of [0, 0.5, 1]) {
1436
+ mixer.setTime(amount)
1437
+ scene.updateMatrixWorld(true)
1438
+ const live = buildDoorPreviewMesh({ ...node, swingAngle: (amount * Math.PI) / 2 })
1439
+ live.updateMatrixWorld(true)
1440
+ const leaves = live.children.filter((part) => part.userData.pascalSwingLeaf)
1441
+ for (const [i, track] of clip.tracks.entries()) {
1442
+ const target = scene.getObjectByProperty('uuid', track.name.replace('.quaternion', ''))!
1443
+ const exportedHardware = target.children.filter(
1444
+ (part) => part.userData.slotId === 'hardware',
1445
+ )
1446
+ const liveHardware = leaves[i]!.children.filter(
1447
+ (part) => part.userData.slotId === 'hardware',
1448
+ )
1449
+ expect(exportedHardware).toHaveLength(2)
1450
+ for (const [j, part] of exportedHardware.entries()) {
1451
+ const source = liveHardware[j]!
1452
+ expect(
1453
+ part
1454
+ .getWorldPosition(new THREE.Vector3())
1455
+ .distanceTo(source.getWorldPosition(new THREE.Vector3())),
1456
+ ).toBeLessThan(1e-6)
1457
+ expect(
1458
+ part
1459
+ .getWorldQuaternion(new THREE.Quaternion())
1460
+ .angleTo(source.getWorldQuaternion(new THREE.Quaternion())),
1461
+ ).toBeLessThan(1e-6)
1462
+ expect((part as THREE.Mesh).geometry).not.toBe((source as THREE.Mesh).geometry)
1463
+ expect((part as THREE.Mesh).geometry.userData.shared).toBeUndefined()
1464
+ }
1465
+ }
1466
+ }
1467
+ mixer.stopAllAction()
1468
+ })
1469
+ }
1470
+
1206
1471
  test('bakes registry-owned open clips and stamps the node openable', () => {
1207
1472
  const root = new THREE.Group()
1208
1473
  const nodeGroup = new THREE.Group()
@@ -2461,3 +2726,65 @@ test('exports a plugin LOD once without invoking its argument-requiring construc
2461
2726
  expect(meshes[0]!.geometry).not.toBe(mesh.geometry)
2462
2727
  expect(lod.levels).toHaveLength(2)
2463
2728
  })
2729
+
2730
+ for (const config of [
2731
+ { doorType: 'sliding', leafCount: 3, slidingStyle: 'patio', tracks: 1 },
2732
+ { doorType: 'sliding', leafCount: 4, slidingStyle: 'patio', tracks: 2 },
2733
+ { doorType: 'sliding', leafCount: 4, slidingStyle: 'bypass', tracks: 2 },
2734
+ { doorType: 'folding', leafCount: 6, tracks: 6 },
2735
+ { doorType: 'folding', leafCount: 8, tracks: 8 },
2736
+ ]) {
2737
+ test(`GLB samples ${config.slidingStyle ?? ''} ${config.doorType} with ${config.leafCount} panels`, () => {
2738
+ const node = DoorNode.parse({
2739
+ ...config,
2740
+ width: 4,
2741
+ slideDirection: 'right',
2742
+ operationState: 0.5,
2743
+ })
2744
+ const mesh = buildDoorPreviewMesh(node)
2745
+ const root = new THREE.Group()
2746
+ root.add(mesh)
2747
+ sceneRegistry.nodes.set(node.id, mesh)
2748
+ try {
2749
+ const { animations } = prepareSceneForExport(root, { [node.id]: node })
2750
+ expect(animations).toHaveLength(1)
2751
+ expect(animations[0]!.tracks).toHaveLength(config.tracks)
2752
+ for (const track of animations[0]!.tracks) {
2753
+ expect(track.times[0]).toBe(0)
2754
+ expect(track.times[8]).toBe(0.5)
2755
+ expect(track.times[16]).toBe(1)
2756
+ }
2757
+ } finally {
2758
+ sceneRegistry.nodes.delete(node.id)
2759
+ }
2760
+ })
2761
+ }
2762
+
2763
+ test('GLB entry-door clip moves its leaf and keeps sidelights and transom static', () => {
2764
+ const node = DoorNode.parse({
2765
+ width: 1,
2766
+ height: 2.1,
2767
+ sidelightLeft: 0.3,
2768
+ sidelightRight: 0.5,
2769
+ transomHeight: 0.4,
2770
+ swingAngle: 0.6,
2771
+ })
2772
+ const mesh = buildDoorPreviewMesh(node)
2773
+ const root = new THREE.Group()
2774
+ root.add(mesh)
2775
+ sceneRegistry.nodes.set(node.id, mesh)
2776
+ try {
2777
+ const { scene, animations } = prepareSceneForExport(root, { [node.id]: node })
2778
+ expect(animations).toHaveLength(1)
2779
+ const fixed = scene.getObjectByName('door-fixed-lights')!
2780
+ expect(fixed).toBeDefined()
2781
+ const staticIds = new Set<string>()
2782
+ fixed.traverse((part) => staticIds.add(part.uuid))
2783
+ expect(animations[0]!.tracks.length).toBeGreaterThan(0)
2784
+ expect(animations[0]!.tracks.some((track) => staticIds.has(track.name.split('.')[0]!))).toBe(
2785
+ false,
2786
+ )
2787
+ } finally {
2788
+ sceneRegistry.nodes.delete(node.id)
2789
+ }
2790
+ })
@@ -26,6 +26,7 @@ import {
26
26
  isViewerPresentationTextureBorrowed,
27
27
  poseDoorMovingParts,
28
28
  poseWindowMovingParts,
29
+ restoreWindowRollerShutter,
29
30
  SCENE_LAYER,
30
31
  snapLevelsToTruePositions,
31
32
  type ViewerPresentationContribution,
@@ -914,6 +915,8 @@ function cloneSceneForExport(
914
915
  let geometry = cache.geometries.get(renderable.geometry)
915
916
  if (!geometry) {
916
917
  geometry = renderable.geometry.clone()
918
+ // Export owns its clones even when the live geometry belongs to a shared cache.
919
+ delete geometry.userData.shared
917
920
  cache.geometries.set(renderable.geometry, geometry)
918
921
  }
919
922
  renderableClone.geometry = geometry
@@ -1710,14 +1713,68 @@ function openClip(id: string, tracks: THREE.KeyframeTrack[]): THREE.AnimationCli
1710
1713
  return clip
1711
1714
  }
1712
1715
 
1716
+ /** Shutters share a toggle, independently of the sash's open clip. */
1717
+ function bakeWindowClip(
1718
+ id: string,
1719
+ node: WindowNode,
1720
+ windowObject: THREE.Object3D,
1721
+ ): THREE.AnimationClip[] {
1722
+ const clips: THREE.AnimationClip[] = []
1723
+ restoreWindowRollerShutter(node, windowObject)
1724
+ const sash = bakeWindowOpenClip(id, node, windowObject)
1725
+ if (sash) clips.push(sash)
1726
+ const hinged = Boolean(node.shutters && node.shutters !== 'none')
1727
+ const roller = node.rollerShutter === 'box' || node.rollerShutter === 'concealed'
1728
+ if (!hinged && !roller) return clips
1729
+
1730
+ const leaves: THREE.Object3D[] = []
1731
+ windowObject.traverse((part) => {
1732
+ if (['shutter-left', 'shutter-right', 'shutter-outer'].includes(part.name)) leaves.push(part)
1733
+ })
1734
+ const rotations = new Map(leaves.map((leaf) => [leaf, [] as number[]]))
1735
+ // A midpoint disambiguates the direction of a half-turn for quaternion interpolation.
1736
+ for (const value of [0, 0.5, 1]) {
1737
+ poseWindowMovingParts(node, windowObject, 0, value)
1738
+ for (const leaf of leaves) rotations.get(leaf)!.push(...leaf.quaternion.toArray())
1739
+ }
1740
+ poseWindowMovingParts(node, windowObject, 0)
1741
+ const tracks: THREE.KeyframeTrack[] = leaves.map(
1742
+ (leaf) =>
1743
+ new THREE.QuaternionKeyframeTrack(
1744
+ `${leaf.uuid}.quaternion`,
1745
+ [0, 0.5, 1],
1746
+ rotations.get(leaf)!,
1747
+ ),
1748
+ )
1749
+ if (roller) {
1750
+ restoreWindowRollerShutter({ ...node, rollerShutterOpen: 0 }, windowObject)
1751
+ const curtain = windowObject.getObjectByName('window-roller-curtain')
1752
+ if (curtain) {
1753
+ // Fixed topology: compress the full curtain towards its top, inside the lintel/box.
1754
+ curtain.position.y = node.height / 2
1755
+ for (const child of curtain.children) child.position.y -= node.height / 2
1756
+ curtain.scale.y = 1 - 0.98 * (node.rollerShutterOpen ?? 1)
1757
+ tracks.push(
1758
+ new THREE.VectorKeyframeTrack(`${curtain.uuid}.scale`, [0, 1], [1, 1, 1, 1, 0.02, 1]),
1759
+ )
1760
+ }
1761
+ }
1762
+ if (tracks.length) {
1763
+ const clip = new THREE.AnimationClip(`${id}: shutters`, 1, tracks)
1764
+ clip.userData = { loop: false }
1765
+ clips.push(clip)
1766
+ }
1767
+ return clips
1768
+ }
1769
+
1713
1770
  /**
1714
1771
  * Bake a window's open motion generically: snapshot every part's pose closed,
1715
1772
  * pose the subtree open, and emit a track for whichever parts actually moved
1716
1773
  * (translation for sliding/hung sashes, rotation for casement/awning/louvre).
1717
1774
  * Reusing the live `poseWindowMovingParts` keeps one source of truth for window
1718
- * kinematics. The subtree is left posed closed as the GLB's rest state.
1775
+ * kinematics. The sash is left closed; shutters keep their saved rest pose.
1719
1776
  */
1720
- function bakeWindowClip(
1777
+ function bakeWindowOpenClip(
1721
1778
  id: string,
1722
1779
  node: WindowNode,
1723
1780
  windowObject: THREE.Object3D,
@@ -1855,6 +1912,17 @@ function stampIdentity(
1855
1912
  const clipNames = clipNamesByNode.get(id)
1856
1913
  if (clipNames?.length) {
1857
1914
  if (clipNames.some((name) => name.endsWith(': open'))) extras.openable = true
1915
+ if (node.type === 'window' && clipNames.includes(`${id}: shutters`)) {
1916
+ extras.shutters = true
1917
+ extras.shuttersOpen =
1918
+ node.shutters && node.shutters !== 'none'
1919
+ ? (node.shutterOpen ?? 1)
1920
+ : (node.rollerShutterOpen ?? 1)
1921
+ if (node.rollerShutter === 'box' || node.rollerShutter === 'concealed') {
1922
+ extras.rollerShutterOpen = node.rollerShutterOpen ?? 1
1923
+ extras.rollerShutterAnimation = 'top-pivot-scale-y'
1924
+ }
1925
+ }
1858
1926
  extras.clips = clipNames
1859
1927
  }
1860
1928
  if (node.type === 'zone') {
@@ -1,6 +1,11 @@
1
- import { expect, test } from 'bun:test'
2
- import { Group, Vector3 } from 'three'
3
- import { gridLocalNormal, gridLocalPoint } from './grid-frame'
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Group, Quaternion, Vector3 } from 'three'
3
+ import {
4
+ gridLocalNormal,
5
+ gridLocalPoint,
6
+ HORIZONTAL_GRID_QUATERNION,
7
+ layHorizontalGrid,
8
+ } from './grid-frame'
4
9
 
5
10
  test('grid floor and cursor stay in model coordinates after God-view pan, scale and yaw', () => {
6
11
  const root = new Group()
@@ -17,3 +22,51 @@ test('grid floor and cursor stay in model coordinates after God-view pan, scale
17
22
  ).toBeLessThan(1e-8)
18
23
  expect(world.distanceTo(local)).toBeGreaterThan(1)
19
24
  })
25
+
26
+ // The grid plane's lattice lines run at multiples of the cell size in its own
27
+ // XY, from its origin: where does plane-local (u, v) land?
28
+ function planePoint(position: Vector3, quaternion: Quaternion, u: number, v: number) {
29
+ return new Vector3(u, v, 0).applyQuaternion(quaternion).add(position)
30
+ }
31
+
32
+ describe('layHorizontalGrid', () => {
33
+ test("lays the lattice on a turned, offset building's own frame", () => {
34
+ const yaw = (17 * Math.PI) / 180
35
+ const position = new Vector3()
36
+ const quaternion = new Quaternion()
37
+ layHorizontalGrid({ position: [12.37, 0, -8.91], rotationY: yaw }, 0.301, position, quaternion)
38
+
39
+ expect(position.toArray()).toEqual([12.37, 0.301, -8.91])
40
+ // Flat…
41
+ expect(new Vector3(0, 0, 1).applyQuaternion(quaternion).y).toBeCloseTo(1, 12)
42
+ // …and each lattice point is a building-local lattice point: plane (1, -1.5)
43
+ // is building-local (1, 1.5), wherever the building's yaw puts it.
44
+ const point = planePoint(position, quaternion, 1, -1.5)
45
+ const cos = Math.cos(yaw)
46
+ const sin = Math.sin(yaw)
47
+ expect(point.x).toBeCloseTo(12.37 + cos + 1.5 * sin, 12)
48
+ expect(point.y).toBeCloseTo(0.301, 12)
49
+ expect(point.z).toBeCloseTo(-8.91 - sin + 1.5 * cos, 12)
50
+ })
51
+
52
+ test('with no building the world grid stays anchored at the origin, as before', () => {
53
+ const position = new Vector3(5, 5, 5)
54
+ const quaternion = new Quaternion()
55
+ layHorizontalGrid(null, 0.001, position, quaternion)
56
+
57
+ expect(position.toArray()).toEqual([0, 0.001, 0])
58
+ expect(Math.abs(quaternion.dot(HORIZONTAL_GRID_QUATERNION))).toBeCloseTo(1, 12)
59
+ const point = planePoint(position, quaternion, 2, -3)
60
+ expect(point.x).toBeCloseTo(2, 12)
61
+ expect(point.z).toBeCloseTo(3, 12)
62
+ })
63
+
64
+ test('an unturned building at the origin draws the same grid as no building', () => {
65
+ const position = new Vector3()
66
+ const quaternion = new Quaternion()
67
+ layHorizontalGrid({ position: [0, 0, 0], rotationY: 0 }, 0.001, position, quaternion)
68
+
69
+ expect(position.toArray()).toEqual([0, 0.001, 0])
70
+ expect(Math.abs(quaternion.dot(HORIZONTAL_GRID_QUATERNION))).toBeCloseTo(1, 12)
71
+ })
72
+ })
@@ -1,4 +1,5 @@
1
- import type { Object3D, Vector3 } from 'three'
1
+ import type { BuildingPose } from '@pascal-app/core'
2
+ import { type Object3D, Quaternion, Vector3 } from 'three'
2
3
 
3
4
  // Placement surfaces are world-space; the grid lives under the scalable XR
4
5
  // scene root. Keep its lattice and reveal in that root's model coordinates.
@@ -16,3 +17,29 @@ export function gridLocalNormal(parent: Object3D | null, normal: Vector3, target
16
17
  if (parent) target.transformDirection(parent.matrixWorld.clone().invert())
17
18
  return target
18
19
  }
20
+
21
+ const UP = new Vector3(0, 1, 0)
22
+
23
+ /** Lays the grid's PlaneGeometry (which faces +Z) flat, its normal → +Y. */
24
+ export const HORIZONTAL_GRID_QUATERNION = new Quaternion().setFromUnitVectors(
25
+ new Vector3(0, 0, 1),
26
+ UP,
27
+ )
28
+
29
+ /**
30
+ * Lay the horizontal grid plane on the lattice the active tools snap on: a
31
+ * building's own frame (`pose`, its yaw and offset — the lattice
32
+ * `snapWorldXZForActiveBuilding` snaps to), or with `pose` null the world XZ
33
+ * grid anchored at (0, 0). The lattice lines run at multiples of the cell size
34
+ * from the plane's origin, so a turned house's walls sit square on them. `y`
35
+ * is the plane's height in the grid parent's frame.
36
+ */
37
+ export function layHorizontalGrid(
38
+ pose: BuildingPose | null,
39
+ y: number,
40
+ position: Vector3,
41
+ quaternion: Quaternion,
42
+ ) {
43
+ position.set(pose?.position[0] ?? 0, y, pose?.position[2] ?? 0)
44
+ quaternion.setFromAxisAngle(UP, pose?.rotationY ?? 0).multiply(HORIZONTAL_GRID_QUATERNION)
45
+ }
@@ -488,7 +488,8 @@ describe('standalone history source invalidation', () => {
488
488
  const effects = []
489
489
  mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect), useRef: current => ({ current }) }))
490
490
  const segment = core.StairSegmentNode.parse({ id: 'sseg_history', parentId: 'stair_history', height: 2.5 })
491
- const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, children: [segment.id] })
491
+ // Off the slab: the flight rises from the level base, whatever elects the stair's support.
492
+ const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, position: [10, 0, 10], children: [segment.id] })
492
493
  scene.setState({ nodes: { ...baseline, [level.id]: { ...level, height: 2.5, children: [stair.id] }, [stair.id]: stair, [segment.id]: segment } })
493
494
  const { StairOpeningSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../core/src/systems/stair/stair-opening-system.tsx'))})
494
495
  StairOpeningSystem()