@pascal-app/editor 0.7.0 → 0.9.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -2,54 +2,52 @@ import {
2
2
  type AnyNodeId,
3
3
  type BuildingNode,
4
4
  type CeilingNode,
5
+ nodeRegistry,
5
6
  type SlabNode,
6
7
  useScene,
7
8
  } from '@pascal-app/core'
8
9
  import { useViewer } from '@pascal-app/viewer'
10
+ import { type ComponentType, lazy, Suspense } from 'react'
9
11
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
10
- import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
11
- import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
12
- import { CeilingTool } from './ceiling/ceiling-tool'
13
- import { ColumnTool } from './column/column-tool'
14
- import { DoorTool } from './door/door-tool'
15
- import { CurveFenceTool } from './fence/curve-fence-tool'
16
- import { FenceTool } from './fence/fence-tool'
17
- import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
18
- import { ItemTool } from './item/item-tool'
12
+ import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
13
+ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
14
+ import { ElevatorTool } from './elevator/elevator-tool'
19
15
  import { MoveTool } from './item/move-tool'
20
16
  import { RoofTool } from './roof/roof-tool'
17
+ import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
21
18
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
22
- import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
23
- import { SlabHoleEditor } from './slab/slab-hole-editor'
24
- import { SlabTool } from './slab/slab-tool'
25
- import { SpawnTool } from './spawn/spawn-tool'
26
19
  import { StairTool } from './stair/stair-tool'
27
- import { CurveWallTool } from './wall/curve-wall-tool'
28
- import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
29
- import { WallTool } from './wall/wall-tool'
30
- import { WindowTool } from './window/window-tool'
31
20
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
32
21
  import { ZoneTool } from './zone/zone-tool'
33
22
 
23
+ // Cache lazy tool components keyed by their loader so React.lazy isn't
24
+ // re-invoked across renders.
25
+ const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
26
+
27
+ function getRegistryTool(tool: Tool | null): ComponentType | null {
28
+ if (!tool) return null
29
+ const def = nodeRegistry.get(tool)
30
+ if (!def?.tool) return null
31
+ const cached = lazyToolCache.get(def.tool)
32
+ if (cached) return cached
33
+ const Comp = lazy(def.tool as () => Promise<{ default: ComponentType }>)
34
+ lazyToolCache.set(def.tool, Comp)
35
+ return Comp
36
+ }
37
+
38
+ // Legacy tool fallbacks — kinds whose placement tools haven't migrated
39
+ // to `def.tool` yet. Wall / fence / slab / ceiling / door / window /
40
+ // item / shelf / spawn now go through the registry path above.
34
41
  const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
35
42
  site: {
36
43
  'property-line': SiteBoundaryEditor,
37
44
  },
38
45
  structure: {
39
- wall: WallTool,
40
- fence: FenceTool,
41
- slab: SlabTool,
42
- ceiling: CeilingTool,
43
46
  roof: RoofTool,
44
47
  stair: StairTool,
45
- door: DoorTool,
46
- item: ItemTool,
47
48
  zone: ZoneTool,
48
- window: WindowTool,
49
- },
50
- furnish: {
51
- item: ItemTool,
52
49
  },
50
+ furnish: {},
53
51
  }
54
52
 
