@pascal-app/editor 1.0.3 → 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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ applySceneOperationPatch,
6
+ BuildingNode,
7
+ clearSceneHistory,
8
+ LevelNode,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import { createSessionWrites } from './session-writes'
13
+
14
+ // A live session (a floor-plan drag, a height scrub) takes back only what it
15
+ // wrote itself: an edit a collaborator lands on the same node mid-drag survives
16
+ // the cancel and the commit.
17
+
18
+ globalThis.requestAnimationFrame ??= (callback) => {
19
+ callback(0)
20
+ return 0
21
+ }
22
+ globalThis.cancelAnimationFrame ??= () => {}
23
+
24
+ const level = LevelNode.parse({ id: 'level_writes', parentId: 'building_writes' })
25
+ const building = BuildingNode.parse({ id: 'building_writes', children: [level.id] })
26
+ const wall = WallNode.parse({ id: 'wall_writes', parentId: level.id, start: [0, 0], end: [4, 0] })
27
+ const nodes = () => useScene.getState().nodes as Record<string, AnyNode>
28
+ const data = () => JSON.parse(JSON.stringify(nodes()))
29
+ const id = wall.id as AnyNodeId
30
+
31
+ beforeEach(() => {
32
+ useScene.setState({
33
+ nodes: {
34
+ [building.id]: building,
35
+ [level.id]: { ...level, children: [wall.id] },
36
+ [wall.id]: wall,
37
+ },
38
+ rootNodeIds: [building.id],
39
+ dirtyNodes: new Set(),
40
+ collections: {},
41
+ materials: {},
42
+ readOnly: false,
43
+ })
44
+ clearSceneHistory()
45
+ })
46
+
47
+ /** A collaborator's change, applied outside the session like a remote operation. */
48
+ const remote = (patch: Partial<WallNode>) =>
49
+ useScene.setState({ nodes: { ...nodes(), [id]: { ...nodes()[id]!, ...patch } as AnyNode } })
50
+
51
+ describe('session writes', () => {
52
+ test('cancel takes back the session’s fields and keeps a collaborator’s edit', () => {
53
+ const writes = createSessionWrites()
54
+ writes.record(() => useScene.getState().updateNodes([{ id, data: { end: [6, 0] } }]))
55
+ remote({ name: 'Kitchen wall' })
56
+ writes.record(() => useScene.getState().updateNodes([{ id, data: { end: [7, 0] } }]))
57
+ writes.revert()
58
+ expect(nodes()[id]).toMatchObject({ end: [4, 0], name: 'Kitchen wall' })
59
+ })
60
+
61
+ test('commit re-applies only the session’s fields', () => {
62
+ const writes = createSessionWrites()
63
+ writes.record(() => useScene.getState().updateNodes([{ id, data: { end: [6, 0] } }]))
64
+ remote({ name: 'Kitchen wall' })
65
+ const changes = writes.changes()
66
+ expect(changes.update).toEqual([{ id, data: { end: [6, 0] } }])
67
+ writes.revert()
68
+ useScene.getState().applyNodeChanges(changes)
69
+ expect(nodes()[id]).toMatchObject({ end: [6, 0], name: 'Kitchen wall' })
70
+ })
71
+
72
+ test('a session that previews through overrides writes nothing back', () => {
73
+ const writes = createSessionWrites()
74
+ writes.record(() => {})
75
+ remote({ name: 'Kitchen wall' })
76
+ const before = data()
77
+ writes.revert()
78
+ expect(data()).toEqual(before)
79
+ expect(writes.changes()).toEqual({ create: [], update: [], delete: [] })
80
+ })
81
+
82
+ test('nodes the session created go, nodes it deleted come back, outside history', () => {
83
+ const writes = createSessionWrites()
84
+ const extra = WallNode.parse({
85
+ id: 'wall_extra',
86
+ parentId: level.id,
87
+ start: [4, 0],
88
+ end: [4, 4],
89
+ })
90
+ const before = data()
91
+ writes.record(() => {
92
+ useScene.getState().createNode(extra, level.id as AnyNodeId)
93
+ useScene.getState().deleteNode(id)
94
+ })
95
+ const past = useScene.temporal.getState().pastStates.length
96
+ writes.revert()
97
+ expect(data()).toEqual(before)
98
+ expect(useScene.temporal.getState().pastStates).toHaveLength(past)
99
+ })
100
+
101
+ test('a watched node: another local writer’s draft goes back, a collaborator’s change stays', () => {
102
+ const writes = createSessionWrites()
103
+ writes.watch([id])
104
+ // The 3D tool mounted beside the plan drafts the same node…
105
+ useScene.getState().updateNodes([{ id, data: { metadata: { isTransient: true } } }])
106
+ // …while a collaborator's rename arrives from the host.
107
+ applySceneOperationPatch({
108
+ materialChanges: [],
109
+ nodeUpdates: [{ id, data: { name: 'Kitchen wall' }, removeFields: [] }],
110
+ nodeCreates: [],
111
+ nodeDeletes: [],
112
+ })
113
+ writes.revert()
114
+ expect(nodes()[id]).toMatchObject({ metadata: {}, name: 'Kitchen wall' })
115
+ // The revert ended the watch: later writes are no longer the session's.
116
+ useScene.getState().updateNodes([{ id, data: { end: [5, 0] } }])
117
+ writes.revert()
118
+ expect(nodes()[id]).toMatchObject({ end: [5, 0] })
119
+ })
120
+ })
@@ -0,0 +1,205 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ isApplyingRemoteSceneChange,
5
+ pauseSceneHistory,
6
+ resumeSceneHistory,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+
10
+ type NodeMap = Record<string, AnyNode>
11
+
12
+ /**
13
+ * What a live session wrote to the scene: for each node it touched, the value
14
+ * before its first write and the value it last wrote (`undefined` for
15
+ * "absent"). Only writes made inside `record` count, so a collaborator's change
16
+ * applied between two pointer moves is never mistaken for the session's.
17
+ */
18
+ export type SessionWrites = {
19
+ /** Runs one step of the session and records what it wrote. */
20
+ record: <T>(run: () => T) => T
21
+ /**
22
+ * Also records every local write to `ids` until the session ends, whoever
23
+ * makes it (the 3D tool mounted beside a plan session in split view drafts
24
+ * the same node) — never a collaborator's change applied from the host.
25
+ */
26
+ watch: (ids: readonly string[]) => void
27
+ /** Stops watching (the revert stops too). */
28
+ stop: () => void
29
+ /**
30
+ * Takes back what the session wrote, outside history. A node changed again
31
+ * since by someone else keeps that change: only the session's own fields go
32
+ * back. A session that never wrote the scene touches nothing.
33
+ */
34
+ revert: () => void
35
+ /** The session's writes as one change batch from the originals (the commit a legacy session makes). */
36
+ changes: () => {
37
+ create: { node: AnyNode; parentId?: AnyNodeId }[]
38
+ update: { id: AnyNodeId; data: Partial<AnyNode> }[]
39
+ delete: AnyNodeId[]
40
+ }
41
+ readonly size: number
42
+ }
43
+
44
+ /** The value a field had before the session first wrote it, and the value it last wrote. */
45
+ type FieldWrite = { had: boolean; from: unknown; to: unknown }
46
+
47
+ type Entry = {
48
+ original: AnyNode | undefined
49
+ wrote: AnyNode | undefined
50
+ /** Fields the session changed on a node that existed before and after its write. */
51
+ fields: Map<string, FieldWrite>
52
+ }
53
+
54
+ export function createSessionWrites(): SessionWrites {
55
+ const writes = new Map<string, Entry>()
56
+ let unwatch: (() => void) | null = null
57
+
58
+ const note = (before: NodeMap, after: NodeMap, only?: ReadonlySet<string>) => {
59
+ if (before === after) return
60
+ const ids = only ?? new Set([...Object.keys(before), ...Object.keys(after)])
61
+ for (const id of ids) {
62
+ const prev = before[id]
63
+ const next = after[id]
64
+ if (prev === next) continue
65
+ let entry = writes.get(id)
66
+ if (!entry) {
67
+ entry = { original: prev, wrote: next, fields: new Map() }
68
+ writes.set(id, entry)
69
+ } else entry.wrote = next
70
+ if (!(prev && next)) continue
71
+ const from = prev as Record<string, unknown>
72
+ const to = next as Record<string, unknown>
73
+ for (const key of new Set([...Object.keys(from), ...Object.keys(to)])) {
74
+ if (Object.is(from[key], to[key])) continue
75
+ const field = entry.fields.get(key)
76
+ if (field) field.to = to[key]
77
+ else entry.fields.set(key, { had: Object.hasOwn(from, key), from: from[key], to: to[key] })
78
+ }
79
+ }
80
+ }
81
+
82
+ const created = (id: string) => {
83
+ const entry = writes.get(id)
84
+ return !!entry && !entry.original && !!entry.wrote
85
+ }
86
+ const deleted = (id: string) => {
87
+ const entry = writes.get(id)
88
+ return !!entry && !!entry.original && !entry.wrote
89
+ }
90
+
91
+ // Field by field: a field the session wrote goes back unless someone else
92
+ // has written it since; every other field keeps its current value. A child
93
+ // list drops the session's creations and gets back what it deleted.
94
+ const revertFields = (current: AnyNode, entry: Entry): AnyNode | null => {
95
+ const merged: Record<string, unknown> = { ...current }
96
+ const cur = current as Record<string, unknown>
97
+ let changed = false
98
+ for (const [key, { had, from, to }] of entry.fields) {
99
+ if (key === 'children' && Array.isArray(cur[key]) && Array.isArray(from)) {
100
+ const children = (cur[key] as string[]).filter((id) => !created(id))
101
+ for (const id of from as string[])
102
+ if (deleted(id) && !children.includes(id)) children.push(id)
103
+ if (children.join('|') !== (cur[key] as string[]).join('|')) {
104
+ merged[key] = children
105
+ changed = true
106
+ }
107
+ continue
108
+ }
109
+ if (!Object.is(cur[key], to)) continue
110
+ if (had) merged[key] = from
111
+ else delete merged[key]
112
+ changed = true
113
+ }
114
+ if (!changed) return null
115
+ // Nothing else moved: the node the session found, as it was (same object).
116
+ const original = entry.original as Record<string, unknown> | undefined
117
+ if (
118
+ original &&
119
+ Object.keys(merged).length === Object.keys(original).length &&
120
+ Object.keys(merged).every((key) => Object.is(merged[key], original[key]))
121
+ )
122
+ return entry.original!
123
+ return merged as AnyNode
124
+ }
125
+
126
+ const stop = () => {
127
+ unwatch?.()
128
+ unwatch = null
129
+ }
130
+
131
+ return {
132
+ watch(ids) {
133
+ stop()
134
+ const scope = new Set(ids)
135
+ unwatch = useScene.subscribe((state, previous) => {
136
+ if (isApplyingRemoteSceneChange()) return
137
+ note(previous.nodes as NodeMap, state.nodes as NodeMap, scope)
138
+ })
139
+ },
140
+ stop,
141
+ record(run) {
142
+ const before = useScene.getState().nodes as NodeMap
143
+ try {
144
+ return run()
145
+ } finally {
146
+ note(before, useScene.getState().nodes as NodeMap)
147
+ }
148
+ },
149
+ revert() {
150
+ stop()
151
+ if (writes.size === 0) return
152
+ const nodes = useScene.getState().nodes as NodeMap
153
+ const next: NodeMap = { ...nodes }
154
+ const touched: AnyNode[] = []
155
+ for (const [id, entry] of writes) {
156
+ const current = nodes[id]
157
+ if (!entry.original) {
158
+ if (!current) continue
159
+ delete next[id]
160
+ } else if (!current) {
161
+ next[id] = entry.original
162
+ } else {
163
+ const reverted = revertFields(current, entry)
164
+ if (!reverted) continue
165
+ next[id] = reverted
166
+ }
167
+ touched.push(entry.original ?? current!)
168
+ }
169
+ writes.clear()
170
+ if (touched.length === 0) return
171
+ pauseSceneHistory(useScene)
172
+ try {
173
+ useScene.setState({ nodes: next as ReturnType<typeof useScene.getState>['nodes'] })
174
+ } finally {
175
+ resumeSceneHistory(useScene)
176
+ }
177
+ const { markDirty } = useScene.getState()
178
+ for (const node of touched) {
179
+ if (next[node.id]) markDirty(node.id as AnyNodeId)
180
+ if (node.parentId && next[node.parentId]) markDirty(node.parentId as AnyNodeId)
181
+ }
182
+ },
183
+ changes() {
184
+ const batch: ReturnType<SessionWrites['changes']> = { create: [], update: [], delete: [] }
185
+ for (const [id, { original, wrote, fields }] of writes) {
186
+ if (!original && wrote) {
187
+ batch.create.push({ node: wrote, parentId: (wrote.parentId ?? undefined) as AnyNodeId })
188
+ } else if (original && !wrote) {
189
+ batch.delete.push(id as AnyNodeId)
190
+ } else if (original && wrote) {
191
+ const data: Record<string, unknown> = {}
192
+ // Child lists follow the creates and deletes.
193
+ for (const [key, { from, to }] of fields)
194
+ if (key !== 'children' && JSON.stringify(from) !== JSON.stringify(to)) data[key] = to
195
+ if (Object.keys(data).length)
196
+ batch.update.push({ id: id as AnyNodeId, data: data as Partial<AnyNode> })
197
+ }
198
+ }
199
+ return batch
200
+ },
201
+ get size() {
202
+ return writes.size
203
+ },
204
+ }
205
+ }
@@ -28,6 +28,9 @@ type SFXEvents = {
28
28
  'sfx:menu-click': undefined
29
29
  'sfx:paint-apply': undefined
30
30
  'sfx:success': undefined
31
+ 'sfx:turn-done': undefined
32
+ 'sfx:turn-failed': undefined
33
+ 'sfx:turn-needs-input': undefined
31
34
  'sfx:terrain-sculpt-start': TerrainVerb
32
35
  'sfx:terrain-sculpt-stop': undefined
33
36
  }
