@pascal-app/editor 1.0.6 → 1.1.0

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 (382) 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 +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -2,17 +2,19 @@ import { describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  createRoomTopologyIndex,
5
+ formatLinearMeasurement,
5
6
  LevelNode,
6
7
  type Point,
7
8
  WallNode,
8
9
  } from '@pascal-app/core'
9
- import { formatLinearMeasurement } from './measurements'
10
10
  import {
11
11
  DIMENSION_FLOOR_LIFT,
12
12
  mezzanineEdgeDimensions,
13
13
  type RoomDimension,
14
+ type WallDimensionFaces,
14
15
  wallPushDimensionRooms,
15
16
  wallPushDimensions,
17
+ wallSelectionDimensions,
16
18
  } from './room-push-dimensions'
17
19
 
18
20
  // Rooms are real: the walls go through core's room topology, so the faces the
@@ -66,6 +68,20 @@ function measure(
66
68
  })
67
69
  }
68
70
 
71
+ function selection({ nodes, walls }: Scene, wall: WallNode, faces: WallDimensionFaces) {
72
+ const index = createRoomTopologyIndex()
73
+ index.rebuildLevel(LEVEL, nodes)
74
+ const rooms = index.getLevelTopology(LEVEL)?.rooms ?? []
75
+ return wallSelectionDimensions({
76
+ rooms: wallPushDimensionRooms(rooms, { wallId: wall.id, t0: 0, t1: 1 }, () => 0),
77
+ wall,
78
+ walls,
79
+ faces,
80
+ resolve: (id) => nodes[id],
81
+ baseElevation: 0,
82
+ })
83
+ }
84
+
69
85
  const distances = (dimensions: RoomDimension[]) =>
70
86
  dimensions.map((dimension) => Number(dimension.distance.toFixed(4)))
71
87
 
@@ -206,6 +222,27 @@ describe('two rooms sharing the moving wall', () => {
206
222
  expect(west.key).not.toBe(east.key)
207
223
  })
208
224
 
