@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ DEFAULT_ANGLE_STEP,
4
5
  type DoorNode,
5
6
  getScaledDimensions,
6
7
  type ItemNode,
8
+ runAsSingleSceneHistoryStep,
9
+ snapPointAlongAngleRay,
7
10
  useScene,
8
11
  type WallNode,
9
12
  WallNode as WallSchema,
@@ -11,51 +14,57 @@ import {
11
14
  } from '@pascal-app/core'
12
15
  import { useViewer } from '@pascal-app/viewer'
13
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
14
- import useEditor from '../../../store/use-editor'
17
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
18
+ import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
15
19
  import {
16
20
  distanceSquared,
17
21
  findWallSnapTarget,
18
22
  findWallSpecialPointSnap,
19
23
  projectPointOntoWall,
24
+ WALL_CONNECT_SNAP_RADIUS,
20
25
  WALL_JOIN_SNAP_RADIUS,
21
26
  type WallDraftSnapResult,
22
27
  type WallPlanPoint,
28
+ type WallSnapRadii,
23
29
  } from './wall-snap-geometry'
24
30
 
25
31
  // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
26
32
  // existing importers (fence drafting, the editor barrel) keep their paths.
27
33
  export {
34
+ chainEndJoinsExistingWall,
28
35
  findWallSnapTarget,
36
+ WALL_CONNECT_SNAP_RADIUS,
29
37
  WALL_JOIN_SNAP_RADIUS,
30
38
  type WallDraftSnapKind,
31
39
  type WallDraftSnapResult,
32
40
  type WallPlanPoint,
41
+ type WallSnapRadii,
33
42
  } from './wall-snap-geometry'
34
43
 
35
44
  export const WALL_GRID_STEP = 0.5
36
- // Smallest available grid snap. Used as a precision-mode step (Shift +
37
- // drag) so a drag can land on values the regular grid skips.
38
- export const WALL_FINE_GRID_STEP = 0.05
39
45
  export const WALL_MIN_LENGTH = 0.01
40
- const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
41
-
42
- const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
43
- 0.5: Math.PI / 4,
44
- 0.25: Math.PI / 8,
45
- 0.1: Math.PI / 12,
46
- 0.05: Math.PI / 36,
47
- }
46
+ // An endpoint projecting within this distance of an existing wall's corner
47
+ // resolves to the corner without splitting — splitting there would mint a
48
+ // sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
49
+ // can ever target again.
50
+ const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
48
51
 
49
52
  type WallSplitIntersection = {
50
- wallId: WallNode['id']
53
+ /** `null` = snap-only outcome: resolve to `point` but split no wall. */
54
+ wallId: WallNode['id'] | null
51
55
  point: WallPlanPoint
52
56
  }
53
57
 
54
58
  export function getSegmentGridStep(): number {
55
- return useEditor.getState().gridSnapStep
59
+ // A 0 step means "no grid lattice" — every grid-snap consumer guards on
60
+ // `step <= 0` and returns the raw value, so disabling grid here suppresses
61
+ // the lattice for walls, fences, and every node move/affordance that reads
62
+ // this choke point, without retuning their snap math.
63
+ return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
56
64
  }
57
65
 
58
66
  export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
67
+ if (step <= 0) return value
59
68
  return Math.round(value / step) * step
60
69
  }
61
70
 
@@ -63,35 +72,6 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
63
72
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
64
73
  }
65
74
 
66
- export function snapPointTo45Degrees(
67
- start: WallPlanPoint,
68
- cursor: WallPlanPoint,
69
- step = WALL_GRID_STEP,
70
- angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
71
- /**
72
- * Optional grid-snap callback. Lets the caller route the final
73
- * snap through a world-XZ grid (or any other axis system) instead
74
- * of the local-axis grid `snapPointToGrid` uses. When omitted,
75
- * falls back to the local-axis snap at `step`.
76
- */
77
- gridSnap?: (point: WallPlanPoint) => WallPlanPoint,
78
- ): WallPlanPoint {
79
- const dx = cursor[0] - start[0]
80
- const dz = cursor[1] - start[1]
81
- const angle = Math.atan2(dz, dx)
82
- const snappedAngle = Math.round(angle / angleStep) * angleStep
83
- const distance = Math.sqrt(dx * dx + dz * dz)
84
- const point: WallPlanPoint = [
85
- start[0] + Math.cos(snappedAngle) * distance,
86
- start[1] + Math.sin(snappedAngle) * distance,
87
- ]
88
- return gridSnap ? gridSnap(point) : snapPointToGrid(point, step)
89
- }
90
-
91
- export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
92
- return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
93
- }
94
-
95
75
  function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
