@pascal-app/editor 0.8.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.
- package/package.json +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -2,55 +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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
-
spawn: SpawnTool,
|
|
49
|
-
window: WindowTool,
|
|
50
|
-
},
|
|
51
|
-
furnish: {
|
|
52
|
-
item: ItemTool,
|
|
53
49
|
},
|
|
50
|
+
furnish: {},
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
export const ToolManager: React.FC = () => {
|
|
@@ -67,6 +64,7 @@ export const ToolManager: React.FC = () => {
|
|
|
67
64
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
68
65
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
69
66
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
|
67
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
70
68
|
const nodes = useScene((state) => state.nodes)
|
|
71
69
|
|
|
72
70
|
// Building transform for the local group — all building-relative tools live inside this group
|
|
@@ -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
|
-
//
|
|
91
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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] })
|
|
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
|
+
})
|
|
132
159
|
}
|
|
133
160
|
|
|
134
161
|
return (
|
|
135
162
|
<>
|
|
136
163
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
|
137
164
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
138
|
-
{movingNode?.type === 'building' &&
|
|
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 &&
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
})()}
|
|
261
|
+
{movingNode && movingNode.type !== 'building' && (
|
|
262
|
+
<MoveTool
|
|
263
|
+
onNodeMoved={handlePlacedNodeSelected}
|
|
264
|
+
onSpawnMoved={handlePlacedNodeSelected}
|
|
265
|
+
/>
|
|
154
266
|
)}
|
|
155
|
-
{
|
|
156
|
-
|
|
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>
|
|
157
273
|
)}
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
274
|
+
{!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && (
|
|
275
|
+
<ElevatorTool
|
|
276
|
+
buildingId={buildingId as BuildingNode['id'] | null}
|
|
277
|
+
levelId={activeLevelId ?? null}
|
|
278
|
+
onPlaced={handlePlacedElevatorSelected}
|
|
279
|
+
/>
|
|
164
280
|
)}
|
|
165
|
-
{!movingNode && BuildToolComponent && tool
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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 &&
|
|
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 (!
|
|
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
|
+
})
|