@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.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- 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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
116
|
+
candidateDistanceSquared > radius * radius ||
|
|
136
117
|
candidateDistanceSquared >= bestDistanceSquared
|
|
137
118
|
) {
|
|
138
119
|
continue
|
|
139
120
|
}
|
|
140
121
|
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
?
|
|
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, {
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
515
|
+
if (
|
|
516
|
+
!isSegmentLongEnough(resolvedStart, resolvedEnd) ||
|
|
517
|
+
pointsEqual(resolvedStart, resolvedEnd)
|
|
518
|
+
) {
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
445
521
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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, {
|
|
21
|
+
updateNode(zoneId, {
|
|
22
|
+
polygon: newPolygon,
|
|
23
|
+
autoFromWalls: false,
|
|
24
|
+
boundaryWallIds: [],
|
|
25
|
+
})
|
|
22
26
|
},
|
|
23
27
|
[zoneId, updateNode],
|
|
24
28
|
)
|