@@ -58,6 +61,9 @@ const handleMenuHover = () => playSFX('menuHover')
58
61
  const handleMenuClick = () => playSFX('menuClick')
59
62
  const handlePaintApply = () => playSFX('paintApply')
60
63
  const handleSuccess = () => playSFX('success')
64
+ const handleTurnDone = () => playSFX('turnDone')
65
+ const handleTurnFailed = () => playSFX('turnFailed')
66
+ const handleTurnNeedsInput = () => playSFX('turnNeedsInput')
61
67
  const TERRAIN_LOOP_BY_VERB = {
62
68
  raise: 'terrainRaise',
63
69
  lower: 'terrainLower',
@@ -89,9 +95,13 @@ export function initSFXBus() {
89
95
  sfxEmitter.on('sfx:menu-click', handleMenuClick)
90
96
  sfxEmitter.on('sfx:paint-apply', handlePaintApply)
91
97
  sfxEmitter.on('sfx:success', handleSuccess)
98
+ sfxEmitter.on('sfx:turn-done', handleTurnDone)
99
+ sfxEmitter.on('sfx:turn-failed', handleTurnFailed)
100
+ sfxEmitter.on('sfx:turn-needs-input', handleTurnNeedsInput)
92
101
  sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
93
102
  sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
94
103
  unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
104
+ updateSFXVolumes()
95
105
  }
96
106
 
97
107
  export function disposeSFXBus() {
@@ -110,6 +120,9 @@ export function disposeSFXBus() {
110
120
  sfxEmitter.off('sfx:menu-click', handleMenuClick)
111
121
  sfxEmitter.off('sfx:paint-apply', handlePaintApply)
112
122
  sfxEmitter.off('sfx:success', handleSuccess)
123
+ sfxEmitter.off('sfx:turn-done', handleTurnDone)
124
+ sfxEmitter.off('sfx:turn-failed', handleTurnFailed)
125
+ sfxEmitter.off('sfx:turn-needs-input', handleTurnNeedsInput)
113
126
  sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
114
127
  sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
115
128
  unsubscribeAudio?.()
@@ -1,10 +1,10 @@
1
1
  import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import useAudio from '../store/use-audio'
2
3
 
3
4
  type FakeContext = { id: string }
4
5
 
5
6
  let activeContext: FakeContext = { id: 'first' }
6
7
  let initialState: 'loaded' | 'loading' = 'loaded'
7
- let muted = false
8
8
  let throwOnPlay = false
9
9
  const instances: FakeHowl[] = []
10
10
 
@@ -92,12 +92,6 @@ const fakeHowler = {
92
92
  }
93
93
 
94
94
  mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
95
- mock.module('../store/use-audio', () => ({
96
- default: {
97
- getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }),
98
- subscribe: () => () => {},
99
- },
100
- }))
101
95
 
102
96
  const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } =
103
97
  await import('./sfx-player')
@@ -107,7 +101,7 @@ beforeEach(() => {
107
101
  disposeSFXBus()
108
102
  activeContext = { id: 'first' }
109
103
  initialState = 'loaded'
110
- muted = false
104
+ useAudio.setState({ masterVolume: 100, muted: false, sfxVolume: 100 })
111
105
  throwOnPlay = false
112
106
  instances.length = 0
113
107
  })
@@ -118,6 +112,51 @@ afterAll(() => {
118
112
  })
119
113
 
120
114
  describe('SFX audio context lifecycle', () => {
115
+ test('editor startup warms assets without playing and repeated init does not duplicate delivery', () => {
116
+ initSFXBus()
117
+ const warmed = [...instances]
118
+ expect(warmed.length).toBeGreaterThan(0)
119
+ expect(warmed.every((sound) => sound.playCount === 0)).toBe(true)
120
+ initSFXBus()
121
+ expect(instances).toHaveLength(warmed.length)
122
+ triggerSFX('sfx:item-place')
123
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
124
+ })
125
+
126
+ test('a one-shot requested before loading is dropped, not replayed on load', () => {
127
+ initialState = 'loading'
128
+ initSFXBus()
129
+ triggerSFX('sfx:item-place')
130
+ for (const sound of instances) sound.finishLoading()
131
+ expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
132
+ // A different fresh cue avoids relying on the per-name repeat clock.
133
+ triggerSFX('sfx:structure-build')
134
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
135
+ })
136
+
137
+ test('muted startup neither starts audio nor consumes a later fresh cue', () => {
138
+ useAudio.setState({ muted: true })
139
+ initSFXBus()
140
+ triggerSFX('sfx:item-place')
141
+ expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
142
+ useAudio.setState({ muted: false })
143
+ updateSFXVolumes()
144
+ triggerSFX('sfx:item-place')
145
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
146
+ })
147
+
148
+ test('cleanup detaches delivery and unloads resources once, then remount plays once', () => {
149
+ initSFXBus()
150
+ const warmed = [...instances]
151
+ disposeSFXBus()
152
+ disposeSFXBus()
153
+ triggerSFX('sfx:item-place')
154
+ expect(warmed.every((sound) => sound.playCount === 0 && sound.unloadCount === 1)).toBe(true)
155
+ initSFXBus()
156
+ triggerSFX('sfx:item-place')
157
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
158
+ })
159
+
121
160
  test('reuses the preloaded cache while the Howler context is unchanged', () => {
122
161
  preloadSFX()
123
162
  const initialCount = instances.length
@@ -234,7 +273,7 @@ describe('SFX audio context lifecycle', () => {
234
273
  startLoopSFX('terrainLower')
235
274
  const lower = instances.find((sound) => sound.loop && sound.playCount === 1)
236
275
 
237
- muted = true
276
+ useAudio.setState({ muted: true })
238
277
  updateSFXVolumes()
239
278
 
240
279
  expect(lower?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1])
@@ -263,4 +302,26 @@ describe('SFX audio context lifecycle', () => {
263
302
 
264
303
  expect(lower?.playCount).toBe(0)
265
304
  })
305
+
306
+ test('turn cues play at the master and effects volume, and not in a muted tab', () => {
307
+ const cues = ['sfx:turn-done', 'sfx:turn-failed', 'sfx:turn-needs-input'] as const
308
+ useAudio.setState({ masterVolume: 50, muted: true, sfxVolume: 40 })
309
+ initSFXBus()
310
+
311
+ for (const cue of cues) triggerSFX(cue)
312
+ expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
313
+
314
+ useAudio.setState({ muted: false })
315
+ for (const cue of cues) triggerSFX(cue)
316
+
317
+ const played = instances.filter((sound) => sound.playCount === 1)
318
+ expect(played.map((sound) => sound.src)).toEqual([
319
+ '/audios/sfx/turn_done.mp3',
320
+ '/audios/sfx/turn_failed.mp3',
321
+ '/audios/sfx/turn_needs_input.mp3',
322
+ ])
323
+ for (const sound of played) {
324
+ expect(sound.volumeCalls.find(([, id]) => id === 1)?.[0]).toBeCloseTo(0.2)
325
+ }
326
+ })
266
327
  })
@@ -120,6 +120,12 @@ export const SFX: Record<string, SFXConfig> = {
120
120
  src: '/audios/sfx/success.mp3',
121
121
  minIntervalMs: 3_000,
122
122
  },
123
+ // An agent's turn, heard from another tab or another app: it ended with its work done, it failed,
124
+ // or it waits on the person. Two-note chimes (`scripts/synth-turn-cues.ts`), so no jitter, as for
125
+ // `success`. The host plays each once per turn; no gap here could tell two turns from one.
126
+ turnDone: { src: '/audios/sfx/turn_done.mp3' },
127
+ turnFailed: { src: '/audios/sfx/turn_failed.mp3' },
128
+ turnNeedsInput: { src: '/audios/sfx/turn_needs_input.mp3' },
123
129
  } as const
124
130
 
125
131
  export type SFXName = keyof typeof SFX
@@ -0,0 +1,62 @@
1
+ import { useEffect } from 'react'
2
+ import { useSidebarStore } from '../components/ui/primitives/sidebar'
3
+ import type { SidebarTab } from '../components/ui/sidebar/tab-bar'
4
+ import useEditor from '../store/use-editor'
5
+
6
+ /** The narrowest a reopened sidebar panel may be (the layout's resize floor). */
7
+ export const SIDEBAR_MIN_WIDTH = 300
8
+
9
+ // The desktop layout's rail tabs, published while it is mounted. Hosts pick
10
+ // their own tabs, so a shortcut names the panels it would like, in order, and
11
+ // gets the first one the host has.
12
+ let sidebarTabIds: readonly string[] = []
13
+
14
+ export function setSidebarTabIds(ids: readonly string[]): void {
15
+ sidebarTabIds = ids
16
+ }
17
+
18
+ /**
19
+ * Pick panel `id` and expand a collapsed sidebar onto it, at a usable width.
20
+ * The pick goes first: expanding first would reopen the panel left open for a
21
+ * moment, and a host may arm that panel's tool as it opens (Paint).
22
+ */
23
+ export function showSidebarPanel(id: string): void {
24
+ useEditor.getState().setActiveSidebarPanel(id)
25
+ const sidebar = useSidebarStore.getState()
26
+ if (!sidebar.isCollapsed) return
27
+ sidebar.setIsCollapsed(false)
28
+ if (sidebar.width < SIDEBAR_MIN_WIDTH) sidebar.setWidth(SIDEBAR_MIN_WIDTH)
29
+ }
30
+
31
+ /**
32
+ * Show the first of `preferred` the host provides (`showSidebarPanel`).
33
+ * Returns the panel shown, or null when the host has none of them (e.g. on
34
+ * mobile).
35
+ */
36
+ export function openSidebarPanel(preferred: readonly string[]): string | null {
37
+ const id = preferred.find((candidate) => sidebarTabIds.includes(candidate))
38
+ if (!id) return null
39
+ showSidebarPanel(id)
40
+ return id
41
+ }
42
+
43
+ /**
44
+ * The rail tab on screen: the panel picked in this workspace when the rail has
45
+ * it, else the first tab with a panel. `activeSidebarPanel` follows what shows,
46
+ * so every reader agrees with the screen, while the pick (`sidebarPanels`)
47
+ * stays: a plugin's panel joins the rail only once the scene's plugins load,
48
+ * so a panel restored on reload is missing for a moment and comes back then.
49
+ */
50
+ export function useShownSidebarPanel(tabs: readonly SidebarTab[]): string {
51
+ const activePanel = useEditor((s) => s.activeSidebarPanel)
52
+ const pickedPanel = useEditor((s) => s.sidebarPanels[s.workspaceMode] ?? s.activeSidebarPanel)
53
+ const shownPanel = tabs.some((tab) => tab.id === pickedPanel)
54
+ ? pickedPanel
55
+ : ((tabs.find((tab) => !tab.noPanel) ?? tabs[0])?.id ?? pickedPanel)
56
+
57
+ useEffect(() => {
58
+ if (shownPanel !== activePanel) useEditor.setState({ activeSidebarPanel: shownPanel })
59
+ }, [shownPanel, activePanel])
60
+
61
+ return shownPanel
62
+ }
@@ -9,7 +9,7 @@ import { siteBoundaryHandlesEnabled } from './site-boundary'
9
9
  */
10
10
 
11
11
  const MODES: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt']
12
- const PHASES: Phase[] = ['site', 'structure', 'furnish']
12
+ const PHASES: Phase[] = ['site', 'building']
13
13
 
14
14
  describe('siteBoundaryHandlesEnabled', () => {
15
15
  test('sculpt hides the handles in every phase', () => {
@@ -29,15 +29,13 @@ describe('siteBoundaryHandlesEnabled', () => {
29
29
 
30
30
  test('select mode offers them outside the site phase', () => {
31
31
  // The flags double as the affordance that *enters* site editing, so select mode
32
- // has to show them from structure/furnish or the lot becomes unreachable.
33
- expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'structure' })).toBe(true)
34
- expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'furnish' })).toBe(true)
32
+ // has to show them from the building or the lot becomes unreachable.
33
+ expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'building' })).toBe(true)
35
34
  })