55
53
  export const ToolManager: React.FC = () => {
@@ -63,9 +61,9 @@ export const ToolManager: React.FC = () => {
63
61
  const curvingFence = useEditor((state) => state.curvingFence)
64
62
  const editingHole = useEditor((state) => state.editingHole)
65
63
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
66
- const selectedLevelId = useViewer((state) => state.selection.levelId)
67
- const buildingId = useViewer((state) => state.selection.buildingId)
68
64
  const selectedIds = useViewer((state) => state.selection.selectedIds)
65
+ const buildingId = useViewer((state) => state.selection.buildingId)
66
+ const activeLevelId = useViewer((state) => state.selection.levelId)
69
67
  const setSelection = useViewer((state) => state.setSelection)
70
68
  const nodes = useScene((state) => state.nodes)
71
69
 
@@ -81,25 +79,34 @@ export const ToolManager: React.FC = () => {
81
79
  const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
82
80
  | SlabNode['id']
83
81
  | undefined
82
+ const selectedSlab = selectedSlabId ? (nodes[selectedSlabId as AnyNodeId] as SlabNode) : null
83
+ const editingSlabHoleIsManual =
84
+ selectedSlabId !== undefined &&
85
+ editingHole?.nodeId === selectedSlabId &&
86
+ selectedSlab?.holeMetadata?.[editingHole.holeIndex]?.source === 'manual'
84
87
 
85
88
  // Check if a ceiling is selected
86
89
  const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
87
90
  | CeilingNode['id']
88
91
  | undefined
89
92
 
90
- // Show site boundary editor when in site phase (toggle controls entry/exit)
91
- const showSiteBoundaryEditor = phase === 'site'
93
+ // Keep the site vertex flags available in select mode; the editor component
94
+ // switches to full polygon editing only after a flag activates site mode.
95
+ const showSiteBoundaryEditor = phase === 'site' || mode === 'select'
92
96
 
93
97
  // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
94
98
  const showSlabBoundaryEditor =
95
99
  phase === 'structure' &&
96
100
  mode === 'select' &&
97
101
  selectedSlabId !== undefined &&
98
- (!editingHole || editingHole.nodeId !== selectedSlabId)
102
+ !editingSlabHoleIsManual
99
103
 
100
104
  // Show slab hole editor when editing a hole on the selected slab
101
105
  const showSlabHoleEditor =
102
- selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
106
+ selectedSlabId !== undefined &&
107
+ editingHole !== null &&
108
+ editingHole.nodeId === selectedSlabId &&
109
+ editingSlabHoleIsManual
103
110
 
104
111
  // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
105
112
  const showCeilingBoundaryEditor =
@@ -126,16 +133,38 @@ export const ToolManager: React.FC = () => {
126
133
  // Show build tools when in build mode
127
134
  const showBuildTool = mode === 'build' && tool !== null
128
135
 
129
- const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
136
+ // Registry-first: if the active tool's kind has a NodeDefinition with a
137
+ // tool contribution, the registry-driven tool takes over.
138
+ const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
139
+ const useRegistryTool = RegistryToolComponent != null
140
+
141
+ const BuildToolComponent = showBuildTool && !useRegistryTool ? tools[phase]?.[tool] : null
130
142
  const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
131
143
  setSelection({ selectedIds: [nodeId] })
132
144
  }
145
+ const handlePlacedElevatorSelected = (
146
+ nodeId: AnyNodeId,
147
+ elevatorBuildingId: BuildingNode['id'],
148
+ ) => {
149
+ // Preserve the active level. `setSelection`'s hierarchy guard nulls
150
+ // `levelId` whenever `buildingId` is passed without an explicit
151
+ // `levelId` — which deselected the current floor plan the moment an
152
+ // elevator was placed. Pass the current level through so the floor
153
+ // plan stays selected.
154
+ setSelection({
155
+ buildingId: elevatorBuildingId,
156
+ levelId: activeLevelId ?? null,
157
+ selectedIds: [nodeId],
158
+ })
159
+ }
133
160
 
134
161
  return (
135
162
  <>
136
- {showSiteBoundaryEditor && <SiteBoundaryEditor />}
137
163
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
138
- {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
164
+ {showSiteBoundaryEditor && <SiteBoundaryEditor />}
165
+ {movingNode?.type === 'building' && (
166
+ <MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
167
+ )}
139
168
 
140
169
  {/* Building-local group: all other tools are relative to the selected building.
141
170
  Cursor visuals set positions in building-local space; this group applies the
@@ -145,30 +174,117 @@ export const ToolManager: React.FC = () => {
145
174
  rotation={buildingRotation as [number, number, number]}
146
175
  >
147
176
  {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
148
- {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
149
- {showSlabHoleEditor && selectedSlabId && editingHole && (
150
- <SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
151
- )}
152
- {showCeilingBoundaryEditor && selectedCeilingId && (
153
- <CeilingBoundaryEditor ceilingId={selectedCeilingId} />
154
- )}
155
- {showCeilingHoleEditor && selectedCeilingId && editingHole && (
156
- <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
157
- )}
158
- {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
159
- {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
160
- {curvingWall && <CurveWallTool node={curvingWall} />}
161
- {curvingFence && <CurveFenceTool node={curvingFence} />}
177
+ {showSlabBoundaryEditor &&
178
+ selectedSlabId &&
179
+ (() => {
180
+ const Registry = getRegistryAffordanceTool('slab', 'boundary-edit')
181
+ return Registry ? (
182
+ <Suspense fallback={null}>
183
+ <Registry slabId={selectedSlabId} />
184
+ </Suspense>
185
+ ) : null
186
+ })()}
187
+ {showSlabHoleEditor &&
188
+ selectedSlabId &&
189
+ editingHole &&
190
+ (() => {
191
+ const Registry = getRegistryAffordanceTool('slab', 'hole-edit')
192
+ return Registry ? (
193
+ <Suspense fallback={null}>
194
+ <Registry holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
195
+ </Suspense>
196
+ ) : null
197
+ })()}
198
+ {showCeilingBoundaryEditor &&
199
+ selectedCeilingId &&
200
+ (() => {
201
+ const Registry = getRegistryAffordanceTool('ceiling', 'boundary-edit')
202
+ return Registry ? (
203
+ <Suspense fallback={null}>
204
+ <Registry ceilingId={selectedCeilingId} />
205
+ </Suspense>
206
+ ) : null
207
+ })()}
208
+ {showCeilingHoleEditor &&
209
+ selectedCeilingId &&
210
+ editingHole &&
211
+ (() => {
212
+ const Registry = getRegistryAffordanceTool('ceiling', 'hole-edit')
213
+ return Registry ? (
214
+ <Suspense fallback={null}>
215
+ <Registry ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
216
+ </Suspense>
217
+ ) : null
218
+ })()}
219
+ {movingWallEndpoint &&
220
+ (() => {
221
+ const RegistryAffordance = getRegistryAffordanceTool(
222
+ movingWallEndpoint.wall.type,
223
+ 'move-endpoint',
224
+ )
225
+ return RegistryAffordance ? (
226
+ <Suspense fallback={null}>
227
+ <RegistryAffordance target={movingWallEndpoint} />
228
+ </Suspense>
229
+ ) : null
230
+ })()}
231
+ {movingFenceEndpoint &&
232
+ (() => {
233
+ const RegistryAffordance = getRegistryAffordanceTool(
234
+ movingFenceEndpoint.fence.type,
235
+ 'move-endpoint',
236
+ )
237
+ return RegistryAffordance ? (
238
+ <Suspense fallback={null}>
239
+ <RegistryAffordance target={movingFenceEndpoint} />
240
+ </Suspense>
241
+ ) : null
242
+ })()}
243
+ {curvingWall &&
244
+ (() => {
245
+ const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
246
+ return Registry ? (
247
+ <Suspense fallback={null}>
248
+ <Registry node={curvingWall} />
249
+ </Suspense>
250
+ ) : null
251
+ })()}
252
+ {curvingFence &&
253
+ (() => {
254
+ const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
255
+ return RegistryAffordance ? (
256
+ <Suspense fallback={null}>
257
+ <RegistryAffordance node={curvingFence} />
258
+ </Suspense>
259
+ ) : null
260
+ })()}
162
261
  {movingNode && movingNode.type !== 'building' && (
163
- <MoveTool onSpawnMoved={handlePlacedNodeSelected} />
262
+ <MoveTool
263
+ onNodeMoved={handlePlacedNodeSelected}
264
+ onSpawnMoved={handlePlacedNodeSelected}
265
+ />
164
266
  )}
165
- {!movingNode && showBuildTool && tool === 'spawn' && (
166
- <SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
267
+ {/* Registry-first: when the active tool's kind has a registered
268
+ NodeDefinition with a tool contribution, mount it here. */}
269
+ {!movingNode && useRegistryTool && RegistryToolComponent && (
270
+ <Suspense fallback={null}>
271
+ <RegistryToolComponent />
272
+ </Suspense>
167
273
  )}
168
- {!movingNode && showBuildTool && tool === 'column' && (
169
- <ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
274
+ {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && (
275
+ <ElevatorTool
276
+ buildingId={buildingId as BuildingNode['id'] | null}
277
+ levelId={activeLevelId ?? null}
278
+ onPlaced={handlePlacedElevatorSelected}
279
+ />
170
280
  )}
171
- {!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
281
+ {!movingNode && BuildToolComponent && tool !== 'elevator' ? <BuildToolComponent /> : null}
282
+ {/* Figma-style alignment guides published by the move / placement
283
+ tools above. Lives inside the building-local group so the
284
+ building-local guide coords render at the right world position. */}
285
+ <Alignment3DGuideLayer />
286
+ {/* "Magnetic" beacon at the active wall-draft snap point. */}
287
+ <WallSnapBeaconLayer />
172
288
  </group>
173
289
  </>
174
290
  )
@@ -3,10 +3,7 @@ import {
3
3
  type AnyNodeId,
4
4
  type DoorNode,
5
5
  getScaledDimensions,
6
- getWallCurveFrameAt,
7
- getWallCurveLength,
8
6
  type ItemNode,
9
- isCurvedWall,
10
7
  useScene,
11
8
  type WallNode,
12
9
  WallNode as WallSchema,
@@ -15,11 +12,30 @@ import {
15
12
  import { useViewer } from '@pascal-app/viewer'
16
13
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
14
  import useEditor from '../../../store/use-editor'
18
-
19
- export type WallPlanPoint = [number, number]
15
+ import {
16
+ distanceSquared,
17
+ findWallSnapTarget,
18
+ findWallSpecialPointSnap,
19
+ projectPointOntoWall,
20
+ WALL_JOIN_SNAP_RADIUS,
21
+ type WallDraftSnapResult,
22
+ type WallPlanPoint,
23
+ } from './wall-snap-geometry'
24
+
25
+ // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
26
+ // existing importers (fence drafting, the editor barrel) keep their paths.
27
+ export {
28
+ findWallSnapTarget,
29
+ WALL_JOIN_SNAP_RADIUS,
30
+ type WallDraftSnapKind,
31
+ type WallDraftSnapResult,
32
+ type WallPlanPoint,
33
+ } from './wall-snap-geometry'
20
34
 
21
35
  export const WALL_GRID_STEP = 0.5
22
- export const WALL_JOIN_SNAP_RADIUS = 0.35
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
23
39
  export const WALL_MIN_LENGTH = 0.01
24
40
  const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
25
41
 
@@ -35,13 +51,7 @@ type WallSplitIntersection = {
35
51
  point: WallPlanPoint
36
52
  }
37
53
 
38
- function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
39
- const dx = a[0] - b[0]
40
- const dz = a[1] - b[1]
41
- return dx * dx + dz * dz
42
- }
43
-
44
- export function getWallGridStep(): number {
54
+ export function getSegmentGridStep(): number {
45
55
  return useEditor.getState().gridSnapStep
46
56
  }
47
57
 
@@ -58,41 +68,30 @@ export function snapPointTo45Degrees(
58
68
  cursor: WallPlanPoint,
59
69
  step = WALL_GRID_STEP,
60
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,
61
78
  ): WallPlanPoint {
62
79
  const dx = cursor[0] - start[0]
63
80
  const dz = cursor[1] - start[1]
64
81
  const angle = Math.atan2(dz, dx)
65
82
  const snappedAngle = Math.round(angle / angleStep) * angleStep
66
83
  const distance = Math.sqrt(dx * dx + dz * dz)
67
-
68
- return snapPointToGrid(
69
- [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
70
- step,
71
- )
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)
72
89
  }
73
90
 
74
- export function getWallAngleSnapStep(step = getWallGridStep()): number {
91
+ export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
75
92
  return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
76
93
  }
77
94
 
78
- function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
79
- const [x1, z1] = wall.start
80
- const [x2, z2] = wall.end
81
- const dx = x2 - x1
82
- const dz = z2 - z1
83
- const lengthSquared = dx * dx + dz * dz
84
- if (lengthSquared < 1e-9) {
85
- return null
86
- }
87
-
88
- const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
89
- if (t <= 0 || t >= 1) {
90
- return null
91
- }
92
-
93
- return [x1 + dx * t, z1 + dz * t]
94
- }
95
-
96
95
  function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
97
96
  const { id: _id, parentId: _parentId, children, ...rest } = wall
98
97
 
@@ -324,76 +323,72 @@ function splitWallIfNeeded(
324
323
  }
325
324
  }
326
325
 
327
- export function findWallSnapTarget(
328
- point: WallPlanPoint,
329
- walls: WallNode[],
330
- options?: { ignoreWallIds?: string[]; radius?: number },
331
- ): WallPlanPoint | null {
332
- const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
333
- const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
334
- let bestTarget: WallPlanPoint | null = null
335
- let bestDistanceSquared = Number.POSITIVE_INFINITY
336
-
337
- for (const wall of walls) {
338
- if (ignoreWallIds.has(wall.id)) {
339
- continue
340
- }
341
-
342
- const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
343
-
344
- if (isCurvedWall(wall)) {
345
- const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
346
- for (let index = 0; index <= sampleCount; index += 1) {
347
- const frame = getWallCurveFrameAt(wall, index / sampleCount)
348
- candidates.push([frame.point.x, frame.point.y])
349
- }
350
- } else {
351
- candidates.push(projectPointOntoWall(point, wall))
352
- }
353
- for (const candidate of candidates) {
354
- if (!candidate) {
355
- continue
356
- }
357
-
358
- const candidateDistanceSquared = distanceSquared(point, candidate)
359
- if (
360
- candidateDistanceSquared > radiusSquared ||
361
- candidateDistanceSquared >= bestDistanceSquared
362
- ) {
363
- continue
364
- }
365
-
366
- bestTarget = candidate
367
- bestDistanceSquared = candidateDistanceSquared
368
- }
369
- }
370
-
371
- return bestTarget
372
- }
373
-
374
- export function snapWallDraftPoint(args: {
326
+ type SnapWallDraftArgs = {
375
327
  point: WallPlanPoint
376
328
  walls: WallNode[]
377
329
  start?: WallPlanPoint
378
330
  angleSnap?: boolean
379
331
  ignoreWallIds?: string[]
380
- }): WallPlanPoint {
381
- const { point, walls, start, angleSnap = false, ignoreWallIds } = args
382
- const step = getWallGridStep()
332
+ /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
333
+ step?: number
334
+ /**
335
+ * Magnetic snapping to existing wall geometry (corners, midpoints,
336
+ * crossings, wall bodies). When `false`, only grid/angle snap applies and
337
+ * `snap` is always `null`. Defaults to `true` so callers that don't care
338
+ * keep the prior behaviour.
339
+ */
340
+ magnetic?: boolean
341
+ /**
342
+ * Optional grid-snap override. Lets the caller route grid snapping
343
+ * through a world-XZ aligned snap (so a rotated building's draft
344
+ * lands on the visible grid). When omitted, falls back to the
345
+ * local-axis grid at `step`.
346
+ */
347
+ gridSnap?: (point: WallPlanPoint) => WallPlanPoint
348
+ }
349
+
350
+ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
351
+ const {
352
+ point,
353
+ walls,
354
+ start,
355
+ angleSnap = false,
356
+ ignoreWallIds,
357
+ step: overrideStep,
358
+ magnetic = true,
359
+ gridSnap,
360
+ } = args
361
+
362
+ // Discrete special points (corner / midpoint / crossing) are taken from the
363
+ // raw cursor so an interim grid snap can't mask them. A corner always wins,
364
+ // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
365
+ if (magnetic) {
366
+ const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
367
+ if (special) return special
368
+ }
369
+
370
+ const step = overrideStep ?? getSegmentGridStep()
383
371
  const angleStep = getWallAngleSnapStep(step)
384
372
  const basePoint =
385
373
  start && angleSnap
386
- ? snapPointTo45Degrees(start, point, step, angleStep)
387
- : snapPointToGrid(point, step)
374
+ ? snapPointTo45Degrees(start, point, step, angleStep, gridSnap)
375
+ : gridSnap
376
+ ? gridSnap(point)
377
+ : snapPointToGrid(point, step)
378
+
379
+ if (magnetic) {
380
+ const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
381
+ if (wallSnap) return { point: wallSnap, snap: 'wall' }
382
+ }
388
383
 
389
- return (
390
- findWallSnapTarget(basePoint, walls, {
391
- ignoreWallIds,
392
- }) ?? basePoint
393
- )
384
+ return { point: basePoint, snap: null }
385
+ }
386
+
387
+ export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint {
388
+ return snapWallDraftPointDetailed(args).point
394
389
  }
395
390
 
396
- export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
391
+ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
397
392
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
398
393
  }
399
394
 
@@ -405,7 +400,7 @@ export function createWallOnCurrentLevel(
405
400
  const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
406
401
  const { updateNodes } = useScene.getState()
407
402
 
408
- if (!(currentLevelId && isWallLongEnough(start, end))) {
403
+ if (!(currentLevelId && isSegmentLongEnough(start, end))) {
409
404
  return null
410
405
  }
411
406
 
@@ -444,7 +439,7 @@ export function createWallOnCurrentLevel(
444
439
  resolvedStart = splitStart.point
445
440
  }
446
441
 
447
- if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
442
+ if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
448
443
  return null
449
444
  }
450
445
 
@@ -458,7 +453,12 @@ export function createWallOnCurrentLevel(
458
453
  }
459
454
 
460
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 ?? {}
461
460
  const wall = WallSchema.parse({
461
+ ...defaults,
462
462
  name: `Wall ${wallCount + 1}`,
463
463
  start: resolvedStart,
464
464
  end: resolvedEnd,
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { WallNode } from '@pascal-app/core'
3
+ import {
4
+ findWallSnapTarget,
5
+ findWallSpecialPointSnap,
6
+ type WallPlanPoint,
7
+ } from './wall-snap-geometry'
8
+
9
+ function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNode {
10
+ return {
11
+ object: 'node',
12
+ id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'],
13
+ type: 'wall',
14
+ name: 'Wall',
15
+ parentId: null,
16
+ visible: true,
17
+ metadata: {},
18
+ children: [],
19
+ start,
20
+ end,
21
+ thickness: 0.1,
22
+ frontSide: 'unknown',
23
+ backSide: 'unknown',
24
+ } as unknown as WallNode
25
+ }
26
+
27
+ describe('findWallSpecialPointSnap', () => {
28
+ test('snaps to a wall corner (endpoint) when near it', () => {
29
+ const walls = [makeWall([0, 0], [4, 0])]
30
+ const result = findWallSpecialPointSnap([0.1, 0.1], walls)
31
+ expect(result?.snap).toBe('endpoint')
32
+ expect(result?.point).toEqual([0, 0])
33
+ })
34
+
35
+ test('snaps to a wall midpoint when near it (not a corner)', () => {
36
+ const walls = [makeWall([0, 0], [4, 0])]
37
+ const result = findWallSpecialPointSnap([2.1, 0.2], walls)
38
+ expect(result?.snap).toBe('midpoint')
39
+ expect(result?.point).toEqual([2, 0])
40
+ })
41
+
42
+ test('snaps to the crossing of two walls (intersection)', () => {
43
+ // A runs along z=0; B crosses it at x=1. B's own midpoint is [1,1], far
44
+ // from the crossing, so the snap is the intersection, not a midpoint.
45
+ const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([1, -1], [1, 3], 'b')]
46
+ const result = findWallSpecialPointSnap([1.1, 0.1], walls)
47
+ expect(result?.snap).toBe('intersection')
48
+ expect(result?.point[0]).toBeCloseTo(1, 6)
49
+ expect(result?.point[1]).toBeCloseTo(0, 6)
50
+ })
51
+
52
+ test('corner wins over a midpoint when both are in range', () => {
53
+ const walls = [makeWall([0, 0], [1, 0])]
54
+ const result = findWallSpecialPointSnap([0.9, 0.1], walls)
55
+ expect(result?.snap).toBe('endpoint')
56
+ expect(result?.point).toEqual([1, 0])
57
+ })
58
+
59
+ test('returns null when no special point is in range', () => {
60
+ const walls = [makeWall([0, 0], [4, 0])]
61
+ // Near the wall body but far from corner/midpoint — that's an edge snap,
62
+ // handled separately by findWallSnapTarget, not a special point.
63
+ expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
64
+ })
65
+ })
66
+
67
+ describe('findWallSnapTarget (edge / along-wall)', () => {
68
+ test('projects onto a wall body within range', () => {
69
+ const walls = [makeWall([0, 0], [4, 0])]
70
+ const result = findWallSnapTarget([1.2, 0.1], walls)
71
+ expect(result?.[0]).toBeCloseTo(1.2, 6)
72
+ expect(result?.[1]).toBeCloseTo(0, 6)
73
+ })
74
+
75
+ test('returns null when too far from any wall', () => {
76
+ const walls = [makeWall([0, 0], [4, 0])]
77
+ expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
78
+ })
79
+ })