@pascal-app/editor 1.0.3 → 1.0.4

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 (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,474 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNodeId,
4
+ type AssetInput,
5
+ acquireSceneHistoryPause,
6
+ beginSceneHistoryPauseSession,
7
+ clearSceneHistory,
8
+ emitter,
9
+ getSceneHistoryPauseDepth,
10
+ ItemNode,
11
+ initSpaceDetectionSync,
12
+ LevelNode,
13
+ pauseSceneHistory,
14
+ resumeSceneHistory,
15
+ type SceneCommit,
16
+ sceneRegistry,
17
+ spatialGridManager,
18
+ subscribeSceneCommits,
19
+ useLiveNodeOverrides,
20
+ useLiveTransforms,
21
+ useScene,
22
+ WallNode,
23
+ } from '@pascal-app/core'
24
+ import { useViewer } from '@pascal-app/viewer'
25
+ import { Html } from '@react-three/drei'
26
+ import { act, create } from '@react-three/test-renderer'
27
+ import { Children, Component, cloneElement, isValidElement, type ReactNode } from 'react'
28
+ import { Group } from 'three'
29
+ import useEditor from '../../../store/use-editor'
30
+ import useInteractionScope from '../../../store/use-interaction-scope'
31
+ import { useDraftNode } from './use-draft-node'
32
+ import { usePlacementCoordinator } from './use-placement-coordinator'
33
+
34
+ // The item move / placement history contract, driven through the real placement coordinator
35
+ // with core's space-detection sync attached. The carry pauses no history: the draft is kept out
36
+ // of history snapshots, so the drop is one ordinary tracked step, a write someone else makes
37
+ // mid-carry is its own step (in commit order, reconciled by space detection), and undoing that
38
+ // step mid-carry leaves the draft alone. Every exit path releases only its own pauses.
39
+
40
+ const level = LevelNode.parse({ id: 'level_placement_history', level: 0 })
41
+ const wallA = WallNode.parse({
42
+ id: 'wall_placement_history_a',
43
+ parentId: level.id,
44
+ start: [-6, -6],
45
+ end: [6, -6],
46
+ })
47
+ const wallB = WallNode.parse({
48
+ id: 'wall_placement_history_b',
49
+ parentId: level.id,
50
+ start: [6, -6],
51
+ end: [6, 6],
52
+ })
53
+ const wallC = WallNode.parse({
54
+ id: 'wall_placement_history_c',
55
+ parentId: level.id,
56
+ start: [6, 6],
57
+ end: [-6, 6],
58
+ })
59
+ const closingWall = WallNode.parse({
60
+ id: 'wall_placement_history_closing',
61
+ parentId: level.id,
62
+ start: [-6, 6],
63
+ end: [-6, -6],
64
+ })
65
+ const bathtub: AssetInput = {
66
+ id: 'bathtub',
67
+ name: 'Bathtub',
68
+ category: 'bathroom',
69
+ thumbnail: '',
70
+ src: '/bathtub.glb',
71
+ dimensions: [1.7, 0.6, 0.8],
72
+ }
73
+ const item = ItemNode.parse({
74
+ id: 'item_placement_history_bathtub',
75
+ parentId: level.id,
76
+ asset: bathtub,
77
+ position: [0, 0, 0],
78
+ rotation: [0, 0, 0],
79
+ })
80
+
81
+ let savedScene: ReturnType<typeof useScene.getState>
82
+ let savedEditor: ReturnType<typeof useEditor.getState>
83
+ let savedViewer: ReturnType<typeof useViewer.getState>
84
+ let savedScope: ReturnType<typeof useInteractionScope.getState>
85
+ let restoreGlobals: () => void
86
+ let stopSpaceDetection: () => void
87
+
88
+ beforeEach(() => {
89
+ savedScene = useScene.getState()
90
+ savedEditor = useEditor.getState()
91
+ savedViewer = useViewer.getState()
92
+ savedScope = useInteractionScope.getState()
93
+ const names = ['window', 'document', 'requestAnimationFrame', 'cancelAnimationFrame'] as const
94
+ const descriptors = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name))
95
+ restoreGlobals = () =>
96
+ names.forEach((name, i) => {
97
+ const descriptor = descriptors[i]
98
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor)
99
+ else Reflect.deleteProperty(globalThis, name)
100
+ })
101
+ globalThis.window = new EventTarget() as Window & typeof globalThis
102
+ globalThis.document = { body: { style: { cursor: '' } } } as Document
103
+ globalThis.requestAnimationFrame = () => 0
104
+ globalThis.cancelAnimationFrame = () => {}
105
+
106
+ useScene.setState({
107
+ nodes: {
108
+ [level.id]: { ...level, children: [wallA.id, wallB.id, wallC.id, item.id] },
109
+ [wallA.id]: structuredClone(wallA),
110
+ [wallB.id]: structuredClone(wallB),
111
+ [wallC.id]: structuredClone(wallC),
112
+ [item.id]: structuredClone(item),
113
+ },
114
+ rootNodeIds: [level.id],
115
+ collections: {},
116
+ materials: {},
117
+ dirtyNodes: new Set(),
118
+ readOnly: false,
119
+ } as never)
120
+ clearSceneHistory()
121
+ spatialGridManager.clear()
122
+ useLiveNodeOverrides.getState().clearAll()
123
+ useLiveTransforms.getState().clearAll()
124
+ useInteractionScope.getState().end()
125
+ useEditor.setState({
126
+ mode: 'build',
127
+ tool: 'item',
128
+ movingNodeOrigin: '3d',
129
+ placementDragMode: false,
130
+ })
131
+ useEditor.getState().setSnappingMode('item', 'grid')
132
+ useEditor.setState({ gridSnapStep: 0.1 })
133
+ useViewer.setState({
134
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
135
+ })
136
+ sceneRegistry.nodes.set(level.id, new Group())
137
+ stopSpaceDetection = initSpaceDetectionSync(useScene, {
138
+ getState: () => ({ spaces: {}, setSpaces: () => {} }),
139
+ })
140
+ })
141
+
142
+ afterEach(() => {
143
+ stopSpaceDetection()
144
+ sceneRegistry.nodes.clear()
145
+ spatialGridManager.clear()
146
+ useLiveNodeOverrides.getState().clearAll()
147
+ useLiveTransforms.getState().clearAll()
148
+ clearSceneHistory()
149
+ useScene.setState(savedScene)
150
+ useEditor.setState(savedEditor)
151
+ useViewer.setState(savedViewer)
152
+ useInteractionScope.setState(savedScope)
153
+ restoreGlobals()
154
+ })
155
+
156
+ // The DOM-only measurement labels need a canvas; the cursor refs they sit beside do not.
157
+ function withoutLabels(element: ReactNode): ReactNode {
158
+ if (!isValidElement<{ children?: ReactNode }>(element)) return element
159
+ if (element.type === Html) return null
160
+ return cloneElement(element, {}, Children.map(element.props.children, withoutLabels))
161
+ }
162
+
163
+ function Placement({
164
+ source,
165
+ repeat = false,
166
+ failSetup = false,
167
+ }: {
168
+ source?: ItemNode
169
+ repeat?: boolean
170
+ failSetup?: boolean
171
+ }) {
172
+ const draftNode = useDraftNode()
173
+ return withoutLabels(
174
+ usePlacementCoordinator({
175
+ asset: source?.asset ?? bathtub,
176
+ draftNode,
177
+ initDraft: (position) => {
178
+ if (failSetup) throw new Error('injected setup failure')
179
+ if (source) {
180
+ draftNode.adopt(source)
181
+ position.set(...draftNode.current!.position)
182
+ } else draftNode.create(position, bathtub)
183
+ },
184
+ onCommitted: () => repeat,
185
+ onCancel: () => draftNode.destroy(),
186
+ }),
187
+ )
188
+ }
189
+
190
+ class SetupBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
191
+ state = { failed: false }
192
+ static getDerivedStateFromError() {
193
+ return { failed: true }
194
+ }
195
+ render() {
196
+ return this.state.failed ? null : this.props.children
197
+ }
198
+ }
199
+
200
+ const history = () => {
201
+ const temporal = useScene.temporal.getState()
202
+ return {
203
+ past: temporal.pastStates.length,
204
+ tracking: temporal.isTracking,
205
+ depth: getSceneHistoryPauseDepth(),
206
+ }
207
+ }
208
+ const liveItem = () => useScene.getState().nodes[item.id as AnyNodeId] as ItemNode
209
+ const levelItems = () =>
210
+ Object.values(useScene.getState().nodes).filter(
211
+ (node) => node.type === 'item' && !node.metadata?.isTransient,
212
+ )
213
+
214
+ async function grid(kind: 'move' | 'click', x: number, z = 0) {
215
+ await act(async () => {
216
+ emitter.emit(`grid:${kind}`, {
217
+ position: [x, 0, z],
218
+ localPosition: [x, 0, z],
219
+ nativeEvent: {},
220
+ stopPropagation() {},
221
+ } as never)
222
+ await new Promise((resolve) => setTimeout(resolve, 5))
223
+ })
224
+ }
225
+
226
+ async function carry(from: number, to: number) {
227
+ for (let step = from; step <= to; step++) await grid('move', step * 0.1, 0.5)
228
+ }
229
+
230
+ /** Another refcounted owner's balanced pause/resume pair landing mid-carry. */
231
+ function foreignPausePair() {
232
+ pauseSceneHistory(useScene)
233
+ resumeSceneHistory(useScene)
234
+ }
235
+
236
+ /** Someone else closes the room mid-carry; the space-detection sync owes it a slab and ceiling. */
237
+ function closeRoom() {
238
+ useScene.getState().createNode(structuredClone(closingWall), level.id as AnyNodeId)
239
+ }
240
+ const autoRoomNodes = () =>
241
+ Object.values(useScene.getState().nodes).filter(
242
+ (node) => (node.type === 'slab' || node.type === 'ceiling') && 'autoFromWalls' in node,
243
+ )
244
+ const hasNode = (id: string) => Boolean(useScene.getState().nodes[id as AnyNodeId])
245
+
246
+ describe('item move history', () => {
247
+ test('a move adds one entry and one undo restores the item', async () => {
248
+ const before = history()
249
+ const renderer = await create(<Placement source={item} />)
250
+ try {
251
+ await carry(1, 6)
252
+ expect(history().past).toBe(before.past)
253
+ await grid('click', 0.6, 0.5)
254
+ } finally {
255
+ await renderer.unmount()
256
+ }
257
+ expect(liveItem().position.map((value) => Number(value.toFixed(6)))).toEqual([0.6, 0, 0.5])
258
+ expect(history()).toEqual({ past: before.past + 1, tracking: true, depth: 0 })
259
+ useScene.temporal.getState().undo()
260
+ expect(liveItem()).toEqual(item)
261
+ })
262
+
263
+ test('a foreign pause owner mid-carry leaves the drop one entry', async () => {
264
+ const before = history()
265
+ const renderer = await create(<Placement source={item} />)
266
+ try {
267
+ await carry(1, 3)
268
+ foreignPausePair()
269
+ await carry(4, 6)
270
+ expect(history().past).toBe(before.past)
271
+ await grid('click', 0.6, 0.5)
272
+ } finally {
273
+ await renderer.unmount()
274
+ }
275
+ expect(history()).toEqual({ past: before.past + 1, tracking: true, depth: 0 })
276
+ useScene.temporal.getState().undo()
277
+ expect(liveItem()).toEqual(item)
278
+ })
279
+
280
+ test('a room closed mid-carry is its own step, in commit order, before the drop', async () => {
281
+ const before = history()
282
+ const commits: SceneCommit[] = []
283
+ const stop = subscribeSceneCommits((commit) => commits.push(commit))
284
+ const renderer = await create(<Placement source={item} />)
285
+ try {
286
+ await carry(1, 3)
287
+ closeRoom()
288
+ expect(history().past).toBe(before.past + 1)
289
+ await carry(4, 6)
290
+ await grid('click', 0.6, 0.5)
291
+ } finally {
292
+ await renderer.unmount()
293
+ stop()
294
+ }
295
+ expect(history()).toEqual({ past: before.past + 2, tracking: true, depth: 0 })
296
+ expect(
297
+ autoRoomNodes()
298
+ .map((node) => node.type)
299
+ .sort(),
300
+ ).toEqual(['ceiling', 'slab'])
301
+ const local = commits.filter((commit) => commit.origin === 'local')
302
+ expect(
303
+ local.map(
304
+ (commit) =>
305
+ Boolean(commit.current.nodes[closingWall.id as AnyNodeId]) &&
306
+ !commit.before.nodes[closingWall.id as AnyNodeId],
307
+ ),
308
+ ).toEqual([true, false])
309
+ const dropped = local[1]!.current.nodes[item.id as AnyNodeId] as ItemNode
310
+ expect(dropped.position.map((value) => Number(value.toFixed(6)))).toEqual([0.6, 0, 0.5])
311
+ // The draft never reaches history: every recorded snapshot has the item as it was.
312
+ expect(local[0]!.current.nodes[item.id as AnyNodeId]).toEqual(item)
313
+
314
+ useScene.temporal.getState().undo()
315
+ expect(liveItem()).toEqual(item)
316
+ expect(hasNode(closingWall.id)).toBe(true)
317
+ expect(autoRoomNodes()).toHaveLength(2)
318
+
319
+ useScene.temporal.getState().undo()
320
+ expect(liveItem()).toEqual(item)
321
+ expect(hasNode(closingWall.id)).toBe(false)
322
+ expect(autoRoomNodes()).toHaveLength(0)
323
+ expect(history().past).toBe(before.past)
324
+ })
325
+
326
+ test('undoing the foreign step mid-carry leaves the draft where it is', async () => {
327
+ const before = history()
328
+ const renderer = await create(<Placement source={item} />)
329
+ try {
330
+ await carry(1, 3)
331
+ closeRoom()
332
+ const carried = liveItem()
333
+ useScene.temporal.getState().undo()
334
+ expect(hasNode(closingWall.id)).toBe(false)
335
+ expect(liveItem()).toEqual(carried)
336
+ await carry(4, 6)
337
+ await grid('click', 0.6, 0.5)
338
+ } finally {
339
+ await renderer.unmount()
340
+ }
341
+ expect(history()).toEqual({ past: before.past + 1, tracking: true, depth: 0 })
342
+ expect(liveItem().position.map((value) => Number(value.toFixed(6)))).toEqual([0.6, 0, 0.5])
343
+ useScene.temporal.getState().undo()
344
+ expect(liveItem()).toEqual(item)
345
+ })
346
+
347
+ test('a room closed during a cancelled carry is its own step and the item is unchanged', async () => {
348
+ const before = history()
349
+ const renderer = await create(<Placement source={item} />)
350
+ try {
351
+ await carry(1, 3)
352
+ closeRoom()
353
+ await act(async () => {
354
+ emitter.emit('tool:cancel')
355
+ })
356
+ } finally {
357
+ await renderer.unmount()
358
+ }
359
+ expect(liveItem()).toEqual(item)
360
+ expect(history()).toEqual({ past: before.past + 1, tracking: true, depth: 0 })
361
+ useScene.temporal.getState().undo()
362
+ expect(hasNode(closingWall.id)).toBe(false)
363
+ expect(liveItem()).toEqual(item)
364
+ })
365
+
366
+ test('cancel and unmount add no entry and restore the item', async () => {
367
+ for (const exit of ['cancel', 'unmount'] as const) {
368
+ const before = history()
369
+ const renderer = await create(<Placement source={item} />)
370
+ await carry(1, 4)
371
+ if (exit === 'cancel') await act(async () => emitter.emit('tool:cancel'))
372
+ await renderer.unmount()
373
+ expect(liveItem()).toEqual(item)
374
+ expect(history()).toEqual({ past: before.past, tracking: true, depth: 0 })
375
+ }
376
+ })
377
+
378
+ test('with history at its 50-entry limit, the wall step and the drop still undo in order', async () => {
379
+ for (let index = 0; index < 60; index++) {
380
+ useScene.getState().updateNode(wallA.id as AnyNodeId, { thickness: 0.1 + index / 1000 })
381
+ }
382
+ expect(history().past).toBe(50)
383
+ const renderer = await create(<Placement source={item} />)
384
+ try {
385
+ await carry(1, 3)
386
+ closeRoom()
387
+ await grid('click', 0.3, 0.5)
388
+ } finally {
389
+ await renderer.unmount()
390
+ }
391
+ expect(history().past).toBe(50)
392
+ useScene.temporal.getState().undo()
393
+ expect(liveItem()).toEqual(item)
394
+ expect(hasNode(closingWall.id)).toBe(true)
395
+ useScene.temporal.getState().undo()
396
+ expect(hasNode(closingWall.id)).toBe(false)
397
+ })
398
+
399
+ test('in split view the 2D overlay co-owns the gesture, and the 3D drop is one step', async () => {
400
+ useEditor.getState().setMovingNode(item)
401
+ const overlay = beginSceneHistoryPauseSession(useScene, { gesture: item.id })
402
+ const before = history()
403
+ const renderer = await create(<Placement source={item} />)
404
+ try {
405
+ await carry(1, 6)
406
+ await grid('click', 0.6, 0.5)
407
+ } finally {
408
+ await renderer.unmount()
409
+ overlay.end()
410
+ }
411
+ expect(history()).toEqual({ past: before.past + 1, tracking: true, depth: 0 })
412
+ useScene.temporal.getState().undo()
413
+ expect(liveItem()).toEqual(item)
414
+ })
415
+
416
+ test("never releases another owner's pause on commit or unmount", async () => {
417
+ const releaseOuter = acquireSceneHistoryPause(useScene)
418
+ const before = history()
419
+ const renderer = await create(<Placement source={item} />)
420
+ try {
421
+ await carry(1, 6)
422
+ await grid('click', 0.6, 0.5)
423
+ expect(history().tracking).toBe(false)
424
+ } finally {
425
+ await renderer.unmount()
426
+ }
427
+ expect(history()).toEqual({ past: before.past, tracking: false, depth: 1 })
428
+ releaseOuter()
429
+ expect(history()).toEqual({ past: before.past, tracking: true, depth: 0 })
430
+ })
431
+
432
+ test('repeat placement adds one entry per drop', async () => {
433
+ const before = history()
434
+ const itemsBefore = levelItems().length
435
+ const renderer = await create(<Placement repeat />)
436
+ try {
437
+ await carry(10, 14)
438
+ await grid('click', 1.4, 0.5)
439
+ expect(history().past).toBe(before.past + 1)
440
+ await carry(20, 24)
441
+ foreignPausePair()
442
+ await grid('click', 2.4, 0.5)
443
+ expect(history().past).toBe(before.past + 2)
444
+ await carry(30, 32)
445
+ } finally {
446
+ await renderer.unmount()
447
+ }
448
+ expect(history()).toEqual({ past: before.past + 2, tracking: true, depth: 0 })
449
+ expect(levelItems()).toHaveLength(itemsBefore + 2)
450
+ useScene.temporal.getState().undo()
451
+ expect(levelItems()).toHaveLength(itemsBefore + 1)
452
+ expect(liveItem()).toEqual(item)
453
+ })
454
+
455
+ test('a setup failure cannot leave history paused', async () => {
456
+ const before = history()
457
+ const reportError = globalThis.reportError
458
+ const consoleError = console.error
459
+ globalThis.reportError = () => {}
460
+ console.error = () => {}
461
+ try {
462
+ const renderer = await create(
463
+ <SetupBoundary>
464
+ <Placement failSetup source={item} />
465
+ </SetupBoundary>,
466
+ )
467
+ await renderer.unmount()
468
+ } finally {
469
+ globalThis.reportError = reportError
470
+ console.error = consoleError
471
+ }
472
+ expect(history()).toEqual({ past: before.past, tracking: true, depth: 0 })
473
+ })
474
+ })
@@ -20,6 +20,8 @@ import {
20
20
  createSceneApi,
21
21
  getRoofSegmentWallFace,
22
22
  getScaledDimensions,
23
+ getWallBodyCenterOffset,
24
+ getWallLocalFaceZ,
23
25
  nodeRegistry,
24
26
  resolveSurfacePlacement,
25
27
  roofFacePointToSegment,
@@ -168,7 +170,9 @@ function resolveWallPlacementPose(
168
170
  cursorRotationY: number
169
171
  } {
170
172
  const localZ =
171
- attachTo === 'wall-side' ? ((event.node.thickness ?? 0.1) / 2) * (side === 'front' ? 1 : -1) : 0
173
+ attachTo === 'wall-side'
174
+ ? getWallLocalFaceZ(event.node, side === 'front' ? 'a' : 'b')
175
+ : getWallBodyCenterOffset(event.node)
172
176
  event.object.updateWorldMatrix(true, false)
173
177
  const world = event.object.localToWorld(new Vector3(localX, localY, localZ))
174
178
  const wallYaw = -Math.atan2(
@@ -176,7 +180,7 @@ function resolveWallPlacementPose(
176
180
  event.node.end[0] - event.node.start[0],
177
181
  )
178
182
  return {
179
- position: [localX, localY, localZ],
183
+ position: [localX, localY, attachTo === 'wall' ? 0 : localZ],
180
184
  cursorPosition: [world.x, world.y, world.z],
181
185
  // Same composition the 2D floorplan resolves a wall child with
182
186
  // (`resolveItemTransform`): the cursor frame IS the item frame.
@@ -428,7 +432,7 @@ function resolveRoofWallTarget(
428
432
  const u = snapToHalf(hit.u)
429
433
  const centerV = snapToHalf(hit.v) + height / 2
430
434
  const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
431
- if (!fitted && !freePlace) return null
435
+ if (!(fitted || freePlace)) return null
432
436
  const finalU = fitted?.u ?? u
433
437
  const finalV = fitted?.v ?? centerV
434
438
 
@@ -559,7 +563,7 @@ export const roofWallStrategy = {
559
563
  if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
560
564
  // Alt mirrors the wall flow's stubbed validators: skip profile-fit
561
565
  // and overlap checks entirely.
562
- if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
566
+ if (!(freePlace || canPlaceOnRoofWall(ctx))) return null
563
567
 
564
568
  return {
565
569
  nodeUpdate: {