@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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet'
|
|
2
|
+
export type ContinuationMode = string
|
|
3
|
+
|
|
4
|
+
export const CONTINUATION_PROFILES: Record<
|
|
5
|
+
ContinuationContext,
|
|
6
|
+
{
|
|
7
|
+
options: ContinuationMode[]
|
|
8
|
+
default: ContinuationMode
|
|
9
|
+
labels: Record<string, string>
|
|
10
|
+
icons: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
> = {
|
|
13
|
+
wall: {
|
|
14
|
+
options: ['room', 'single'],
|
|
15
|
+
default: 'room',
|
|
16
|
+
labels: { room: 'Room (auto-close)', single: 'Single wall' },
|
|
17
|
+
icons: { room: 'lucide:square', single: 'lucide:minus' },
|
|
18
|
+
},
|
|
19
|
+
fence: {
|
|
20
|
+
options: ['single', 'continuous', 'curved'],
|
|
21
|
+
default: 'continuous',
|
|
22
|
+
labels: {
|
|
23
|
+
continuous: 'Continuous',
|
|
24
|
+
single: 'Single fence',
|
|
25
|
+
curved: 'Curved fence',
|
|
26
|
+
},
|
|
27
|
+
icons: {
|
|
28
|
+
continuous: 'lucide:waypoints',
|
|
29
|
+
single: 'lucide:minus',
|
|
30
|
+
curved: 'lucide:spline',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
point: {
|
|
34
|
+
options: ['once', 'repeat'],
|
|
35
|
+
default: 'once',
|
|
36
|
+
labels: { once: 'Place once', repeat: 'Place multiple' },
|
|
37
|
+
icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
|
|
38
|
+
},
|
|
39
|
+
cabinet: {
|
|
40
|
+
options: ['single', 'continuous'],
|
|
41
|
+
default: 'single',
|
|
42
|
+
labels: { single: 'Single cabinet', continuous: 'Continuous run' },
|
|
43
|
+
icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' },
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
|
|
48
|
+
|
|
49
|
+
export function nextContinuation(
|
|
50
|
+
context: ContinuationContext,
|
|
51
|
+
current: ContinuationMode,
|
|
52
|
+
): ContinuationMode {
|
|
53
|
+
const profile = CONTINUATION_PROFILES[context]
|
|
54
|
+
const index = profile.options.indexOf(current)
|
|
55
|
+
if (index === -1) return profile.default
|
|
56
|
+
return profile.options[(index + 1) % profile.options.length] ?? profile.default
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function continuationContextOf(kind: string): ContinuationContext | null {
|
|
60
|
+
if (kind === 'wall') return 'wall'
|
|
61
|
+
if (kind === 'fence') return 'fence'
|
|
62
|
+
if (kind === 'cabinet') return 'cabinet'
|
|
63
|
+
return POINT_KINDS.has(kind) ? 'point' : null
|
|
64
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
5
|
+
DEFAULT_ANGLE_STEP,
|
|
6
|
+
nodeRegistry,
|
|
7
|
+
registerNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import {
|
|
11
|
+
canDirectMoveNode,
|
|
12
|
+
resolveDirectManipulationNode,
|
|
13
|
+
resolveDirectRotationDragDelta,
|
|
14
|
+
snapDirectRotationDelta,
|
|
15
|
+
} from './direct-manipulation'
|
|
16
|
+
|
|
17
|
+
function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
|
|
18
|
+
if (nodeRegistry.has(kind)) return
|
|
19
|
+
registerNode({
|
|
20
|
+
kind,
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
schema: z.object({ type: z.literal(kind) }) as never,
|
|
23
|
+
category: 'structure',
|
|
24
|
+
defaults: () => ({ type: kind }) as never,
|
|
25
|
+
capabilities: {},
|
|
26
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
27
|
+
...overrides,
|
|
28
|
+
} as AnyNodeDefinition)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('snapDirectRotationDelta', () => {
|
|
32
|
+
test('snaps rotation deltas to the default angle increment', () => {
|
|
33
|
+
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
|
|
34
|
+
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
|
|
35
|
+
DEFAULT_ANGLE_STEP,
|
|
36
|
+
)
|
|
37
|
+
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
|
|
38
|
+
-DEFAULT_ANGLE_STEP,
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('keeps the raw rotation delta while free-rotating', () => {
|
|
43
|
+
const rawDelta = DEFAULT_ANGLE_STEP * 0.42
|
|
44
|
+
expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('resolveDirectRotationDragDelta', () => {
|
|
49
|
+
test('maps horizontal pointer motion to the direct rotation delta direction', () => {
|
|
50
|
+
const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
|
|
51
|
+
|
|
52
|
+
expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
|
|
53
|
+
-DEFAULT_ANGLE_STEP,
|
|
54
|
+
)
|
|
55
|
+
expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
|
|
56
|
+
DEFAULT_ANGLE_STEP,
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('keeps unsnapped drag deltas while free-rotating', () => {
|
|
61
|
+
expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('canDirectMoveNode', () => {
|
|
66
|
+
// Accepts kinds with a 3D-mountable move tool (`movable` or
|
|
67
|
+
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
|
|
68
|
+
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
|
|
69
|
+
const kind = 'direct-move-floorplan-only-test'
|
|
70
|
+
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
|
71
|
+
|
|
72
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('rejects MEP kinds that own move through bespoke selection affordances', () => {
|
|
76
|
+
for (const kind of [
|
|
77
|
+
'duct-segment',
|
|
78
|
+
'duct-fitting',
|
|
79
|
+
'pipe-segment',
|
|
80
|
+
'pipe-fitting',
|
|
81
|
+
'lineset',
|
|
82
|
+
'liquid-line',
|
|
83
|
+
]) {
|
|
84
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('accepts kinds with a bespoke move tool', () => {
|
|
89
|
+
const kind = 'direct-move-bespoke-tool-test'
|
|
90
|
+
registerTestDefinition(kind, {
|
|
91
|
+
affordanceTools: {
|
|
92
|
+
move: async () => ({ default: () => null }),
|
|
93
|
+
} as never,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('accepts nodes with the generic movable capability', () => {
|
|
100
|
+
const kind = 'direct-move-movable-test'
|
|
101
|
+
registerTestDefinition(kind, {
|
|
102
|
+
capabilities: {
|
|
103
|
+
movable: { axes: ['x', 'z'], gridSnap: true },
|
|
104
|
+
},
|
|
105
|
+
} as Partial<AnyNodeDefinition>)
|
|
106
|
+
|
|
107
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('rejects kinds with no registered move path', () => {
|
|
111
|
+
const kind = 'direct-move-none-test'
|
|
112
|
+
registerTestDefinition(kind, {})
|
|
113
|
+
|
|
114
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('resolveDirectManipulationNode', () => {
|
|
119
|
+
test('routes proxied members to their assembly for direct transforms', () => {
|
|
120
|
+
const group = {
|
|
121
|
+
id: 'direct_manipulation_group',
|
|
122
|
+
type: 'direct-manipulation-group-test',
|
|
123
|
+
} as unknown as AnyNode
|
|
124
|
+
const member = {
|
|
125
|
+
id: 'direct_manipulation_member',
|
|
126
|
+
type: 'direct-manipulation-member-test',
|
|
127
|
+
metadata: { nodeSelectionProxyId: group.id },
|
|
128
|
+
} as unknown as AnyNode
|
|
129
|
+
|
|
130
|
+
expect(
|
|
131
|
+
resolveDirectManipulationNode(member, {
|
|
132
|
+
[group.id]: group,
|
|
133
|
+
[member.id]: member,
|
|
134
|
+
}),
|
|
135
|
+
).toBe(group)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('falls back to the selected node when the proxy target is missing', () => {
|
|
139
|
+
const member = {
|
|
140
|
+
id: 'direct_manipulation_orphan_member',
|
|
141
|
+
type: 'direct-manipulation-member-test',
|
|
142
|
+
metadata: { nodeSelectionProxyId: 'missing_group' },
|
|
143
|
+
} as unknown as AnyNode
|
|
144
|
+
|
|
145
|
+
expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('routes parent-frame children to their rotatable parent', () => {
|
|
149
|
+
const parentKind = 'direct-manipulation-parent-frame-parent-test'
|
|
150
|
+
const childKind = 'direct-manipulation-parent-frame-child-test'
|
|
151
|
+
registerTestDefinition(parentKind, {
|
|
152
|
+
capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
|
|
153
|
+
})
|
|
154
|
+
registerTestDefinition(childKind, {
|
|
155
|
+
capabilities: {
|
|
156
|
+
movable: {
|
|
157
|
+
axes: ['x', 'z'],
|
|
158
|
+
gridSnap: true,
|
|
159
|
+
parentFrame: {
|
|
160
|
+
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
|
|
161
|
+
(node.parentId ? nodes[node.parentId] : null) ?? null,
|
|
162
|
+
parentRotationY: () => 0,
|
|
163
|
+
localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
|
|
164
|
+
local[0],
|
|
165
|
+
local[1],
|
|
166
|
+
local[2],
|
|
167
|
+
],
|
|
168
|
+
planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
|
|
169
|
+
planX,
|
|
170
|
+
localY,
|
|
171
|
+
planZ,
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode
|
|
179
|
+
const child = {
|
|
180
|
+
id: 'direct_manipulation_child',
|
|
181
|
+
type: childKind,
|
|
182
|
+
parentId: parent.id,
|
|
183
|
+
} as unknown as AnyNode
|
|
184
|
+
|
|
185
|
+
expect(
|
|
186
|
+
resolveDirectManipulationNode(child, {
|
|
187
|
+
[parent.id]: parent,
|
|
188
|
+
[child.id]: child,
|
|
189
|
+
}),
|
|
190
|
+
).toBe(parent)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type ArcResizeHandle,
|
|
4
|
+
createSceneApi,
|
|
5
|
+
DEFAULT_ANGLE_STEP,
|
|
6
|
+
type HandleDescriptor,
|
|
7
|
+
hasRegistry3DMoveTool,
|
|
8
|
+
isMovable,
|
|
9
|
+
nodeRegistry,
|
|
10
|
+
resolveSelectionProxyId,
|
|
11
|
+
type SceneApi,
|
|
12
|
+
useScene,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
|
|
15
|
+
function resolveHandles(node: AnyNode): HandleDescriptor<AnyNode>[] {
|
|
16
|
+
const handles = nodeRegistry.get(node.type)?.handles
|
|
17
|
+
if (!handles) return []
|
|
18
|
+
return (
|
|
19
|
+
typeof handles === 'function' ? handles(node as never) : handles
|
|
20
|
+
) as HandleDescriptor<AnyNode>[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle<AnyNode> | null {
|
|
24
|
+
for (const handle of resolveHandles(node)) {
|
|
25
|
+
if (handle.kind === 'arc-resize' && handle.shape === 'rotate') {
|
|
26
|
+
return handle as ArcResizeHandle<AnyNode>
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canDirectRotateNode(node: AnyNode): boolean {
|
|
33
|
+
return (
|
|
34
|
+
getDirectRotateHandle(node) !== null ||
|
|
35
|
+
nodeRegistry.get(node.type)?.capabilities?.rotatable !== undefined
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BESPOKE_SELECTION_MOVE_KINDS = new Set([
|
|
40
|
+
'duct-segment',
|
|
41
|
+
'duct-fitting',
|
|
42
|
+
'pipe-segment',
|
|
43
|
+
'pipe-fitting',
|
|
44
|
+
'lineset',
|
|
45
|
+
'liquid-line',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
export function canDirectMoveNode(node: AnyNode): boolean {
|
|
49
|
+
// These MEP kinds own move through bespoke selection rigs (latch cubes,
|
|
50
|
+
// directional arrows, grid-driven previews). Sending body drags/clicks
|
|
51
|
+
// through the generic direct-move handoff conflicts with that path and can
|
|
52
|
+
// leave the editor appearing frozen while their mover waits for the wrong
|
|
53
|
+
// gesture stream.
|
|
54
|
+
if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false
|
|
55
|
+
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
|
|
56
|
+
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
|
|
57
|
+
// floorplan-only movers (zone) for the 2D plan.
|
|
58
|
+
if (!hasRegistry3DMoveTool(node.type)) return false
|
|
59
|
+
// Bespoke movers (`affordanceTools.move`) own their constraints and often
|
|
60
|
+
// deliberately omit `capabilities.movable` — only gate registry-movable
|
|
61
|
+
// kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet
|
|
62
|
+
// run locked behind its selection proxy) can opt out of direct move.
|
|
63
|
+
if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true
|
|
64
|
+
return isMovable(node)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveDirectManipulationNode(
|
|
68
|
+
node: AnyNode,
|
|
69
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
70
|
+
): AnyNode {
|
|
71
|
+
const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
|
|
72
|
+
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
|
|
73
|
+
const parent = parentFrame?.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
|
|
74
|
+
return parent && canDirectRotateNode(parent) ? parent : target
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function snapDirectRotationDelta(delta: number, free: boolean): number {
|
|
78
|
+
return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveDirectRotationDragDelta(
|
|
82
|
+
startX: number,
|
|
83
|
+
clientX: number,
|
|
84
|
+
radiansPerPixel: number,
|
|
85
|
+
free: boolean,
|
|
86
|
+
): number {
|
|
87
|
+
return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveDirectRotationPatch(
|
|
91
|
+
node: AnyNode,
|
|
92
|
+
delta: number,
|
|
93
|
+
sceneApi: SceneApi = createSceneApi(useScene),
|
|
94
|
+
): Partial<AnyNode> | null {
|
|
95
|
+
const rotateHandle = getDirectRotateHandle(node)
|
|
96
|
+
if (rotateHandle) {
|
|
97
|
+
return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rotation = (node as { rotation?: unknown }).rotation
|
|
101
|
+
if (typeof rotation === 'number') {
|
|
102
|
+
return { rotation: rotation - delta } as Partial<AnyNode>
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(rotation)) {
|
|
105
|
+
const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
|
|
106
|
+
return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
package/src/lib/editor-api.ts
CHANGED
|
@@ -1,33 +1,20 @@
|
|
|
1
|
-
import type { AnyNode, EditorApi
|
|
1
|
+
import type { AnyNode, EditorApi } from '@pascal-app/core'
|
|
2
2
|
import useEditor from '../store/use-editor'
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
4
|
+
import {
|
|
5
|
+
controlPointReshapeScope,
|
|
6
|
+
endpointReshapeScope,
|
|
7
|
+
tangentReshapeScope,
|
|
8
|
+
} from './interaction/scope'
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* is a table lookup rather than an `if (type === 'wall')` chain so
|
|
12
|
-
* adding a new endpoint-draggable kind is a one-line entry instead
|
|
13
|
-
* of a new branch. Each entry casts the generic `AnyNode` to its
|
|
14
|
-
* concrete kind — the lookup key already guarantees the type.
|
|
15
|
-
*/
|
|
16
|
-
const endpointEngagers: Record<string, EndpointEngager> = {
|
|
17
|
-
wall: (node, endpoint, editor) =>
|
|
18
|
-
editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }),
|
|
19
|
-
fence: (node, endpoint, editor) =>
|
|
20
|
-
editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }),
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into
|
|
25
|
-
* editor state through this interface; the editor owns the actual setter
|
|
26
|
-
* names so core stays decoupled.
|
|
11
|
+
* Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
|
|
12
|
+
* Descriptors call into editor state through this interface; the editor owns
|
|
13
|
+
* the actual store wiring so core stays decoupled.
|
|
27
14
|
*
|
|
28
|
-
* `engageMove` clears any in-progress endpoint drag or curve gesture
|
|
29
|
-
*
|
|
30
|
-
*
|
|
15
|
+
* `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
|
|
16
|
+
* `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
|
|
17
|
+
* it atomically replaces any prior reshape — there is no separate flag to reset.
|
|
31
18
|
*/
|
|
32
19
|
export function createEditorApi(): EditorApi {
|
|
33
20
|
return {
|
|
@@ -39,10 +26,6 @@ export function createEditorApi(): EditorApi {
|
|
|
39
26
|
// cast lets registry-driven move kinds through without forcing a
|
|
40
27
|
// schema-level type widening.
|
|
41
28
|
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
|
|
42
|
-
editor.setMovingWallEndpoint(null)
|
|
43
|
-
editor.setMovingFenceEndpoint(null)
|
|
44
|
-
editor.setCurvingWall(null)
|
|
45
|
-
editor.setCurvingFence(null)
|
|
46
29
|
},
|
|
47
30
|
engageMoveDrag(node: AnyNode) {
|
|
48
31
|
const editor = useEditor.getState()
|
|
@@ -50,13 +33,18 @@ export function createEditorApi(): EditorApi {
|
|
|
50
33
|
// it at setup and wires its commit-on-release listener.
|
|
51
34
|
editor.setPlacementDragMode(true)
|
|
52
35
|
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
|
|
53
|
-
editor.setMovingWallEndpoint(null)
|
|
54
|
-
editor.setMovingFenceEndpoint(null)
|
|
55
|
-
editor.setCurvingWall(null)
|
|
56
|
-
editor.setCurvingFence(null)
|
|
57
36
|
},
|
|
58
37
|
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
|
|
59
|
-
|
|
38
|
+
// Endpoint reshape is kind-agnostic: the scope carries the node id + which
|
|
39
|
+
// endpoint, and consumers recover the node from the scene. Adding a new
|
|
40
|
+
// endpoint-draggable kind needs no entry here.
|
|
41
|
+
useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
|
|
42
|
+
},
|
|
43
|
+
engageControlPointMove(node: AnyNode, index: number) {
|
|
44
|
+
useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index))
|
|
45
|
+
},
|
|
46
|
+
engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') {
|
|
47
|
+
useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side))
|
|
60
48
|
},
|
|
61
49
|
}
|
|
62
50
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
3
|
+
import { applyFloorplanAlignment } from './apply-alignment'
|
|
4
|
+
|
|
5
|
+
describe('applyFloorplanAlignment', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
useAlignmentGuides.getState().clear()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('can publish passive guides without applying snap', () => {
|
|
11
|
+
useAlignmentGuides.getState().clear()
|
|
12
|
+
|
|
13
|
+
const result = applyFloorplanAlignment(
|
|
14
|
+
[0.04, 2],
|
|
15
|
+
[{ nodeId: 'draft', kind: 'corner', x: 0.04, z: 2 }],
|
|
16
|
+
[{ nodeId: 'wall_a', kind: 'corner', x: 0, z: 0 }],
|
|
17
|
+
{ applySnap: false },
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(result.point).toEqual([0.04, 2])
|
|
21
|
+
expect(result.snapped).toBe(false)
|
|
22
|
+
expect(result.guides).toHaveLength(1)
|
|
23
|
+
expect(useAlignmentGuides.getState().guides).toHaveLength(1)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -2,10 +2,10 @@ import {
|
|
|
2
2
|
type AlignmentAnchor,
|
|
3
3
|
type AlignmentGuide,
|
|
4
4
|
collectAlignmentAnchors,
|
|
5
|
-
resolveAlignment,
|
|
6
5
|
useScene,
|
|
7
6
|
} from '@pascal-app/core'
|
|
8
|
-
import
|
|
7
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
8
|
+
import { resolveAlignmentForFloorplanView } from '../world-grid-snap'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
|
|
@@ -30,9 +30,9 @@ export type FloorplanAlignmentResult = {
|
|
|
30
30
|
*
|
|
31
31
|
* Publishes guides to the `useAlignmentGuides` store as a side effect — set
|
|
32
32
|
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
|
|
33
|
-
* renders them. Returns the adjusted point. When `bypass` is true (Alt
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* renders them. Returns the adjusted point. When `bypass` is true (Alt for
|
|
34
|
+
* alignment-only bypass, or Shift for the full guided-constraint bypass) the
|
|
35
|
+
* point is returned unchanged and guides are cleared.
|
|
36
36
|
*
|
|
37
37
|
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
|
|
38
38
|
* the scene is stable during a single drag, so re-collecting per pointer-move
|
|
@@ -42,14 +42,14 @@ export function applyFloorplanAlignment(
|
|
|
42
42
|
point: readonly [number, number],
|
|
43
43
|
movingAnchors: AlignmentAnchor[],
|
|
44
44
|
candidates: AlignmentAnchor[],
|
|
45
|
-
opts?: { bypass?: boolean; threshold?: number },
|
|
45
|
+
opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
|
|
46
46
|
): FloorplanAlignmentResult {
|
|
47
47
|
if (opts?.bypass) {
|
|
48
48
|
useAlignmentGuides.getState().clear()
|
|
49
49
|
return { point: [point[0], point[1]], snapped: false, guides: [] }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const result =
|
|
52
|
+
const result = resolveAlignmentForFloorplanView({
|
|
53
53
|
moving: movingAnchors,
|
|
54
54
|
candidates,
|
|
55
55
|
threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
|
|
@@ -57,7 +57,7 @@ export function applyFloorplanAlignment(
|
|
|
57
57
|
|
|
58
58
|
useAlignmentGuides.getState().set(result.guides)
|
|
59
59
|
|
|
60
|
-
if (!result.snap) {
|
|
60
|
+
if (!result.snap || opts?.applySnap === false) {
|
|
61
61
|
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
|
|
62
62
|
}
|
|
63
63
|
return {
|
|
@@ -81,8 +81,9 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
|
|
|
81
81
|
*
|
|
82
82
|
* Used by BOTH the move-preview branch and the click-commit handler so the
|
|
83
83
|
* committed vertex lands exactly where the preview showed it. Caller owns the
|
|
84
|
-
* per-kind precedence
|
|
85
|
-
*
|
|
84
|
+
* per-kind precedence: existing-wall endpoint/join snap can still win, while
|
|
85
|
+
* angle-locked segments can pass `applySnap: false` to publish passive guide
|
|
86
|
+
* feedback without pulling the endpoint off its constrained ray.
|
|
86
87
|
*
|
|
87
88
|
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
|
|
88
89
|
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
|
|
@@ -90,7 +91,12 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
|
|
|
90
91
|
*/
|
|
91
92
|
export function alignFloorplanDraftPoint(
|
|
92
93
|
point: readonly [number, number],
|
|
93
|
-
opts?: {
|
|
94
|
+
opts?: {
|
|
95
|
+
applySnap?: boolean
|
|
96
|
+
bypass?: boolean
|
|
97
|
+
threshold?: number
|
|
98
|
+
excludeIds?: readonly string[]
|
|
99
|
+
},
|
|
94
100
|
): [number, number] {
|
|
95
101
|
if (opts?.bypass) {
|
|
96
102
|
useAlignmentGuides.getState().clear()
|
|
@@ -105,7 +111,7 @@ export function alignFloorplanDraftPoint(
|
|
|
105
111
|
point,
|
|
106
112
|
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
|
|
107
113
|
candidates,
|
|
108
|
-
{ threshold: opts?.threshold },
|
|
114
|
+
{ applySnap: opts?.applySnap, threshold: opts?.threshold },
|
|
109
115
|
)
|
|
110
116
|
return snapped
|
|
111
117
|
}
|