96
76
  const { id: _id, parentId: _parentId, children, ...rest } = wall
97
77
 
@@ -118,6 +98,7 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): bool
118
98
  function findWallIntersection(
119
99
  point: WallPlanPoint,
120
100
  walls: WallNode[],
101
+ radius: number,
121
102
  ignoreWallIds?: string[],
122
103
  ): WallSplitIntersection | null {
123
104
  const ignore = new Set(ignoreWallIds ?? [])
@@ -132,13 +113,20 @@ function findWallIntersection(
132
113
 
133
114
  const candidateDistanceSquared = distanceSquared(point, projected)
134
115
  if (
135
- candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
116
+ candidateDistanceSquared > radius * radius ||
136
117
  candidateDistanceSquared >= bestDistanceSquared
137
118
  ) {
138
119
  continue
139
120
  }
140
121
 
141
- best = { wallId: wall.id, point: projected }
122
+ const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find(
123
+ (corner) =>
124
+ distanceSquared(projected, corner) <=
125
+ WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON,
126
+ )
127
+ best = nearCorner
128
+ ? { wallId: null, point: [nearCorner[0], nearCorner[1]] }
129
+ : { wallId: wall.id, point: projected }
142
130
  bestDistanceSquared = candidateDistanceSquared
143
131
  }
144
132
 
@@ -290,6 +278,10 @@ function splitWallIfNeeded(
290
278
  ): { walls: WallNode[]; point: WallPlanPoint } | null {
291
279
  if (!intersection) return null
292
280
 
281
+ if (!intersection.wallId) {
282
+ return { walls, point: intersection.point }
283
+ }
284
+
293
285
  const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
294
286
  if (!wallToSplit) {
295
287
  return { walls, point: intersection.point }
@@ -323,13 +315,51 @@ function splitWallIfNeeded(
323
315
  }
324
316
  }
325
317
 
318
+ /**
319
+ * Commit-time split resolution for an endpoint MOVE — the sibling of the
320
+ * inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is
321
+ * dropped on another wall's interior, split that host exactly like the draw
322
+ * path (duplicate props, migrate attachments by span, skip the split when an
323
+ * opening straddles the point). Mutates the scene store (create halves /
324
+ * migrate attachments / delete host), so callers MUST run it inside the same
325
+ * `runAsSingleSceneHistoryStep` as their endpoint write.
326
+ *
327
+ * Returns the resolved endpoint (projection onto the host, or a nearby corner
328
+ * when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins
329
+ * are not splits), or `null` when the point lands on no wall.
330
+ */
331
+ export function resolveEndpointWallSplit(args: {
332
+ point: WallPlanPoint
333
+ /** Level the moved wall lives on — only its walls are split candidates. */
334
+ levelId: string | null
335
+ /** The moved wall + every wall receiving an endpoint update in the same commit. */
336
+ ignoreWallIds: string[]
337
+ /**
338
+ * Capture radius. The endpoint already snapped onto the wall body during
339
+ * the drag, so the tight connect radius (drop genuinely on the wall) is
340
+ * the default.
341
+ */
342
+ radius?: number
343
+ }): WallPlanPoint | null {
344
+ const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args
345
+ const { nodes, createNodes, updateNodes, deleteNode } = useScene.getState()
346
+ const walls = Object.values(nodes).filter(
347
+ (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
348
+ )
349
+
350
+ const intersection = findWallIntersection(point, walls, radius, ignoreWallIds)
351
+ const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode)
352
+ return split ? split.point : null
353
+ }
354
+
326
355
  type SnapWallDraftArgs = {
327
356
  point: WallPlanPoint
328
357
  walls: WallNode[]
329
358
  start?: WallPlanPoint
330
359
  angleSnap?: boolean
331
360
  ignoreWallIds?: string[]
332
- /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
361
+ bypassSnap?: boolean
362
+ /** Override the grid step. */
333
363
  step?: number
334
364
  /**
335
365
  * Magnetic snapping to existing wall geometry (corners, midpoints,
@@ -345,6 +375,8 @@ type SnapWallDraftArgs = {
345
375
  * local-axis grid at `step`.
346
376
  */
347
377
  gridSnap?: (point: WallPlanPoint) => WallPlanPoint
378
+ /** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
379
+ snapRadii?: WallSnapRadii
348
380
  }
349
381
 
350
382
  export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
@@ -354,33 +386,62 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
354
386
  start,
355
387
  angleSnap = false,
356
388
  ignoreWallIds,
389
+ bypassSnap = false,
357
390
  step: overrideStep,
358
391
  magnetic = true,
359
392
  gridSnap,
393
+ snapRadii,
360
394
  } = args
361
395
 
396
+ if (bypassSnap) return { point, snap: null }
397
+
362
398
  // Discrete special points (corner / midpoint / crossing) are taken from the
363
399
  // raw cursor so an interim grid snap can't mask them. A corner always wins,
364
400
  // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
365
401
  if (magnetic) {
366
- const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
402
+ const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
367
403
  if (special) return special
368
404
  }
369
405
 
370
406
  const step = overrideStep ?? getSegmentGridStep()
371
- const angleStep = getWallAngleSnapStep(step)
372
- const basePoint =
407
+ // The angle path snaps the distance ALONG the 15° ray — a scalar, the
408
+ // same in world and local frames — so the `gridSnap` world-grid override
409
+ // only applies when the angle lock is off.
410
+ const basePoint: WallPlanPoint =
373
411
  start && angleSnap
374
- ? snapPointTo45Degrees(start, point, step, angleStep, gridSnap)
412
+ ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)]
375
413
  : gridSnap
376
414
  ? gridSnap(point)
377
415
  : snapPointToGrid(point, step)
378
416
 
379
417
  if (magnetic) {
380
- const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
418
+ const wallSnap = findWallSnapTarget(basePoint, walls, {
419
+ ignoreWallIds,
420
+ radius: snapRadii?.wall,
421
+ })
381
422
  if (wallSnap) return { point: wallSnap, snap: 'wall' }
423
+ return { point: basePoint, snap: null }
382
424
  }
383
425
 
426
+ // Non-magnetic modes (grid / off / angles): connectivity still sticks so a
427
+ // room can close, but only within a tight radius — placement elsewhere is left
428
+ // to the mode (grid quantise / angle lock / free). Snap from the already
429
+ // positioned `basePoint` so the mode's placement is respected right up to the
430
+ // wall, then the last few cm stick onto it (and the beacon shows).
431
+ const connectRadii: WallSnapRadii = {
432
+ endpoint: WALL_CONNECT_SNAP_RADIUS,
433
+ midpoint: WALL_CONNECT_SNAP_RADIUS,
434
+ intersection: WALL_CONNECT_SNAP_RADIUS,
435
+ wall: WALL_CONNECT_SNAP_RADIUS,
436
+ }
437
+ const connectSpecial = findWallSpecialPointSnap(basePoint, walls, ignoreWallIds, connectRadii)
438
+ if (connectSpecial) return connectSpecial
439
+ const connectWall = findWallSnapTarget(basePoint, walls, {
440
+ ignoreWallIds,
441
+ radius: WALL_CONNECT_SNAP_RADIUS,
442
+ })
443
+ if (connectWall) return { point: connectWall, snap: 'wall' }
444
+
384
445
  return { point: basePoint, snap: null }
385
446
  }
386
447
 
@@ -411,61 +472,77 @@ export function createWallOnCurrentLevel(
411
472
  let resolvedStart = start
412
473
  let resolvedEnd = end
413
474
 
414
- const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
415
- const splitEnd = splitWallIfNeeded(
416
- endIntersection,
417
- workingWalls,
418
- nodes,
419
- createNodes,
420
- updateNodes,
421
- deleteNode,
422
- )
423
- if (splitEnd) {
424
- workingWalls = splitEnd.walls
425
- resolvedEnd = splitEnd.point
426
- }
475
+ // The corner-join / wall-split resolution follows the snapping mode like the
476
+ // draft preview does: magnetic ('lines') keeps the generous join radius,
477
+ // every other mode uses the same tight connect radius the draft path already
478
+ // sticks endpoints with. So an endpoint the user saw connect to a wall body
479
+ // actually splits that wall (and redistributes its attachments) in every
480
+ // mode, while `'off'` / `'angles'` gain no residual long-range snap.
481
+ const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
482
+
483
+ // One undo step for the whole commit: the split ops (create halves, migrate
484
+ // attachments, delete host) plus the new wall each push their own history
485
+ // entry, and a single Ctrl-Z must not strand a half-split wall network.
486
+ return runAsSingleSceneHistoryStep(useScene, () => {
487
+ const endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius)
488
+ const splitEnd = splitWallIfNeeded(
489
+ endIntersection,
490
+ workingWalls,
491
+ nodes,
492
+ createNodes,
493
+ updateNodes,
494
+ deleteNode,
495
+ )
496
+ if (splitEnd) {
497
+ workingWalls = splitEnd.walls
498
+ resolvedEnd = splitEnd.point
499
+ }
427
500
 
428
- const startIntersection = findWallIntersection(resolvedStart, workingWalls)
429
- const splitStart = splitWallIfNeeded(
430
- startIntersection,
431
- workingWalls,
432
- nodes,
433
- createNodes,
434
- updateNodes,
435
- deleteNode,
436
- )
437
- if (splitStart) {
438
- workingWalls = splitStart.walls
439
- resolvedStart = splitStart.point
440
- }
501
+ const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius)
502
+ const splitStart = splitWallIfNeeded(
503
+ startIntersection,
504
+ workingWalls,
505
+ nodes,
506
+ createNodes,
507
+ updateNodes,
508
+ deleteNode,
509
+ )
510
+ if (splitStart) {
511
+ workingWalls = splitStart.walls
512
+ resolvedStart = splitStart.point
513
+ }
441
514
 
442
- if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
443
- return null
444
- }
515
+ if (
516
+ !isSegmentLongEnough(resolvedStart, resolvedEnd) ||
517
+ pointsEqual(resolvedStart, resolvedEnd)
518
+ ) {
519
+ return null
520
+ }
445
521
 
446
- const duplicateWall = workingWalls.some(
447
- (wall) =>
448
- (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
449
- (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
450
- )
451
- if (duplicateWall) {
452
- return null
453
- }
522
+ const duplicateWall = workingWalls.some(
523
+ (wall) =>
524
+ (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
525
+ (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
526
+ )
527
+ if (duplicateWall) {
528
+ return null
529
+ }
454
530
 
455
- const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
456
- // A placed wall preset seeds `toolDefaults.wall` (thickness, height,
457
- // materials, sides) before the tool activates; merge those first so the
458
- // drawn wall reproduces the preset. Identity + endpoints always win.
459
- const defaults = useEditor.getState().toolDefaults.wall ?? {}
460
- const wall = WallSchema.parse({
461
- ...defaults,
462
- name: `Wall ${wallCount + 1}`,
463
- start: resolvedStart,
464
- end: resolvedEnd,
531
+ const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
532
+ // A placed wall preset seeds `toolDefaults.wall` (thickness, height,
533
+ // materials, sides) before the tool activates; merge those first so the
534
+ // drawn wall reproduces the preset. Identity + endpoints always win.
535
+ const defaults = useEditor.getState().toolDefaults.wall ?? {}
536
+ const wall = WallSchema.parse({
537
+ ...defaults,
538
+ name: `Wall ${wallCount + 1}`,
539
+ start: resolvedStart,
540
+ end: resolvedEnd,
541
+ })
542
+
543
+ createNode(wall, currentLevelId)
544
+ sfxEmitter.emit('sfx:structure-build')
545
+
546
+ return wall
465
547
  })
466
-
467
- createNode(wall, currentLevelId)
468
- sfxEmitter.emit('sfx:structure-build')
469
-
470
- return wall
471
548
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import type { WallNode } from '@pascal-app/core'
3
3
  import {
4
+ chainEndJoinsExistingWall,
4
5
  findWallSnapTarget,
5
6
  findWallSpecialPointSnap,
6
7
  type WallPlanPoint,
@@ -62,6 +63,43 @@ describe('findWallSpecialPointSnap', () => {
62
63
  // handled separately by findWallSnapTarget, not a special point.
63
64
  expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
64
65
  })
66
+
67
+ test('honors tighter per-call radii without changing defaults', () => {
68
+ const walls = [makeWall([0, 0], [4, 0])]
69
+
70
+ expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint')
71
+ expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull()
72
+ })
73
+ })
74
+
75
+ describe('chainEndJoinsExistingWall (chain termination)', () => {
76
+ test('true when the end lies on a non-chain wall interior (T junction)', () => {
77
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([2, 2], [2, 0], 'chain_1')]
78
+ expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1'])).toBe(true)
79
+ })
80
+
81
+ test('true when the end lands on a non-chain wall endpoint', () => {
82
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([6, 2], [4, 0], 'chain_1')]
83
+ expect(chainEndJoinsExistingWall([4, 0], walls, ['chain_1'])).toBe(true)
84
+ })
85
+
86
+ test('false when the end only touches the chain walls themselves', () => {
87
+ // Second segment doubles back onto the first one's midpoint — own-chain
88
+ // geometry is excluded, so the chain keeps going.
89
+ const walls = [makeWall([0, 0], [4, 0], 'chain_1'), makeWall([4, 0], [2, 0], 'chain_2')]
90
+ expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1', 'chain_2'])).toBe(false)
91
+ })
92
+
93
+ test('false for a dead end in free space', () => {
94
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([0, 2], [2, 2], 'chain_1')]
95
+ expect(chainEndJoinsExistingWall([2, 2], walls, ['chain_1'])).toBe(false)
96
+ })
97
+
98
+ test('a near miss beyond the tolerance is not a join', () => {
99
+ const walls = [makeWall([0, 0], [4, 0], 'host')]
100
+ expect(chainEndJoinsExistingWall([2, 0.01], walls, [])).toBe(false)
101
+ expect(chainEndJoinsExistingWall([2, 0.0005], walls, [])).toBe(true)
102
+ })
65
103
  })
66
104
 
67
105
  describe('findWallSnapTarget (edge / along-wall)', () => {
@@ -76,4 +114,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
76
114
  const walls = [makeWall([0, 0], [4, 0])]
77
115
  expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
78
116
  })
117
+
118
+ test('honors a tighter wall-body radius', () => {
119
+ const walls = [makeWall([0, 0], [4, 0])]
120
+
121
+ expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
122
+ })
79
123
  })
@@ -15,6 +15,8 @@ export type WallPlanPoint = [number, number]
15
15
  /** Which kind of existing-geometry snap produced a drafted point. */
16
16
  export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
17
17
 
18
+ export type WallSnapRadii = Partial<Record<WallDraftSnapKind, number>>
19
+
18
20
  export type WallDraftSnapResult = {
19
21
  point: WallPlanPoint
20
22
  /**
@@ -26,6 +28,14 @@ export type WallDraftSnapResult = {
26
28
  }
27
29
 
28
30
  export const WALL_JOIN_SNAP_RADIUS = 0.35
31
+ // Tight capture radius for the connectivity snap that runs in NON-magnetic modes
32
+ // (grid / off / angles). Joining an existing wall is treated as connectivity —
33
+ // separate from the 'lines' magnetic alignment — so a room can still close in
34
+ // those modes: within this distance of a wall the endpoint sticks onto it (and
35
+ // the beacon shows); beyond it, positioning is left to the mode. Kept small so
36
+ // only the last few cm near a wall stick, well above the room-detection junction
37
+ // tolerance so a captured endpoint always registers as connected.
38
+ export const WALL_CONNECT_SNAP_RADIUS = 0.05
29
39
  // Generous radius for snapping to an *existing* wall's endpoint while
30
40
  // drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
31
41
  // is the strongest user intent (closing a polygon, attaching to a
@@ -119,9 +129,10 @@ export function findWallEndpointFromRaw(
119
129
  point: WallPlanPoint,
120
130
  walls: WallNode[],
121
131
  ignoreWallIds?: string[],
132
+ radius = WALL_ENDPOINT_SNAP_RADIUS,
122
133
  ): WallPlanPoint | null {
123
134
  const ignored = new Set(ignoreWallIds ?? [])
124
- const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
135
+ const radiusSquared = radius ** 2
125
136
  let best: WallPlanPoint | null = null
126
137
  let bestDistSq = Number.POSITIVE_INFINITY
127
138
 
@@ -152,9 +163,10 @@ export function findWallMidpointFromRaw(
152
163
  point: WallPlanPoint,
153
164
  walls: WallNode[],
154
165
  ignoreWallIds?: string[],
166
+ radius = WALL_MIDPOINT_SNAP_RADIUS,
155
167
  ): WallPlanPoint | null {
156
168
  const ignored = new Set(ignoreWallIds ?? [])
157
- const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
169
+ const radiusSquared = radius ** 2
158
170
  let best: WallPlanPoint | null = null
159
171
  let bestDistSq = Number.POSITIVE_INFINITY
160
172
 
@@ -202,10 +214,11 @@ export function findWallIntersectionFromRaw(
202
214
  point: WallPlanPoint,
203
215
  walls: WallNode[],
204
216
  ignoreWallIds?: string[],
217
+ radius = WALL_INTERSECTION_SNAP_RADIUS,
205
218
  ): WallPlanPoint | null {
206
219
  const ignored = new Set(ignoreWallIds ?? [])
207
220
  const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
208
- const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
221
+ const radiusSquared = radius ** 2
209
222
  let best: WallPlanPoint | null = null
210
223
  let bestDistSq = Number.POSITIVE_INFINITY
211
224
 
@@ -246,6 +259,53 @@ function nearestCandidate(
246
259
  return best
247
260
  }
248
261
 
262
+ // Tolerance for "the committed endpoint actually lies on existing wall
263
+ // geometry". Commit-time resolution (corner join, connect snap, split) puts
264
+ // the endpoint exactly on the geometry, so this only needs to absorb float
265
+ // drift — it is NOT a snap radius.
266
+ export const WALL_CHAIN_JOIN_TOLERANCE = 1e-3
267
+
268
+ /**
269
+ * True when a committed chain segment's resolved `end` lies on wall geometry
270
+ * (an endpoint, or a straight wall's interior) of a wall outside the current
271
+ * draft chain. The wall tools stop chaining there: a segment that tees into
272
+ * the existing network is a termination — continuing would draft the next
273
+ * segment on top of existing walls. `chainWallIds` excludes the chain's own
274
+ * segments (including the just-committed one) so edge/midpoint snaps onto a
275
+ * previous own segment don't read as a join. Curved wall interiors are
276
+ * skipped (their endpoints still count) — resolving an end onto a curve body
277
+ * is rare and continuing there matches the previous behaviour.
278
+ */
279
+ export function chainEndJoinsExistingWall(
280
+ end: WallPlanPoint,
281
+ walls: WallNode[],
282
+ chainWallIds: string[],
283
+ tolerance = WALL_CHAIN_JOIN_TOLERANCE,
284
+ ): boolean {
285
+ const ignored = new Set(chainWallIds)
286
+ const toleranceSquared = tolerance * tolerance
287
+
288
+ for (const wall of walls) {
289
+ if (ignored.has(wall.id)) continue
290
+
291
+ if (
292
+ distanceSquared(end, wall.start) <= toleranceSquared ||
293
+ distanceSquared(end, wall.end) <= toleranceSquared
294
+ ) {
295
+ return true
296
+ }
297
+
298
+ if (isCurvedWall(wall)) continue
299
+
300
+ const projected = projectPointOntoWall(end, wall)
301
+ if (projected && distanceSquared(end, projected) <= toleranceSquared) {
302
+ return true
303
+ }
304
+ }
305
+
306
+ return false
307
+ }
308
+
249
309
  /**
250
310
  * Discrete "special point" snap from the raw cursor, in priority order:
251
311
  * 1. corners (endpoints) — strongest intent, largest radius
@@ -257,12 +317,13 @@ export function findWallSpecialPointSnap(
257
317
  point: WallPlanPoint,
258
318
  walls: WallNode[],
259
319
  ignoreWallIds?: string[],
320
+ radii?: WallSnapRadii,
260
321
  ): WallDraftSnapResult | null {
261
- const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
322
+ const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint)
262
323
  if (endpoint) return { point: endpoint, snap: 'endpoint' }
263
324
 
264
- const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
265
- const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
325
+ const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint)
326
+ const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection)
266
327
  return nearestCandidate(point, [
267
328
  midpoint && { point: midpoint, snap: 'midpoint' },
268
329
  intersection && { point: intersection, snap: 'intersection' },
@@ -18,7 +18,11 @@ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }
18
18
 
19
19
  const handlePolygonChange = useCallback(
20
20
  (newPolygon: Array<[number, number]>) => {
21
- updateNode(zoneId, { polygon: newPolygon })
21
+ updateNode(zoneId, {
22
+ polygon: newPolygon,
23
+ autoFromWalls: false,
24
+ boundaryWallIds: [],
25
+ })
22
26
  },
23
27
  [zoneId, updateNode],
24
28
  )