@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ComponentType, Suspense, useMemo } from 'react'
6
+ import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
7
+
8
+ /**
9
+ * Editor-mounted dispatcher for a kind's selection-time editing UI.
10
+ *
11
+ * Some kinds expose drag-to-edit affordances that should appear only
12
+ * while a single node of that kind is selected — duct / pipe / lineset
13
+ * path-point handles, fitting Alt-axis-cycling listeners. These read
14
+ * `useEditor` (grid snap step, rotation axis) and render the editor's
15
+ * `DimensionPill`, so they must NOT ride in `def.system` (which the
16
+ * viewer package mounts for the read-only route). The kind declares the
17
+ * component under `def.affordanceTools.selection` and this manager —
18
+ * mounted inside the editor only — loads it for the selected kind.
19
+ */
20
+ export function SelectionAffordanceManager() {
21
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
22
+ const selectedKind = useScene((s) => {
23
+ if (selectedIds.length !== 1) return null
24
+ return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null
25
+ })
26
+
27
+ const Component = useMemo<ComponentType | null>(() => {
28
+ if (!selectedKind) return null
29
+ return getRegistryAffordanceTool(selectedKind, 'selection')
30
+ }, [selectedKind])
31
+
32
+ if (!Component) return null
33
+ return (
34
+ <Suspense fallback={null}>
35
+ <Component />
36
+ </Suspense>
37
+ )
38
+ }
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
6
6
  import { useCallback, useEffect, useRef, useState } from 'react'
7
7
  import { createPortal } from 'react-dom'
8
8
  import { useShallow } from 'zustand/react/shallow'
9
+ import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
9
10
  import { sfxEmitter } from '../../../lib/sfx-bus'
10
11
  import useEditor from '../../../store/use-editor'
12
+ import useInteractionScope from '../../../store/use-interaction-scope'
11
13
 
12
14
  // ─── Per-zone label editor ────────────────────────────────────────────────────
13
15
 
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
19
21
  const selectedZoneId = useViewer((s) => s.selection.zoneId)
20
22
  const hoveredId = useViewer((s) => s.hoveredId)
21
23
  const mode = useEditor((s) => s.mode)
24
+ // During an active interaction the zone label is a context badge that steps
25
+ // back: faded + non-interactive so it can't be hovered/clicked mid-action.
26
+ const scope = useInteractionScope((s) => s.scope)
27
+ const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
22
28
  const isSelected = selectedZoneId === zoneId
23
29
  const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
24
30
  const [editing, setEditing] = useState(false)
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
149
155
  fontSize: 14,
150
156
  fontFamily: 'sans-serif',
151
157
  userSelect: 'none',
152
- pointerEvents: 'auto',
158
+ pointerEvents: labelStepBack ? 'none' : 'auto',
159
+ opacity: labelStepBack ? 0.4 : undefined,
153
160
  display: 'inline-flex',
154
161
  alignItems: 'center',
155
162
  gap: 4,
@@ -309,6 +316,12 @@ export function ZoneLabelEditorSystem() {
309
316
  .map((n) => n.id as ZoneNode['id']),
310
317
  ),
311
318
  )
319
+ // The zone renderer unmounts its <Html> label when zones are hidden —
320
+ // unmount the portals with it, or each editor would hold (and rAF-poll for)
321
+ // a detached label element.
322
+ const showZones = useViewer((s) => s.showZones)
323
+
324
+ if (!showZones) return null
312
325
 
