@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
|
@@ -2,27 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
|
+
type AnyNodeDefinition,
|
|
5
6
|
type AnyNodeId,
|
|
6
|
-
|
|
7
|
+
createSceneApi,
|
|
8
|
+
emitter,
|
|
7
9
|
type FloorplanAffordanceSession,
|
|
8
10
|
type FloorplanGeometry,
|
|
9
11
|
type FloorplanPalette,
|
|
10
12
|
type FloorplanPoint,
|
|
11
13
|
type GeometryContext,
|
|
14
|
+
isNodeKindEnabled,
|
|
15
|
+
isRegistryMovable,
|
|
12
16
|
kindsWithFloorplanScope,
|
|
17
|
+
type LiveNodeOverrides,
|
|
18
|
+
type LiveTransform,
|
|
13
19
|
nodeRegistry,
|
|
14
20
|
pauseSceneHistory,
|
|
15
21
|
resolveBuildingForLevel,
|
|
22
|
+
resolveSelectionProxyId,
|
|
16
23
|
resumeSceneHistory,
|
|
17
24
|
useInteractive,
|
|
18
25
|
useLiveNodeOverrides,
|
|
19
26
|
useLiveTransforms,
|
|
20
27
|
useScene,
|
|
21
28
|
} from '@pascal-app/core'
|
|
22
|
-
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
23
29
|
import { useViewer } from '@pascal-app/viewer'
|
|
24
30
|
import {
|
|
25
31
|
memo,
|
|
32
|
+
type MouseEvent as ReactMouseEvent,
|
|
26
33
|
type PointerEvent as ReactPointerEvent,
|
|
27
34
|
useCallback,
|
|
28
35
|
useEffect,
|
|
@@ -30,10 +37,45 @@ import {
|
|
|
30
37
|
useRef,
|
|
31
38
|
useState,
|
|
32
39
|
} from 'react'
|
|
40
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
41
|
+
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
|
|
42
|
+
import {
|
|
43
|
+
canDirectRotateNode,
|
|
44
|
+
resolveDirectManipulationNode,
|
|
45
|
+
resolveDirectRotationDragDelta,
|
|
46
|
+
resolveDirectRotationPatch,
|
|
47
|
+
snapDirectRotationDelta,
|
|
48
|
+
} from '../../../lib/direct-manipulation'
|
|
49
|
+
import { createEditorApi } from '../../../lib/editor-api'
|
|
50
|
+
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
|
51
|
+
import {
|
|
52
|
+
type ActiveInteractionScope,
|
|
53
|
+
boundaryReshapeScope,
|
|
54
|
+
controlPointReshapeScope,
|
|
55
|
+
curveReshapeScope,
|
|
56
|
+
endpointReshapeScope,
|
|
57
|
+
holeEditScope,
|
|
58
|
+
tangentReshapeScope,
|
|
59
|
+
} from '../../../lib/interaction/scope'
|
|
33
60
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
61
|
+
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
|
62
|
+
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
|
34
63
|
import useEditor from '../../../store/use-editor'
|
|
64
|
+
import useInteractionScope, {
|
|
65
|
+
useEndpointReshape,
|
|
66
|
+
useMovingNode,
|
|
67
|
+
} from '../../../store/use-interaction-scope'
|
|
68
|
+
import { startGroupPickUp } from '../../editor/group-actions'
|
|
69
|
+
import { classifyParticipant } from '../../editor/group-transform-shared'
|
|
70
|
+
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
|
71
|
+
import {
|
|
72
|
+
FloorplanGroupSelectionBox,
|
|
73
|
+
startFloorplanGroupMove,
|
|
74
|
+
startFloorplanGroupRotate,
|
|
75
|
+
} from '../floorplan-group-move'
|
|
35
76
|
import { useFloorplanRender } from '../floorplan-render-context'
|
|
36
77
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
78
|
+
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
|
37
79
|
|
|
38
80
|
/**
|
|
39
81
|
* Registry-driven floor-plan layer.
|
|
@@ -70,6 +112,9 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
|
|
|
70
112
|
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
|
|
71
113
|
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
|
|
72
114
|
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
|
115
|
+
const DIRECT_DRAG_THRESHOLD_PX = 4
|
|
116
|
+
const DIRECT_ROTATE_EPSILON = 1e-6
|
|
117
|
+
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
|
73
118
|
|
|
74
119
|
/**
|
|
75
120
|
* Snapshot of node fields captured at drag-start, used by the single-undo
|
|
@@ -82,11 +127,18 @@ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
|
|
82
127
|
|
|
83
128
|
type ActiveDrag = {
|
|
84
129
|
pointerId: number
|
|
130
|
+
captureTarget: Element
|
|
85
131
|
/** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
|
|
86
132
|
handleId: string
|
|
87
133
|
session: FloorplanAffordanceSession
|
|
88
134
|
snapshots: NodeSnapshot[]
|
|
89
135
|
historyPaused: boolean
|
|
136
|
+
/**
|
|
137
|
+
* Last plan point handed to `session.apply` (the grab point until the first
|
|
138
|
+
* move). Lets the modifier-key listeners re-run the session immediately on
|
|
139
|
+
* an Alt/Shift flip instead of waiting for the next pointer move.
|
|
140
|
+
*/
|
|
141
|
+
lastPlanPoint: FloorplanPoint
|
|
90
142
|
/**
|
|
91
143
|
* Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
|
|
92
144
|
* the live angle wedge + degree readout — the 2D twin of the 3D rotate
|
|
@@ -94,6 +146,110 @@ type ActiveDrag = {
|
|
|
94
146
|
* rotate affordance measures it: `atan2(pointer − pivot)`.
|
|
95
147
|
*/
|
|
96
148
|
rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
|
|
149
|
+
/**
|
|
150
|
+
* Node id of the reshaping scope this drag began (boundary / curve / endpoint
|
|
151
|
+
* edits), so the matching `endIf` on release/cancel tears down exactly this
|
|
152
|
+
* scope. Unset for affordances that drive no snapping scope (resize / rotate).
|
|
153
|
+
*/
|
|
154
|
+
reshapeScopeNodeId?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type FloorplanAffordanceCancelEffects = {
|
|
158
|
+
restoreSnapshots: (snapshots: NodeSnapshot[]) => void
|
|
159
|
+
resumeHistory: () => void
|
|
160
|
+
clearPreview: (id: AnyNodeId) => void
|
|
161
|
+
clearSnapFeedback: () => void
|
|
162
|
+
endReshapeScope: (drag: ActiveDrag) => void
|
|
163
|
+
clearDragFeedback?: () => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function cancelFloorplanAffordanceDrag(
|
|
167
|
+
dragRef: { current: ActiveDrag | null },
|
|
168
|
+
effects: FloorplanAffordanceCancelEffects,
|
|
169
|
+
pointerId?: number,
|
|
170
|
+
): boolean {
|
|
171
|
+
const drag = dragRef.current
|
|
172
|
+
if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
|
|
173
|
+
|
|
174
|
+
// Clear ownership before cleanup so a queued pointer-up cannot commit the
|
|
175
|
+
// session while cancellation side effects are still running.
|
|
176
|
+
dragRef.current = null
|
|
177
|
+
|
|
178
|
+
if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
|
|
179
|
+
drag.captureTarget.releasePointerCapture?.(drag.pointerId)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
effects.restoreSnapshots(drag.snapshots)
|
|
183
|
+
if (drag.historyPaused) {
|
|
184
|
+
effects.resumeHistory()
|
|
185
|
+
drag.historyPaused = false
|
|
186
|
+
}
|
|
187
|
+
effects.clearSnapFeedback()
|
|
188
|
+
for (const id of drag.session.affectedIds) effects.clearPreview(id)
|
|
189
|
+
effects.endReshapeScope(drag)
|
|
190
|
+
effects.clearDragFeedback?.()
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function subscribeFloorplanAffordanceToolCancel(
|
|
195
|
+
cancelActiveDrag: () => boolean,
|
|
196
|
+
consumeToolCancel: () => void,
|
|
197
|
+
): () => void {
|
|
198
|
+
const onToolCancel = () => {
|
|
199
|
+
if (cancelActiveDrag()) consumeToolCancel()
|
|
200
|
+
}
|
|
201
|
+
emitter.on('tool:cancel', onToolCancel)
|
|
202
|
+
return () => emitter.off('tool:cancel', onToolCancel)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Map a floor-plan affordance to the reshaping scope it represents, so the
|
|
206
|
+
// dispatcher can drive the contextual snapping HUD (the chip) AND make
|
|
207
|
+
// `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
|
|
208
|
+
// edits that set a direction/shape map to a scope; resize / rotate / body-move
|
|
209
|
+
// affordances return `null` (no polygon/wall snapping chip). Keyed off the
|
|
210
|
+
// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
|
|
211
|
+
// / `curve` / `move-endpoint`).
|
|
212
|
+
function affordanceReshapeScope(
|
|
213
|
+
affordance: string,
|
|
214
|
+
nodeId: string,
|
|
215
|
+
payload: unknown,
|
|
216
|
+
): ActiveInteractionScope | null {
|
|
217
|
+
if (affordance.includes('vertex') || affordance.includes('edge')) {
|
|
218
|
+
const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
|
|
219
|
+
return holeIndex !== undefined
|
|
220
|
+
? holeEditScope({ nodeId, holeIndex })
|
|
221
|
+
: boundaryReshapeScope(nodeId)
|
|
222
|
+
}
|
|
223
|
+
if (affordance.includes('curve')) {
|
|
224
|
+
return curveReshapeScope(nodeId)
|
|
225
|
+
}
|
|
226
|
+
if (affordance.includes('control-point')) {
|
|
227
|
+
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
|
228
|
+
return controlPointReshapeScope(nodeId, index)
|
|
229
|
+
}
|
|
230
|
+
if (affordance.includes('tangent')) {
|
|
231
|
+
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
|
232
|
+
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
|
|
233
|
+
}
|
|
234
|
+
if (affordance.includes('endpoint')) {
|
|
235
|
+
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
|
236
|
+
return endpointReshapeScope(nodeId, endpoint)
|
|
237
|
+
}
|
|
238
|
+
// Roof-segment width/depth resize — a no-angle dimension edit, so the
|
|
239
|
+
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
|
|
240
|
+
// Matched exactly so a still-legacy `*-resize` affordance on another kind
|
|
241
|
+
// doesn't get a chip its snap math can't honour yet.
|
|
242
|
+
if (affordance === 'roof-segment-resize') {
|
|
243
|
+
return boundaryReshapeScope(nodeId)
|
|
244
|
+
}
|
|
245
|
+
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
|
|
246
|
+
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
|
|
247
|
+
// matched, so the contextual HUD shows the "Shift = rotate freely" hint over
|
|
248
|
+
// the drag. The affordance applies the 15° angle step itself.
|
|
249
|
+
if (affordance.includes('rotate')) {
|
|
250
|
+
return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
97
253
|
}
|
|
98
254
|
|
|
99
255
|
/**
|
|
@@ -109,6 +265,61 @@ type RotationOverlayState = {
|
|
|
109
265
|
sweep: number
|
|
110
266
|
}
|
|
111
267
|
|
|
268
|
+
type FloorplanEntryDescriptor = {
|
|
269
|
+
id: AnyNodeId
|
|
270
|
+
node: AnyNode
|
|
271
|
+
dependsOnSiblingInputs: boolean
|
|
272
|
+
ctxOverrides?: FloorplanContextOverrides
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type NodeDeps = {
|
|
276
|
+
node: AnyNode
|
|
277
|
+
live: LiveTransform | undefined
|
|
278
|
+
unit: 'metric' | 'imperial'
|
|
279
|
+
selected: boolean
|
|
280
|
+
highlighted: boolean
|
|
281
|
+
hovered: boolean
|
|
282
|
+
moving: boolean
|
|
283
|
+
liveOverride: LiveNodeOverrides | undefined
|
|
284
|
+
palette: FloorplanPalette | undefined
|
|
285
|
+
siblingEpoch: number
|
|
286
|
+
committedNodes: Record<string, AnyNode> | null
|
|
287
|
+
dependencyNodes: AnyNode[]
|
|
288
|
+
interactiveElevators: unknown
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type CacheEntry = {
|
|
292
|
+
deps: NodeDeps
|
|
293
|
+
base: FloorplanGeometry | null
|
|
294
|
+
overlay: FloorplanGeometry | null
|
|
295
|
+
node: AnyNode
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
type LevelDataCacheEntry = {
|
|
299
|
+
nodes: Record<string, AnyNode>
|
|
300
|
+
liveOverrides: Map<string, LiveNodeOverrides>
|
|
301
|
+
ids: readonly AnyNodeId[]
|
|
302
|
+
value: unknown
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type FloorplanContextOverrides = {
|
|
306
|
+
children: AnyNode[]
|
|
307
|
+
siblings: AnyNode[]
|
|
308
|
+
parent: AnyNode | null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type FloorplanLevelDataHook = (args: {
|
|
312
|
+
siblings: ReadonlyArray<AnyNode>
|
|
313
|
+
nodes: Record<string, AnyNode>
|
|
314
|
+
}) => unknown
|
|
315
|
+
|
|
316
|
+
type FloorplanRenderPass = 'base' | 'overlay'
|
|
317
|
+
|
|
318
|
+
const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
|
|
319
|
+
// Group members advertise the drag-to-move-the-selection gesture.
|
|
320
|
+
const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
|
|
321
|
+
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
|
|
322
|
+
|
|
112
323
|
function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
113
324
|
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
|
114
325
|
// deep-cloned to detach from the live store reference.
|
|
@@ -124,25 +335,32 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|
|
124
335
|
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
|
125
336
|
}
|
|
126
337
|
|
|
338
|
+
// Stable empty sentinel used by per-entry builders while the floor plan is
|
|
339
|
+
// hidden; committed scene edits still flow through `useScene`.
|
|
340
|
+
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
|
341
|
+
|
|
127
342
|
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
128
343
|
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
|
129
344
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
|
345
|
+
const unit = useViewer((s) => s.unit)
|
|
346
|
+
const showMeasurements = useViewer((s) => s.showMeasurements)
|
|
130
347
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
131
348
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
132
349
|
const hoveredId = useViewer((s) => s.hoveredId)
|
|
350
|
+
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
|
|
133
351
|
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
134
352
|
const setSelection = useViewer((s) => s.setSelection)
|
|
135
353
|
const nodes = useScene((s) => s.nodes)
|
|
354
|
+
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
355
|
+
const movingNode = useMovingNode()
|
|
136
356
|
// When a building is being moved, its explicit selection may be
|
|
137
357
|
// cleared as part of the move handoff. Fall back to the
|
|
138
358
|
// mid-drag building id so the dimmed floor keeps rendering
|
|
139
359
|
// throughout the gesture.
|
|
140
|
-
const movingBuildingId =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return def?.capabilities?.floorplanLevelContainer ? moving.id : null
|
|
145
|
-
})
|
|
360
|
+
const movingBuildingId =
|
|
361
|
+
movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
|
|
362
|
+
? movingNode.id
|
|
363
|
+
: null
|
|
146
364
|
const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
|
|
147
365
|
|
|
148
366
|
// When only a building is in scope (no specific level), fall back to
|
|
@@ -152,7 +370,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
152
370
|
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
|
|
153
371
|
if (selectedLevelId || !ambientBuildingSourceId) return null
|
|
154
372
|
const building = nodes[ambientBuildingSourceId]
|
|
155
|
-
if (
|
|
373
|
+
if (building?.type !== 'building') return null
|
|
156
374
|
let zero: AnyNodeId | null = null
|
|
157
375
|
let lowestId: AnyNodeId | null = null
|
|
158
376
|
let lowestIdx = Number.POSITIVE_INFINITY
|
|
@@ -175,8 +393,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
175
393
|
const levelId = selectedLevelId ?? ambientLevelId
|
|
176
394
|
const isAmbient = !selectedLevelId && !!ambientLevelId
|
|
177
395
|
const renderCtx = useFloorplanRender()
|
|
178
|
-
const movingNode = useEditor((s) => s.movingNode)
|
|
179
396
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
397
|
+
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
180
398
|
// Door / window placement (both build and move) needs the SVG's
|
|
181
399
|
// background click handler to run — it finds the closest wall via
|
|
182
400
|
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
|
@@ -189,7 +407,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
189
407
|
const editorTool = useEditor((s) => s.tool)
|
|
190
408
|
const structureLayer = useEditor((s) => s.structureLayer)
|
|
191
409
|
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
192
|
-
const
|
|
410
|
+
const endpointReshape = useEndpointReshape()
|
|
193
411
|
const isOpeningPlacementActive =
|
|
194
412
|
(editorPhase === 'structure' &&
|
|
195
413
|
editorMode === 'build' &&
|
|
@@ -200,24 +418,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
200
418
|
floorplanSelectionTool === 'marquee' &&
|
|
201
419
|
structureLayer !== 'zones' &&
|
|
202
420
|
!movingNode &&
|
|
203
|
-
!
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
// tracks the current / fallback level + cab travel, `useLiveNode
|
|
211
|
-
// Overrides` carries live-edit overrides from the inspector. Builders
|
|
212
|
-
// read both via `getState()` inside `def.floorplan`; subscribing here
|
|
213
|
-
// is what forces the layer to re-render when they change.
|
|
214
|
-
const liveOverrides = useLiveNodeOverrides((s) => s.overrides)
|
|
421
|
+
!endpointReshape
|
|
422
|
+
// While the floor plan is not on screen (pure 3D view), per-entry live
|
|
423
|
+
// selectors freeze to `undefined` so drag publishes do not re-render the
|
|
424
|
+
// hidden floor-plan tree.
|
|
425
|
+
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
|
426
|
+
// Elevator builders read runtime state imperatively, so entries include this
|
|
427
|
+
// rare-changing ref in their cache deps.
|
|
215
428
|
const interactiveElevators = useInteractive((s) => s.elevators)
|
|
216
429
|
|
|
217
430
|
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
218
431
|
// Marquee preview selection — matches the legacy `highlightedIdSet` use
|
|
219
432
|
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
|
|
220
433
|
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
|
|
434
|
+
// Multi-selection: members show highlight only (per-node edit chrome hidden)
|
|
435
|
+
// and transformable members advertise the drag-to-move gesture.
|
|
436
|
+
const isMultiSelect = selectedIds.length > 1
|
|
437
|
+
const groupParticipantIdSet = useMemo(() => {
|
|
438
|
+
if (selectedIds.length < 2 || !levelId) return null
|
|
439
|
+
return new Set(
|
|
440
|
+
selectedIds.filter(
|
|
441
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
}, [selectedIds, levelId, nodes])
|
|
221
445
|
|
|
222
446
|
// Interactive state lives in refs; only the visible feedback bits go
|
|
223
447
|
// into React state to keep re-renders cheap during drag.
|
|
@@ -225,12 +449,89 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
225
449
|
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
|
226
450
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
227
451
|
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
|
452
|
+
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
|
453
|
+
const levelDataCacheRef = useRef<Map<string, LevelDataCacheEntry>>(new Map())
|
|
454
|
+
const nodesRef = useRef(nodes)
|
|
455
|
+
const [siblingEpochs, setSiblingEpochs] = useState<Map<AnyNodeId, number>>(() => new Map())
|
|
456
|
+
// Per-node sibling epoch (replaces a single global epoch). Bumped only for the
|
|
457
|
+
// nodes affected by this frame's live drags, so an unaffected wall/opening
|
|
458
|
+
// keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
|
|
459
|
+
// sibling-dependent nodes were live last frame, so a node that just STOPPED
|
|
460
|
+
// being dragged (override cleared, no commit) still gets one final rebuild to
|
|
461
|
+
// revert — its dependents (host wall, junction neighbours) don't carry its
|
|
462
|
+
// override in their own deps.
|
|
463
|
+
const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
|
|
464
|
+
const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
|
|
228
465
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
nodesRef.current = nodes
|
|
468
|
+
}, [nodes])
|
|
469
|
+
|
|
470
|
+
const bumpAffectedSiblingEpochs = useCallback(() => {
|
|
471
|
+
if (!floorplanVisible) return
|
|
472
|
+
|
|
473
|
+
const sceneNodes = nodesRef.current
|
|
474
|
+
const liveTransforms = useLiveTransforms.getState().transforms
|
|
475
|
+
const liveOverrides = useLiveNodeOverrides.getState().overrides
|
|
476
|
+
const liveFlaggedIds: AnyNodeId[] = []
|
|
477
|
+
|
|
478
|
+
for (const [id] of liveTransforms) {
|
|
479
|
+
const node = sceneNodes[id as AnyNodeId]
|
|
480
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
481
|
+
if (
|
|
482
|
+
node &&
|
|
483
|
+
(def?.floorplanDependsOnSiblings ||
|
|
484
|
+
def?.floorplanSiblingOverrides ||
|
|
485
|
+
def?.floorplanAffectedIds)
|
|
486
|
+
) {
|
|
487
|
+
liveFlaggedIds.push(id as AnyNodeId)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const [id] of liveOverrides) {
|
|
491
|
+
const node = sceneNodes[id as AnyNodeId]
|
|
492
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
493
|
+
if (
|
|
494
|
+
node &&
|
|
495
|
+
(def?.floorplanDependsOnSiblings ||
|
|
496
|
+
def?.floorplanSiblingOverrides ||
|
|
497
|
+
def?.floorplanAffectedIds)
|
|
498
|
+
) {
|
|
499
|
+
liveFlaggedIds.push(id as AnyNodeId)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
|
|
504
|
+
const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, sceneNodes, liveOverrides)
|
|
505
|
+
const nodeSiblingEpochs = nodeSiblingEpochRef.current
|
|
506
|
+
for (const id of affectedSiblingIds) {
|
|
507
|
+
nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
|
|
508
|
+
}
|
|
509
|
+
prevLiveFlaggedIdsRef.current = liveFlaggedIds
|
|
510
|
+
if (affectedSiblingIds.size > 0) {
|
|
511
|
+
setSiblingEpochs(new Map(nodeSiblingEpochs))
|
|
512
|
+
}
|
|
513
|
+
}, [floorplanVisible])
|
|
514
|
+
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
bumpAffectedSiblingEpochs()
|
|
517
|
+
const unsubscribeTransforms = useLiveTransforms.subscribe(bumpAffectedSiblingEpochs)
|
|
518
|
+
const unsubscribeOverrides = useLiveNodeOverrides.subscribe(bumpAffectedSiblingEpochs)
|
|
519
|
+
return () => {
|
|
520
|
+
unsubscribeTransforms()
|
|
521
|
+
unsubscribeOverrides()
|
|
522
|
+
}
|
|
523
|
+
}, [bumpAffectedSiblingEpochs])
|
|
524
|
+
|
|
525
|
+
const applyEntrySelection = useCallback(
|
|
526
|
+
(id: AnyNodeId, shouldToggle: boolean) => {
|
|
527
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
528
|
+
setSelection({
|
|
529
|
+
selectedIds: shouldToggle
|
|
530
|
+
? currentSelectedIds.includes(id)
|
|
531
|
+
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
|
532
|
+
: [...currentSelectedIds, id]
|
|
533
|
+
: [id],
|
|
534
|
+
})
|
|
234
535
|
// Setting selection re-renders the entry — the overlay pass mounts
|
|
235
536
|
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
|
236
537
|
// pointerdown and click. If the click target ends up on the SVG
|
|
@@ -239,129 +540,322 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
239
540
|
// selection we just set. Swallow the next click globally to break
|
|
240
541
|
// that race; the listener removes itself after firing (or after a
|
|
241
542
|
// safety timeout if no click follows).
|
|
242
|
-
|
|
243
|
-
ev.stopPropagation()
|
|
244
|
-
ev.preventDefault()
|
|
245
|
-
window.removeEventListener('click', swallowClick, true)
|
|
246
|
-
}
|
|
247
|
-
window.addEventListener('click', swallowClick, true)
|
|
248
|
-
setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
|
|
543
|
+
swallowNextClick(200)
|
|
249
544
|
},
|
|
250
545
|
[setSelection],
|
|
251
546
|
)
|
|
252
547
|
|
|
548
|
+
const handleSelect = useCallback(
|
|
549
|
+
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
|
550
|
+
if (event.button !== 0) return
|
|
551
|
+
event.stopPropagation()
|
|
552
|
+
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
|
|
553
|
+
},
|
|
554
|
+
[applyEntrySelection],
|
|
555
|
+
)
|
|
556
|
+
|
|
253
557
|
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
|
254
558
|
event.stopPropagation()
|
|
255
559
|
}, [])
|
|
256
560
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// Each entry carries TWO trees:
|
|
261
|
-
// - `base`: filled shapes, strokes, polygons, hatches — anything
|
|
262
|
-
// that should respect the kind's z-order bucket.
|
|
263
|
-
// - `overlay`: interactive handles (vertex / midpoint / edge / move)
|
|
264
|
-
// and labels (text / dimension). These always render on top of
|
|
265
|
-
// every base entry so selection chrome and node names stay visible
|
|
266
|
-
// above walls, items, etc.
|
|
267
|
-
//
|
|
268
|
-
// The split is computed by `splitFloorplanOverlay` from the single
|
|
269
|
-
// tree the builder returns. Builders don't need to know about the
|
|
270
|
-
// partition.
|
|
271
|
-
const entries = useMemo(() => {
|
|
272
|
-
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
|
|
273
|
-
void interactiveElevators
|
|
274
|
-
|
|
275
|
-
if (!levelId) return []
|
|
276
|
-
const out: {
|
|
277
|
-
id: AnyNodeId
|
|
278
|
-
node: AnyNode
|
|
279
|
-
base: FloorplanGeometry | null
|
|
280
|
-
overlay: FloorplanGeometry | null
|
|
281
|
-
selected: boolean
|
|
282
|
-
highlighted: boolean
|
|
283
|
-
}[] = []
|
|
561
|
+
const startDirectMoveDrag = useCallback(
|
|
562
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
|
563
|
+
if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
|
|
284
564
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (live) {
|
|
309
|
-
const floorPlaced = def?.capabilities?.floorPlaced
|
|
310
|
-
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
|
311
|
-
if (floorPlaced && hasPosition) {
|
|
312
|
-
effectiveNode = applyPositionLiveTransform(node, live)
|
|
313
|
-
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
|
314
|
-
const dx = live.position[0]
|
|
315
|
-
const dz = live.position[2]
|
|
316
|
-
if (dx !== 0 || dz !== 0) {
|
|
317
|
-
const surface = node as {
|
|
318
|
-
polygon: Array<[number, number]>
|
|
319
|
-
holes?: Array<Array<[number, number]>>
|
|
320
|
-
}
|
|
321
|
-
effectiveNode = {
|
|
322
|
-
...node,
|
|
323
|
-
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
324
|
-
holes: (surface.holes ?? []).map((h) =>
|
|
325
|
-
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
326
|
-
),
|
|
327
|
-
} as AnyNode
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
// Live-edit overrides: kinds whose `def.floorplan` builder
|
|
332
|
-
// reads cross-sibling data (wall miters, …) declare a
|
|
333
|
-
// `def.floorplanSiblingOverrides` hook that projects the
|
|
334
|
-
// override map into a merged `nodes` snapshot. The merged
|
|
335
|
-
// copy feeds `buildContext` so `ctx.siblings` reflects the
|
|
336
|
-
// live cursor positions, and replaces `effectiveNode` so the
|
|
337
|
-
// kind's own override lands too (covers the case where the
|
|
338
|
-
// node being rendered is itself the dragged one). Kinds
|
|
339
|
-
// without the hook hand the raw `nodes` through — most
|
|
340
|
-
// previews are self-contained.
|
|
341
|
-
const contextNodes = def?.floorplanSiblingOverrides
|
|
342
|
-
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
|
343
|
-
: nodes
|
|
344
|
-
if (contextNodes !== nodes) {
|
|
345
|
-
const merged = contextNodes[id]
|
|
346
|
-
if (merged) effectiveNode = merged
|
|
565
|
+
const node = useScene.getState().nodes[id]
|
|
566
|
+
if (!node || !isRegistryMovable(node.type)) return false
|
|
567
|
+
// Sole selection only: per-node direct manipulation stands down for a
|
|
568
|
+
// multi-selection (the group session owns plain drags there, and Cmd is
|
|
569
|
+
// the selection-toggle key — a wobbly Cmd+click must not yank one
|
|
570
|
+
// member out of the group).
|
|
571
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
572
|
+
if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
|
|
573
|
+
|
|
574
|
+
event.preventDefault()
|
|
575
|
+
event.stopPropagation()
|
|
576
|
+
|
|
577
|
+
const startX = event.clientX
|
|
578
|
+
const startY = event.clientY
|
|
579
|
+
const pointerId = event.pointerId
|
|
580
|
+
let engaged = false
|
|
581
|
+
|
|
582
|
+
const cleanup = () => {
|
|
583
|
+
window.removeEventListener('pointermove', onMove)
|
|
584
|
+
window.removeEventListener('pointerup', onEnd)
|
|
585
|
+
window.removeEventListener('pointercancel', onEnd)
|
|
586
|
+
if (engaged) {
|
|
587
|
+
useViewer.getState().setInputDragging(false)
|
|
347
588
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
592
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
593
|
+
if (engaged) return
|
|
594
|
+
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
|
|
595
|
+
if (distance < DIRECT_DRAG_THRESHOLD_PX) return
|
|
596
|
+
|
|
597
|
+
engaged = true
|
|
598
|
+
useViewer.getState().setInputDragging(true)
|
|
599
|
+
swallowNextClick(300)
|
|
600
|
+
createEditorApi().engageMoveDrag(node)
|
|
601
|
+
|
|
602
|
+
requestAnimationFrame(() => {
|
|
603
|
+
window.dispatchEvent(
|
|
604
|
+
new PointerEvent('pointermove', {
|
|
605
|
+
altKey: moveEvent.altKey,
|
|
606
|
+
bubbles: true,
|
|
607
|
+
buttons: moveEvent.buttons,
|
|
608
|
+
clientX: moveEvent.clientX,
|
|
609
|
+
clientY: moveEvent.clientY,
|
|
610
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
611
|
+
metaKey: moveEvent.metaKey,
|
|
612
|
+
pointerId,
|
|
613
|
+
pointerType: moveEvent.pointerType,
|
|
614
|
+
shiftKey: moveEvent.shiftKey,
|
|
615
|
+
}),
|
|
616
|
+
)
|
|
354
617
|
})
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const onEnd = (endEvent: PointerEvent) => {
|
|
621
|
+
if (endEvent.pointerId !== pointerId) return
|
|
622
|
+
cleanup()
|
|
623
|
+
if (!engaged) {
|
|
624
|
+
applyEntrySelection(id, true)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
window.addEventListener('pointermove', onMove)
|
|
629
|
+
window.addEventListener('pointerup', onEnd)
|
|
630
|
+
window.addEventListener('pointercancel', onEnd)
|
|
631
|
+
return true
|
|
632
|
+
},
|
|
633
|
+
[applyEntrySelection],
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
const startDirectRotateDrag = useCallback(
|
|
637
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
|
638
|
+
if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
|
|
639
|
+
|
|
640
|
+
const sceneNodes = useScene.getState().nodes
|
|
641
|
+
const selectedNode = sceneNodes[id]
|
|
642
|
+
const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
|
|
643
|
+
if (!node || !canDirectRotateNode(node)) return false
|
|
644
|
+
// Sole selection only — same stand-down as the direct move above.
|
|
645
|
+
const selectedIds = useViewer.getState().selection.selectedIds
|
|
646
|
+
if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
|
|
647
|
+
event.preventDefault()
|
|
648
|
+
event.stopPropagation()
|
|
649
|
+
|
|
650
|
+
const nodeId = node.id as AnyNodeId
|
|
651
|
+
const pointerId = event.pointerId
|
|
652
|
+
const startX = event.clientX
|
|
653
|
+
const sceneApi = createSceneApi(useScene)
|
|
654
|
+
let lastPatch: Partial<AnyNode> | null = null
|
|
655
|
+
|
|
656
|
+
const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
|
|
657
|
+
const delta = resolveDirectRotationDragDelta(
|
|
658
|
+
startX,
|
|
659
|
+
pointerEvent.clientX,
|
|
660
|
+
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
|
661
|
+
pointerEvent.shiftKey,
|
|
358
662
|
)
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
663
|
+
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
|
664
|
+
lastPatch = null
|
|
665
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
666
|
+
useScene.getState().markDirty(nodeId)
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
const patch = resolveDirectRotationPatch(node, delta, sceneApi)
|
|
670
|
+
if (!patch) return
|
|
671
|
+
lastPatch = patch
|
|
672
|
+
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
|
|
673
|
+
useScene.getState().markDirty(nodeId)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const cleanup = () => {
|
|
677
|
+
window.removeEventListener('pointermove', onMove, true)
|
|
678
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
679
|
+
window.removeEventListener('pointercancel', onCancel, true)
|
|
680
|
+
window.removeEventListener('contextmenu', preventContextMenu, true)
|
|
681
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
682
|
+
useScene.getState().markDirty(nodeId)
|
|
683
|
+
resumeSceneHistory(useScene)
|
|
684
|
+
useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
|
|
685
|
+
useViewer.getState().setInputDragging(false)
|
|
686
|
+
if (document.body.style.cursor === 'ew-resize') {
|
|
687
|
+
document.body.style.cursor = ''
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
692
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
693
|
+
moveEvent.preventDefault()
|
|
694
|
+
moveEvent.stopPropagation()
|
|
695
|
+
applyDelta(moveEvent)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
699
|
+
if (upEvent.pointerId !== pointerId) return
|
|
700
|
+
upEvent.preventDefault()
|
|
701
|
+
upEvent.stopPropagation()
|
|
702
|
+
swallowNextClick(300)
|
|
703
|
+
if (lastPatch) {
|
|
704
|
+
sceneApi.update(nodeId, lastPatch)
|
|
705
|
+
sfxEmitter.emit('sfx:item-place')
|
|
362
706
|
}
|
|
707
|
+
cleanup()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const onCancel = (cancelEvent: PointerEvent) => {
|
|
711
|
+
if (cancelEvent.pointerId !== pointerId) return
|
|
712
|
+
cleanup()
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const preventContextMenu = (contextEvent: Event) => {
|
|
716
|
+
contextEvent.preventDefault()
|
|
717
|
+
contextEvent.stopPropagation()
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
pauseSceneHistory(useScene)
|
|
721
|
+
useViewer.getState().setInputDragging(true)
|
|
722
|
+
useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
|
|
723
|
+
document.body.style.cursor = 'ew-resize'
|
|
724
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
725
|
+
applyDelta(event)
|
|
726
|
+
|
|
727
|
+
window.addEventListener('pointermove', onMove, true)
|
|
728
|
+
window.addEventListener('pointerup', onUp, true)
|
|
729
|
+
window.addEventListener('pointercancel', onCancel, true)
|
|
730
|
+
window.addEventListener('contextmenu', preventContextMenu, true)
|
|
731
|
+
return true
|
|
732
|
+
},
|
|
733
|
+
[],
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
// Photoshop-style group drag: plain pointer-down on a transformable member
|
|
737
|
+
// of a multi-selection slides the whole selection rigidly; a plain click
|
|
738
|
+
// (no drag) enters the group pick-up instead — parity with the single-item
|
|
739
|
+
// click-to-move (clicking outside still deselects). Modified clicks
|
|
740
|
+
// (selection toggle) and Cmd-drag / direct-rotate keep their existing
|
|
741
|
+
// paths. `immediate` engages without the drag threshold — the move-handle
|
|
742
|
+
// dot's pick-up semantics.
|
|
743
|
+
const startGroupMoveDrag = useCallback(
|
|
744
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>, immediate = false): boolean => {
|
|
745
|
+
if (event.button !== 0) return false
|
|
746
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
|
|
747
|
+
if (movingNode) return false
|
|
748
|
+
if (useEditor.getState().mode === 'delete') return false
|
|
749
|
+
const started = startFloorplanGroupMove(id, event, {
|
|
750
|
+
immediate,
|
|
751
|
+
onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
|
|
752
|
+
})
|
|
753
|
+
if (!started) return false
|
|
754
|
+
event.preventDefault()
|
|
755
|
+
event.stopPropagation()
|
|
756
|
+
suppressBoxSelectForPointer(event)
|
|
757
|
+
return true
|
|
758
|
+
},
|
|
759
|
+
[movingNode],
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
// Move-handle dot variant — routes the dot through the group session when
|
|
763
|
+
// the owning node is part of a multi-selection.
|
|
764
|
+
const handleGroupMoveHandlePointerDown = useCallback(
|
|
765
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => startGroupMoveDrag(id, event, true),
|
|
766
|
+
[startGroupMoveDrag],
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
// The dashed selection box is itself the group's drag handle: a press
|
|
770
|
+
// anywhere inside it slides the group (or picks it up on a plain click),
|
|
771
|
+
// anchored on the first transformable member.
|
|
772
|
+
const handleGroupBoxPointerDown = useCallback(
|
|
773
|
+
(event: ReactPointerEvent<SVGGElement>) => {
|
|
774
|
+
const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
|
|
775
|
+
const sceneNodes = useScene.getState().nodes
|
|
776
|
+
const anchor = currentIds.find(
|
|
777
|
+
(id) =>
|
|
778
|
+
classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
|
|
779
|
+
)
|
|
780
|
+
if (!anchor) return
|
|
781
|
+
startGroupMoveDrag(anchor as AnyNodeId, event)
|
|
782
|
+
},
|
|
783
|
+
[startGroupMoveDrag],
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
// Corner rotate handles on the dashed selection box — 15° steps, Shift
|
|
787
|
+
// free, mirroring the 3D group rotate gizmo.
|
|
788
|
+
const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent<SVGGElement>) => {
|
|
789
|
+
if (event.button !== 0) return
|
|
790
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
791
|
+
if (useEditor.getState().mode === 'delete') return
|
|
792
|
+
if (startFloorplanGroupRotate(event)) {
|
|
793
|
+
event.preventDefault()
|
|
794
|
+
suppressBoxSelectForPointer(event)
|
|
795
|
+
}
|
|
796
|
+
}, [])
|
|
797
|
+
|
|
798
|
+
const handleEntryPointerDown = useCallback(
|
|
799
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
|
800
|
+
if (startDirectMoveDrag(id, event)) return
|
|
801
|
+
if (startDirectRotateDrag(id, event)) return
|
|
802
|
+
if (startGroupMoveDrag(id, event)) return
|
|
803
|
+
handleSelect(id, event)
|
|
804
|
+
},
|
|
805
|
+
[handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
const floorplanData = useMemo(() => {
|
|
809
|
+
if (!levelId) {
|
|
810
|
+
geometryCacheRef.current.clear()
|
|
811
|
+
levelDataCacheRef.current.clear()
|
|
812
|
+
return {
|
|
813
|
+
entries: [] as FloorplanEntryDescriptor[],
|
|
814
|
+
levelNodeIdsByType: new Map<string, AnyNodeId[]>(),
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const out: FloorplanEntryDescriptor[] = []
|
|
819
|
+
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
|
820
|
+
|
|
821
|
+
const collectLevelDataKind = (id: AnyNodeId) => {
|
|
822
|
+
const node = nodes[id]
|
|
823
|
+
if (!node) return
|
|
824
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
825
|
+
const def = nodeRegistry.get(node.type)
|
|
826
|
+
if (def?.computeFloorplanLevelData) {
|
|
827
|
+
const ids = levelNodeIdsByType.get(node.type)
|
|
828
|
+
if (ids) ids.push(id)
|
|
829
|
+
else levelNodeIdsByType.set(node.type, [id])
|
|
363
830
|
}
|
|
364
831
|
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
832
|
+
if (Array.isArray(childIds)) {
|
|
833
|
+
for (const cid of childIds) collectLevelDataKind(cid)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
collectLevelDataKind(levelId as AnyNodeId)
|
|
838
|
+
|
|
839
|
+
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
|
840
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
841
|
+
const def = nodeRegistry.get(node.type)
|
|
842
|
+
if (!def?.floorplan) return
|
|
843
|
+
if (node.type === 'measurement' && !showMeasurements) return
|
|
844
|
+
const dependsOnSiblingInputs = !!(
|
|
845
|
+
def.floorplanDependsOnSiblings ||
|
|
846
|
+
def.floorplanSiblingOverrides ||
|
|
847
|
+
def.floorplanAffectedIds
|
|
848
|
+
)
|
|
849
|
+
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
|
|
850
|
+
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
|
|
851
|
+
out.push(descriptor)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const visit = (id: AnyNodeId) => {
|
|
855
|
+
const node = nodes[id]
|
|
856
|
+
if (!node) return
|
|
857
|
+
pushEntry(id, node)
|
|
858
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
365
859
|
if (Array.isArray(childIds)) {
|
|
366
860
|
for (const cid of childIds) visit(cid)
|
|
367
861
|
}
|
|
@@ -386,54 +880,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
386
880
|
const buildingScopedKindSet = new Set(buildingScopedKinds)
|
|
387
881
|
for (const [id, node] of Object.entries(nodes)) {
|
|
388
882
|
if (!node || !buildingScopedKindSet.has(node.type)) continue
|
|
389
|
-
if ((node as { visible?: boolean }).visible === false) continue
|
|
390
883
|
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
|
391
884
|
if (parentId !== activeBuildingId) continue
|
|
392
885
|
const cid = id as AnyNodeId
|
|
393
|
-
|
|
394
|
-
const builder = def?.floorplan
|
|
395
|
-
if (!builder) continue
|
|
396
|
-
const selected = selectedIdSet.has(cid)
|
|
397
|
-
const highlighted = highlightedIdSet.has(cid)
|
|
398
|
-
const hovered = hoveredId === cid
|
|
399
|
-
const moving = movingNode?.id === cid
|
|
400
|
-
const live = liveTransforms.get(cid)
|
|
401
|
-
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
|
402
|
-
let effectiveNode: AnyNode =
|
|
403
|
-
live && hasPosition ? applyPositionLiveTransform(node, live) : node
|
|
404
|
-
const contextNodes = def?.floorplanSiblingOverrides
|
|
405
|
-
? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
|
|
406
|
-
: nodes
|
|
407
|
-
if (contextNodes !== nodes) {
|
|
408
|
-
const merged = contextNodes[cid]
|
|
409
|
-
if (merged) {
|
|
410
|
-
effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
const ctx: GeometryContext = {
|
|
414
|
-
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
|
415
|
-
contextNodes[rid] as N | undefined,
|
|
886
|
+
pushEntry(cid, node, {
|
|
416
887
|
children: [],
|
|
417
888
|
siblings: [],
|
|
418
889
|
parent: activeLevelNode,
|
|
419
|
-
|
|
420
|
-
? {
|
|
421
|
-
selected,
|
|
422
|
-
highlighted,
|
|
423
|
-
hovered,
|
|
424
|
-
moving,
|
|
425
|
-
palette: renderCtx.palette,
|
|
426
|
-
}
|
|
427
|
-
: undefined,
|
|
428
|
-
}
|
|
429
|
-
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
430
|
-
effectiveNode,
|
|
431
|
-
ctx,
|
|
432
|
-
)
|
|
433
|
-
if (geometry) {
|
|
434
|
-
const { base, overlay } = splitFloorplanOverlay(geometry)
|
|
435
|
-
out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
|
|
436
|
-
}
|
|
890
|
+
})
|
|
437
891
|
}
|
|
438
892
|
}
|
|
439
893
|
|
|
@@ -445,19 +899,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
445
899
|
// DFS visit order (stable sort) so siblings keep their relative
|
|
446
900
|
// priority.
|
|
447
901
|
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
movingNode?.id,
|
|
458
|
-
renderCtx?.palette,
|
|
459
|
-
interactiveElevators,
|
|
460
|
-
])
|
|
902
|
+
const entryIds = new Set(out.map((entry) => entry.id))
|
|
903
|
+
for (const id of geometryCacheRef.current.keys()) {
|
|
904
|
+
if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
|
|
905
|
+
}
|
|
906
|
+
for (const type of levelDataCacheRef.current.keys()) {
|
|
907
|
+
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
|
908
|
+
}
|
|
909
|
+
return { entries: out, levelNodeIdsByType }
|
|
910
|
+
}, [installedPlugins, levelId, nodes, showMeasurements])
|
|
461
911
|
|
|
462
912
|
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
|
463
913
|
//
|
|
@@ -466,6 +916,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
466
916
|
// dispatcher then owns: history pause/resume, snapshot capture,
|
|
467
917
|
// pointer-move/up/cancel routing, and the single-undo dance on
|
|
468
918
|
// commit. Each kind owns the actual mutation logic inside `apply`.
|
|
919
|
+
const commitAffordanceAction = useCallback(
|
|
920
|
+
(
|
|
921
|
+
nodeId: AnyNodeId,
|
|
922
|
+
affordance: string,
|
|
923
|
+
payload: unknown,
|
|
924
|
+
event: ReactMouseEvent<SVGElement>,
|
|
925
|
+
) => {
|
|
926
|
+
if (event.button !== 0 || movingNode || dragRef.current) return
|
|
927
|
+
|
|
928
|
+
const sceneNodes = useScene.getState().nodes
|
|
929
|
+
const node = sceneNodes[nodeId]
|
|
930
|
+
if (!node) return
|
|
931
|
+
const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
|
|
932
|
+
if (!handler) return
|
|
933
|
+
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
|
|
934
|
+
if (!initialPlanPoint) return
|
|
935
|
+
|
|
936
|
+
const session = handler.start({
|
|
937
|
+
node,
|
|
938
|
+
payload,
|
|
939
|
+
nodes: sceneNodes,
|
|
940
|
+
initialPlanPoint,
|
|
941
|
+
gridSnapStep: useEditor.getState().gridSnapStep,
|
|
942
|
+
})
|
|
943
|
+
if (!(session.commit && session.canCommit())) return
|
|
944
|
+
session.commit()
|
|
945
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
946
|
+
},
|
|
947
|
+
[movingNode],
|
|
948
|
+
)
|
|
949
|
+
|
|
469
950
|
const startAffordanceDrag = useCallback(
|
|
470
951
|
(
|
|
471
952
|
nodeId: AnyNodeId,
|
|
@@ -493,6 +974,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
493
974
|
|
|
494
975
|
event.preventDefault()
|
|
495
976
|
event.stopPropagation()
|
|
977
|
+
suppressBoxSelectForPointer(event)
|
|
496
978
|
|
|
497
979
|
const session = handler.start({
|
|
498
980
|
node,
|
|
@@ -525,22 +1007,73 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
525
1007
|
}
|
|
526
1008
|
}
|
|
527
1009
|
|
|
1010
|
+
// Begin the matching reshaping scope so the contextual snapping HUD shows
|
|
1011
|
+
// the right chip during the edit AND `getActiveSnapContext()` resolves the
|
|
1012
|
+
// polygon / wall mode-set the affordance's snap math reads. Torn down on
|
|
1013
|
+
// release / cancel below. `null` for resize / rotate (no snapping chip).
|
|
1014
|
+
const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload)
|
|
1015
|
+
if (reshapeScope) {
|
|
1016
|
+
useInteractionScope.getState().begin(reshapeScope)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const captureTarget = event.currentTarget as Element
|
|
528
1020
|
dragRef.current = {
|
|
529
1021
|
pointerId: event.pointerId,
|
|
1022
|
+
captureTarget,
|
|
530
1023
|
handleId,
|
|
531
1024
|
session,
|
|
532
1025
|
snapshots,
|
|
533
1026
|
historyPaused: true,
|
|
1027
|
+
lastPlanPoint: initialPlanPoint,
|
|
534
1028
|
rotation,
|
|
1029
|
+
reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
|
|
535
1030
|
}
|
|
536
1031
|
setActiveDragId(handleId)
|
|
537
1032
|
setSelection({ selectedIds: [nodeId] })
|
|
538
|
-
|
|
1033
|
+
captureTarget.setPointerCapture?.(event.pointerId)
|
|
539
1034
|
},
|
|
540
1035
|
[movingNode, setSelection],
|
|
541
1036
|
)
|
|
542
1037
|
|
|
543
1038
|
useEffect(() => {
|
|
1039
|
+
// Tear down the scope this drag opened (if any) — a reshaping scope for an
|
|
1040
|
+
// edit affordance, or a handle-drag scope for a rotate-arrow — matched by
|
|
1041
|
+
// node id so a concurrent scope from another path is never ended by mistake.
|
|
1042
|
+
const endReshapeScope = (drag: ActiveDrag) => {
|
|
1043
|
+
if (drag.reshapeScopeNodeId) {
|
|
1044
|
+
useInteractionScope
|
|
1045
|
+
.getState()
|
|
1046
|
+
.endIf(
|
|
1047
|
+
(s) =>
|
|
1048
|
+
(s.kind === 'reshaping' || s.kind === 'handle-drag') &&
|
|
1049
|
+
s.nodeId === drag.reshapeScopeNodeId,
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
|
|
1055
|
+
cancelFloorplanAffordanceDrag(
|
|
1056
|
+
dragRef,
|
|
1057
|
+
{
|
|
1058
|
+
restoreSnapshots: (snapshots) =>
|
|
1059
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
|
|
1060
|
+
resumeHistory: () => resumeSceneHistory(useScene),
|
|
1061
|
+
clearPreview: (id) => {
|
|
1062
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
1063
|
+
useLiveTransforms.getState().clear(id)
|
|
1064
|
+
},
|
|
1065
|
+
clearSnapFeedback: clearSurfacePlanSnapFeedback,
|
|
1066
|
+
endReshapeScope,
|
|
1067
|
+
clearDragFeedback: clearDragFeedback
|
|
1068
|
+
? () => {
|
|
1069
|
+
setActiveDragId(null)
|
|
1070
|
+
setRotationOverlay(null)
|
|
1071
|
+
}
|
|
1072
|
+
: undefined,
|
|
1073
|
+
},
|
|
1074
|
+
pointerId,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
544
1077
|
const onPointerMove = (event: PointerEvent) => {
|
|
545
1078
|
const drag = dragRef.current
|
|
546
1079
|
if (!drag || event.pointerId !== drag.pointerId) return
|
|
@@ -548,6 +1081,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
548
1081
|
const planPoint = clientToPlan(event.clientX, event.clientY)
|
|
549
1082
|
if (!planPoint) return
|
|
550
1083
|
|
|
1084
|
+
drag.lastPlanPoint = planPoint
|
|
551
1085
|
drag.session.apply({
|
|
552
1086
|
planPoint,
|
|
553
1087
|
modifiers: {
|
|
@@ -569,6 +1103,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
569
1103
|
let delta = current - rot.initialAngle
|
|
570
1104
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
571
1105
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1106
|
+
// Match the affordance's 15° angle step (Shift = free) so the wedge +
|
|
1107
|
+
// degree chip read the committed rotation, not the raw pointer bearing.
|
|
1108
|
+
delta = snapDirectRotationDelta(delta, event.shiftKey)
|
|
572
1109
|
if (Math.abs(delta) < 0.0087) {
|
|
573
1110
|
setRotationOverlay(null)
|
|
574
1111
|
} else {
|
|
@@ -603,14 +1140,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
603
1140
|
}
|
|
604
1141
|
drag.session.commit()
|
|
605
1142
|
sfxEmitter.emit('sfx:structure-build')
|
|
1143
|
+
clearSurfacePlanSnapFeedback()
|
|
1144
|
+
endReshapeScope(drag)
|
|
606
1145
|
dragRef.current = null
|
|
607
1146
|
setActiveDragId(null)
|
|
608
1147
|
setRotationOverlay(null)
|
|
609
1148
|
return
|
|
610
1149
|
}
|
|
611
1150
|
|
|
612
|
-
//
|
|
613
|
-
//
|
|
1151
|
+
// Legacy compatibility for sessions that still wrote preview state into
|
|
1152
|
+
// `useScene` during `apply()`: capture the final state BEFORE the revert
|
|
1153
|
+
// so we know what to re-apply post-resume. New sessions should provide a
|
|
1154
|
+
// `commit()` hook and preview through live overrides/transforms instead.
|
|
614
1155
|
const sceneNodes = useScene.getState().nodes
|
|
615
1156
|
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
|
616
1157
|
for (const snap of drag.snapshots) {
|
|
@@ -629,7 +1170,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
629
1170
|
}
|
|
630
1171
|
|
|
631
1172
|
if (commitValid && finalUpdates.length > 0) {
|
|
632
|
-
//
|
|
1173
|
+
// Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
|
|
633
1174
|
// 1. Revert to baseline while history is still paused (untracked).
|
|
634
1175
|
// 2. Resume history.
|
|
635
1176
|
// 3. Re-apply the final state — recorded as one tracked change.
|
|
@@ -654,129 +1195,71 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
654
1195
|
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
655
1196
|
}
|
|
656
1197
|
|
|
1198
|
+
clearSurfacePlanSnapFeedback()
|
|
1199
|
+
endReshapeScope(drag)
|
|
657
1200
|
dragRef.current = null
|
|
658
1201
|
setActiveDragId(null)
|
|
659
1202
|
setRotationOverlay(null)
|
|
660
1203
|
}
|
|
661
1204
|
|
|
662
1205
|
const onPointerCancel = (event: PointerEvent) => {
|
|
663
|
-
|
|
664
|
-
|
|
1206
|
+
cancelActiveDrag(event.pointerId)
|
|
1207
|
+
}
|
|
665
1208
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1209
|
+
// Re-run the active session the moment a modifier key flips so behaviors
|
|
1210
|
+
// like the wall endpoint's Alt-detach / re-attach take effect immediately
|
|
1211
|
+
// instead of waiting for the next pointer move. `event.altKey` & co
|
|
1212
|
+
// already reflect the post-transition state on both keydown and keyup.
|
|
1213
|
+
const onModifierKeyChange = (event: KeyboardEvent) => {
|
|
1214
|
+
const drag = dragRef.current
|
|
1215
|
+
if (!drag || event.repeat) return
|
|
1216
|
+
if (
|
|
1217
|
+
event.key !== 'Alt' &&
|
|
1218
|
+
event.key !== 'Shift' &&
|
|
1219
|
+
event.key !== 'Control' &&
|
|
1220
|
+
event.key !== 'Meta'
|
|
1221
|
+
) {
|
|
1222
|
+
return
|
|
671
1223
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
682
|
-
|
|
683
|
-
dragRef.current = null
|
|
684
|
-
setActiveDragId(null)
|
|
685
|
-
setRotationOverlay(null)
|
|
1224
|
+
drag.session.apply({
|
|
1225
|
+
planPoint: drag.lastPlanPoint,
|
|
1226
|
+
modifiers: {
|
|
1227
|
+
shiftKey: event.shiftKey,
|
|
1228
|
+
altKey: event.altKey,
|
|
1229
|
+
ctrlKey: event.ctrlKey,
|
|
1230
|
+
metaKey: event.metaKey,
|
|
1231
|
+
},
|
|
1232
|
+
})
|
|
686
1233
|
}
|
|
687
1234
|
|
|
688
1235
|
window.addEventListener('pointermove', onPointerMove)
|
|
689
1236
|
window.addEventListener('pointerup', onPointerUp)
|
|
690
1237
|
window.addEventListener('pointercancel', onPointerCancel)
|
|
1238
|
+
window.addEventListener('keydown', onModifierKeyChange)
|
|
1239
|
+
window.addEventListener('keyup', onModifierKeyChange)
|
|
1240
|
+
const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
|
|
1241
|
+
() => cancelActiveDrag(),
|
|
1242
|
+
markToolCancelConsumed,
|
|
1243
|
+
)
|
|
691
1244
|
return () => {
|
|
692
1245
|
window.removeEventListener('pointermove', onPointerMove)
|
|
693
1246
|
window.removeEventListener('pointerup', onPointerUp)
|
|
694
1247
|
window.removeEventListener('pointercancel', onPointerCancel)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (drag) {
|
|
701
|
-
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
702
|
-
if (drag.historyPaused) {
|
|
703
|
-
resumeSceneHistory(useScene)
|
|
704
|
-
}
|
|
705
|
-
const overrides = useLiveNodeOverrides.getState()
|
|
706
|
-
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
707
|
-
dragRef.current = null
|
|
1248
|
+
window.removeEventListener('keydown', onModifierKeyChange)
|
|
1249
|
+
window.removeEventListener('keyup', onModifierKeyChange)
|
|
1250
|
+
unsubscribeToolCancel()
|
|
1251
|
+
if (!cancelActiveDrag(undefined, false)) {
|
|
1252
|
+
clearSurfacePlanSnapFeedback()
|
|
708
1253
|
}
|
|
709
|
-
// Clear any alignment guide a session left behind on mid-drag unmount.
|
|
710
|
-
useAlignmentGuides.getState().clear()
|
|
711
1254
|
}
|
|
712
1255
|
}, [])
|
|
713
1256
|
|
|
1257
|
+
const entries = floorplanData.entries
|
|
714
1258
|
if (entries.length === 0) return null
|
|
715
1259
|
|
|
716
1260
|
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
|
|
717
1261
|
const palette = renderCtx?.palette
|
|
718
1262
|
|
|
719
|
-
const renderEntry = (id: AnyNodeId, geometry: FloorplanGeometry, key: string) => (
|
|
720
|
-
<g
|
|
721
|
-
className="floorplan-registry-entry"
|
|
722
|
-
data-node-id={id}
|
|
723
|
-
key={key}
|
|
724
|
-
onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop}
|
|
725
|
-
onPointerDown={
|
|
726
|
-
isOpeningPlacementActive || isMarqueeSelectionActive
|
|
727
|
-
? undefined
|
|
728
|
-
: (e) => handleSelect(id, e)
|
|
729
|
-
}
|
|
730
|
-
// Mirror the sidebar tree nodes' hover wiring — `useViewer.
|
|
731
|
-
// hoveredId` drives the highlight halo in 3D as well as the
|
|
732
|
-
// wall / fence floor-plan hover stroke. Setting it on
|
|
733
|
-
// pointer-enter and clearing on leave keeps the two views in
|
|
734
|
-
// sync. Without this the registry-driven kinds had hover
|
|
735
|
-
// visuals defined but never reached because the entry `<g>`
|
|
736
|
-
// never updated the store.
|
|
737
|
-
onPointerEnter={() => setHoveredId(id)}
|
|
738
|
-
onPointerLeave={() => {
|
|
739
|
-
// Only clear when this entry is the one we last set —
|
|
740
|
-
// avoids racing with sibling entries during fast-moving
|
|
741
|
-
// pointer scans.
|
|
742
|
-
if (useViewer.getState().hoveredId === id) setHoveredId(null)
|
|
743
|
-
}}
|
|
744
|
-
style={{ cursor: 'pointer' }}
|
|
745
|
-
>
|
|
746
|
-
<InteractiveGeometry
|
|
747
|
-
activeDragId={activeDragId}
|
|
748
|
-
geometry={geometry}
|
|
749
|
-
hatchPatternId={renderCtx?.hatchPatternId}
|
|
750
|
-
hoveredHandleId={hoveredHandleId}
|
|
751
|
-
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
752
|
-
nodeId={id}
|
|
753
|
-
onHandleHoverChange={setHoveredHandleId}
|
|
754
|
-
onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
|
|
755
|
-
startAffordanceDrag(
|
|
756
|
-
id,
|
|
757
|
-
makeHandleId(id, payload),
|
|
758
|
-
affordance,
|
|
759
|
-
payload,
|
|
760
|
-
event,
|
|
761
|
-
rotationPivot,
|
|
762
|
-
)
|
|
763
|
-
}
|
|
764
|
-
onMoveHandlePointerDown={(event) => {
|
|
765
|
-
if (event.button !== 0) return
|
|
766
|
-
const node = useScene.getState().nodes[id]
|
|
767
|
-
if (!node) return
|
|
768
|
-
event.preventDefault()
|
|
769
|
-
event.stopPropagation()
|
|
770
|
-
sfxEmitter.emit('sfx:item-pick')
|
|
771
|
-
setMovingNode(node as never)
|
|
772
|
-
}}
|
|
773
|
-
palette={palette}
|
|
774
|
-
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
775
|
-
unitsPerPixel={unitsPerPixel}
|
|
776
|
-
/>
|
|
777
|
-
</g>
|
|
778
|
-
)
|
|
779
|
-
|
|
780
1263
|
return (
|
|
781
1264
|
// The outer wrapper stops `click` events that escape an entry's
|
|
782
1265
|
// `onClick={handleClickStop}`. The base+overlay split means
|
|
@@ -796,14 +1279,55 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
796
1279
|
className="floorplan-registry-layer"
|
|
797
1280
|
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
|
798
1281
|
opacity={isAmbient ? 0.3 : undefined}
|
|
799
|
-
style={isAmbient ?
|
|
1282
|
+
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
|
800
1283
|
>
|
|
801
1284
|
{/* Base pass — rank-sorted body geometry (polygons, paths, fills,
|
|
802
1285
|
strokes, hatches). Lower-rank kinds (zones) paint first so
|
|
803
1286
|
higher-rank kinds (slabs, then walls / items / shelves) layer
|
|
804
1287
|
on top in the expected document-order z-stack. */}
|
|
805
1288
|
<g className="floorplan-registry-base">
|
|
806
|
-
{entries.map((
|
|
1289
|
+
{entries.map((entry) => (
|
|
1290
|
+
<FloorplanRegistryEntry
|
|
1291
|
+
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1292
|
+
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
|
1293
|
+
floorplanVisible={floorplanVisible}
|
|
1294
|
+
geometryCacheRef={geometryCacheRef}
|
|
1295
|
+
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1296
|
+
highlighted={highlightedIdSet.has(entry.id)}
|
|
1297
|
+
hovered={hoveredId === entry.id}
|
|
1298
|
+
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1299
|
+
interactiveElevators={interactiveElevators}
|
|
1300
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1301
|
+
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
1302
|
+
key={`base-${entry.id}`}
|
|
1303
|
+
levelDataCacheRef={levelDataCacheRef}
|
|
1304
|
+
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
1305
|
+
moving={movingNode?.id === entry.id}
|
|
1306
|
+
node={entry.node}
|
|
1307
|
+
nodeId={entry.id}
|
|
1308
|
+
nodes={nodes}
|
|
1309
|
+
onClickStop={handleClickStop}
|
|
1310
|
+
onEntryPointerDown={handleEntryPointerDown}
|
|
1311
|
+
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
|
1312
|
+
onHandleHoverChange={setHoveredHandleId}
|
|
1313
|
+
onHandleDoubleClick={commitAffordanceAction}
|
|
1314
|
+
onHandlePointerDown={startAffordanceDrag}
|
|
1315
|
+
onHoveredIdChange={setHoveredId}
|
|
1316
|
+
palette={palette}
|
|
1317
|
+
pass="base"
|
|
1318
|
+
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
1319
|
+
selected={selectedIdSet.has(entry.id)}
|
|
1320
|
+
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
|
1321
|
+
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
|
1322
|
+
setMovingNode={setMovingNode}
|
|
1323
|
+
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1324
|
+
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1325
|
+
unit={unit}
|
|
1326
|
+
unitsPerPixel={unitsPerPixel}
|
|
1327
|
+
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1328
|
+
ctxOverrides={entry.ctxOverrides}
|
|
1329
|
+
/>
|
|
1330
|
+
))}
|
|
807
1331
|
</g>
|
|
808
1332
|
{/* Overlay pass — interactive handles (vertex / midpoint / edge /
|
|
809
1333
|
move) and labels (text / dimensions). Painted after every base
|
|
@@ -813,10 +1337,58 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
813
1337
|
still routes through the same selection-handling `<g>` so a
|
|
814
1338
|
click on a zone's name selects the zone. */}
|
|
815
1339
|
<g className="floorplan-registry-overlay">
|
|
816
|
-
{entries.map((
|
|
817
|
-
|
|
818
|
-
|
|
1340
|
+
{entries.map((entry) => (
|
|
1341
|
+
<FloorplanRegistryEntry
|
|
1342
|
+
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1343
|
+
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
|
1344
|
+
floorplanVisible={floorplanVisible}
|
|
1345
|
+
geometryCacheRef={geometryCacheRef}
|
|
1346
|
+
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1347
|
+
highlighted={highlightedIdSet.has(entry.id)}
|
|
1348
|
+
hovered={hoveredId === entry.id}
|
|
1349
|
+
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1350
|
+
interactiveElevators={interactiveElevators}
|
|
1351
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1352
|
+
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
1353
|
+
key={`overlay-${entry.id}`}
|
|
1354
|
+
levelDataCacheRef={levelDataCacheRef}
|
|
1355
|
+
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
1356
|
+
moving={movingNode?.id === entry.id}
|
|
1357
|
+
node={entry.node}
|
|
1358
|
+
nodeId={entry.id}
|
|
1359
|
+
nodes={nodes}
|
|
1360
|
+
onClickStop={handleClickStop}
|
|
1361
|
+
onEntryPointerDown={handleEntryPointerDown}
|
|
1362
|
+
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
|
1363
|
+
onHandleHoverChange={setHoveredHandleId}
|
|
1364
|
+
onHandleDoubleClick={commitAffordanceAction}
|
|
1365
|
+
onHandlePointerDown={startAffordanceDrag}
|
|
1366
|
+
onHoveredIdChange={setHoveredId}
|
|
1367
|
+
palette={palette}
|
|
1368
|
+
pass="overlay"
|
|
1369
|
+
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
1370
|
+
selected={selectedIdSet.has(entry.id)}
|
|
1371
|
+
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
|
1372
|
+
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
|
1373
|
+
setMovingNode={setMovingNode}
|
|
1374
|
+
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1375
|
+
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1376
|
+
unit={unit}
|
|
1377
|
+
unitsPerPixel={unitsPerPixel}
|
|
1378
|
+
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1379
|
+
ctxOverrides={entry.ctxOverrides}
|
|
1380
|
+
/>
|
|
1381
|
+
))}
|
|
819
1382
|
</g>
|
|
1383
|
+
{/* Dashed group bbox — shows what a group drag carries along while a
|
|
1384
|
+
multi-selection exists, rides the live delta mid-drag, and doubles
|
|
1385
|
+
as the group's whole-area drag handle. */}
|
|
1386
|
+
<FloorplanGroupSelectionBox
|
|
1387
|
+
onPointerDown={handleGroupBoxPointerDown}
|
|
1388
|
+
onRotatePointerDown={handleGroupBoxRotatePointerDown}
|
|
1389
|
+
palette={palette}
|
|
1390
|
+
unitsPerPixel={unitsPerPixel}
|
|
1391
|
+
/>
|
|
820
1392
|
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
|
821
1393
|
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
|
822
1394
|
{rotationOverlay && palette ? (
|
|
@@ -831,32 +1403,517 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
831
1403
|
)
|
|
832
1404
|
})
|
|
833
1405
|
|
|
834
|
-
|
|
1406
|
+
type FloorplanRegistryEntryProps = {
|
|
1407
|
+
activeDragId: string | null
|
|
1408
|
+
activeRotateNodeId: AnyNodeId | null
|
|
1409
|
+
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1410
|
+
floorplanVisible: boolean
|
|
1411
|
+
geometryCacheRef: { current: Map<string, CacheEntry> }
|
|
1412
|
+
hatchPatternId: string | undefined
|
|
1413
|
+
highlighted: boolean
|
|
1414
|
+
hovered: boolean
|
|
1415
|
+
hoveredHandleId: string | null
|
|
1416
|
+
interactiveElevators: unknown
|
|
1417
|
+
isMarqueeSelectionActive: boolean
|
|
1418
|
+
isOpeningPlacementActive: boolean
|
|
1419
|
+
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
|
1420
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
1421
|
+
moving: boolean
|
|
1422
|
+
node: AnyNode
|
|
1423
|
+
nodeId: AnyNodeId
|
|
1424
|
+
nodes: Record<string, AnyNode>
|
|
1425
|
+
/** Selected member of a multi-selection: hide its per-node edit chrome. */
|
|
1426
|
+
suppressHandles: boolean
|
|
1427
|
+
/** Transformable member of a multi-selection: advertise drag-to-move. */
|
|
1428
|
+
groupMoveCursor: boolean
|
|
1429
|
+
onClickStop: (event: React.MouseEvent<SVGGElement>) => void
|
|
1430
|
+
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
|
|
1431
|
+
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
|
|
1432
|
+
onHandleHoverChange: (id: string | null) => void
|
|
1433
|
+
onHandleDoubleClick: (
|
|
1434
|
+
nodeId: AnyNodeId,
|
|
1435
|
+
affordance: string,
|
|
1436
|
+
payload: unknown,
|
|
1437
|
+
event: ReactMouseEvent<SVGElement>,
|
|
1438
|
+
) => void
|
|
1439
|
+
onHandlePointerDown: (
|
|
1440
|
+
nodeId: AnyNodeId,
|
|
1441
|
+
handleId: string,
|
|
1442
|
+
affordance: string,
|
|
1443
|
+
payload: unknown,
|
|
1444
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1445
|
+
rotationPivot?: FloorplanPoint,
|
|
1446
|
+
) => void
|
|
1447
|
+
onHoveredIdChange: (id: AnyNodeId | null) => void
|
|
1448
|
+
palette: FloorplanPalette | undefined
|
|
1449
|
+
pass: FloorplanRenderPass
|
|
1450
|
+
sceneRotationDeg: number
|
|
1451
|
+
selected: boolean
|
|
1452
|
+
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
|
1453
|
+
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
|
1454
|
+
siblingEpoch: number
|
|
1455
|
+
unit: 'metric' | 'imperial'
|
|
1456
|
+
unitsPerPixel: number
|
|
1457
|
+
visibilityRootId: AnyNodeId | undefined
|
|
1458
|
+
}
|
|
835
1459
|
|
|
836
|
-
function
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1460
|
+
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
1461
|
+
activeDragId,
|
|
1462
|
+
activeRotateNodeId,
|
|
1463
|
+
ctxOverrides,
|
|
1464
|
+
floorplanVisible,
|
|
1465
|
+
geometryCacheRef,
|
|
840
1466
|
hatchPatternId,
|
|
1467
|
+
highlighted,
|
|
1468
|
+
hovered,
|
|
841
1469
|
hoveredHandleId,
|
|
842
|
-
|
|
1470
|
+
interactiveElevators,
|
|
843
1471
|
isMarqueeSelectionActive,
|
|
1472
|
+
isOpeningPlacementActive,
|
|
1473
|
+
levelDataCacheRef,
|
|
1474
|
+
levelNodeIdsByType,
|
|
1475
|
+
moving,
|
|
1476
|
+
node,
|
|
844
1477
|
nodeId,
|
|
845
|
-
|
|
1478
|
+
nodes,
|
|
1479
|
+
suppressHandles,
|
|
1480
|
+
groupMoveCursor,
|
|
1481
|
+
onClickStop,
|
|
1482
|
+
onEntryPointerDown,
|
|
1483
|
+
onGroupMovePointerDown,
|
|
846
1484
|
onHandleHoverChange,
|
|
1485
|
+
onHandleDoubleClick,
|
|
847
1486
|
onHandlePointerDown,
|
|
848
|
-
|
|
849
|
-
|
|
1487
|
+
onHoveredIdChange,
|
|
1488
|
+
palette,
|
|
1489
|
+
pass,
|
|
1490
|
+
sceneRotationDeg,
|
|
1491
|
+
selected,
|
|
1492
|
+
setMovingNode,
|
|
1493
|
+
setMovingNodeOrigin,
|
|
1494
|
+
siblingEpoch,
|
|
1495
|
+
unit,
|
|
1496
|
+
unitsPerPixel,
|
|
1497
|
+
visibilityRootId,
|
|
1498
|
+
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
|
1499
|
+
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
|
1500
|
+
const liveOverride = useLiveNodeOverrides((s) =>
|
|
1501
|
+
floorplanVisible ? s.overrides.get(nodeId) : undefined,
|
|
1502
|
+
)
|
|
1503
|
+
const liveOverrides = floorplanVisible
|
|
1504
|
+
? useLiveNodeOverrides.getState().overrides
|
|
1505
|
+
: EMPTY_LIVE_OVERRIDES
|
|
1506
|
+
|
|
1507
|
+
const handlePointerDown = useCallback(
|
|
1508
|
+
(event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
|
|
1509
|
+
[nodeId, onEntryPointerDown],
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
|
|
1513
|
+
// the highlight halo in 3D as well as registry floor-plan hover strokes.
|
|
1514
|
+
const handlePointerEnter = useCallback(() => {
|
|
1515
|
+
const node = useScene.getState().nodes[nodeId]
|
|
1516
|
+
onHoveredIdChange(
|
|
1517
|
+
node
|
|
1518
|
+
? resolveSelectionProxyId(
|
|
1519
|
+
node,
|
|
1520
|
+
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1521
|
+
)
|
|
1522
|
+
: nodeId,
|
|
1523
|
+
)
|
|
1524
|
+
}, [nodeId, onHoveredIdChange])
|
|
1525
|
+
|
|
1526
|
+
const handlePointerLeave = useCallback(() => {
|
|
1527
|
+
const node = useScene.getState().nodes[nodeId]
|
|
1528
|
+
const targetId = node
|
|
1529
|
+
? resolveSelectionProxyId(
|
|
1530
|
+
node,
|
|
1531
|
+
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1532
|
+
)
|
|
1533
|
+
: nodeId
|
|
1534
|
+
if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
|
|
1535
|
+
}, [nodeId, onHoveredIdChange])
|
|
1536
|
+
|
|
1537
|
+
const handleHandlePointerDown = useCallback(
|
|
1538
|
+
(
|
|
1539
|
+
affordance: string,
|
|
1540
|
+
payload: unknown,
|
|
1541
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1542
|
+
rotationPivot?: FloorplanPoint,
|
|
1543
|
+
) => {
|
|
1544
|
+
onHandlePointerDown(
|
|
1545
|
+
nodeId,
|
|
1546
|
+
makeHandleId(nodeId, payload),
|
|
1547
|
+
affordance,
|
|
1548
|
+
payload,
|
|
1549
|
+
event,
|
|
1550
|
+
rotationPivot,
|
|
1551
|
+
)
|
|
1552
|
+
},
|
|
1553
|
+
[nodeId, onHandlePointerDown],
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
const handleHandleDoubleClick = useCallback(
|
|
1557
|
+
(affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
|
|
1558
|
+
onHandleDoubleClick(nodeId, affordance, payload, event)
|
|
1559
|
+
},
|
|
1560
|
+
[nodeId, onHandleDoubleClick],
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
const handleMoveHandlePointerDown = useCallback(
|
|
1564
|
+
(event: ReactPointerEvent<SVGGElement>) => {
|
|
1565
|
+
if (event.button !== 0) return
|
|
1566
|
+
const currentNode = useScene.getState().nodes[nodeId]
|
|
1567
|
+
if (!currentNode) return
|
|
1568
|
+
event.preventDefault()
|
|
1569
|
+
event.stopPropagation()
|
|
1570
|
+
suppressBoxSelectForPointer(event)
|
|
1571
|
+
// In a multi-selection the move dot drives the group session, matching
|
|
1572
|
+
// the body-drag gesture — the whole selection slides, not one member.
|
|
1573
|
+
if (onGroupMovePointerDown(nodeId, event)) return
|
|
1574
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
1575
|
+
setMovingNode(currentNode as never)
|
|
1576
|
+
// Claim 2D ownership of this move at the source. `setMovingNode`
|
|
1577
|
+
// resets the origin to null, so this must follow it.
|
|
1578
|
+
setMovingNodeOrigin('2d')
|
|
1579
|
+
},
|
|
1580
|
+
[nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
const cacheEntry = buildFloorplanEntryGeometry({
|
|
1584
|
+
ctxOverrides,
|
|
1585
|
+
geometryCache: geometryCacheRef.current,
|
|
1586
|
+
highlighted,
|
|
1587
|
+
hovered,
|
|
1588
|
+
interactiveElevators,
|
|
1589
|
+
levelDataCache: levelDataCacheRef.current,
|
|
1590
|
+
levelNodeIdsByType,
|
|
1591
|
+
live,
|
|
1592
|
+
liveOverride,
|
|
1593
|
+
liveOverrides,
|
|
1594
|
+
moving,
|
|
1595
|
+
node,
|
|
1596
|
+
nodeId,
|
|
1597
|
+
nodes,
|
|
1598
|
+
palette,
|
|
1599
|
+
selected,
|
|
1600
|
+
siblingEpoch,
|
|
1601
|
+
unit,
|
|
1602
|
+
visibilityRootId,
|
|
1603
|
+
})
|
|
1604
|
+
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
|
1605
|
+
// Multi-selection shows highlight only: strip this member's edit handles /
|
|
1606
|
+
// dimension chrome (all of which live in the overlay pass) while keeping
|
|
1607
|
+
// its highlighted body geometry.
|
|
1608
|
+
const geometry =
|
|
1609
|
+
rawGeometry && suppressHandles && pass === 'overlay'
|
|
1610
|
+
? stripHandleChrome(rawGeometry)
|
|
1611
|
+
: rawGeometry
|
|
1612
|
+
if (!geometry) return null
|
|
1613
|
+
|
|
1614
|
+
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
|
|
1615
|
+
const entryPointerDown =
|
|
1616
|
+
isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
|
|
1617
|
+
|
|
1618
|
+
return (
|
|
1619
|
+
<g
|
|
1620
|
+
className="floorplan-registry-entry"
|
|
1621
|
+
data-node-id={nodeId}
|
|
1622
|
+
onClick={entryClick}
|
|
1623
|
+
onPointerDown={entryPointerDown}
|
|
1624
|
+
onPointerEnter={handlePointerEnter}
|
|
1625
|
+
onPointerLeave={handlePointerLeave}
|
|
1626
|
+
style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
|
|
1627
|
+
>
|
|
1628
|
+
<InteractiveGeometry
|
|
1629
|
+
activeDragId={activeDragId}
|
|
1630
|
+
activeRotateNodeId={activeRotateNodeId}
|
|
1631
|
+
geometry={geometry}
|
|
1632
|
+
hatchPatternId={hatchPatternId}
|
|
1633
|
+
hoveredHandleId={hoveredHandleId}
|
|
1634
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1635
|
+
nodeId={nodeId}
|
|
1636
|
+
onHandleDoubleClick={handleHandleDoubleClick}
|
|
1637
|
+
onHandleHoverChange={onHandleHoverChange}
|
|
1638
|
+
onHandlePointerDown={handleHandlePointerDown}
|
|
1639
|
+
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
|
1640
|
+
palette={palette}
|
|
1641
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1642
|
+
unitsPerPixel={unitsPerPixel}
|
|
1643
|
+
/>
|
|
1644
|
+
</g>
|
|
1645
|
+
)
|
|
1646
|
+
}, shallowPropsAreEqual)
|
|
1647
|
+
|
|
1648
|
+
type BuildFloorplanEntryGeometryArgs = {
|
|
1649
|
+
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1650
|
+
geometryCache: Map<string, CacheEntry>
|
|
1651
|
+
highlighted: boolean
|
|
1652
|
+
hovered: boolean
|
|
1653
|
+
interactiveElevators: unknown
|
|
1654
|
+
levelDataCache: Map<string, LevelDataCacheEntry>
|
|
1655
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
1656
|
+
live: LiveTransform | undefined
|
|
1657
|
+
liveOverride: LiveNodeOverrides | undefined
|
|
1658
|
+
liveOverrides: Map<string, LiveNodeOverrides>
|
|
1659
|
+
moving: boolean
|
|
1660
|
+
node: AnyNode
|
|
1661
|
+
nodeId: AnyNodeId
|
|
1662
|
+
nodes: Record<string, AnyNode>
|
|
1663
|
+
palette: FloorplanPalette | undefined
|
|
1664
|
+
selected: boolean
|
|
1665
|
+
siblingEpoch: number
|
|
1666
|
+
unit: 'metric' | 'imperial'
|
|
1667
|
+
visibilityRootId: AnyNodeId | undefined
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
export function collectFloorplanDependencyNodes(
|
|
1671
|
+
def: AnyNodeDefinition,
|
|
1672
|
+
node: AnyNode,
|
|
1673
|
+
nodes: Record<string, AnyNode>,
|
|
1674
|
+
liveOverrides?: Map<string, LiveNodeOverrides>,
|
|
1675
|
+
): AnyNode[] {
|
|
1676
|
+
return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
|
|
1677
|
+
const dependency = nodes[id]
|
|
1678
|
+
if (!dependency) return []
|
|
1679
|
+
const dependencyOverride = liveOverrides?.get(dependency.id)
|
|
1680
|
+
const effectiveDependency = dependencyOverride
|
|
1681
|
+
? ({ ...dependency, ...dependencyOverride } as AnyNode)
|
|
1682
|
+
: dependency
|
|
1683
|
+
const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
|
|
1684
|
+
if (!parent) return [effectiveDependency]
|
|
1685
|
+
const parentOverride = liveOverrides?.get(parent.id)
|
|
1686
|
+
const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
|
|
1687
|
+
return [effectiveDependency, effectiveParent]
|
|
1688
|
+
})
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function buildFloorplanEntryGeometry({
|
|
1692
|
+
ctxOverrides,
|
|
1693
|
+
geometryCache,
|
|
1694
|
+
highlighted,
|
|
1695
|
+
hovered,
|
|
1696
|
+
interactiveElevators,
|
|
1697
|
+
levelDataCache,
|
|
1698
|
+
levelNodeIdsByType,
|
|
1699
|
+
live,
|
|
1700
|
+
liveOverride,
|
|
1701
|
+
liveOverrides,
|
|
1702
|
+
moving,
|
|
1703
|
+
node,
|
|
1704
|
+
nodeId,
|
|
1705
|
+
nodes,
|
|
1706
|
+
palette,
|
|
1707
|
+
selected,
|
|
1708
|
+
siblingEpoch,
|
|
1709
|
+
unit,
|
|
1710
|
+
visibilityRootId,
|
|
1711
|
+
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
|
|
1712
|
+
const def = nodeRegistry.get(node.type)
|
|
1713
|
+
const builder = def?.floorplan
|
|
1714
|
+
if (!builder) return null
|
|
1715
|
+
|
|
1716
|
+
const visible = visibilityRootId
|
|
1717
|
+
? isFloorplanHierarchyVisible(node, nodes, liveOverrides, visibilityRootId)
|
|
1718
|
+
: isFloorplanNodeVisible(node, liveOverride)
|
|
1719
|
+
if (!visible) {
|
|
1720
|
+
geometryCache.delete(nodeId)
|
|
1721
|
+
return null
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const dependsOnSiblingInputs = !!(
|
|
1725
|
+
def.floorplanDependsOnSiblings ||
|
|
1726
|
+
def.floorplanSiblingOverrides ||
|
|
1727
|
+
def.floorplanAffectedIds
|
|
1728
|
+
)
|
|
1729
|
+
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
|
|
1730
|
+
const deps: NodeDeps = {
|
|
1731
|
+
node,
|
|
1732
|
+
live,
|
|
1733
|
+
unit,
|
|
1734
|
+
selected,
|
|
1735
|
+
highlighted,
|
|
1736
|
+
hovered,
|
|
1737
|
+
moving,
|
|
1738
|
+
liveOverride,
|
|
1739
|
+
palette,
|
|
1740
|
+
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
|
1741
|
+
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
|
1742
|
+
// committed state via `ctx`, so committed sibling edits still invalidate.
|
|
1743
|
+
committedNodes: dependsOnSiblingInputs ? nodes : null,
|
|
1744
|
+
dependencyNodes,
|
|
1745
|
+
interactiveElevators,
|
|
1746
|
+
}
|
|
1747
|
+
const cached = geometryCache.get(nodeId)
|
|
1748
|
+
if (cached && nodeDepsEqual(cached.deps, deps)) return cached
|
|
1749
|
+
|
|
1750
|
+
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
|
|
1751
|
+
if (!live) return sourceNode
|
|
1752
|
+
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
|
|
1753
|
+
const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
|
|
1754
|
+
?.parentFrame?.floorplanLiveTransform
|
|
1755
|
+
if (parentFrameProjection) {
|
|
1756
|
+
return parentFrameProjection({ node: sourceNode, live })
|
|
1757
|
+
}
|
|
1758
|
+
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
|
|
1759
|
+
const r = (sourceNode as { rotation?: unknown }).rotation
|
|
1760
|
+
return {
|
|
1761
|
+
...sourceNode,
|
|
1762
|
+
position: live.position,
|
|
1763
|
+
rotation: Array.isArray(r)
|
|
1764
|
+
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
|
1765
|
+
: r,
|
|
1766
|
+
} as AnyNode
|
|
1767
|
+
}
|
|
1768
|
+
if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
|
|
1769
|
+
return applyPositionLiveTransform(sourceNode, live)
|
|
1770
|
+
}
|
|
1771
|
+
if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
|
|
1772
|
+
const dx = live.position[0]
|
|
1773
|
+
const dz = live.position[2]
|
|
1774
|
+
if (dx === 0 && dz === 0) return sourceNode
|
|
1775
|
+
const surface = sourceNode as {
|
|
1776
|
+
polygon: Array<[number, number]>
|
|
1777
|
+
holes?: Array<Array<[number, number]>>
|
|
1778
|
+
}
|
|
1779
|
+
return {
|
|
1780
|
+
...sourceNode,
|
|
1781
|
+
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
1782
|
+
holes: (surface.holes ?? []).map((h) =>
|
|
1783
|
+
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
1784
|
+
),
|
|
1785
|
+
} as AnyNode
|
|
1786
|
+
}
|
|
1787
|
+
return sourceNode
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const contextNodes = def.floorplanSiblingOverrides
|
|
1791
|
+
? def.floorplanSiblingOverrides({
|
|
1792
|
+
nodeId,
|
|
1793
|
+
nodes,
|
|
1794
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
1795
|
+
liveOverrides,
|
|
1796
|
+
})
|
|
1797
|
+
: nodes
|
|
1798
|
+
const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
|
|
1799
|
+
const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
|
|
1800
|
+
const effectiveNode = applyLiveTransform(overrideNode)
|
|
1801
|
+
const levelData = getFloorplanLevelData(
|
|
1802
|
+
node.type,
|
|
1803
|
+
nodes,
|
|
1804
|
+
liveOverrides,
|
|
1805
|
+
levelNodeIdsByType,
|
|
1806
|
+
levelDataCache,
|
|
1807
|
+
)
|
|
1808
|
+
const viewState = {
|
|
1809
|
+
selected,
|
|
1810
|
+
unit,
|
|
1811
|
+
highlighted,
|
|
1812
|
+
hovered,
|
|
1813
|
+
moving,
|
|
1814
|
+
palette,
|
|
1815
|
+
}
|
|
1816
|
+
const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
|
|
1817
|
+
const contextNode = contextNodes[rid]
|
|
1818
|
+
if (!contextNode) return undefined
|
|
1819
|
+
const contextOverride = liveOverrides.get(contextNode.id)
|
|
1820
|
+
return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
|
|
1821
|
+
}
|
|
1822
|
+
const ctx: GeometryContext = ctxOverrides
|
|
1823
|
+
? {
|
|
1824
|
+
resolve: resolveContextNode,
|
|
1825
|
+
children: ctxOverrides.children,
|
|
1826
|
+
siblings: ctxOverrides.siblings,
|
|
1827
|
+
parent: ctxOverrides.parent,
|
|
1828
|
+
levelData,
|
|
1829
|
+
viewState: palette
|
|
1830
|
+
? {
|
|
1831
|
+
selected,
|
|
1832
|
+
unit,
|
|
1833
|
+
highlighted,
|
|
1834
|
+
hovered,
|
|
1835
|
+
moving,
|
|
1836
|
+
palette,
|
|
1837
|
+
}
|
|
1838
|
+
: undefined,
|
|
1839
|
+
}
|
|
1840
|
+
: {
|
|
1841
|
+
...buildContext(effectiveNode, contextNodes, viewState, levelData),
|
|
1842
|
+
resolve: resolveContextNode,
|
|
1843
|
+
}
|
|
1844
|
+
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
1845
|
+
effectiveNode,
|
|
1846
|
+
ctx,
|
|
1847
|
+
)
|
|
1848
|
+
const { base, overlay } = geometry
|
|
1849
|
+
? splitFloorplanOverlay(geometry)
|
|
1850
|
+
: { base: null, overlay: null }
|
|
1851
|
+
const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
|
|
1852
|
+
geometryCache.set(nodeId, entry)
|
|
1853
|
+
return entry
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export function getFloorplanLevelData(
|
|
1857
|
+
type: string,
|
|
1858
|
+
nodes: Record<string, AnyNode>,
|
|
1859
|
+
liveOverrides: Map<string, LiveNodeOverrides>,
|
|
1860
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>,
|
|
1861
|
+
levelDataCache: Map<string, LevelDataCacheEntry>,
|
|
1862
|
+
): unknown {
|
|
1863
|
+
const def = nodeRegistry.get(type)
|
|
1864
|
+
if (!def?.computeFloorplanLevelData) return undefined
|
|
1865
|
+
const ids = levelNodeIdsByType.get(type)
|
|
1866
|
+
const sampleId = ids?.[0]
|
|
1867
|
+
if (!ids || !sampleId) return undefined
|
|
1868
|
+
|
|
1869
|
+
const cached = levelDataCache.get(type)
|
|
1870
|
+
if (
|
|
1871
|
+
cached &&
|
|
1872
|
+
cached.nodes === nodes &&
|
|
1873
|
+
cached.liveOverrides === liveOverrides &&
|
|
1874
|
+
cached.ids === ids
|
|
1875
|
+
) {
|
|
1876
|
+
return cached.value
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
|
|
1880
|
+
const contextNodes = def.floorplanSiblingOverrides
|
|
1881
|
+
? def.floorplanSiblingOverrides({
|
|
1882
|
+
nodeId: sampleId,
|
|
1883
|
+
nodes,
|
|
1884
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
1885
|
+
liveOverrides,
|
|
1886
|
+
})
|
|
1887
|
+
: nodes
|
|
1888
|
+
const siblings: AnyNode[] = []
|
|
1889
|
+
for (const id of ids) {
|
|
1890
|
+
const sibling = contextNodes[id]
|
|
1891
|
+
if (sibling?.type === type) siblings.push(sibling)
|
|
1892
|
+
}
|
|
1893
|
+
const value = computeLevelData({ siblings, nodes: contextNodes })
|
|
1894
|
+
levelDataCache.set(type, { nodes, liveOverrides, ids, value })
|
|
1895
|
+
return value
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// ── Interactive geometry walker ──────────────────────────────────────
|
|
1899
|
+
|
|
1900
|
+
type InteractiveGeometryProps = {
|
|
850
1901
|
geometry: FloorplanGeometry
|
|
851
1902
|
unitsPerPixel: number
|
|
852
1903
|
palette: FloorplanPalette | undefined
|
|
853
1904
|
hatchPatternId: string | undefined
|
|
854
1905
|
hoveredHandleId: string | null
|
|
855
1906
|
activeDragId: string | null
|
|
1907
|
+
activeRotateNodeId: AnyNodeId | null
|
|
856
1908
|
isMarqueeSelectionActive: boolean
|
|
857
1909
|
nodeId: AnyNodeId
|
|
858
1910
|
sceneRotationDeg: number
|
|
859
1911
|
onHandleHoverChange: (id: string | null) => void
|
|
1912
|
+
onHandleDoubleClick: (
|
|
1913
|
+
affordance: string,
|
|
1914
|
+
payload: unknown,
|
|
1915
|
+
event: ReactMouseEvent<SVGElement>,
|
|
1916
|
+
) => void
|
|
860
1917
|
onHandlePointerDown: (
|
|
861
1918
|
affordance: string,
|
|
862
1919
|
payload: unknown,
|
|
@@ -866,7 +1923,24 @@ function InteractiveGeometry({
|
|
|
866
1923
|
rotationPivot?: FloorplanPoint,
|
|
867
1924
|
) => void
|
|
868
1925
|
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
|
869
|
-
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
1929
|
+
geometry,
|
|
1930
|
+
unitsPerPixel,
|
|
1931
|
+
palette,
|
|
1932
|
+
hatchPatternId,
|
|
1933
|
+
hoveredHandleId,
|
|
1934
|
+
activeDragId,
|
|
1935
|
+
activeRotateNodeId,
|
|
1936
|
+
isMarqueeSelectionActive,
|
|
1937
|
+
nodeId,
|
|
1938
|
+
sceneRotationDeg,
|
|
1939
|
+
onHandleDoubleClick,
|
|
1940
|
+
onHandleHoverChange,
|
|
1941
|
+
onHandlePointerDown,
|
|
1942
|
+
onMoveHandlePointerDown,
|
|
1943
|
+
}: InteractiveGeometryProps): React.ReactElement {
|
|
870
1944
|
return renderInteractive(geometry, 0)
|
|
871
1945
|
|
|
872
1946
|
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
|
@@ -911,6 +1985,7 @@ function InteractiveGeometry({
|
|
|
911
1985
|
case 'endpoint-handle': {
|
|
912
1986
|
if (!palette) return <></>
|
|
913
1987
|
const handleId = makeHandleId(nodeId, g.payload)
|
|
1988
|
+
const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
|
|
914
1989
|
const isHovered = hoveredHandleId === handleId
|
|
915
1990
|
const isActive = activeDragId === handleId
|
|
916
1991
|
// Variant picks the colour-set. Endpoint dots use the orange
|
|
@@ -992,6 +2067,15 @@ function InteractiveGeometry({
|
|
|
992
2067
|
cx={g.point[0]}
|
|
993
2068
|
cy={g.point[1]}
|
|
994
2069
|
fill="transparent"
|
|
2070
|
+
onDoubleClick={
|
|
2071
|
+
doubleClickAffordance
|
|
2072
|
+
? (event) => {
|
|
2073
|
+
event.preventDefault()
|
|
2074
|
+
event.stopPropagation()
|
|
2075
|
+
onHandleDoubleClick(doubleClickAffordance, g.payload, event)
|
|
2076
|
+
}
|
|
2077
|
+
: undefined
|
|
2078
|
+
}
|
|
995
2079
|
onPointerDown={(e) =>
|
|
996
2080
|
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
997
2081
|
}
|
|
@@ -1095,7 +2179,7 @@ function InteractiveGeometry({
|
|
|
1095
2179
|
// each end pointing tangentially in opposite directions —
|
|
1096
2180
|
// "rotate either way."
|
|
1097
2181
|
const handleId = makeHandleId(nodeId, g.payload)
|
|
1098
|
-
const isHovered = hoveredHandleId === handleId
|
|
2182
|
+
const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
|
|
1099
2183
|
// Arc geometry (all values precomputed for a 72° arc of
|
|
1100
2184
|
// radius 0.13 — comparable footprint to `move-arrow`).
|
|
1101
2185
|
const R = 0.13
|
|
@@ -1402,35 +2486,96 @@ function InteractiveGeometry({
|
|
|
1402
2486
|
// and flip by 180° if it falls outside (-90, 90] — that keeps
|
|
1403
2487
|
// text reading left-to-right, top-to-bottom regardless of the
|
|
1404
2488
|
// building's orientation.
|
|
2489
|
+
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
|
|
2490
|
+
|
|
2491
|
+
const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
|
|
2492
|
+
const outlined = g.appearance === 'outlined'
|
|
2493
|
+
const padX = labelUnitsPerPixel * 6
|
|
2494
|
+
const padY = labelUnitsPerPixel * 3
|
|
2495
|
+
const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
|
|
2496
|
+
// Rough text width approximation — SVG can't measure text without
|
|
2497
|
+
// the DOM. 6.2px per char at 10px font keeps the plate visually
|
|
2498
|
+
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
|
|
2499
|
+
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
|
|
2500
|
+
const plateW = textWidth + padX * 2
|
|
2501
|
+
const plateH = fontSize + padY * 2
|
|
2502
|
+
return (
|
|
2503
|
+
<g
|
|
2504
|
+
key={keyHint}
|
|
2505
|
+
pointerEvents="none"
|
|
2506
|
+
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
|
|
2507
|
+
>
|
|
2508
|
+
{outlined ? null : (
|
|
2509
|
+
<rect
|
|
2510
|
+
fill={palette.measurementLabelBackground}
|
|
2511
|
+
height={plateH}
|
|
2512
|
+
opacity={0.92}
|
|
2513
|
+
rx={labelUnitsPerPixel * 3}
|
|
2514
|
+
ry={labelUnitsPerPixel * 3}
|
|
2515
|
+
stroke={palette.measurementStroke}
|
|
2516
|
+
strokeWidth={labelUnitsPerPixel * 0.5}
|
|
2517
|
+
vectorEffect="non-scaling-stroke"
|
|
2518
|
+
width={plateW}
|
|
2519
|
+
x={-plateW / 2}
|
|
2520
|
+
y={-plateH / 2}
|
|
2521
|
+
/>
|
|
2522
|
+
)}
|
|
2523
|
+
<text
|
|
2524
|
+
dominantBaseline="middle"
|
|
2525
|
+
fill={outlined ? '#ffffff' : palette.measurementLabelText}
|
|
2526
|
+
fontFamily={
|
|
2527
|
+
outlined
|
|
2528
|
+
? 'system-ui, -apple-system, sans-serif'
|
|
2529
|
+
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
2530
|
+
}
|
|
2531
|
+
fontSize={fontSize}
|
|
2532
|
+
fontWeight={outlined ? 500 : 600}
|
|
2533
|
+
paintOrder={outlined ? 'stroke' : undefined}
|
|
2534
|
+
stroke={outlined ? palette.measurementStroke : undefined}
|
|
2535
|
+
strokeLinecap={outlined ? 'round' : undefined}
|
|
2536
|
+
strokeLinejoin={outlined ? 'round' : undefined}
|
|
2537
|
+
strokeWidth={outlined ? fontSize * 0.35 : undefined}
|
|
2538
|
+
textAnchor="middle"
|
|
2539
|
+
x={0}
|
|
2540
|
+
y={0}
|
|
2541
|
+
>
|
|
2542
|
+
{g.text}
|
|
2543
|
+
</text>
|
|
2544
|
+
</g>
|
|
2545
|
+
)
|
|
2546
|
+
}
|
|
2547
|
+
case 'equal-spacing-badge': {
|
|
2548
|
+
// A distinct accent (Figma-style "=" rhythm) so equal spacing reads
|
|
2549
|
+
// apart from the orange placement dimensions. Same screen-upright flip
|
|
2550
|
+
// as the dimension-label case above.
|
|
2551
|
+
const accent = '#ec4899'
|
|
1405
2552
|
let degrees = (g.angle * 180) / Math.PI
|
|
1406
2553
|
let screenDegrees = degrees + sceneRotationDeg
|
|
1407
2554
|
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
|
1408
2555
|
if (screenDegrees > 90) degrees -= 180
|
|
1409
2556
|
else if (screenDegrees <= -90) degrees += 180
|
|
1410
2557
|
|
|
2558
|
+
const label = `= ${g.text}`
|
|
1411
2559
|
const padX = unitsPerPixel * 6
|
|
1412
2560
|
const padY = unitsPerPixel * 3
|
|
1413
2561
|
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
|
1414
|
-
|
|
1415
|
-
// the DOM. 6.2px per char at 10px font keeps the plate visually
|
|
1416
|
-
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
|
|
1417
|
-
const textWidth = g.text.length * unitsPerPixel * 6.2
|
|
2562
|
+
const textWidth = label.length * unitsPerPixel * 6.2
|
|
1418
2563
|
const plateW = textWidth + padX * 2
|
|
1419
2564
|
const plateH = fontSize + padY * 2
|
|
1420
2565
|
return (
|
|
1421
2566
|
<g
|
|
1422
2567
|
key={keyHint}
|
|
1423
2568
|
pointerEvents="none"
|
|
1424
|
-
transform={`translate(${g.
|
|
2569
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
|
|
1425
2570
|
>
|
|
1426
2571
|
<rect
|
|
1427
|
-
fill=
|
|
2572
|
+
fill="#ffffff"
|
|
1428
2573
|
height={plateH}
|
|
1429
|
-
opacity={0.
|
|
2574
|
+
opacity={0.95}
|
|
1430
2575
|
rx={unitsPerPixel * 3}
|
|
1431
2576
|
ry={unitsPerPixel * 3}
|
|
1432
|
-
stroke={
|
|
1433
|
-
strokeWidth={unitsPerPixel * 0.
|
|
2577
|
+
stroke={accent}
|
|
2578
|
+
strokeWidth={unitsPerPixel * 0.75}
|
|
1434
2579
|
vectorEffect="non-scaling-stroke"
|
|
1435
2580
|
width={plateW}
|
|
1436
2581
|
x={-plateW / 2}
|
|
@@ -1438,15 +2583,15 @@ function InteractiveGeometry({
|
|
|
1438
2583
|
/>
|
|
1439
2584
|
<text
|
|
1440
2585
|
dominantBaseline="middle"
|
|
1441
|
-
fill={
|
|
2586
|
+
fill={accent}
|
|
1442
2587
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
1443
2588
|
fontSize={fontSize}
|
|
1444
|
-
fontWeight={
|
|
2589
|
+
fontWeight={700}
|
|
1445
2590
|
textAnchor="middle"
|
|
1446
2591
|
x={0}
|
|
1447
2592
|
y={0}
|
|
1448
2593
|
>
|
|
1449
|
-
{
|
|
2594
|
+
{label}
|
|
1450
2595
|
</text>
|
|
1451
2596
|
</g>
|
|
1452
2597
|
)
|
|
@@ -1634,10 +2779,25 @@ function InteractiveGeometry({
|
|
|
1634
2779
|
)
|
|
1635
2780
|
}
|
|
1636
2781
|
}
|
|
1637
|
-
}
|
|
2782
|
+
}, shallowPropsAreEqual)
|
|
1638
2783
|
|
|
1639
2784
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1640
2785
|
|
|
2786
|
+
function shallowPropsAreEqual<T extends object>(a: T, b: T): boolean {
|
|
2787
|
+
const aKeys = Object.keys(a) as Array<keyof T>
|
|
2788
|
+
const bKeys = Object.keys(b) as Array<keyof T>
|
|
2789
|
+
if (aKeys.length !== bKeys.length) return false
|
|
2790
|
+
for (const key of aKeys) {
|
|
2791
|
+
if (!Object.is(a[key], b[key])) return false
|
|
2792
|
+
}
|
|
2793
|
+
return true
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function handleIdForNode(handleId: string | null, nodeId: AnyNodeId): string | null {
|
|
2797
|
+
if (!handleId) return null
|
|
2798
|
+
return handleId === nodeId || handleId.startsWith(`${nodeId}:`) ? handleId : null
|
|
2799
|
+
}
|
|
2800
|
+
|
|
1641
2801
|
function applyPositionLiveTransform(
|
|
1642
2802
|
node: AnyNode,
|
|
1643
2803
|
live: { position: [number, number, number]; rotation: number },
|
|
@@ -1661,16 +2821,44 @@ function applyPositionLiveTransform(
|
|
|
1661
2821
|
} as AnyNode
|
|
1662
2822
|
}
|
|
1663
2823
|
|
|
1664
|
-
function
|
|
2824
|
+
export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
|
|
2825
|
+
const overrideVisible = liveOverride?.visible
|
|
2826
|
+
if (typeof overrideVisible === 'boolean') return overrideVisible
|
|
2827
|
+
return (node as { visible?: boolean }).visible !== false
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
function isFloorplanHierarchyVisible(
|
|
2831
|
+
node: AnyNode,
|
|
2832
|
+
nodes: Record<string, AnyNode>,
|
|
2833
|
+
liveOverrides: Map<string, LiveNodeOverrides>,
|
|
2834
|
+
rootId: AnyNodeId,
|
|
2835
|
+
): boolean {
|
|
2836
|
+
let current: AnyNode | undefined = node
|
|
2837
|
+
const seen = new Set<AnyNodeId>()
|
|
2838
|
+
while (current) {
|
|
2839
|
+
if (seen.has(current.id)) return true
|
|
2840
|
+
seen.add(current.id)
|
|
2841
|
+
if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
|
|
2842
|
+
if (current.id === rootId) return true
|
|
2843
|
+
const parentId = current.parentId as AnyNodeId | null
|
|
2844
|
+
if (!parentId) return true
|
|
2845
|
+
current = nodes[parentId]
|
|
2846
|
+
}
|
|
2847
|
+
return true
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
export function buildContext(
|
|
1665
2851
|
node: AnyNode,
|
|
1666
2852
|
nodes: Record<string, AnyNode>,
|
|
1667
2853
|
viewState: {
|
|
1668
2854
|
selected: boolean
|
|
2855
|
+
unit: 'metric' | 'imperial'
|
|
1669
2856
|
highlighted: boolean
|
|
1670
2857
|
hovered: boolean
|
|
1671
2858
|
moving: boolean
|
|
1672
2859
|
palette: FloorplanPalette | undefined
|
|
1673
2860
|
},
|
|
2861
|
+
levelData?: unknown,
|
|
1674
2862
|
): GeometryContext {
|
|
1675
2863
|
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
1676
2864
|
|
|
@@ -1703,9 +2891,11 @@ function buildContext(
|
|
|
1703
2891
|
children,
|
|
1704
2892
|
siblings,
|
|
1705
2893
|
parent,
|
|
2894
|
+
levelData,
|
|
1706
2895
|
viewState: viewState.palette
|
|
1707
2896
|
? {
|
|
1708
2897
|
selected: viewState.selected,
|
|
2898
|
+
unit: viewState.unit,
|
|
1709
2899
|
highlighted: viewState.highlighted,
|
|
1710
2900
|
hovered: viewState.hovered,
|
|
1711
2901
|
moving: viewState.moving,
|
|
@@ -1735,6 +2925,14 @@ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
|
|
|
1735
2925
|
return `${nodeId}:${String(payload)}`
|
|
1736
2926
|
}
|
|
1737
2927
|
|
|
2928
|
+
export function floorplanHandleDoubleClickAffordance(
|
|
2929
|
+
geometry: FloorplanGeometry,
|
|
2930
|
+
): 'delete-vertex' | null {
|
|
2931
|
+
return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
|
|
2932
|
+
? 'delete-vertex'
|
|
2933
|
+
: null
|
|
2934
|
+
}
|
|
2935
|
+
|
|
1738
2936
|
/**
|
|
1739
2937
|
* Geometry kinds that always render in the overlay pass — interactive
|
|
1740
2938
|
* handles and node labels. These need to sit above every kind's base
|
|
@@ -1754,6 +2952,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
|
1754
2952
|
'rotate-arrow',
|
|
1755
2953
|
'dimension',
|
|
1756
2954
|
'dimension-label',
|
|
2955
|
+
'equal-spacing-badge',
|
|
1757
2956
|
])
|
|
1758
2957
|
|
|
1759
2958
|
/**
|
|
@@ -1766,7 +2965,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
|
1766
2965
|
* / translations apply in both passes. Empty groups collapse to `null`
|
|
1767
2966
|
* so the caller can skip emitting an `<g>` when there's nothing to draw.
|
|
1768
2967
|
*/
|
|
1769
|
-
function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
2968
|
+
export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
1770
2969
|
base: FloorplanGeometry | null
|
|
1771
2970
|
overlay: FloorplanGeometry | null
|
|
1772
2971
|
} {
|
|
@@ -1794,6 +2993,172 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
|
1794
2993
|
return { base: g, overlay: null }
|
|
1795
2994
|
}
|
|
1796
2995
|
|
|
2996
|
+
/**
|
|
2997
|
+
* Per-node edit chrome hidden while a multi-selection is active: the group is
|
|
2998
|
+
* manipulated as one rigid piece (drag to move, R/T to rotate), so individual
|
|
2999
|
+
* handles / dimension labels on each member would mislead. `text` stays —
|
|
3000
|
+
* zone names are identification, not editing chrome.
|
|
3001
|
+
*/
|
|
3002
|
+
const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
3003
|
+
'endpoint-handle',
|
|
3004
|
+
'midpoint-handle',
|
|
3005
|
+
'edge-handle',
|
|
3006
|
+
'move-handle',
|
|
3007
|
+
'move-arrow',
|
|
3008
|
+
'rotate-arrow',
|
|
3009
|
+
'dimension',
|
|
3010
|
+
'dimension-label',
|
|
3011
|
+
'equal-spacing-badge',
|
|
3012
|
+
])
|
|
3013
|
+
|
|
3014
|
+
function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
|
|
3015
|
+
if (HANDLE_CHROME_KINDS.has(g.kind)) return null
|
|
3016
|
+
if (g.kind === 'group') {
|
|
3017
|
+
const children = g.children
|
|
3018
|
+
.map(stripHandleChrome)
|
|
3019
|
+
.filter((c): c is FloorplanGeometry => c !== null)
|
|
3020
|
+
if (children.length === 0) return null
|
|
3021
|
+
return { kind: 'group', children, transform: g.transform }
|
|
3022
|
+
}
|
|
3023
|
+
return g
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
// Stable string key for a wall endpoint, rounded to 1 mm so floating-point
|
|
3027
|
+
// drift collapses while distinct corners stay distinct.
|
|
3028
|
+
function endpointKey(x: number, y: number): string {
|
|
3029
|
+
return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// Given the sibling-dependent nodes with a live drag in flight, the set of
|
|
3033
|
+
// floor-plan geometries that must rebuild this frame. A node's geometry depends
|
|
3034
|
+
// on more than its own data:
|
|
3035
|
+
// - a wall's miters depend on the walls meeting at each of its endpoints, so a
|
|
3036
|
+
// dragged wall invalidates the walls at its old AND new junctions, plus its
|
|
3037
|
+
// own door/window children (their cuts are drawn into it);
|
|
3038
|
+
// - a door/window cut is drawn into its host wall, so it invalidates that wall;
|
|
3039
|
+
// - a gutter join depends on sibling gutters under the same roof.
|
|
3040
|
+
// Everything else stays cached, so dragging one wall/opening rebuilds a handful
|
|
3041
|
+
// of geometries rather than every wall + opening on the level.
|
|
3042
|
+
export function computeAffectedSiblingIds(
|
|
3043
|
+
liveFlaggedIds: readonly AnyNodeId[],
|
|
3044
|
+
nodes: Record<string, AnyNode>,
|
|
3045
|
+
liveOverrides: Map<string, Record<string, unknown>>,
|
|
3046
|
+
): Set<AnyNodeId> {
|
|
3047
|
+
const affected = new Set<AnyNodeId>()
|
|
3048
|
+
if (liveFlaggedIds.length === 0) return affected
|
|
3049
|
+
|
|
3050
|
+
// Junction map (committed wall endpoint → wall ids), built lazily on first use.
|
|
3051
|
+
let junctions: Map<string, AnyNodeId[]> | null = null
|
|
3052
|
+
const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
|
|
3053
|
+
if (!junctions) {
|
|
3054
|
+
junctions = new Map()
|
|
3055
|
+
for (const id in nodes) {
|
|
3056
|
+
const n = nodes[id]
|
|
3057
|
+
if (n?.type !== 'wall') continue
|
|
3058
|
+
const w = n as unknown as { start: [number, number]; end: [number, number] }
|
|
3059
|
+
for (const [px, py] of [w.start, w.end]) {
|
|
3060
|
+
const key = endpointKey(px, py)
|
|
3061
|
+
const arr = junctions.get(key)
|
|
3062
|
+
if (arr) arr.push(id as AnyNodeId)
|
|
3063
|
+
else junctions.set(key, [id as AnyNodeId])
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
return junctions.get(endpointKey(x, y)) ?? []
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
for (const id of liveFlaggedIds) {
|
|
3071
|
+
const node = nodes[id]
|
|
3072
|
+
if (!node) continue
|
|
3073
|
+
affected.add(id)
|
|
3074
|
+
const def = nodeRegistry.get(node.type)
|
|
3075
|
+
const extraAffectedIds = def?.floorplanAffectedIds?.({
|
|
3076
|
+
nodeId: id,
|
|
3077
|
+
node,
|
|
3078
|
+
nodes: nodes as Record<AnyNodeId, AnyNode>,
|
|
3079
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
3080
|
+
liveOverrides,
|
|
3081
|
+
})
|
|
3082
|
+
if (extraAffectedIds) {
|
|
3083
|
+
for (const extraId of extraAffectedIds) affected.add(extraId)
|
|
3084
|
+
}
|
|
3085
|
+
if (node.type === 'wall') {
|
|
3086
|
+
const w = node as unknown as {
|
|
3087
|
+
start: [number, number]
|
|
3088
|
+
end: [number, number]
|
|
3089
|
+
children?: AnyNodeId[]
|
|
3090
|
+
}
|
|
3091
|
+
// Use the live (override-merged) endpoints as well as the committed ones,
|
|
3092
|
+
// so walls at both the wall's old and new junctions get fresh miters.
|
|
3093
|
+
const ov = liveOverrides.get(id) as
|
|
3094
|
+
| { start?: [number, number]; end?: [number, number] }
|
|
3095
|
+
| undefined
|
|
3096
|
+
const points: [number, number][] = [w.start, w.end]
|
|
3097
|
+
if (ov?.start) points.push(ov.start)
|
|
3098
|
+
if (ov?.end) points.push(ov.end)
|
|
3099
|
+
for (const [px, py] of points) {
|
|
3100
|
+
for (const wid of wallsAtPoint(px, py)) affected.add(wid)
|
|
3101
|
+
}
|
|
3102
|
+
if (Array.isArray(w.children)) {
|
|
3103
|
+
for (const cid of w.children) {
|
|
3104
|
+
const child = nodes[cid]
|
|
3105
|
+
if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
} else if (node.type === 'door' || node.type === 'window') {
|
|
3109
|
+
const hostId = (node as { parentId?: string }).parentId
|
|
3110
|
+
if (hostId) affected.add(hostId as AnyNodeId)
|
|
3111
|
+
const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
|
|
3112
|
+
if (liveHostId) affected.add(liveHostId as AnyNodeId)
|
|
3113
|
+
} else if (node.type === 'gutter') {
|
|
3114
|
+
const roofId = (node as { parentId?: string }).parentId
|
|
3115
|
+
if (roofId) {
|
|
3116
|
+
for (const sid in nodes) {
|
|
3117
|
+
const s = nodes[sid]
|
|
3118
|
+
if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
|
|
3119
|
+
affected.add(sid as AnyNodeId)
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
return affected
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
|
3129
|
+
const keys: Array<keyof NodeDeps> = [
|
|
3130
|
+
'node',
|
|
3131
|
+
'live',
|
|
3132
|
+
'unit',
|
|
3133
|
+
'selected',
|
|
3134
|
+
'highlighted',
|
|
3135
|
+
'hovered',
|
|
3136
|
+
'moving',
|
|
3137
|
+
'liveOverride',
|
|
3138
|
+
'palette',
|
|
3139
|
+
'siblingEpoch',
|
|
3140
|
+
'committedNodes',
|
|
3141
|
+
'dependencyNodes',
|
|
3142
|
+
'interactiveElevators',
|
|
3143
|
+
]
|
|
3144
|
+
for (const key of keys) {
|
|
3145
|
+
if (!depsValueEqual(a[key], b[key])) return false
|
|
3146
|
+
}
|
|
3147
|
+
return true
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function depsValueEqual(a: unknown, b: unknown): boolean {
|
|
3151
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
3152
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
3153
|
+
if (a.length !== b.length) return false
|
|
3154
|
+
for (let i = 0; i < a.length; i++) {
|
|
3155
|
+
if (!Object.is(a[i], b[i])) return false
|
|
3156
|
+
}
|
|
3157
|
+
return true
|
|
3158
|
+
}
|
|
3159
|
+
return Object.is(a, b)
|
|
3160
|
+
}
|
|
3161
|
+
|
|
1797
3162
|
/**
|
|
1798
3163
|
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
|
1799
3164
|
* sits under everything with a higher rank. SVG renders in document
|
|
@@ -1809,7 +3174,7 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
|
1809
3174
|
* Sort is stable in modern JS engines, so siblings within the same
|
|
1810
3175
|
* bucket keep their DFS order (= scene tree order).
|
|
1811
3176
|
*/
|
|
1812
|
-
function floorplanLayerRank(type: string): number {
|
|
3177
|
+
export function floorplanLayerRank(type: string): number {
|
|
1813
3178
|
switch (type) {
|
|
1814
3179
|
case 'zone':
|
|
1815
3180
|
return 0
|
|
@@ -1855,14 +3220,17 @@ const ROTATION_WEDGE_SEGMENTS = 48
|
|
|
1855
3220
|
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
|
1856
3221
|
* horizontally regardless of the building's on-screen orientation.
|
|
1857
3222
|
*/
|
|
1858
|
-
function RotationAngleOverlay({
|
|
3223
|
+
export function RotationAngleOverlay({
|
|
1859
3224
|
overlay,
|
|
1860
3225
|
palette,
|
|
1861
3226
|
unitsPerPixel,
|
|
1862
3227
|
sceneRotationDeg,
|
|
1863
3228
|
}: {
|
|
1864
3229
|
overlay: RotationOverlayState
|
|
1865
|
-
palette:
|
|
3230
|
+
palette: Pick<
|
|
3231
|
+
FloorplanPalette,
|
|
3232
|
+
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
|
3233
|
+
>
|
|
1866
3234
|
unitsPerPixel: number
|
|
1867
3235
|
sceneRotationDeg: number
|
|
1868
3236
|
}): React.ReactElement {
|
|
@@ -1944,20 +3312,14 @@ function formatGroupTransform(t?: {
|
|
|
1944
3312
|
return parts.length > 0 ? parts.join(' ') : undefined
|
|
1945
3313
|
}
|
|
1946
3314
|
|
|
1947
|
-
function
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
point.x = clientX
|
|
1958
|
-
point.y = clientY
|
|
1959
|
-
const transformed = point.matrixTransform(ctm.inverse())
|
|
1960
|
-
// The floor-plan `<g>` maps plan X/Z directly to SVG x/y (Z stored as
|
|
1961
|
-
// the Y axis on screen — same convention as `toSvgPlanPoint`).
|
|
1962
|
-
return [transformed.x, transformed.y]
|
|
3315
|
+
function swallowNextClick(timeoutMs = 0) {
|
|
3316
|
+
const swallowClick = (event: MouseEvent) => {
|
|
3317
|
+
event.stopPropagation()
|
|
3318
|
+
event.preventDefault()
|
|
3319
|
+
window.removeEventListener('click', swallowClick, true)
|
|
3320
|
+
}
|
|
3321
|
+
window.addEventListener('click', swallowClick, true)
|
|
3322
|
+
setTimeout(() => {
|
|
3323
|
+
window.removeEventListener('click', swallowClick, true)
|
|
3324
|
+
}, timeoutMs)
|
|
1963
3325
|
}
|