225
+ test('selected, a shared wall shows both room widths and no outside size, whatever the faces', () => {
226
+ for (const faces of ['both', 'interior', 'exterior'] as const) {
227
+ const dimensions = selection(rooms, middle, faces)
228
+ expect(dimensions.map((dimension) => dimension.faces)).toEqual(['interior', 'interior'])
229
+ expect(distances(dimensions).sort()).toEqual([3.8, 3.8])
230
+ }
231
+ })
232
+
233
+ test('selected, an exterior wall adds the outside size across the building', () => {
234
+ const east = wallAt(rooms, [8, 0], [8, 4])
235
+ const dimensions = selection(rooms, east, 'both')
236
+ const outside = dimensions.find((dimension) => dimension.faces === 'exterior')!
237
+ // Outer face to outer face, past the middle wall, clear of the room width's line.
238
+ expect(outside.distance).toBeCloseTo(8 + THICKNESS)
239
+ expect(outside.from[0]).toBeCloseTo(8 + THICKNESS / 2)
240
+ expect(outside.to[0]).toBeCloseTo(-THICKNESS / 2)
241
+ expect(distances(dimensions.filter((dimension) => dimension.faces === 'interior'))).toEqual([
242
+ 3.8,
243
+ ])
244
+ })
245
+
209
246
  test('a push over part of the wall measures only the rooms along that part', () => {
210
247
  const dimensions = measure(rooms, middle, [1, 0], 0, { t0: 0, t1: 0.5 })
211
248
  expect(dimensions).toHaveLength(2)
@@ -4,6 +4,7 @@ import {
4
4
  getWallFaceOffsets,
5
5
  isCurvedWall,
6
6
  type Point,
7
+ type SeparatorNode,
7
8
  type WallNode,
8
9
  } from '@pascal-app/core'
9
10
 
@@ -153,12 +154,62 @@ export function wallPushDimensionRooms(
153
154
  )
154
155
  }
155
156
 
157
+ /**
158
+ * The rooms along a pushed edge of invisible walls, as `wallPushDimensionRooms`
159
+ * reads them along a wall: each room's stretch of the edge's separators, put
160
+ * on the edge's one `line` (`[0, 1]` its length), faced as that line runs.
161
+ */
162
+ export function separatorEdgeDimensionRooms(
163
+ rooms: ReadonlyArray<{ id: string; spans: readonly BoundarySpan[] }>,
164
+ edge: {
165
+ line: Pick<SeparatorNode, 'start' | 'end'>
166
+ separators: ReadonlyArray<{ separatorId: string; t0: number; t1: number }>
167
+ },
168
+ resolve: (id: string) => AnyNode | undefined,
169
+ elevationOf: (roomId: string) => number,
170
+ ): PushDimensionRoom[] {
171
+ const { start, end } = edge.line
172
+ const dx = end[0] - start[0]
173
+ const dz = end[1] - start[1]
174
+ const lengthSq = dx * dx + dz * dz
175
+ if (lengthSq < 1e-12) return []
176
+ const members = new Set(edge.separators.map((member) => member.separatorId))
177
+ const along = ([x, z]: Point) => ((x - start[0]) * dx + (z - start[1]) * dz) / lengthSq
178
+ return rooms.flatMap((room) =>
179
+ room.spans.flatMap((own): PushDimensionRoom[] => {
180
+ const member = edge.separators.find((entry) => entry.separatorId === own.boundaryId)
181
+ const separator = resolve(own.boundaryId)
182
+ if (!member || separator?.type !== 'separator') return []
183
+ const t0 = Math.max(member.t0, own.t0)
184
+ const t1 = Math.min(member.t1, own.t1)
185
+ if (t1 - t0 < 1e-4) return []
186
+ const sx = separator.end[0] - separator.start[0]
187
+ const sz = separator.end[1] - separator.start[1]
188
+ const point = (t: number): Point => [separator.start[0] + sx * t, separator.start[1] + sz * t]
189
+ const [u0, u1] = [along(point(t0)), along(point(t1))]
190
+ // A separator drawn against the edge's line has its faces the other way round.
191
+ const same = sx * dx + sz * dz > 0
192
+ return [
193
+ {
194
+ id: room.id,
195
+ face: same ? own.face : own.face === 'a' ? 'b' : 'a',
196
+ t0: Math.min(u0, u1),
197
+ t1: Math.max(u0, u1),
198
+ others: room.spans.filter((other) => !members.has(other.boundaryId)),
199
+ elevation: elevationOf(room.id),
200
+ },
201
+ ]
202
+ }),
203
+ )
204
+ }
205
+
156
206
  /**
157
207
  * The live dimensions of a wall push: for every room along the pushed stretch,
158
208
  * the moving face (the resting face moved `distance` along `outward` — a push
159
209
  * is a pure translation) to each facing parallel boundary of that room, read
160
210
  * through `resolve` so the preview's geometry counts. Per side of the wall:
161
- * nearest first, one per distinct distance, at most three.
211
+ * nearest first, one per distinct distance, at most three. An edge of
212
+ * invisible walls is measured as its one line.
162
213
  */
163
214
  export function wallPushDimensions({
164
215
  rooms,
@@ -168,7 +219,7 @@ export function wallPushDimensions({
168
219
  resolve,
169
220
  }: {
170
221
  rooms: readonly PushDimensionRoom[]
171
- wall: WallNode
222
+ wall: WallNode | SeparatorNode
172
223
  outward: Point
173
224
  distance: number
174
225
  resolve: (id: string) => AnyNode | undefined
@@ -195,6 +246,179 @@ export function wallPushDimensions({
195
246
  )
196
247
  }
197
248
 
249
+ /** Whether two dimensions measure the same span, either way round. */
250
+ export function sameRoomDimension(a: RoomDimension, b: RoomDimension): boolean {
251
+ const near = (p: Point, q: Point) => Math.hypot(p[0] - q[0], p[1] - q[1]) < 1e-3
252
+ return (near(a.from, b.from) && near(a.to, b.to)) || (near(a.from, b.to) && near(a.to, b.from))
253
+ }
254
+
255
+ /** A selected wall's temporary dimension: a room width (ID) or the outside size (OD). */
256
+ export type WallSelectionDimension = RoomDimension & { faces: 'interior' | 'exterior' }
257
+
258
+ /** A selection's temporary dimension with the node it runs from: typing it moves that node. */
259
+ export type OwnedSelectionDimension = WallSelectionDimension & { ownerId: string }
260
+
261
+ /**
262
+ * Several selected nodes' temporary dimensions as one list, keys unique across
263
+ * owners. Two selected walls across one room both measure it: the width shows
264
+ * once, owned by the first.
265
+ */
266
+ export function mergeSelectionDimensions(
267
+ owners: ReadonlyArray<{ ownerId: string; dimensions: readonly WallSelectionDimension[] }>,
268
+ ): OwnedSelectionDimension[] {
269
+ const kept: OwnedSelectionDimension[] = []
270
+ for (const { ownerId, dimensions } of owners)
271
+ for (const dimension of dimensions)
272
+ if (!kept.some((other) => sameRoomDimension(other, dimension)))
273
+ kept.push({ ...dimension, key: `${ownerId}:${dimension.key}`, ownerId })
274
+ return kept
275
+ }
276
+
277
+ /** Which faces a selected wall's temporary dimensions measure to. */
278
+ export type WallDimensionFaces = 'interior' | 'exterior' | 'both'
279
+
280
+ const CONNECTION_TOLERANCE = 0.03
281
+ /** Where along the shared stretch the outside size is drawn, clear of the room widths at its middle. */
282
+ const EXTERIOR_DIMENSION_AT = 0.25
283
+
284
+ function pointSegmentDistance(p: Point, a: Point, b: Point): number {
285
+ const dx = b[0] - a[0]
286
+ const dz = b[1] - a[1]
287
+ const lengthSq = dx * dx + dz * dz
288
+ const t =
289
+ lengthSq < 1e-12
290
+ ? 0
291
+ : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dz) / lengthSq))
292
+ return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dz * t))
293
+ }
294
+
295
+ function wallsTouch(left: WallNode, right: WallNode): boolean {
296
+ return (
297
+ [left.start, left.end].some(
298
+ (p) => pointSegmentDistance(p, right.start, right.end) <= CONNECTION_TOLERANCE,
299
+ ) ||
300
+ [right.start, right.end].some(
301
+ (p) => pointSegmentDistance(p, left.start, left.end) <= CONNECTION_TOLERANCE,
302
+ )
303
+ )
304
+ }
305
+
306
+ /** The walls joined to `wall` through shared corners and T junctions, itself included. */
307
+ function connectedWalls(wall: WallNode, walls: readonly WallNode[]): WallNode[] {
308
+ const found = new Map<string, WallNode>([[wall.id, wall]])
309
+ const queue = [wall]
310
+ for (let current = queue.pop(); current; current = queue.pop()) {
311
+ for (const candidate of walls) {
312
+ if (found.has(candidate.id) || !wallsTouch(current, candidate)) continue
313
+ found.set(candidate.id, candidate)
314
+ queue.push(candidate)
315
+ }
316
+ }
317
+ return [...found.values()]
318
+ }
319
+
320
+ /**
321
+ * The outside size across the building from a wall's exterior face: to the
322
+ * far face of the farthest parallel wall of the same connected building that
323
+ * shares some of its length — the opposite exterior wall. Null when nothing
324
+ * is across (a lone wall, a curved one).
325
+ */
326
+ function overallExteriorDimension(
327
+ wall: WallNode,
328
+ exteriorFace: 'a' | 'b',
329
+ walls: readonly WallNode[],
330
+ ): Measured | null {
331
+ const own = boundaryFace(wall, 0, 1, exteriorFace)
332
+ if (!own) return null
333
+ const dx = own.end[0] - own.start[0]
334
+ const dz = own.end[1] - own.start[1]
335
+ const length = Math.hypot(dx, dz)
336
+ const dir: Point = [dx / length, dz / length]
337
+ // The face's room-ward normal points out of the building here: across is its opposite.
338
+ const across: Point = [-own.inward[0], -own.inward[1]]
339
+ const along = (p: Point) => (p[0] - own.start[0]) * dir[0] + (p[1] - own.start[1]) * dir[1]
340
+ let best: { distance: number; lo: number; hi: number } | null = null
341
+ for (const other of connectedWalls(wall, walls)) {
342
+ if (other.id === wall.id) continue
343
+ for (const face of ['a', 'b'] as const) {
344
+ const candidate = boundaryFace(other, 0, 1, face)
345
+ if (!candidate) continue
346
+ const fx = candidate.end[0] - candidate.start[0]
347
+ const fz = candidate.end[1] - candidate.start[1]
348
+ const faceLength = Math.hypot(fx, fz)
349
+ if (Math.abs(dir[0] * fz - dir[1] * fx) / faceLength > PARALLEL_SIN) continue
350
+ const distance =
351
+ (candidate.start[0] - own.start[0]) * across[0] +
352
+ (candidate.start[1] - own.start[1]) * across[1]
353
+ if (distance <= MIN_GAP) continue
354
+ const a = along(candidate.start)
355
+ const b = along(candidate.end)
356
+ const lo = Math.max(0, Math.min(a, b))
357
+ const hi = Math.min(length, Math.max(a, b))
358
+ if (hi - lo < MIN_OVERLAP) continue
359
+ if (!best || distance > best.distance + SAME_DISTANCE) best = { distance, lo, hi }
360
+ }
361
+ }
362
+ if (!best) return null
363
+ const at = (u: number): Point => [own.start[0] + dir[0] * u, own.start[1] + dir[1] * u]
364
+ const [lo, hi] = [at(best.lo), at(best.hi)]
365
+ // Measured from the same end of the stretch whichever way the wall was drawn,
366
+ // so the walls on either side of the building put their outside size in one place.
367
+ const fromLo = Math.abs(lo[0] - hi[0]) > 1e-6 ? lo[0] < hi[0] : lo[1] <= hi[1]
368
+ const offset = (best.hi - best.lo) * EXTERIOR_DIMENSION_AT
369
+ const from = at(fromLo ? best.lo + offset : best.hi - offset)
370
+ return {
371
+ from,
372
+ to: [from[0] + across[0] * best.distance, from[1] + across[1] * best.distance],
373
+ distance: best.distance,
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The temporary dimensions of a selected wall at rest, in 2D and 3D: the room
379
+ * widths a push of it shows (its face to each facing wall, both sides — ID),
380
+ * and for an exterior wall the outside size across the building (OD). `faces`
381
+ * picks which; a wall with no outside (rooms on both faces) shows its room
382
+ * widths whatever the choice. Each dimension runs `from` the selected wall,
383
+ * so typing a value moves that wall (`commitDimensionValue`).
384
+ */
385
+ export function wallSelectionDimensions({
386
+ rooms,
387
+ wall,
388
+ walls,
389
+ faces,
390
+ resolve,
391
+ baseElevation,
392
+ }: {
393
+ rooms: readonly PushDimensionRoom[]
394
+ wall: WallNode
395
+ /** The level's walls, for the opposite exterior wall. */
396
+ walls: readonly WallNode[]
397
+ faces: WallDimensionFaces
398
+ resolve: (id: string) => AnyNode | undefined
399
+ /** The wall's base, for a dimension with no room floor to stand on. */
400
+ baseElevation: number
401
+ }): WallSelectionDimension[] {
402
+ const interior = wallPushDimensions({ rooms, wall, outward: [0, 0], distance: 0, resolve }).map(
403
+ (dimension): WallSelectionDimension => ({ ...dimension, faces: 'interior' }),
404
+ )
405
+ const roomFaces = new Set(rooms.map((room) => room.face))
406
+ const exteriorFace = roomFaces.size === 1 ? (roomFaces.has('a') ? 'b' : 'a') : null
407
+ if (!exteriorFace || faces === 'interior') return interior
408
+ const overall = overallExteriorDimension(wall, exteriorFace, walls)
409
+ const exterior: WallSelectionDimension[] = overall
410
+ ? [
411
+ {
412
+ ...overall,
413
+ key: 'exterior',
414
+ faces: 'exterior',
415
+ elevation: (rooms[0]?.elevation ?? baseElevation) + DIMENSION_FLOOR_LIFT,
416
+ },
417
+ ]
418
+ : []
419
+ return faces === 'exterior' ? exterior : [...interior, ...exterior]
420
+ }
421
+
198
422
  /**
199
423
  * The live dimensions of a mezzanine edge push: the resting edge moved
200
424
  * `distance` along `outward`, to each facing parallel edge of the outline the
@@ -868,17 +868,34 @@ describe('room drill-down state through the mounted selection manager', () => {
868
868
  expect(useViewer.getState().selection.selectedIds).toEqual(['wall_free'])
869
869
  })
870
870
  })
871
- test('the 2D plan resolves walls room-first and drills on the second click', async () => {
871
+ test('the 2D plan gives a wall hit to the wall and a room interior to the room', async () => {
872
872
  await withRooms(async ({ left, right }) => {
873
- expect(resolvePlanRoomHit('wall_west', [-0.05, 2])).toEqual(left)
873
+ // Without the kind's `planHitSelectsNode`, a band hit stays room-first.
874
874
  expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toEqual(left)
875
- expect(resolvePlanRoomHit('wall_shared', [4.05, 2])).toEqual(right)
876
- expect(resolvePlanRoomHit('wall_free', [9.95, 1])).toBeNull()
877
- const hit = resolvePlanRoomHit('wall_west', [-0.05, 2])
875
+ // The wall definition declares it (its floor-plan extension).
876
+ const get = nodeRegistry.get.bind(nodeRegistry)
877
+ const spy = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
878
+ kind === 'wall'
879
+ ? ({
880
+ ...get(kind),
881
+ extensions: { 'pascal:editor/floorplan': { planHitSelectsNode: true } },
882
+ } as AnyNodeDefinition)
883
+ : get(kind)) as typeof nodeRegistry.get)
884
+ try {
885
+ // Either half of a shared wall's band is the wall, never the room beside it.
886
+ expect(resolvePlanRoomHit('wall_west', [-0.05, 2])).toBeNull()
887
+ expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toBeNull()
888
+ expect(resolvePlanRoomHit('wall_shared', [4.05, 2])).toBeNull()
889
+ expect(resolvePlanRoomHit('wall_free', [9.95, 1])).toBeNull()
890
+ } finally {
891
+ spy.mockRestore()
892
+ }
893
+ expect(resolvePlanRoomHit(null, [2, 2])).toEqual(left)
894
+ expect(resolvePlanRoomHit(null, [6, 2])).toEqual(right)
878
895
  const select = async (modifiers = plain) => {
879
896
  let selected = false
880
897
  await act(async () => {
881
- selected = selectRoomFromHit(hit, modifiers, 'wall_west')
898
+ selected = selectRoomFromHit(resolvePlanRoomHit(null, [2, 2]), modifiers, null)
882
899
  })
883
900
  return selected
884
901
  }
@@ -172,6 +172,150 @@ describe('computeSceneBoundsXZ', () => {
172
172
  expect(bounds!.center).toEqual([2, 2])
173
173
  })
174
174
 
175
+ describe('ancestor transforms', () => {
176
+ function makeBuilding(
177
+ id: string,
178
+ position: [number, number, number],
179
+ rotationY = 0,
180
+ parentId: string | null = null,
181
+ ): AnyNode {
182
+ return {
183
+ object: 'node',
184
+ id,
185
+ type: 'building',
186
+ parentId,
187
+ visible: true,
188
+ metadata: {},
189
+ children: [],
190
+ position,
191
+ rotation: [0, rotationY, 0],
192
+ } as unknown as AnyNode
193
+ }
194
+
195
+ function makeLevel(id: string, parentId: string): AnyNode {
196
+ return {
197
+ object: 'node',
198
+ id,
199
+ type: 'level',
200
+ parentId,
201
+ visible: true,
202
+ metadata: {},
203
+ children: [],
204
+ level: 0,
205
+ } as unknown as AnyNode
206
+ }
207
+
208
+ function child(node: AnyNode, parentId: string): AnyNode {
209
+ return { ...node, parentId } as AnyNode
210
+ }
211
+
212
+ function expectClose(actual: [number, number], expected: [number, number]) {
213
+ expect(actual[0]).toBeCloseTo(expected[0], 9)
214
+ expect(actual[1]).toBeCloseTo(expected[1], 9)
215
+ }
216
+
217
+ test('carries building-local walls and zones through a translated building', () => {
218
+ const nodes = [
219
+ makeBuilding('building_a', [100, 0, 100]),
220
+ makeLevel('level_a', 'building_a'),
221
+ child(makeWall([-5, -4], [5, -4]), 'level_a'),
222
+ child(
223
+ makeZone([
224
+ [-5, -4],
225
+ [5, -4],
226
+ [5, 4],
227
+ [-5, 4],
228
+ ]),
229
+ 'level_a',
230
+ ),
231
+ ]
232
+ const bounds = computeSceneBoundsXZ(nodes)!
233
+ expectClose(bounds.min, [95, 96])
234
+ expectClose(bounds.max, [105, 104])
235
+ expectClose(bounds.center, [100, 100])
236
+ })
237
+
238
+ test('turns building-local geometry by the building yaw', () => {
239
+ // three.js Y rotation by +90°: local (x, z) → (z, −x).
240
+ const nodes = [
241
+ makeBuilding('building_r', [10, 0, 0], Math.PI / 2),
242
+ makeLevel('level_r', 'building_r'),
243
+ child(makeWall([0, 0], [8, 0]), 'level_r'),
244
+ child(makeWall([8, 0], [8, 2]), 'level_r'),
245
+ ]
246
+ const bounds = computeSceneBoundsXZ(nodes)!
247
+ expectClose(bounds.min, [10, -8])
248
+ expectClose(bounds.max, [12, 0])
249
+ expectClose(bounds.size, [2, 8])
250
+ })
251
+
252
+ test('composes nested positioned and rotated ancestors', () => {
253
+ const item = {
254
+ object: 'node',
255
+ id: 'item_host',
256
+ type: 'item',
257
+ parentId: 'level_n',
258
+ visible: true,
259
+ metadata: {},
260
+ children: [],
261
+ position: [2, 0, 0],
262
+ rotation: [0, Math.PI / 2, 0],
263
+ } as unknown as AnyNode
264
+ const hosted = {
265
+ object: 'node',
266
+ id: 'item_hosted',
267
+ type: 'item',
268
+ parentId: 'item_host',
269
+ visible: true,
270
+ metadata: {},
271
+ children: [],
272
+ position: [1, 0, 0],
273
+ rotation: [0, 0, 0],
274
+ } as unknown as AnyNode
275
+ // Building at (50, 50) turned 90°: the host lands at (50, 48), and the
276
+ // hosted item (1, 0) under a total 180° yaw lands one metre further −x.
277
+ const nodes = [
278
+ makeBuilding('building_n', [50, 0, 50], Math.PI / 2),
279
+ makeLevel('level_n', 'building_n'),
280
+ item,
281
+ hosted,
282
+ ]
283
+ const bounds = computeSceneBoundsXZ(nodes)!
284
+ expectClose(bounds.min, [49, 48])
285
+ expectClose(bounds.max, [50, 50])
286
+ })
287
+
288
+ test('leaves geometry under an untransformed building unchanged', () => {
289
+ const nodes = [
290
+ makeBuilding('building_0', [0, 0, 0]),
291
+ makeLevel('level_0', 'building_0'),
292
+ child(makeWall([0, 0], [4, 0]), 'level_0'),
293
+ child(makeWall([4, 0], [4, 3]), 'level_0'),
294
+ ]
295
+ const bounds = computeSceneBoundsXZ(nodes)!
296
+ expect(bounds.min).toEqual([0, 0])
297
+ expect(bounds.max).toEqual([4, 3])
298
+ })
299
+ })
300
+
301
+ test('a door on a wall is bounded by the wall, not its wall-local position', () => {
302
+ const wall = makeWall([50, 50], [60, 50])
303
+ const door = {
304
+ object: 'node',
305
+ id: 'door_1',
306
+ type: 'door',
307
+ parentId: wall.id,
308
+ visible: true,
309
+ metadata: {},
310
+ position: [2, 1, 0],
311
+ } as unknown as AnyNode
312
+ const bounds = computeSceneBoundsXZ([wall, door])
313
+ expect(bounds).not.toBeNull()
314
+ expect(bounds!.min[0]).toBe(50)
315
+ expect(bounds!.max[0]).toBe(60)
316
+ expect(bounds!.center).toEqual([55, 50])
317
+ })
318
+
175
319
  test('skips non-finite coordinates', () => {
176
320
  const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])]
177
321
  const bounds = computeSceneBoundsXZ(nodes)
@@ -11,6 +11,7 @@
11
11
  * tree and derives an axis-aligned bounding box on the XZ (plan) plane.
12
12
  */
13
13
 
14
+ import { turnPlanPoint, yawOf } from '@pascal-app/core'
14
15
  import type { AnyNode } from '@pascal-app/core/schema'
15
16
 
16
17
  export type SceneBoundsXZ = {
@@ -27,13 +28,19 @@ export type SceneBoundsXZ = {
27
28
  // A very small guard against degenerate bounds (e.g. a single wall of zero length).
28
29
  const MIN_BOUNDS_EXTENT = 0.0001
29
30
 
30
- function extendPoint(
31
- acc: { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean },
32
- x: unknown,
33
- z: unknown,
34
- ): void {
35
- if (typeof x !== 'number' || typeof z !== 'number') return
36
- if (!(Number.isFinite(x) && Number.isFinite(z))) return
31
+ /** A node's plan frame in world XZ: local (x, z) → origin + turnPlanPoint(x, z, yaw). */
32
+ type PlanFrame = { x: number; z: number; yaw: number }
33
+
34
+ const WORLD_FRAME: PlanFrame = { x: 0, z: 0, yaw: 0 }
35
+
36
+ type BoundsAcc = { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean }
37
+
38
+ function extendPoint(acc: BoundsAcc, frame: PlanFrame, localX: unknown, localZ: unknown): void {
39
+ if (typeof localX !== 'number' || typeof localZ !== 'number') return
40
+ if (!(Number.isFinite(localX) && Number.isFinite(localZ))) return
41
+ const [tx, tz] = frame.yaw === 0 ? [localX, localZ] : turnPlanPoint(localX, localZ, frame.yaw)
42
+ const x = frame.x + tx
43
+ const z = frame.z + tz
37
44
  if (x < acc.minX) acc.minX = x
38
45
  if (x > acc.maxX) acc.maxX = x
39
46
  if (z < acc.minZ) acc.minZ = z
@@ -50,6 +57,9 @@ function extendPoint(
50
57
  * - `polygon` → zone, slab, site-boundary polygons.
51
58
  * - `position` → building/item/door/window position; uses [x, z] only.
52
59
  *
60
+ * Each point is in its parent's frame, so it is carried to world XZ through
61
+ * the ancestors' `position` and `rotation` yaw (a moved or turned building).
62
+ *
53
63
  * Site-node polygons are intentionally excluded when they are the default
54
64
  * 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
55
65
  * an empty square around the origin. We still include site polygons that
@@ -63,7 +73,7 @@ export function computeSceneBoundsXZ(
63
73
  const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
64
74
  if (list.length === 0) return null
65
75
 
66
- const acc = {
76
+ const acc: BoundsAcc = {
67
77
  minX: Number.POSITIVE_INFINITY,
68
78
  minZ: Number.POSITIVE_INFINITY,
69
79
  maxX: Number.NEGATIVE_INFINITY,
@@ -71,15 +81,24 @@ export function computeSceneBoundsXZ(
71
81
  hasPoint: false,
72
82
  }
73
83
 
84
+ const byId = new Map<string, AnyNode>()
85
+ for (const node of list) {
86
+ if (node && typeof node === 'object' && typeof node.id === 'string') byId.set(node.id, node)
87
+ }
88
+ const frameOf = createFrameResolver(byId)
89
+
74
90
  for (const node of list) {
75
91
  if (!node || typeof node !== 'object') continue
76
92
  const anyNode = node as unknown as Record<string, unknown>
93
+ // A node's own fields live in its parent's frame (a building's walls are
94
+ // building-local), so every point goes through the ancestors' transforms.
95
+ const frame = frameOf(node.parentId ?? null)
77
96
 
78
97
  // Wall / fence endpoints in level coordinates.
79
98
  const start = anyNode.start as unknown
80
99
  const end = anyNode.end as unknown
81
- if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
82
- if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
100
+ if (Array.isArray(start) && start.length >= 2) extendPoint(acc, frame, start[0], start[1])
101
+ if (Array.isArray(end) && end.length >= 2) extendPoint(acc, frame, end[0], end[1])
83
102
 
84
103
  // Zone / slab polygons (and explicit polygon-shaped site boundaries).
85
104
  const polygon = anyNode.polygon as unknown
@@ -88,7 +107,7 @@ export function computeSceneBoundsXZ(
88
107
  // points under `polygon.points` (a discriminated PropertyLineData shape).
89
108
  for (const point of polygon) {
90
109
  if (Array.isArray(point) && point.length >= 2) {
91
- extendPoint(acc, point[0], point[1])
110
+ extendPoint(acc, frame, point[0], point[1])
92
111
  }
93
112
  }
94
113
  } else if (
@@ -105,16 +124,18 @@ export function computeSceneBoundsXZ(
105
124
  } else {
106
125
  for (const point of points) {
107
126
  if (Array.isArray(point) && point.length >= 2) {
108
- extendPoint(acc, point[0], point[1])
127
+ extendPoint(acc, frame, point[0], point[1])
109
128
  }
110
129
  }
111
130
  }
112
131
  }
113
132
 
114
- // Position on the XZ plane (3D position = [x, y, z]).
133
+ // Position on the XZ plane (3D position = [x, y, z]). A node hosted on a
134
+ // wall (door, window, wall item) has a wall-local position, and the
135
+ // wall's endpoints already bound it.
115
136
  const position = anyNode.position as unknown
116
- if (Array.isArray(position) && position.length >= 3) {
117
- extendPoint(acc, position[0], position[2])
137
+ if (Array.isArray(position) && position.length >= 3 && !isWallHosted(node, byId)) {
138
+ extendPoint(acc, frame, position[0], position[2])
118
139
  }
119
140
  }
120
141
 
@@ -146,6 +167,49 @@ export function computeSceneBoundsXZ(
146
167
  }
147
168
  }
148
169
 
170
+ /**
171
+ * World plan frame of each node, composed down the parent chain the way the
172
+ * renderers nest groups: a node with a `position` (building, item) translates
173
+ * its children and turns them by its `rotation` yaw; nodes without one (site,
174
+ * level) pass their parent's frame through.
175
+ */
176
+ function createFrameResolver(byId: Map<string, AnyNode>): (id: string | null) => PlanFrame {
177
+ const cache = new Map<string, PlanFrame>()
178
+ const resolving = new Set<string>()
179
+
180
+ const resolve = (id: string | null): PlanFrame => {
181
+ if (!id) return WORLD_FRAME
182
+ const cached = cache.get(id)
183
+ if (cached) return cached
184
+ const node = byId.get(id)
185
+ if (!node || resolving.has(id)) return WORLD_FRAME
186
+ resolving.add(id)
187
+ const parent = resolve(node.parentId ?? null)
188
+ resolving.delete(id)
189
+
190
+ const anyNode = node as unknown as Record<string, unknown>
191
+ const position = anyNode.position
192
+ let frame = parent
193
+ if (Array.isArray(position) && position.length >= 3) {
194
+ const px = Number.isFinite(position[0]) ? (position[0] as number) : 0
195
+ const pz = Number.isFinite(position[2]) ? (position[2] as number) : 0
196
+ const [tx, tz] = turnPlanPoint(px, pz, parent.yaw)
197
+ frame = { x: parent.x + tx, z: parent.z + tz, yaw: parent.yaw + yawOf(anyNode.rotation) }
198
+ }
199
+ cache.set(id, frame)
200
+ return frame
201
+ }
202
+
203
+ return resolve
204
+ }
205
+
206
+ function isWallHosted(node: AnyNode, byId: Map<string, AnyNode>): boolean {
207
+ const parent = node.parentId ? byId.get(node.parentId) : undefined
208
+ if (!parent) return false
209
+ const anyParent = parent as unknown as Record<string, unknown>
210
+ return Array.isArray(anyParent.start) && Array.isArray(anyParent.end)
211
+ }
212
+
149
213
  /**
150
214
  * Matches the `SiteNode` bootstrap polygon defined in
151
215
  * `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).