313
326
  return (
314
327
  <>
@@ -1,21 +1,46 @@
1
1
  import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { useFrame } from '@react-three/fiber'
4
+ import { useEffect } from 'react'
4
5
  import { type Group, MathUtils, type Mesh } from 'three'
5
6
  import type { MeshBasicNodeMaterial } from 'three/webgpu'
7
+ import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
6
8
  import useEditor from '../../../store/use-editor'
9
+ import useInteractionScope from '../../../store/use-interaction-scope'
7
10
 
8
11
  // Disable raycasting on zone geometry so clicks pass through to items underneath.
9
12
  // Zone selection in the editor is handled exclusively via the HTML label overlay.
10
13
  const noopRaycast = () => {}
11
14
 
12
15
  export const ZoneSystem = () => {
16
+ // Outside the zones layer (or during snapshot capture) zones unmount
17
+ // entirely — meshes AND drei <Html> labels, which cost per-frame matrix work
18
+ // + live DOM even at opacity 0. The renderer reads this viewer flag; the
19
+ // unmount cleanup restores the default so preview / first-person surfaces
20
+ // (which swap this system for ViewerZoneSystem) keep their labels.
21
+ const structureLayerState = useEditor((s) => s.structureLayer)
22
+ const isCaptureModeState = useEditor((s) => s.isCaptureMode)
23
+ useEffect(() => {
24
+ useViewer.getState().setShowZones(structureLayerState === 'zones' && !isCaptureModeState)
25
+ return () => useViewer.getState().setShowZones(true)
26
+ }, [structureLayerState, isCaptureModeState])
27
+
13
28
  useFrame((_, delta) => {
29
+ if (!useViewer.getState().showZones) return
30
+
14
31
  const structureLayer = useEditor.getState().structureLayer
15
32
  const editorMode = useEditor.getState().mode
16
33
  const selectedLevelId = useViewer.getState().selection.levelId
17
34
  const selectedZoneId = useViewer.getState().selection.zoneId
18
35
  const hoveredId = useViewer.getState().hoveredId
36
+ // Snapshot capture is a clean, camera-only surface — never show zone
37
+ // geometry or the HTML zone tags in the framed shot.
38
+ const isCaptureMode = useEditor.getState().isCaptureMode
39
+
40
+ // During any active interaction zone labels step back entirely — they are
41
+ // not a primary editing concern and would distract / invite misclicks.
42
+ const zoneLabelsHidden =
43
+ resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
19
44
 
20
45
  const zoneGeometryVisible = structureLayer === 'zones'
21
46
  const zones = sceneRegistry.byType.zone || new Set()
@@ -35,12 +60,22 @@ export const ZoneSystem = () => {
35
60
  // Keep group visible (so <Html> labels stay active), hide/show meshes only.
36
61
  // Show meshes when: in zone mode, selected, or delete-hovered.
37
62
  if (!obj.visible) obj.visible = true
38
- const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
39
- const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
63
+ const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
64
+ const targetOpacity = isCaptureMode
65
+ ? 0
66
+ : isSelected || isDeleteHovered
67
+ ? 1
68
+ : zoneGeometryVisible
69
+ ? 1
70
+ : 0
40
71
 
72
+ // Raycast is re-disabled per frame (not once per group): the meshes
73
+ // remount whenever the zones layer toggles, so a one-shot flag on the
74
+ // persistent group would leave fresh meshes clickable.
41
75
  const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
42
76
  if (walls) {
43
77
  walls.visible = meshVisible
78
+ walls.raycast = noopRaycast
44
79
  const material = walls.material as MeshBasicNodeMaterial
45
80
  if (material?.userData?.uOpacity) {
46
81
  material.userData.uOpacity.value = MathUtils.lerp(
@@ -54,6 +89,7 @@ export const ZoneSystem = () => {
54
89
  const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
55
90
  if (floor) {
56
91
  floor.visible = meshVisible
92
+ floor.raycast = noopRaycast
57
93
  const material = floor.material as MeshBasicNodeMaterial
58
94
  if (material?.userData?.uOpacity) {
59
95
  material.userData.uOpacity.value = MathUtils.lerp(
@@ -64,17 +100,10 @@ export const ZoneSystem = () => {
64
100
  }
65
101
  }
66
102
 
67
- // Disable raycasting once per zone object so geometry never intercepts clicks
68
- if (!obj.userData.__raycastDisabled) {
69
- obj.raycast = noopRaycast
70
- obj.traverse((child) => {
71
- child.raycast = noopRaycast
72
- })
73
- obj.userData.__raycastDisabled = true
74
- }
75
-
76
- // Labels: always visible on the current level (regardless of mode)
77
- const showLabel = !!selectedLevelId && isOnSelectedLevel
103
+ // Labels: visible on the current level (regardless of mode), but never
104
+ // during snapshot capture.
105
+ const showLabel =
106
+ !isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
78
107
  const labelOpacity = showLabel ? '1' : '0'
79
108
  const labelEl = document.getElementById(`${zoneId}-label`)
80
109
  if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -9,11 +9,18 @@ import {
9
9
  resolveAlignment,
10
10
  useScene,
11
11
  } from '@pascal-app/core'
12
- import { useAlignmentGuides } from '@pascal-app/editor'
13
12
  import { useEffect, useMemo, useRef } from 'react'
14
13
  import * as THREE from 'three'
15
14
  import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
16
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
+
17
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
18
+ import useEditor, {
19
+ isAlignmentGuideActive,
20
+ isGridSnapActive,
21
+ isMagneticSnapActive,
22
+ } from '../../../store/use-editor'
23
+
17
24
  import usePlacementPreview from '../../../store/use-placement-preview'
18
25
  import { CursorSphere } from '../shared/cursor-sphere'
19
26
  import {
@@ -163,13 +170,16 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
163
170
  // point: resolving against the grid point would only ever catch anchors
164
171
  // that happen to sit on a grid line, so off-grid items (furniture, angled
165
172
  // walls) would never surface a guide. The matched axis locks exactly to the
166
- // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
173
+ // candidate's coordinate; the other axis keeps its grid snap. Guides are
174
+ // published in every snapping mode (including Off); the magnetic pull toward
175
+ // them (applySnap) applies only in 'lines' mode.
167
176
  const alignPoint = (
168
177
  gridX: number,
169
178
  gridZ: number,
170
179
  rawX: number,
171
180
  rawZ: number,
172
181
  bypass: boolean,
182
+ applySnap: boolean,
173
183
  ): [number, number] => {
174
184
  if (bypass || alignmentCandidates.length === 0) {
175
185
  useAlignmentGuides.getState().clear()
@@ -185,6 +195,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
185
195
  return [gridX, gridZ]
186
196
  }
187
197
  useAlignmentGuides.getState().set(ar.guides)
198
+ if (!applySnap) return [gridX, gridZ]
188
199
  let x = gridX
189
200
  let z = gridZ
190
201
  for (const guide of ar.guides) {
@@ -195,12 +206,20 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
195
206
  }
196
207
 
197
208
  const onGridMove = (event: GridEvent) => {
209
+ // Grid snap follows the global mode (live step so the HUD chip is
210
+ // honest); Off keeps the raw cursor. Shift cycles the mode centrally.
211
+ const step = useEditor.getState().gridSnapStep
198
212
  const [gridX, gridZ] = alignPoint(
199
- Math.round(event.localPosition[0] * 2) / 2,
200
- Math.round(event.localPosition[2] * 2) / 2,
213
+ isGridSnapActive()
214
+ ? Math.round(event.localPosition[0] / step) * step
215
+ : event.localPosition[0],
216
+ isGridSnapActive()
217
+ ? Math.round(event.localPosition[2] / step) * step
218
+ : event.localPosition[2],
201
219
  event.localPosition[0],
202
220
  event.localPosition[2],
203
- event.nativeEvent?.altKey === true,
221
+ !isAlignmentGuideActive(),
222
+ isMagneticSnapActive(),
204
223
  )
205
224
  const supportY = resolveElevatorSupportY({
206
225
  buildingId: currentBuildingId,
@@ -220,6 +239,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
220
239
  })
221
240
 
222
241
  if (
242
+ (isGridSnapActive() || isMagneticSnapActive()) &&
223
243
  previousGridPosRef.current &&
224
244
  (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
225
245
  ) {
@@ -237,12 +257,18 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
237
257
  })
238
258
  if (!latestBuildingId) return
239
259
 
260
+ const step = useEditor.getState().gridSnapStep
240
261
  const [gridX, gridZ] = alignPoint(
241
- Math.round(event.localPosition[0] * 2) / 2,
242
- Math.round(event.localPosition[2] * 2) / 2,
262
+ isGridSnapActive()
263
+ ? Math.round(event.localPosition[0] / step) * step
264
+ : event.localPosition[0],
265
+ isGridSnapActive()
266
+ ? Math.round(event.localPosition[2] / step) * step
267
+ : event.localPosition[2],
243
268
  event.localPosition[0],
244
269
  event.localPosition[2],
245
- event.nativeEvent?.altKey === true,
270
+ !isAlignmentGuideActive(),
271
+ isMagneticSnapActive(),
246
272
  )
247
273
  commitElevatorPlacement(
248
274
  latestBuildingId,
@@ -15,6 +15,7 @@ import {
15
15
  import { useCallback, useEffect, useRef, useState } from 'react'
16
16
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
17
  import { resolveElevatorSupportY } from '../../../lib/elevator-support'
18
+ import { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
18
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
19
20
  import useEditor from '../../../store/use-editor'
20
21
  import { CursorSphere } from '../shared/cursor-sphere'
@@ -131,8 +132,9 @@ export function MoveElevatorTool({
131
132
  }
132
133
 
133
134
  const onGridMove = (event: GridEvent) => {
134
- const rawX = Math.round(event.localPosition[0] * 2) / 2
135
- const rawZ = Math.round(event.localPosition[2] * 2) / 2
135
+ const bypassSnap = event.nativeEvent?.shiftKey === true
136
+ const rawX = bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2
137
+ const rawZ = bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2
136
138
  const anchor = dragAnchorRef.current ?? [rawX, rawZ]
137
139
  dragAnchorRef.current = anchor
138
140
  const gridX = movingNode.position[0] + (rawX - anchor[0])
@@ -145,6 +147,7 @@ export function MoveElevatorTool({
145
147
  })
146
148
 
147
149
  if (
150
+ !bypassSnap &&
148
151
  previousGridPosRef.current &&
149
152
  (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
150
153
  ) {
@@ -158,6 +161,7 @@ export function MoveElevatorTool({
158
161
  }
159
162
 
160
163
  const onGridClick = (event: GridEvent) => {
164
+ if (wasCommitted) return
161
165
  const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
162
166
 
163
167
  wasCommitted = true
@@ -187,6 +191,11 @@ export function MoveElevatorTool({
187
191
  event.nativeEvent?.stopPropagation?.()
188
192
  }
189
193
 
194
+ const onPlacementDragPointerUp = (event: PointerEvent) => {
195
+ if (!consumePlacementDragRelease(event)) return
196
+ onGridClick({ nativeEvent: event } as unknown as GridEvent)
197
+ }
198
+
190
199
  const onCancel = () => {
191
200
  wasCancelled = true
192
201
  clearPreview()
@@ -229,6 +238,7 @@ export function MoveElevatorTool({
229
238
  emitter.on('grid:click', onGridClick)
230
239
  emitter.on('tool:cancel', onCancel)
231
240
  window.addEventListener('keydown', onKeyDown)
241
+ window.addEventListener('pointerup', onPlacementDragPointerUp)
232
242
 
233
243
  return () => {
234
244
  clearPreview()
@@ -245,6 +255,7 @@ export function MoveElevatorTool({
245
255
  emitter.off('grid:click', onGridClick)
246
256
  emitter.off('tool:cancel', onCancel)
247
257
  window.removeEventListener('keydown', onKeyDown)
258
+ window.removeEventListener('pointerup', onPlacementDragPointerUp)
248
259
  }
249
260
  }, [movingNode, exitMoveMode])
250
261
 
@@ -1,8 +1,12 @@
1
1
  import {
2
+ DEFAULT_ANGLE_STEP,
2
3
  FenceNode,
4
+ getTwoPointFenceCurveTangents,
3
5
  getWallCurveFrameAt,
4
6
  getWallCurveLength,
5
7
  isCurvedWall,
8
+ resolveFenceSupportSlabPatch,
9
+ snapPointAlongAngleRay,
6
10
  useScene,
7
11
  type WallNode,
8
12
  } from '@pascal-app/core'
@@ -12,9 +16,7 @@ import useEditor from '../../../store/use-editor'
12
16
  import {
13
17
  findWallSnapTarget,
14
18
  getSegmentGridStep,
15
- getWallAngleSnapStep,
16
19
  isSegmentLongEnough,
17
- snapPointTo45Degrees,
18
20
  snapPointToGrid,
19
21
  type WallPlanPoint,
20
22
  } from '../wall/wall-drafting'
@@ -132,7 +134,9 @@ export function snapFenceDraftPoint(args: {
132
134
  start?: FencePlanPoint
133
135
  angleSnap?: boolean
134
136
  ignoreFenceIds?: string[]
135
- /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
137
+ bypassSnap?: boolean
138
+ magnetic?: boolean
139
+ /** Override the grid step. */
136
140
  step?: number
137
141
  /**
138
142
  * Optional grid-snap function. When provided, replaces the default
@@ -142,23 +146,64 @@ export function snapFenceDraftPoint(args: {
142
146
  */
143
147
  gridSnap?: (point: FencePlanPoint) => FencePlanPoint
144
148
  }): FencePlanPoint {
145
- const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
149
+ const {
150
+ point,
151
+ walls,
152
+ fences,
153
+ start,
154
+ angleSnap = false,
155
+ ignoreFenceIds,
156
+ bypassSnap = false,
157
+ magnetic = true,
158
+ step,
159
+ gridSnap,
160
+ } = args
161
+ if (bypassSnap) return point
162
+
146
163
  const gridStep = step ?? getSegmentGridStep()
147
- const angleStep = getWallAngleSnapStep(gridStep)
148
- const basePoint =
164
+
165
+ // Magnetic endpoint snap must beat the angle lock, and the lock can pull
166
+ // the cursor far enough off an endpoint that probing the locked point
167
+ // would never engage — so under the lock, probe from the RAW cursor
168
+ // first (mirrors `snapWallDraftPointDetailed`'s special-point pre-pass).
169
+ if (start && angleSnap) {
170
+ const rawTarget =
171
+ magnetic &&
172
+ (findFenceSnapTarget(point, fences, ignoreFenceIds) ?? findWallSnapTarget(point, walls))
173
+ if (rawTarget) return rawTarget
174
+ }
175
+
176
+ // The angle path snaps the distance ALONG the 15° ray — a scalar, the
177
+ // same in world and local frames — so the `gridSnap` world-grid override
178
+ // only applies when the angle lock is off.
179
+ const basePoint: FencePlanPoint =
149
180
  start && angleSnap
150
- ? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
181
+ ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, gridStep)]
151
182
  : gridSnap
152
183
  ? gridSnap(point)
153
184
  : snapPointToGrid(point, gridStep)
154
- const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
185
+ if (!magnetic) return basePoint
155
186
 
187
+ const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
156
188
  return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
157
189
  }
158
190
 
191
+ export type FenceCommitOptions = {
192
+ /**
193
+ * Pointer-decided support cap (level-local Y) from
194
+ * `resolvePointerSupportSurface` — the 3D tool passes the elevation of
195
+ * the surface the commit click actually aimed at, so a fence drawn on a
196
+ * deck top persists the deck as its lift host while one drawn at the
197
+ * floor underneath stays grounded. Omitted by 2D floor-plan commits (no
198
+ * camera ray): those keep the uncapped max election.
199
+ */
200
+ supportCap?: number | null
201
+ }
202
+
159
203
  export function createFenceOnCurrentLevel(
160
204
  start: FencePlanPoint,
161
205
  end: FencePlanPoint,
206
+ options?: FenceCommitOptions,
162
207
  ): FenceNode | null {
163
208
  const currentLevelId = useViewer.getState().selection.levelId
164
209
  const { createNode, nodes } = useScene.getState()
@@ -178,6 +223,60 @@ export function createFenceOnCurrentLevel(
178
223
  start,
179
224
  end,
180
225
  })
226
+ // Fences run no per-frame support election — the persisted host IS the
227
+ // lift (absent = level floor), so elect it at commit, pointer-capped.
228
+ fence.supportSlabId = resolveFenceSupportSlabPatch(
229
+ { ...fence, parentId: currentLevelId },
230
+ nodes,
231
+ { maxElevation: options?.supportCap ?? null },
232
+ ).supportSlabId
233
+
234
+ createNode(fence, currentLevelId)
235
+ sfxEmitter.emit('sfx:structure-build')
236
+
237
+ return fence
238
+ }
239
+
240
+ /**
241
+ * Commit a smooth spline fence from a list of drawn control points. The
242
+ * centerline becomes a Catmull-Rom curve through `path`; `start`/`end` are
243
+ * pinned to the first/last point so endpoint handles, bbox, and miter
244
+ * references stay valid. Requires >= 2 points spanning a usable distance.
245
+ */
246
+ export function createSplineFenceOnCurrentLevel(
247
+ path: FencePlanPoint[],
248
+ tangents = getTwoPointFenceCurveTangents(path),
249
+ options?: FenceCommitOptions,
250
+ ): FenceNode | null {
251
+ const currentLevelId = useViewer.getState().selection.levelId
252
+ const { createNode, nodes } = useScene.getState()
253
+
254
+ if (!currentLevelId || path.length < 2) {
255
+ return null
256
+ }
257
+ const start = path[0]!
258
+ const end = path[path.length - 1]!
259
+ // A degenerate single-point-ish path (all clicks on one spot) is rejected
260
+ // the same way a too-short straight segment is.
261
+ if (!isSegmentLongEnough(start, end) && path.length < 3) {
262
+ return null
263
+ }
264
+
265
+ const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
266
+ const defaults = useEditor.getState().toolDefaults.fence ?? {}
267
+ const fence = FenceNode.parse({
268
+ ...defaults,
269
+ name: `Fence ${fenceCount + 1}`,
270
+ start,
271
+ end,
272
+ path,
273
+ tangents,
274
+ })
275
+ fence.supportSlabId = resolveFenceSupportSlabPatch(
276
+ { ...fence, parentId: currentLevelId },
277
+ nodes,
278
+ { maxElevation: options?.supportCap ?? null },
279
+ ).supportSlabId
181
280
 
182
281
  createNode(fence, currentLevelId)
183
282
  sfxEmitter.emit('sfx:structure-build')
@@ -1,7 +1,7 @@
1
1
  import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
2
2
  import { nodeRegistry } from '@pascal-app/core'
3
3
  import { Suspense } from 'react'
4
- import useEditor from '../../../store/use-editor'
4
+ import { useMovingNode } from '../../../store/use-interaction-scope'
5
5
  import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
6
  import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
7
7
  import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
@@ -9,14 +9,17 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
9
9
  /**
10
10
  * MoveTool dispatcher. Routes to (in order):
11
11
  *
12
- * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
13
- * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
14
- * ).
15
- * 2. `def.affordanceTools.move` kind-owned move component, lazy-loaded
16
- * via `getRegistryAffordanceTool`. Covers both generic movers
17
- * (slab / ceiling / wall / fence / column / item / door / window) and
18
- * the bespoke roof / roof-segment / stair / stair-segment / building
19
- * movers ported into `@pascal-app/nodes`.
12
+ * 1. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
13
+ * via `getRegistryAffordanceTool`. Covers generic movers
14
+ * (slab / ceiling / wall / fence / column / item / door / window), the
15
+ * bespoke roof / roof-segment / stair / stair-segment / building
16
+ * movers, and the polyline / fitting ghost-placement movers
17
+ * (duct-segment / duct-fitting). A kind that ships its own mover wins
18
+ * even if it also declares `capabilities.movable` (duct-fitting keeps
19
+ * `movable` for the inspector / hint readers but places via its ghost).
20
+ * 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only
21
+ * declare `capabilities.movable` (shelf, spawn, duct-terminal,
22
+ * hvac-equipment, …).
20
23
  * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
21
24
  * mover hasn't been ported to a kind-owned affordance yet.
22
25
  */
@@ -24,14 +27,11 @@ export const MoveTool: React.FC<{
24
27
  onNodeMoved?: (nodeId: AnyNodeId) => void
25
28
  onSpawnMoved?: (nodeId: SpawnNode['id']) => void
26
29
  }> = ({ onNodeMoved }) => {
27
- const movingNode = useEditor((state) => state.movingNode)
30
+ const movingNode = useMovingNode()
28
31
 
29
32
  if (!movingNode) return null
30
33
 
31
34
  const def = nodeRegistry.get(movingNode.type)
32
- if (def?.capabilities?.movable) {
33
- return <MoveRegistryNodeTool node={movingNode} />
34
- }
35
35
 
36
36
  const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
37
37
  if (RegistryMove) {
@@ -42,6 +42,10 @@ export const MoveTool: React.FC<{
42
42
  )
43
43
  }
44
44
 
45
+ if (def?.capabilities?.movable) {
46
+ return <MoveRegistryNodeTool node={movingNode} />
47
+ }
48
+
45
49
  if (movingNode.type === 'elevator')
46
50
  return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
47
51
  return null
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { stripTransient } from './placement-math'
2
+ import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math'
3
3
 
4
4
  describe('stripTransient', () => {
5
5
  test('removes placement-only metadata flags before commit', () => {
@@ -8,3 +8,15 @@ describe('stripTransient', () => {
8
8
  })
9
9
  })
10
10
  })
11
+
12
+ describe('getDetachedAttachmentPreviewLift', () => {
13
+ test('raises attach-only item previews while they are detached from their host', () => {
14
+ expect(getDetachedAttachmentPreviewLift('wall')).toBeGreaterThan(0)
15
+ expect(getDetachedAttachmentPreviewLift('wall-side')).toBeGreaterThan(0)
16
+ expect(getDetachedAttachmentPreviewLift('ceiling')).toBeGreaterThan(0)
17
+ })
18
+
19
+ test('keeps floor item previews on the floor', () => {
20
+ expect(getDetachedAttachmentPreviewLift(undefined)).toBe(0)
21
+ })
22
+ })
@@ -1,9 +1,26 @@
1
1
  import { type AssetInput, isObject } from '@pascal-app/core'
2
2
  import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
3
- import useEditor from '../../../store/use-editor'
3
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
4
+ import useEditor, { getActiveSnappingMode } from '../../../store/use-editor'
4
5
 
6
+ // Sentinel returned when the active context's snapping mode disables grid snap.
7
+ // The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
8
+ // raw value through. For items the default mode is now `lines` (grid off), so
9
+ // item placement/move is free + line-snap unless the user opts into `grid`.
5
10
  function getGridSnapStep(): number {
6
- return useEditor.getState().gridSnapStep
11
+ return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
12
+ }
13
+
14
+ const ROTATION_QUANTUM = Math.PI / 4
15
+
16
+ /**
17
+ * R/T rotation: round the current angle to the nearest 45° then step ONE
18
+ * increment in `direction` (+1 / -1), so the node always lands on a clean 45°
19
+ * multiple regardless of its starting angle (12° → 45°, 40° → 90°) rather than a
20
+ * blind ±45° from an arbitrary angle.
21
+ */
22
+ export function steppedRotation(current: number, direction: 1 | -1): number {
23
+ return (Math.round(current / ROTATION_QUANTUM) + direction) * ROTATION_QUANTUM
7
24
  }
8
25
 
9
26
  function positiveModulo(value: number, divisor: number): number {
@@ -14,6 +31,7 @@ function positiveModulo(value: number, divisor: number): number {
14
31
  * Snaps a position to the active grid step, aligning item edges to grid lines.
15
32
  */
16
33
  export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
34
+ if (step <= 0) return position
17
35
  const halfDim = dimension / 2
18
36
  const offset = positiveModulo(halfDim, step)
19
37
  return Math.round((position - offset) / step) * step + offset
@@ -23,6 +41,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
23
41
  * Snap a value to the active grid step (used for wall-local positions).
24
42
  */
25
43
  export function snapToHalf(value: number, step = getGridSnapStep()): number {
44
+ if (step <= 0) return value
26
45
  return Math.round(value / step) * step
27
46
  }
28
47
 
@@ -30,6 +49,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
30
49
  * Round a value up to the next multiple of `step`, with a minimum of `step`.
31
50
  */
32
51
  export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
52
+ if (step <= 0) return value
33
53
  return Math.max(step, Math.ceil(value / step) * step)
34
54
  }
35
55
 
@@ -55,6 +75,12 @@ export function getGridAlignedDimensions(
55
75
  return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
56
76
  }
57
77
 
78
+ export function getDetachedAttachmentPreviewLift(
79
+ attachTo: AssetInput['attachTo'] | null | undefined,
80
+ ): number {
81
+ return attachTo ? 0.45 : 0
82
+ }
83
+
58
84
  /**
59
85
  * Calculate cursor rotation in WORLD space from wall normal and orientation.
60
86
  */