36
35
 
37
36
  test('a drafting or painting mode outside the site phase hides them', () => {
38
37
  for (const mode of ['edit', 'delete', 'build', 'material-paint'] as Mode[]) {
39
- expect(siteBoundaryHandlesEnabled({ mode, phase: 'structure' })).toBe(false)
40
- expect(siteBoundaryHandlesEnabled({ mode, phase: 'furnish' })).toBe(false)
38
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'building' })).toBe(false)
41
39
  }
42
40
  })
43
41
 
@@ -11,7 +11,7 @@ export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
11
11
  * clause was added to only one of them.
12
12
  *
13
13
  * The disagreement was reachable in exactly one configuration, which is why it was
14
- * easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and*
14
+ * easy to miss: arming sculpt from the 2D view opens 3D beside the plan *and*
15
15
  * the phase to `site`, so the floorplan's `phase === 'site'` term went true at the
16
16
  * same moment the 3D editor's `!sculpting` term went false — handles gone in one
17
17
  * pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`,
@@ -130,6 +130,10 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
130
130
  tool: string | null = null,
131
131
  ) => snapContextOf({ scope, mode, tool, profileOf, profileOfNode })
132
132
 
133
+ it('dividing a room draws like a wall draft (wall, with angles)', () => {
134
+ expect(ctx({ kind: 'room-divide' })).toBe('wall')
135
+ })
136
+
133
137
  it('translating a whole structural node has no angle (polygon, not wall)', () => {
134
138
  expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
135
139
  expect(ctx({ kind: 'moving', nodeType: 'slab' })).toBe('polygon')
@@ -161,8 +161,12 @@ export function snapContextOf(args: {
161
161
  // like `profileOf` so `snapping-mode` need not import the registry; defaults
162
162
  // to `true` (the structural draw default) when not supplied.
163
163
  draftDirectionalOf?: (typeOrTool: string) => boolean
164
+ // The paint tool's region sub-mode draws lines and boxes on a surface: the
165
+ // no-angle 'polygon' set (grid / lines / off), idle or mid-gesture.
166
+ paintRegion?: boolean
164
167
  }): SnapContext | null {
165
- const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args
168
+ const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf, paintRegion } = args
169
+ if (paintRegion && mode === 'material-paint') return 'polygon'
166
170
  // The group-move gizmo translates the whole selection — same no-angle
167
171
  // treatment as a single-node move, so Shift cycles the 'item' modes and the
168
172
  // HUD shows the item snapping chips for the drag.
@@ -170,6 +174,9 @@ export function snapContextOf(args: {
170
174
  return 'item'
171
175
  }
172
176
  switch (scope.kind) {
177
+ // Dividing a room draws a segment like a wall draft: same modes, same keys.
178
+ case 'room-divide':
179
+ return 'wall'
173
180
  case 'mesh-editing':
174
181
  return scope.nodeId
175
182
  ? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
@@ -37,6 +37,31 @@ describe('stair level helpers', () => {
37
37
  expect(plan?.createdLevel?.parentId).toBe(building.id)
38
38
  })
39
39
 
40
+ // Every generated one-storey house has a Roof level: the stair tool sent its flight up into it.
41
+ test('climbs to a new storey under the roof level, which moves up over it', () => {
42
+ const ground = LevelNode.parse({ level: 0, children: [] })
43
+ const roofLevel = LevelNode.parse({ level: 1, children: [], metadata: { role: 'roof' } })
44
+ const building = BuildingNode.parse({ children: [ground.id, roofLevel.id] })
45
+ const nodes = {
46
+ [building.id]: building,
47
+ [ground.id]: { ...ground, parentId: building.id },
48
+ [roofLevel.id]: { ...roofLevel, parentId: building.id },
49
+ } as Record<AnyNodeId, AnyNode>
50
+
51
+ const plan = resolveStairDestinationLevel({
52
+ createMissing: true,
53
+ fromLevelId: ground.id,
54
+ nodes,
55
+ })
56
+
57
+ expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
58
+ expect(plan?.createdLevel?.level).toBe(1)
59
+ expect(plan?.liftedLevels).toEqual([{ id: roofLevel.id, data: { level: 2 } }])
60
+ expect(resolveStairDestinationLevel({ fromLevelId: ground.id, nodes })?.toLevel.id).toBe(
61
+ ground.id,
62
+ )
63
+ })
64
+
40
65
  test('uses the nearest higher sibling level instead of creating one', () => {
41
66
  const building = BuildingNode.parse({})
42
67
  const ground = LevelNode.parse({ level: 0, parentId: building.id })