@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1 @@
1
+ {"rootNodeIds":["site_ekt539rsa4txytwe"],"nodes":{"site_ekt539rsa4txytwe":{"id":"site_ekt539rsa4txytwe","type":"site","object":"node","polygon":{"type":"polygon","points":[[-15,-15],[15,-15],[15,15],[-15,15]]},"visible":true,"children":["building_4ky4qkd1clukblus"],"metadata":{},"parentId":null},"slab_0000f6wk43tfbe74":{"id":"slab_0000f6wk43tfbe74","name":"Floor plate","type":"slab","holes":[[[-3.8,-6.48],[-3.8,-0.32],[3.8,-0.32],[3.8,-6.48]]],"object":"node","polygon":[[-12.12,-9.12],[-4.88,-9.12],[-4.88,-7.62],[4.88,-7.62],[4.88,-9.12],[12.12,-9.12],[12.12,9.12],[4.88,9.12],[4.88,9],[-4.88,9],[-4.88,9.12],[-12.12,9.12]],"visible":true,"zoneIds":["zone_10ykaw7obss3jfad","zone_233g1wh3ofdeur6z","zone_3pt3w07jcn417jzl","zone_ba1q0l3i9sdysves","zone_ickl6phxwyezlorp","zone_l72rviyakbgnjhg3","zone_mje3oqluacx3ufq6","zone_o6krq03y5nf1dzhr","zone_p0q43h11dk9sps3u","zone_qmby30jjopkyloj7","zone_tdau0743kn2mfh69","zone_uaccinve76vvqvd0","zone_v79h6iejuucieox1","zone_xr6iobeauwhqrg9c"],"boundary":"auto","metadata":{},"parentId":"level_r5lgsth3fm300izv","recessed":false,"elevation":0.28,"plateRole":"base","thickness":0.28,"foundation":{"type":"none"},"holeMetadata":[{"source":"floor-opening","openingId":"floor-opening_pqar28n7pt8fayn6"}],"autoFromWalls":true},"slab_00020k2tgw4zuz5b":{"id":"slab_00020k2tgw4zuz5b","name":"Floor plate","type":"slab","holes":[[[9.395,4.82],[9.4057,4.78],[9.435,4.7507],[9.475,4.74],[10.825,4.74],[10.865,4.7507],[10.8943,4.78],[10.905,4.82],[10.905,8.46],[10.8943,8.5],[10.865,8.5293],[10.825,8.54],[9.475,8.54],[9.435,8.5293],[9.4057,8.5],[9.395,8.46]]],"object":"node","polygon":[[-12.12,-9.12],[-4.88,-9.12],[-4.88,-7.62],[4.88,-7.62],[4.88,-9.12],[12.12,-9.12],[12.12,9.12],[-12.12,9.12]],"visible":true,"zoneIds":["zone_30jrn9r7ja1x0bj4","zone_3kthuc9xs4o7xwrq","zone_3vw0q3dj45gjdz1u","zone_jj1d8mn2el59egkk","zone_ksy8w3n4io6w9zrm","zone_myokm4yb0tve0uo7","zone_r0frcuss9d9goc0i","zone_s745i25x6uu2f273","zone_t2fgc9il25qg9515","zone_wyrqla1do0fzjtfy","zone_xqjjcm74b6pt7k3t","zone_z4vjbfrugyr4ge6w"],"boundary":"auto","metadata":{},"parentId":"level_ztimtvrgdzzd6c9f","recessed":false,"elevation":0.28,"plateRole":"base","thickness":0.28,"foundation":{"type":"none"},"holeMetadata":[{"source":"floor-opening","openingId":"floor-opening_00034f1vlwholz49"}],"autoFromWalls":true},"slab_0002aqnsksqdfx80":{"id":"slab_0002aqnsksqdfx80","name":"Floor plate","type":"slab","holes":[],"object":"node","polygon":[[-12.12,-9.12],[-4.88,-9.12],[-4.88,-7.62],[4.88,-7.62],[4.88,-9.12],[12.12,-9.12],[12.12,9.12],[-12.12,9.12]],"visible":true,"zoneIds":["zone_2qxzq4p9si4kwhcb","zone_c9jd7ih9vfq9mwve","zone_friqqpuzkgtoiwcy","zone_kn3bcniyia721vsg","zone_m4wk2wcrhvgwlssv","zone_n6wjb58arf9t5i1i","zone_pc9mgllbo1pt0g80","zone_wag03w02hr670o0j","zone_ynrorsrrryu06p4r","zone_yoip906tq61l6nqg"],"boundary":"auto","metadata":{},"parentId":"level_68jf2zqaukfjof3m","recessed":false,"elevation":0.28,"plateRole":"base","thickness":0.28,"foundation":{"type":"none"},"floorHeight":0.28,"holeMetadata":[],"autoFromWalls":true},"wall_05exvmub3ng63jqi":{"id":"wall_05exvmub3ng63jqi","end":[5,9],"name":"Wall 29","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[8.5,9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_0yntsdh15uyckt93":{"id":"wall_0yntsdh15uyckt93","end":[5,9],"name":"Wall 16","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_1bz18h1h7zid6d75":{"id":"wall_1bz18h1h7zid6d75","end":[-5,0],"name":"Wall 4","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_6r8hb9hjm1en1ja1":{"id":"wall_6r8hb9hjm1en1ja1","end":[-12,0],"name":"Wall 7","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,0],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_70x0p36be0al8bac":{"id":"wall_70x0p36be0al8bac","end":[-12,9],"name":"Wall 14","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_764640i0o7uboxal":{"id":"wall_764640i0o7uboxal","end":[8.5,1],"name":"Wall 23","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[8.5,-3],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_7ne9gpvu6kza1s01":{"id":"wall_7ne9gpvu6kza1s01","end":[12,-3],"name":"Wall 19","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[12,-9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_7rjsbv43bfpp5a70":{"id":"wall_7rjsbv43bfpp5a70","end":[-12,5.5],"name":"Wall 11","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,5.5],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_8eq8y5s0dezxkt4h":{"id":"wall_8eq8y5s0dezxkt4h","end":[-5,-7.5],"name":"Wall 6","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,-9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_c46635rzij5piumj":{"id":"wall_c46635rzij5piumj","end":[-12,5.5],"name":"Wall 15","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-12,9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_dqaincn4yuayv5fj":{"id":"wall_dqaincn4yuayv5fj","end":[12,-9],"name":"Wall 18","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,-9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_dv85nvk60dcg0jcx":{"id":"wall_dv85nvk60dcg0jcx","end":[-5,-7.5],"name":"Wall 4","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,0],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_g2643xmto97pwfit":{"id":"wall_g2643xmto97pwfit","end":[8.5,1],"name":"Wall 27","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[12,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_h8gpb4au9vagpsn5":{"id":"wall_h8gpb4au9vagpsn5","end":[5,-7.5],"name":"Wall 1","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,-7.5],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_hexb7lbj5xiyg0z2":{"id":"wall_hexb7lbj5xiyg0z2","end":[-12,-9],"name":"Wall 9","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-12,0],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_iarqppigelgtncu5":{"id":"wall_iarqppigelgtncu5","end":[-5,9],"name":"Wall 13","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,5.5],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_j4gutc3elqr3yjon":{"id":"wall_j4gutc3elqr3yjon","end":[12,9],"name":"Wall 30","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[12,1],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_mf7krq856wnzkqa1":{"id":"wall_mf7krq856wnzkqa1","end":[8.5,9],"name":"Wall 31","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[12,9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_mkkyvfzx5fnb95h6":{"id":"wall_mkkyvfzx5fnb95h6","end":[5,-3],"name":"Wall 20","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[8.5,-3],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_mml77khvs2pzc7ij":{"id":"wall_mml77khvs2pzc7ij","end":[-12,0],"name":"Wall 12","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-12,5.5],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_pz7mdor1tci9vxry":{"id":"wall_pz7mdor1tci9vxry","end":[-5,1],"name":"Wall 3","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_qibiq6sp9kup3dc3":{"id":"wall_qibiq6sp9kup3dc3","end":[8.5,-3],"name":"Wall 20","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[12,-3],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_u8s7fnq3z2vhjdgj":{"id":"wall_u8s7fnq3z2vhjdgj","end":[-5,9],"name":"Wall 17","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_v2t2hssnckwayush":{"id":"wall_v2t2hssnckwayush","end":[-5,5.5],"name":"Wall 10","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-5,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_vly8fyqxowy7vfu4":{"id":"wall_vly8fyqxowy7vfu4","end":[-5,-9],"name":"Wall 5","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[-12,-9],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_vv4zdgy03aroxv6t":{"id":"wall_vv4zdgy03aroxv6t","end":[5,1],"name":"Wall 25","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[8.5,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_wmde7vr9ua1ld14u":{"id":"wall_wmde7vr9ua1ld14u","end":[12,1],"name":"Wall 26","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[12,-3],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_xw9tyzlm4prohfm8":{"id":"wall_xw9tyzlm4prohfm8","end":[5,1],"name":"Wall 2","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,-3],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_y1pccro5nxw4fhau":{"id":"wall_y1pccro5nxw4fhau","end":[5,-9],"name":"Wall 22","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,-7.5],"object":"node","visible":true,"backSide":"exterior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_y6i53f5om5wkq11t":{"id":"wall_y6i53f5om5wkq11t","end":[5,-3],"name":"Wall 2","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[5,-7.5],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"wall_zy8deq6665s59whd":{"id":"wall_zy8deq6665s59whd","end":[8.5,9],"name":"Wall 28","type":"wall","slots":{"a":"scene:mat_oxedhi6dw5m4cgj6","b":"scene:mat_oxedhi6dw5m4cgj6"},"start":[8.5,1],"object":"node","visible":true,"backSide":"interior","children":[],"metadata":{},"parentId":"level_68jf2zqaukfjof3m","frontSide":"interior","thickness":0.24},"level_68jf2zqaukfjof3m":{"id":"level_68jf2zqaukfjof3m","name":"Lower Ground · Private Club","type":"level","level":-1,"height":3.4,"object":"node","visible":true,"children":["wall_h8gpb4au9vagpsn5","wall_pz7mdor1tci9vxry","slab_0002aqnsksqdfx80","wall_vly8fyqxowy7vfu4","wall_8eq8y5s0dezxkt4h","wall_1bz18h1h7zid6d75","wall_dv85nvk60dcg0jcx","wall_6r8hb9hjm1en1ja1","wall_hexb7lbj5xiyg0z2","wall_v2t2hssnckwayush","wall_7rjsbv43bfpp5a70","wall_mml77khvs2pzc7ij","wall_iarqppigelgtncu5","wall_70x0p36be0al8bac","wall_c46635rzij5piumj","wall_0yntsdh15uyckt93","wall_u8s7fnq3z2vhjdgj","wall_dqaincn4yuayv5fj","wall_7ne9gpvu6kza1s01","wall_y6i53f5om5wkq11t","wall_xw9tyzlm4prohfm8","wall_y1pccro5nxw4fhau","wall_qibiq6sp9kup3dc3","wall_mkkyvfzx5fnb95h6","wall_764640i0o7uboxal","wall_vv4zdgy03aroxv6t","wall_wmde7vr9ua1ld14u","wall_g2643xmto97pwfit","wall_zy8deq6665s59whd","wall_05exvmub3ng63jqi","wall_j4gutc3elqr3yjon","wall_mf7krq856wnzkqa1"],"metadata":{},"parentId":"building_4ky4qkd1clukblus","baseElevation":-3.63},"level_dnwzuuqbl4xu7yri":{"id":"level_dnwzuuqbl4xu7yri","name":"Roof","type":"level","level":2,"height":1.2738958199317083,"object":"node","visible":true,"children":[],"metadata":{"role":"roof","label":"Roof","referenceLevelId":"level_r5lgsth3fm300izv"},"parentId":"building_4ky4qkd1clukblus","baseElevation":0},"level_r5lgsth3fm300izv":{"id":"level_r5lgsth3fm300izv","name":"Private Suites · Bel Etage","type":"level","level":1,"height":3.7,"object":"node","visible":true,"children":["slab_0000f6wk43tfbe74"],"metadata":{},"parentId":"building_4ky4qkd1clukblus","baseElevation":0},"level_ztimtvrgdzzd6c9f":{"id":"level_ztimtvrgdzzd6c9f","name":"Piano Nobile · Reception","type":"level","level":0,"height":4.2,"object":"node","visible":true,"children":["slab_00020k2tgw4zuz5b","slab_pool-ground-0ht87qhh4cp0ukwl"],"metadata":{},"parentId":"building_4ky4qkd1clukblus","baseElevation":0},"building_4ky4qkd1clukblus":{"id":"building_4ky4qkd1clukblus","name":"Villa Aurelia · Private Estate","type":"building","object":"node","visible":true,"children":["level_ztimtvrgdzzd6c9f","level_68jf2zqaukfjof3m","level_r5lgsth3fm300izv","level_dnwzuuqbl4xu7yri"],"metadata":{},"parentId":"site_ekt539rsa4txytwe","position":[0,0,0],"rotation":[0,0,0]},"slab_pool-ground-0ht87qhh4cp0ukwl":{"id":"slab_pool-ground-0ht87qhh4cp0ukwl","name":"Ground opening for pool_0ht87qhh4cp0ukwl","type":"slab","holes":[[[-8.745,10.455],[8.745,10.455],[8.745,14.345],[-8.745,14.345]]],"object":"node","polygon":[[-8.72,10.48],[8.72,10.48],[8.72,14.32],[-8.72,14.32]],"visible":true,"metadata":{"poolGroundOpeningFor":"pool_0ht87qhh4cp0ukwl"},"parentId":"level_ztimtvrgdzzd6c9f","recessed":true,"elevation":-0.02,"thickness":0.05,"holeMetadata":[{"source":"manual"}],"autoFromWalls":false,"recessedRimElevation":0}}}
@@ -0,0 +1,177 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ LevelNode,
7
+ levelFloorWorldY,
8
+ levelGradeStanding,
9
+ RoofNode,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { addStoreyBelow, addStoreyOnTop, setGroundFloor } from './add-storey'
13
+
14
+ // The editor's "add a floor" on a roofed house: the new storey goes under the roof, not over it.
15
+ const BUILDING = 'building_add-storey' as BuildingNode['id']
16
+
17
+ function seed() {
18
+ const ground = LevelNode.parse({ id: 'level_ground', parentId: BUILDING, level: 0 })
19
+ const roofLevel = LevelNode.parse({
20
+ id: 'level_roof',
21
+ parentId: BUILDING,
22
+ level: 1,
23
+ name: 'Roof',
24
+ children: ['roof_main'],
25
+ metadata: { role: 'roof' },
26
+ })
27
+ const roof = RoofNode.parse({ id: 'roof_main', parentId: roofLevel.id })
28
+ const building = BuildingNode.parse({ id: BUILDING, children: [ground.id, roofLevel.id] })
29
+ useScene.setState({
30
+ nodes: Object.fromEntries(
31
+ [building, ground, roofLevel, roof].map((node) => [node.id, node as AnyNode]),
32
+ ),
33
+ rootNodeIds: [BUILDING],
34
+ collections: {},
35
+ dirtyNodes: new Set(),
36
+ } as never)
37
+ }
38
+
39
+ describe('addStoreyOnTop', () => {
40
+ beforeEach(() => {
41
+ useScene.temporal.getState().clear()
42
+ useScene.temporal.getState().resume()
43
+ seed()
44
+ })
45
+
46
+ test('puts the new storey under the roof level, which moves up one', () => {
47
+ const level = addStoreyOnTop(BUILDING)
48
+ const nodes = useScene.getState().nodes
49
+ expect(nodes[level.id as AnyNodeId]).toMatchObject({ type: 'level', level: 1 })
50
+ expect(nodes['level_roof' as AnyNodeId]).toMatchObject({ level: 2 })
51
+ })
52
+
53
+ test('is one undo step', () => {
54
+ const level = addStoreyOnTop(BUILDING)
55
+ useScene.temporal.getState().undo()
56
+ const nodes = useScene.getState().nodes
57
+ expect(nodes[level.id as AnyNodeId]).toBeUndefined()
58
+ expect(nodes['level_roof' as AnyNodeId]).toMatchObject({ level: 1 })
59
+ })
60
+ })
61
+
62
+ // The editor's "add a basement": under the lowest storey, the building staying where it stands.
63
+ describe('addStoreyBelow', () => {
64
+ beforeEach(() => {
65
+ useScene.temporal.getState().clear()
66
+ useScene.temporal.getState().resume()
67
+ seed()
68
+ })
69
+
70
+ const floorOf = (id: string) => {
71
+ const floor = levelFloorWorldY(useScene.getState().nodes, id)
72
+ return floor === null ? floor : Math.round(floor * 1000) / 1000 + 0
73
+ }
74
+
75
+ test('puts the basement under the ground floor and leaves the building where it was', () => {
76
+ const basement = addStoreyBelow(BUILDING)
77
+ expect(floorOf('level_ground')).toBe(0)
78
+ expect(floorOf('level_roof')).toBe(2.5)
79
+ expect(floorOf(basement.id)).toBe(-2.5)
80
+ expect(levelGradeStanding(useScene.getState().nodes, basement.id)).toBe('below-grade')
81
+ })
82
+
83
+ test('three more basements stack down, each under the last, the building untouched', () => {
84
+ const ids = [addStoreyBelow(BUILDING), addStoreyBelow(BUILDING), addStoreyBelow(BUILDING)]
85
+ expect(ids.map((level) => floorOf(level.id))).toEqual([-2.5, -5, -7.5])
86
+ expect(ids.map((level) => level.level)).toEqual([-1, -2, -3])
87
+ expect(floorOf('level_ground')).toBe(0)
88
+ expect(floorOf('level_roof')).toBe(2.5)
89
+ for (const level of ids)
90
+ expect(levelGradeStanding(useScene.getState().nodes, level.id)).toBe('below-grade')
91
+ })
92
+
93
+ test('the owner’s villa: a second basement goes under Lower Ground, nothing above it moves', () => {
94
+ useScene.setState({
95
+ nodes: {
96
+ ...useScene.getState().nodes,
97
+ level_ground: { ...useScene.getState().nodes['level_ground' as AnyNodeId], level: 0 },
98
+ level_lower: LevelNode.parse({
99
+ id: 'level_lower',
100
+ parentId: BUILDING,
101
+ level: -1,
102
+ height: 3.4,
103
+ baseElevation: -3.63,
104
+ }),
105
+ [BUILDING]: {
106
+ ...useScene.getState().nodes[BUILDING as AnyNodeId],
107
+ children: ['level_lower', 'level_ground', 'level_roof'],
108
+ },
109
+ } as never,
110
+ })
111
+ const before = ['level_lower', 'level_ground', 'level_roof'].map(floorOf)
112
+ const basement = addStoreyBelow(BUILDING)
113
+ expect(['level_lower', 'level_ground', 'level_roof'].map(floorOf)).toEqual(before)
114
+ expect(floorOf(basement.id)).toBe(Math.round((-3.63 - 2.5) * 1000) / 1000)
115
+ })
116
+
117
+ test('is one undo step', () => {
118
+ const basement = addStoreyBelow(BUILDING)
119
+ useScene.temporal.getState().undo()
120
+ expect(useScene.getState().nodes[basement.id as AnyNodeId]).toBeUndefined()
121
+ expect(floorOf('level_ground')).toBe(0)
122
+ })
123
+ })
124
+
125
+ // The editor's "Set as ground floor": the owner's mansion, whose stack stood Lower Ground at grade.
126
+ describe('setGroundFloor', () => {
127
+ beforeEach(() => {
128
+ useScene.temporal.getState().clear()
129
+ useScene.temporal.getState().resume()
130
+ const level = (id: string, index: number, baseElevation: number, height: number) =>
131
+ LevelNode.parse({ id, parentId: BUILDING, level: index, baseElevation, height })
132
+ const levels = [
133
+ level('level_b2', -2, -3.4, 3.4),
134
+ level('level_lower', -1, 0, 3.4),
135
+ level('level_reception', 0, 0, 4),
136
+ ]
137
+ const building = BuildingNode.parse({ id: BUILDING, children: levels.map((entry) => entry.id) })
138
+ useScene.setState({
139
+ nodes: Object.fromEntries([building, ...levels].map((node) => [node.id, node as AnyNode])),
140
+ rootNodeIds: [BUILDING],
141
+ collections: {},
142
+ dirtyNodes: new Set(),
143
+ } as never)
144
+ })
145
+
146
+ const standing = (id: string) => levelGradeStanding(useScene.getState().nodes, id)
147
+
148
+ test('seats the building so the level stands 8 in over grade, the ones under it below it, in one undo step', () => {
149
+ expect(standing('level_lower')).toBe('at-grade')
150
+ expect(setGroundFloor('level_reception' as LevelNode['id'])).toBe(true)
151
+ const nodes = useScene.getState().nodes
152
+ expect((nodes[BUILDING as AnyNodeId] as BuildingNode).position).toEqual([0, -3.197, 0])
153
+ expect((nodes[BUILDING as AnyNodeId] as BuildingNode).groundLevelId).toBe('level_reception')
154
+ expect(standing('level_reception')).toBe('at-grade')
155
+ expect(standing('level_lower')).toBe('below-grade')
156
+ expect(standing('level_b2')).toBe('below-grade')
157
+
158
+ useScene.temporal.getState().undo()
159
+ const undone = useScene.getState().nodes[BUILDING as AnyNodeId] as BuildingNode
160
+ expect(undone.position).toEqual([0, 0, 0])
161
+ expect(undone.groundLevelId).toBeUndefined()
162
+ expect(standing('level_lower')).toBe('at-grade')
163
+ })
164
+
165
+ test('says nothing changed when the floor is already the ground floor, seated', () => {
166
+ setGroundFloor('level_reception' as LevelNode['id'])
167
+ expect(setGroundFloor('level_reception' as LevelNode['id'])).toBe(false)
168
+ })
169
+
170
+ test('a basement added after stacks under the last and the ground floor stays seated', () => {
171
+ setGroundFloor('level_reception' as LevelNode['id'])
172
+ const basement = addStoreyBelow(BUILDING)
173
+ expect(basement.level).toBe(-3)
174
+ expect(standing(basement.id)).toBe('below-grade')
175
+ expect(levelFloorWorldY(useScene.getState().nodes, 'level_reception')).toBeCloseTo(0.203, 9)
176
+ })
177
+ })
@@ -0,0 +1,59 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type BuildingNode,
5
+ DEFAULT_LEVEL_HEIGHT,
6
+ LevelNode,
7
+ storeyInsertIndex,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { buildGroundLevelChanges, buildLevelBelowChanges } from '@pascal-app/core/building'
11
+
12
+ /**
13
+ * The editor's "add a floor": an empty storey on top of the building's storeys, under its roof
14
+ * level, which moves up one in the same undo step, so the roof stays on top (add_level's rule).
15
+ */
16
+ export function addStoreyOnTop(buildingId: BuildingNode['id']): LevelNode {
17
+ const scene = useScene.getState()
18
+ const { floorIndex, shift } = storeyInsertIndex(scene.nodes, buildingId)
19
+ const level = LevelNode.parse({
20
+ level: floorIndex,
21
+ height: DEFAULT_LEVEL_HEIGHT,
22
+ children: [],
23
+ parentId: buildingId,
24
+ })
25
+ scene.applyNodeChanges({
26
+ create: [{ node: level, parentId: buildingId }],
27
+ update: shift.map(({ id, data }) => ({ id: id as AnyNodeId, data: data as Partial<AnyNode> })),
28
+ })
29
+ return level
30
+ }
31
+
32
+ /**
33
+ * The editor's "add a basement": an empty storey under the building's lowest, the floors above it
34
+ * staying where they are (`buildLevelBelowChanges`), in one undo step. Every floor under the ground
35
+ * is its own storey down, so a second, third or fourth basement stacks below the one before.
36
+ */
37
+ export function addStoreyBelow(buildingId: BuildingNode['id']): LevelNode {
38
+ const scene = useScene.getState()
39
+ const { newLevel, create, update } = buildLevelBelowChanges({
40
+ nodes: scene.nodes,
41
+ buildingId,
42
+ height: DEFAULT_LEVEL_HEIGHT,
43
+ })
44
+ scene.applyNodeChanges({ create, update })
45
+ return newLevel
46
+ }
47
+
48
+ /**
49
+ * The editor's "Set as ground floor": the level stands at grade, the building seated to it and the
50
+ * floors renumbered so it is 0 (`buildGroundLevelChanges`), in one undo step. `false` when it
51
+ * already is the ground floor, seated.
52
+ */
53
+ export function setGroundFloor(levelId: LevelNode['id']): boolean {
54
+ const scene = useScene.getState()
55
+ const built = buildGroundLevelChanges({ nodes: scene.nodes, levelId })
56
+ if (!built || built.unchanged) return false
57
+ scene.applyNodeChanges({ update: built.update })
58
+ return true
59
+ }
@@ -0,0 +1,171 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ type LevelNode,
7
+ LevelNode as LevelSchema,
8
+ SlabNode,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { buildGroundLevelChanges } from '@pascal-app/core/building'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { act, create } from '@react-three/test-renderer'
14
+ import useEditor from '../store/use-editor'
15
+ import villaAurelia from './__fixtures__/owner-villa-aurelia-basement.json'
16
+ import {
17
+ belowGradeShown,
18
+ followBelowGrade,
19
+ followGroundWindowBox,
20
+ gradeStandingShown,
21
+ groundWindowBoxShown,
22
+ } from './below-grade'
23
+ import { useEditorLevelDisplay } from './editor-level-display'
24
+
25
+ // The owner's villa (13 Oct), trimmed: its Lower Ground lies 3.35 m under the lawn.
26
+ const villa = villaAurelia.nodes as unknown as Record<AnyNodeId, AnyNode>
27
+ const lowerGround = 'level_68jf2zqaukfjof3m' as LevelNode['id']
28
+ const pianoNobile = 'level_ztimtvrgdzzd6c9f' as LevelNode['id']
29
+
30
+ const shown = (levelId: LevelNode['id'], hideLevelsAboveSelection = true) => ({
31
+ hideLevelsAboveSelection,
32
+ selection: { levelId },
33
+ })
34
+
35
+ let previous: {
36
+ viewer: ReturnType<typeof useViewer.getState>
37
+ scene: ReturnType<typeof useScene.getState>
38
+ editor: ReturnType<typeof useEditor.getState>
39
+ }
40
+ beforeEach(() => {
41
+ previous = {
42
+ viewer: useViewer.getState(),
43
+ scene: useScene.getState(),
44
+ editor: useEditor.getState(),
45
+ }
46
+ useScene.setState({ nodes: villa } as never)
47
+ })
48
+ afterEach(() => {
49
+ useViewer.setState(previous.viewer, true)
50
+ useScene.setState(previous.scene, true)
51
+ useEditor.setState(previous.editor, true)
52
+ })
53
+
54
+ test('the ground window’s box is the shown level’s while its upper storeys are hidden and the building’s while it stands, follows the display, and closes when it stops', () => {
55
+ const levelBox = groundWindowBoxShown(shown(lowerGround), villa)
56
+ const buildingBox = groundWindowBoxShown(shown(lowerGround, false), villa)
57
+ expect(levelBox).not.toBeNull()
58
+ expect(buildingBox).not.toBeNull()
59
+ // The building wraps the level: no wider claim than that is this test's to make.
60
+ expect(buildingBox?.min[1]).toBeLessThanOrEqual(levelBox?.min[1] ?? 0)
61
+ expect(buildingBox?.max[1]).toBeGreaterThan(levelBox?.max[1] ?? 0)
62
+
63
+ useViewer.setState({ hideLevelsAboveSelection: true })
64
+ useViewer.getState().setSelection({ levelId: lowerGround })
65
+ const stop = followGroundWindowBox()
66
+ expect(useViewer.getState().groundWindowBox).not.toBeNull()
67
+ stop()
68
+ expect(useViewer.getState().groundWindowBox).toBeNull()
69
+ })
70
+
71
+ test('the flag follows the level shown (below grade with the storeys above it hidden; never a level at grade, never while the whole building stands), and clears when it stops', () => {
72
+ useViewer.setState({ hideLevelsAboveSelection: true })
73
+ useViewer.getState().setSelection({ levelId: pianoNobile })
74
+ const stop = followBelowGrade()
75
+ expect(useViewer.getState().belowGrade).toBe(false)
76
+ expect(belowGradeShown(shown(pianoNobile), villa)).toBe(false)
77
+
78
+ useViewer.getState().setSelection({ levelId: lowerGround })
79
+ expect(useViewer.getState().belowGrade).toBe(true)
80
+ expect(useViewer.getState().gradeStanding).toBe('below-grade')
81
+ expect(gradeStandingShown(shown(lowerGround), villa)).toBe('below-grade')
82
+ useViewer.setState({ hideLevelsAboveSelection: false })
83
+ expect(useViewer.getState().belowGrade).toBe(false)
84
+ expect(belowGradeShown(shown(lowerGround, false), villa)).toBe(false)
85
+ useViewer.setState({ hideLevelsAboveSelection: true })
86
+ expect(useViewer.getState().belowGrade).toBe(true)
87
+
88
+ stop()
89
+ expect(useViewer.getState().belowGrade).toBe(false)
90
+ expect(useViewer.getState().gradeStanding).toBe('at-grade')
91
+ })
92
+
93
+ test("the editor's level display marks it, except on the site, where the ground is the work", async () => {
94
+ useViewer.getState().setSelection({ levelId: lowerGround })
95
+ useEditor.setState({ phase: 'site' })
96
+ function LevelDisplay() {
97
+ useEditorLevelDisplay(false)
98
+ return null
99
+ }
100
+ const renderer = await create(<LevelDisplay />)
101
+ try {
102
+ expect(useViewer.getState().hideLevelsAboveSelection).toBe(true)
103
+ expect(useViewer.getState().belowGrade).toBe(false)
104
+ expect(useViewer.getState().groundWindowBox).toBeNull()
105
+
106
+ await act(async () => useEditor.setState({ phase: 'building' }))
107
+ expect(useViewer.getState().belowGrade).toBe(true)
108
+ expect(useViewer.getState().groundWindowBox).not.toBeNull()
109
+ } finally {
110
+ await renderer.unmount()
111
+ }
112
+ expect(useViewer.getState().belowGrade).toBe(false)
113
+ expect(useViewer.getState().groundWindowBox).toBeNull()
114
+ })
115
+
116
+ // The owner's mansion (15 Oct): two basements, the stack standing Lower Ground at grade.
117
+ test('with the ground line set on a mansion, the flag follows the level shown: -1, then -2, then the ground floor', () => {
118
+ const plate = (id: string, size: number) =>
119
+ SlabNode.parse({
120
+ id: `slab_${id}`,
121
+ parentId: id,
122
+ elevation: 0.2,
123
+ polygon: [
124
+ [0, 0],
125
+ [size, 0],
126
+ [size, size],
127
+ [0, size],
128
+ ],
129
+ })
130
+ const level = (id: string, index: number, baseElevation: number, height: number) =>
131
+ LevelSchema.parse({
132
+ id,
133
+ parentId: 'building_mansion',
134
+ level: index,
135
+ baseElevation,
136
+ height,
137
+ children: [`slab_${id}`],
138
+ })
139
+ const levels = [
140
+ level('level_basement_2', -2, -3.4, 3.4),
141
+ level('level_lower_ground', -1, 0, 3.4),
142
+ level('level_reception', 0, 0, 4),
143
+ ]
144
+ const mansion = Object.fromEntries(
145
+ [
146
+ BuildingNode.parse({ id: 'building_mansion', children: levels.map((entry) => entry.id) }),
147
+ ...levels,
148
+ plate('level_basement_2', 14),
149
+ plate('level_lower_ground', 8),
150
+ plate('level_reception', 10),
151
+ ].map((node) => [node.id, node as AnyNode]),
152
+ ) as Record<AnyNodeId, AnyNode>
153
+ const lower = 'level_lower_ground' as LevelNode['id']
154
+ const deep = 'level_basement_2' as LevelNode['id']
155
+ const reception = 'level_reception' as LevelNode['id']
156
+ // "Set as ground floor" on the reception seats the stack: both basements are below grade.
157
+ const built = buildGroundLevelChanges({ nodes: mansion, levelId: reception })
158
+ const seated: Record<string, AnyNode> = { ...mansion }
159
+ for (const { id, data } of built?.update ?? []) seated[id] = { ...seated[id], ...data } as AnyNode
160
+ useScene.setState({ nodes: seated as Record<AnyNodeId, AnyNode> } as never)
161
+ useViewer.setState({ hideLevelsAboveSelection: true })
162
+ const stop = followBelowGrade()
163
+ const flag = () => useViewer.getState().gradeStanding
164
+ useViewer.getState().setSelection({ levelId: lower })
165
+ expect(flag()).toBe('below-grade')
166
+ useViewer.getState().setSelection({ levelId: deep })
167
+ expect(flag()).toBe('below-grade')
168
+ useViewer.getState().setSelection({ levelId: reception })
169
+ expect(flag()).toBe('at-grade')
170
+ stop()
171
+ })
@@ -0,0 +1,133 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ buildingGroundBox,
5
+ type GroundBox,
6
+ type LevelGradeStanding,
7
+ levelGradeStanding,
8
+ levelGroundBox,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import { useEffect } from 'react'
13
+
14
+ type LevelDisplay = {
15
+ hideLevelsAboveSelection: boolean
16
+ selection: { levelId: string | null; buildingId?: string | null }
17
+ }
18
+
19
+ /**
20
+ * The shown level's standing against the ground: the storeys above the selected level are
21
+ * hidden and that level's floor lies under the ground — all of it, or the part of it a
22
+ * hillside stands over — which is then veiled over it.
23
+ */
24
+ export function gradeStandingShown(
25
+ viewer: LevelDisplay,
26
+ nodes: Record<AnyNodeId, AnyNode>,
27
+ ): LevelGradeStanding {
28
+ const { levelId } = viewer.selection
29
+ if (!viewer.hideLevelsAboveSelection || levelId === null) return 'at-grade'
30
+ return levelGradeStanding(nodes, levelId)
31
+ }
32
+
33
+ /** Whether the level shown lies below grade, on all of its footprint or part of it. */
34
+ export function belowGradeShown(viewer: LevelDisplay, nodes: Record<AnyNodeId, AnyNode>): boolean {
35
+ return gradeStandingShown(viewer, nodes) !== 'at-grade'
36
+ }
37
+
38
+ /**
39
+ * Keeps the viewer's `belowGrade` and `gradeStanding` on the shown level while the
40
+ * selection, the level display and the scene change. The returned function stops, and the
41
+ * ground stands solid again.
42
+ */
43
+ export function followBelowGrade(): () => void {
44
+ const update = () => {
45
+ const gradeStanding = gradeStandingShown(useViewer.getState(), useScene.getState().nodes)
46
+ const viewer = useViewer.getState()
47
+ if (
48
+ viewer.gradeStanding !== gradeStanding ||
49
+ viewer.belowGrade !== (gradeStanding !== 'at-grade')
50
+ )
51
+ useViewer.setState({ gradeStanding, belowGrade: gradeStanding !== 'at-grade' })
52
+ }
53
+ update()
54
+ // The flag itself is watched too: the other host's level display, handing over, resets it.
55
+ const stopViewer = useViewer.subscribe((state, previous) => {
56
+ if (
57
+ state.selection.levelId !== previous.selection.levelId ||
58
+ state.hideLevelsAboveSelection !== previous.hideLevelsAboveSelection ||
59
+ state.belowGrade !== previous.belowGrade ||
60
+ state.gradeStanding !== previous.gradeStanding
61
+ )
62
+ update()
63
+ })
64
+ const stopScene = useScene.subscribe((state, previous) => {
65
+ if (state.nodes !== previous.nodes) update()
66
+ })
67
+ return () => {
68
+ stopViewer()
69
+ stopScene()
70
+ useViewer.setState({ belowGrade: false, gradeStanding: 'at-grade' })
71
+ }
72
+ }
73
+
74
+ /** A host's level display marks a level below grade as shown while `active`. */
75
+ export function useBelowGrade(active: boolean) {
76
+ useEffect(() => (active ? followBelowGrade() : undefined), [active])
77
+ }
78
+
79
+ /**
80
+ * The box the ground window opens for: the shown level while the editor's level display
81
+ * hides what is above it, the whole building when it stands whole (Preview).
82
+ */
83
+ export function groundWindowBoxShown(
84
+ viewer: LevelDisplay,
85
+ nodes: Record<AnyNodeId, AnyNode>,
86
+ ): GroundBox | null {
87
+ const { levelId, buildingId } = viewer.selection
88
+ if (viewer.hideLevelsAboveSelection && levelId !== null) return levelGroundBox(nodes, levelId)
89
+ return buildingGroundBox(nodes, buildingId ?? null)
90
+ }
91
+
92
+ function sameBox(a: GroundBox | null, b: GroundBox | null): boolean {
93
+ if (a === null || b === null) return a === b
94
+ return (
95
+ a.min.every((value, axis) => value === b.min[axis]) &&
96
+ a.max.every((value, axis) => value === b.max[axis])
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Keeps the viewer's `groundWindowBox` on {@link groundWindowBoxShown} while the selection,
102
+ * the level display and the scene change. The returned function stops, and the window closes.
103
+ */
104
+ export function followGroundWindowBox(): () => void {
105
+ const update = () => {
106
+ const box = groundWindowBoxShown(useViewer.getState(), useScene.getState().nodes)
107
+ if (!sameBox(useViewer.getState().groundWindowBox, box))
108
+ useViewer.setState({ groundWindowBox: box })
109
+ }
110
+ update()
111
+ const stopViewer = useViewer.subscribe((state, previous) => {
112
+ if (
113
+ state.selection.levelId !== previous.selection.levelId ||
114
+ state.selection.buildingId !== previous.selection.buildingId ||
115
+ state.hideLevelsAboveSelection !== previous.hideLevelsAboveSelection ||
116
+ state.groundWindowBox !== previous.groundWindowBox
117
+ )
118
+ update()
119
+ })
120
+ const stopScene = useScene.subscribe((state, previous) => {
121
+ if (state.nodes !== previous.nodes) update()
122
+ })
123
+ return () => {
124
+ stopViewer()
125
+ stopScene()
126
+ useViewer.setState({ groundWindowBox: null })
127
+ }
128
+ }
129
+
130
+ /** The ground window follows the shown level or building while `active` (not on the site). */
131
+ export function useGroundWindowBox(active: boolean) {
132
+ useEffect(() => (active ? followGroundWindowBox() : undefined), [active])
133
+ }
@@ -0,0 +1,24 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { cameraPoseStore, publishCameraPose } from '../store/camera-pose-store'
3
+ import { cameraFacingYaw } from './camera-facing'
4
+
5
+ afterEach(() => cameraPoseStore.setState({ pose: null }))
6
+
7
+ function viewFrom(x: number, z: number) {
8
+ publishCameraPose({ position: [x, 8, z], target: [0, 0, 0], projection: 'perspective' })
9
+ }
10
+
11
+ test('a new item turns its front toward the camera, in quarter turns', () => {
12
+ viewFrom(0, 10)
13
+ expect(cameraFacingYaw()).toBe(0)
14
+ viewFrom(10, 2)
15
+ expect(cameraFacingYaw()).toBeCloseTo(Math.PI / 2)
16
+ viewFrom(-1, -10)
17
+ expect(cameraFacingYaw()).toBeCloseTo(Math.PI)
18
+ viewFrom(-10, -3)
19
+ expect(cameraFacingYaw()).toBeCloseTo((3 * Math.PI) / 2)
20
+ })
21
+
22
+ test('before any view is published the item keeps its authored front', () => {
23
+ expect(cameraFacingYaw()).toBe(0)
24
+ })
@@ -0,0 +1,24 @@
1
+ import { cameraPoseStore } from '../store/camera-pose-store'
2
+ import useEditor from '../store/use-editor'
3
+ import { getActiveBuildingPose } from './world-grid-snap'
4
+
5
+ const QUARTER_TURN = Math.PI / 2
6
+
7
+ /**
8
+ * The yaw, local to the active building and snapped to 90°, that turns a node's
9
+ * front (local +Z) toward the camera — the view a placement starts from. Falls
10
+ * back to the synced plan azimuth when the camera looks straight down, and to 0
11
+ * before any view has been published.
12
+ */
13
+ export function cameraFacingYaw(): number {
14
+ const pose = cameraPoseStore.getState().pose
15
+ let azimuth = useEditor.getState().navigationSyncPose?.azimuth ?? 0
16
+ if (pose) {
17
+ const dx = pose.position[0] - pose.target[0]
18
+ const dz = pose.position[2] - pose.target[2]
19
+ if (Math.hypot(dx, dz) > 1e-6) azimuth = Math.atan2(dx, dz)
20
+ }
21
+ const local = azimuth - (getActiveBuildingPose()?.rotationY ?? 0)
22
+ const quarter = (((Math.round(local / QUARTER_TURN) % 4) + 4) % 4) as 0 | 1 | 2 | 3
23
+ return quarter * QUARTER_TURN
24
+ }
@@ -107,6 +107,7 @@ export function resolveDirectManipulationNode(
107
107
  ): AnyNode {
108
108
  const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
109
109
  const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
110
+ if (parentFrame?.independent) return target
110
111
  const parent = parentFrame?.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
111
112
  return parent && canDirectRotateNode(parent) ? parent : target
112
113
  }
@@ -116,6 +117,7 @@ export function resolveMoveActionNode(
116
117
  nodes: Readonly<Record<string, AnyNode | undefined>>,
117
118
  ): AnyNode {
118
119
  const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
120
+ if (parentFrame?.independent) return node
119
121
  const parent = parentFrame?.resolveParent(node, nodes as Readonly<Record<string, AnyNode>>)
120
122
  return parent && (parent.type === node.type || canDirectRotateNode(parent)) ? parent : node
121
123
  }