@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -28,11 +28,13 @@ import {
|
|
|
28
28
|
} from '@pascal-app/core'
|
|
29
29
|
import { useViewer } from '@pascal-app/viewer'
|
|
30
30
|
import {
|
|
31
|
+
type ComponentProps,
|
|
31
32
|
memo,
|
|
32
33
|
type MouseEvent as ReactMouseEvent,
|
|
33
34
|
type PointerEvent as ReactPointerEvent,
|
|
34
35
|
useCallback,
|
|
35
36
|
useEffect,
|
|
37
|
+
useLayoutEffect,
|
|
36
38
|
useMemo,
|
|
37
39
|
useRef,
|
|
38
40
|
useState,
|
|
@@ -47,6 +49,24 @@ import {
|
|
|
47
49
|
snapDirectRotationDelta,
|
|
48
50
|
} from '../../../lib/direct-manipulation'
|
|
49
51
|
import { createEditorApi } from '../../../lib/editor-api'
|
|
52
|
+
import {
|
|
53
|
+
type FloorplanAnnotationVisibility,
|
|
54
|
+
filterFloorplanAnnotationGeometry,
|
|
55
|
+
} from '../../../lib/floorplan/annotation-visibility'
|
|
56
|
+
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
|
|
57
|
+
import {
|
|
58
|
+
createFloorplanContextExtensions,
|
|
59
|
+
type FloorplanAnnotationRole,
|
|
60
|
+
type FloorplanWallDimensionReference,
|
|
61
|
+
getFloorplanNodeExtension,
|
|
62
|
+
readFloorplanGeometryMetadata,
|
|
63
|
+
withFloorplanGeometryMetadata,
|
|
64
|
+
} from '../../../lib/floorplan/floorplan-extension'
|
|
65
|
+
import {
|
|
66
|
+
type FloorplanMode,
|
|
67
|
+
resolveFloorplanAnnotationVisibility,
|
|
68
|
+
resolveFloorplanWallDimensionReference,
|
|
69
|
+
} from '../../../lib/floorplan/floorplan-mode'
|
|
50
70
|
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
|
51
71
|
import {
|
|
52
72
|
type ActiveInteractionScope,
|
|
@@ -55,13 +75,19 @@ import {
|
|
|
55
75
|
curveReshapeScope,
|
|
56
76
|
endpointReshapeScope,
|
|
57
77
|
holeEditScope,
|
|
78
|
+
isIdle,
|
|
58
79
|
tangentReshapeScope,
|
|
59
80
|
} from '../../../lib/interaction/scope'
|
|
81
|
+
import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
|
|
60
82
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
61
83
|
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
|
62
84
|
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
|
63
|
-
import
|
|
85
|
+
import useDrawingView from '../../../store/use-drawing-view'
|
|
86
|
+
import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
|
|
87
|
+
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
|
|
88
|
+
import useFloorplanMode from '../../../store/use-floorplan-mode'
|
|
64
89
|
import useInteractionScope, {
|
|
90
|
+
getMovingNode,
|
|
65
91
|
useEndpointReshape,
|
|
66
92
|
useMovingNode,
|
|
67
93
|
} from '../../../store/use-interaction-scope'
|
|
@@ -73,9 +99,24 @@ import {
|
|
|
73
99
|
startFloorplanGroupMove,
|
|
74
100
|
startFloorplanGroupRotate,
|
|
75
101
|
} from '../floorplan-group-move'
|
|
76
|
-
import {
|
|
102
|
+
import {
|
|
103
|
+
useFloorplanSceneRotation,
|
|
104
|
+
useFloorplanStaticRender,
|
|
105
|
+
useFloorplanStaticUnitsPerPixel,
|
|
106
|
+
} from '../floorplan-render-context'
|
|
107
|
+
import {
|
|
108
|
+
floorplanAnnotationObstacleMode,
|
|
109
|
+
isFloorplanAnnotationObstacleGeometry,
|
|
110
|
+
resolveSvgAnnotationCollisions,
|
|
111
|
+
svgAnnotationLabelId,
|
|
112
|
+
} from './floorplan-annotation-layout'
|
|
113
|
+
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
|
|
77
114
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
78
|
-
import {
|
|
115
|
+
import {
|
|
116
|
+
resolveFloorplanAnnotationUpdate,
|
|
117
|
+
resolveFloorplanLabelAngle,
|
|
118
|
+
updateSvgFloorplanLabelOrientations,
|
|
119
|
+
} from './floorplan-label-angle'
|
|
79
120
|
|
|
80
121
|
/**
|
|
81
122
|
* Registry-driven floor-plan layer.
|
|
@@ -102,8 +143,8 @@ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
|
|
102
143
|
*/
|
|
103
144
|
// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
|
|
104
145
|
// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
|
|
105
|
-
// dispatcher multiplies by `unitsPerPixel` so handles stay
|
|
106
|
-
//
|
|
146
|
+
// dispatcher multiplies by `unitsPerPixel` so handles stay screen-sized
|
|
147
|
+
// until the world-space ceiling takes over at extreme zoom-out.
|
|
107
148
|
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
|
|
108
149
|
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
|
|
109
150
|
const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
|
|
@@ -115,6 +156,18 @@ const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
|
|
115
156
|
const DIRECT_DRAG_THRESHOLD_PX = 4
|
|
116
157
|
const DIRECT_ROTATE_EPSILON = 1e-6
|
|
117
158
|
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
|
159
|
+
const MAX_HANDLE_UNITS_PER_PIXEL = 0.015
|
|
160
|
+
|
|
161
|
+
export function resolveFloorplanHandleUnitsPerPixel(unitsPerPixel: number): number {
|
|
162
|
+
return Math.min(unitsPerPixel, MAX_HANDLE_UNITS_PER_PIXEL)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
|
|
166
|
+
props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
|
|
167
|
+
) {
|
|
168
|
+
const unitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
|
169
|
+
return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
|
|
170
|
+
})
|
|
118
171
|
|
|
119
172
|
/**
|
|
120
173
|
* Snapshot of node fields captured at drag-start, used by the single-undo
|
|
@@ -209,7 +262,7 @@ export function subscribeFloorplanAffordanceToolCancel(
|
|
|
209
262
|
// affordances return `null` (no polygon/wall snapping chip). Keyed off the
|
|
210
263
|
// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
|
|
211
264
|
// / `curve` / `move-endpoint`).
|
|
212
|
-
function
|
|
265
|
+
export function floorplanAffordanceReshapeScope(
|
|
213
266
|
affordance: string,
|
|
214
267
|
nodeId: string,
|
|
215
268
|
payload: unknown,
|
|
@@ -217,30 +270,30 @@ function affordanceReshapeScope(
|
|
|
217
270
|
if (affordance.includes('vertex') || affordance.includes('edge')) {
|
|
218
271
|
const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
|
|
219
272
|
return holeIndex !== undefined
|
|
220
|
-
? holeEditScope({ nodeId, holeIndex })
|
|
221
|
-
: boundaryReshapeScope(nodeId)
|
|
273
|
+
? holeEditScope({ nodeId, holeIndex, driver: 'floorplan' })
|
|
274
|
+
: boundaryReshapeScope(nodeId, 'floorplan')
|
|
222
275
|
}
|
|
223
276
|
if (affordance.includes('curve')) {
|
|
224
|
-
return curveReshapeScope(nodeId)
|
|
277
|
+
return curveReshapeScope(nodeId, 'floorplan')
|
|
225
278
|
}
|
|
226
279
|
if (affordance.includes('control-point')) {
|
|
227
280
|
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
|
228
|
-
return controlPointReshapeScope(nodeId, index)
|
|
281
|
+
return controlPointReshapeScope(nodeId, index, 'floorplan')
|
|
229
282
|
}
|
|
230
283
|
if (affordance.includes('tangent')) {
|
|
231
284
|
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
|
232
|
-
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
|
|
285
|
+
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out', 'floorplan')
|
|
233
286
|
}
|
|
234
287
|
if (affordance.includes('endpoint')) {
|
|
235
288
|
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
|
236
|
-
return endpointReshapeScope(nodeId, endpoint)
|
|
289
|
+
return endpointReshapeScope(nodeId, endpoint, 'floorplan')
|
|
237
290
|
}
|
|
238
291
|
// Roof-segment width/depth resize — a no-angle dimension edit, so the
|
|
239
292
|
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
|
|
240
293
|
// Matched exactly so a still-legacy `*-resize` affordance on another kind
|
|
241
294
|
// doesn't get a chip its snap math can't honour yet.
|
|
242
295
|
if (affordance === 'roof-segment-resize') {
|
|
243
|
-
return boundaryReshapeScope(nodeId)
|
|
296
|
+
return boundaryReshapeScope(nodeId, 'floorplan')
|
|
244
297
|
}
|
|
245
298
|
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
|
|
246
299
|
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
|
|
@@ -273,9 +326,12 @@ type FloorplanEntryDescriptor = {
|
|
|
273
326
|
}
|
|
274
327
|
|
|
275
328
|
type NodeDeps = {
|
|
329
|
+
automaticDimensions: boolean
|
|
276
330
|
node: AnyNode
|
|
277
331
|
live: LiveTransform | undefined
|
|
278
332
|
unit: 'metric' | 'imperial'
|
|
333
|
+
metricNotation: 'meters' | 'millimeters'
|
|
334
|
+
wallDimensionReference: FloorplanWallDimensionReference
|
|
279
335
|
selected: boolean
|
|
280
336
|
highlighted: boolean
|
|
281
337
|
hovered: boolean
|
|
@@ -320,6 +376,35 @@ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
|
|
|
320
376
|
const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
|
|
321
377
|
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
|
|
322
378
|
|
|
379
|
+
export function isFloorplanOpeningPlacementState({
|
|
380
|
+
phase,
|
|
381
|
+
mode,
|
|
382
|
+
tool,
|
|
383
|
+
movingNodeHasWallOpeningPlacement,
|
|
384
|
+
}: {
|
|
385
|
+
phase: string
|
|
386
|
+
mode: string
|
|
387
|
+
tool: string | null
|
|
388
|
+
movingNodeHasWallOpeningPlacement: boolean
|
|
389
|
+
}): boolean {
|
|
390
|
+
return (
|
|
391
|
+
(phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
|
|
392
|
+
movingNodeHasWallOpeningPlacement
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isFloorplanOpeningPlacementActiveNow(): boolean {
|
|
397
|
+
const { phase, mode, tool } = useEditor.getState()
|
|
398
|
+
const movingNode = getMovingNode()
|
|
399
|
+
return isFloorplanOpeningPlacementState({
|
|
400
|
+
phase,
|
|
401
|
+
mode,
|
|
402
|
+
tool,
|
|
403
|
+
movingNodeHasWallOpeningPlacement:
|
|
404
|
+
movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
323
408
|
function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
324
409
|
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
|
325
410
|
// deep-cloned to detach from the live store reference.
|
|
@@ -343,12 +428,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
343
428
|
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
|
344
429
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
|
345
430
|
const unit = useViewer((s) => s.unit)
|
|
346
|
-
const
|
|
347
|
-
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
348
|
-
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
349
|
-
const hoveredId = useViewer((s) => s.hoveredId)
|
|
350
|
-
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
|
|
351
|
-
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
431
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
352
432
|
const setSelection = useViewer((s) => s.setSelection)
|
|
353
433
|
const nodes = useScene((s) => s.nodes)
|
|
354
434
|
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
@@ -392,7 +472,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
392
472
|
|
|
393
473
|
const levelId = selectedLevelId ?? ambientLevelId
|
|
394
474
|
const isAmbient = !selectedLevelId && !!ambientLevelId
|
|
395
|
-
const renderCtx =
|
|
475
|
+
const renderCtx = useFloorplanStaticRender()
|
|
476
|
+
const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
|
|
396
477
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
397
478
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
398
479
|
// Door / window placement (both build and move) needs the SVG's
|
|
@@ -402,17 +483,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
402
483
|
// wall's registry entry would otherwise swallow the click via
|
|
403
484
|
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
|
404
485
|
// Pass clicks through in that case.
|
|
405
|
-
const editorPhase = useEditor((s) => s.phase)
|
|
406
486
|
const editorMode = useEditor((s) => s.mode)
|
|
407
|
-
const editorTool = useEditor((s) => s.tool)
|
|
408
487
|
const structureLayer = useEditor((s) => s.structureLayer)
|
|
409
488
|
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
410
489
|
const endpointReshape = useEndpointReshape()
|
|
411
|
-
const isOpeningPlacementActive =
|
|
412
|
-
(editorPhase === 'structure' &&
|
|
413
|
-
editorMode === 'build' &&
|
|
414
|
-
(editorTool === 'door' || editorTool === 'window')) ||
|
|
415
|
-
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
|
416
490
|
const isMarqueeSelectionActive =
|
|
417
491
|
editorMode === 'select' &&
|
|
418
492
|
floorplanSelectionTool === 'marquee' &&
|
|
@@ -423,26 +497,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
423
497
|
// selectors freeze to `undefined` so drag publishes do not re-render the
|
|
424
498
|
// hidden floor-plan tree.
|
|
425
499
|
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
|
500
|
+
const drawingType = useDrawingView((s) => s.drawingType)
|
|
501
|
+
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
|
|
502
|
+
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
|
|
503
|
+
const floorplanMode = useFloorplanMode((s) => s.mode)
|
|
504
|
+
const effectiveWallDimensionReference = resolveFloorplanWallDimensionReference(
|
|
505
|
+
floorplanMode,
|
|
506
|
+
wallDimensionReference,
|
|
507
|
+
)
|
|
426
508
|
// Elevator builders read runtime state imperatively, so entries include this
|
|
427
509
|
// rare-changing ref in their cache deps.
|
|
428
510
|
const interactiveElevators = useInteractive((s) => s.elevators)
|
|
429
511
|
|
|
430
|
-
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
431
|
-
// Marquee preview selection — matches the legacy `highlightedIdSet` use
|
|
432
|
-
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
|
|
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])
|
|
445
|
-
|
|
446
512
|
// Interactive state lives in refs; only the visible feedback bits go
|
|
447
513
|
// into React state to keep re-renders cheap during drag.
|
|
448
514
|
const dragRef = useRef<ActiveDrag | null>(null)
|
|
@@ -525,13 +591,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
525
591
|
const applyEntrySelection = useCallback(
|
|
526
592
|
(id: AnyNodeId, shouldToggle: boolean) => {
|
|
527
593
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
? currentSelectedIds.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
594
|
+
const nextSelectedIds = shouldToggle
|
|
595
|
+
? currentSelectedIds.includes(id)
|
|
596
|
+
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
|
597
|
+
: [...currentSelectedIds, id]
|
|
598
|
+
: [id]
|
|
599
|
+
setSelection({ selectedIds: nextSelectedIds })
|
|
600
|
+
if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
|
|
601
|
+
const node = useScene.getState().nodes[id]
|
|
602
|
+
if (node) emitCanvasNodeSelection(node)
|
|
603
|
+
}
|
|
535
604
|
// Setting selection re-renders the entry — the overlay pass mounts
|
|
536
605
|
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
|
537
606
|
// pointerdown and click. If the click target ends up on the SVG
|
|
@@ -555,6 +624,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
555
624
|
)
|
|
556
625
|
|
|
557
626
|
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
|
627
|
+
if (isFloorplanOpeningPlacementActiveNow()) return
|
|
558
628
|
event.stopPropagation()
|
|
559
629
|
}, [])
|
|
560
630
|
|
|
@@ -658,7 +728,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
658
728
|
startX,
|
|
659
729
|
pointerEvent.clientX,
|
|
660
730
|
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
|
661
|
-
|
|
731
|
+
!isAngleSnapActive(),
|
|
662
732
|
)
|
|
663
733
|
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
|
664
734
|
lastPatch = null
|
|
@@ -797,6 +867,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
797
867
|
|
|
798
868
|
const handleEntryPointerDown = useCallback(
|
|
799
869
|
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
|
870
|
+
// Keep this handler mounted during opening placement and arbitrate from
|
|
871
|
+
// the stores at event time. Commit clears the interaction scope before
|
|
872
|
+
// React paints the next frame; a render-time `undefined` handler leaves
|
|
873
|
+
// a short dead zone where the first post-placement selection is lost.
|
|
874
|
+
if (isFloorplanOpeningPlacementActiveNow()) return
|
|
800
875
|
if (startDirectMoveDrag(id, event)) return
|
|
801
876
|
if (startDirectRotateDrag(id, event)) return
|
|
802
877
|
if (startGroupMoveDrag(id, event)) return
|
|
@@ -838,15 +913,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
838
913
|
|
|
839
914
|
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
|
840
915
|
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
841
|
-
const
|
|
916
|
+
const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
|
|
917
|
+
if (!drawingNode) return
|
|
918
|
+
const def = nodeRegistry.get(drawingNode.type)
|
|
842
919
|
if (!def?.floorplan) return
|
|
843
|
-
if (node.type === 'measurement' && !showMeasurements) return
|
|
844
920
|
const dependsOnSiblingInputs = !!(
|
|
845
921
|
def.floorplanDependsOnSiblings ||
|
|
846
922
|
def.floorplanSiblingOverrides ||
|
|
847
923
|
def.floorplanAffectedIds
|
|
848
924
|
)
|
|
849
|
-
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
|
|
925
|
+
const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
|
|
850
926
|
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
|
|
851
927
|
out.push(descriptor)
|
|
852
928
|
}
|
|
@@ -863,6 +939,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
863
939
|
|
|
864
940
|
visit(levelId as AnyNodeId)
|
|
865
941
|
|
|
942
|
+
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
|
943
|
+
if (activeLevelNode) {
|
|
944
|
+
const collectedIds = new Set(out.map((entry) => entry.id))
|
|
945
|
+
for (const linked of collectFloorplanLinkedLevelNodes(
|
|
946
|
+
nodes,
|
|
947
|
+
levelId as AnyNodeId,
|
|
948
|
+
collectedIds,
|
|
949
|
+
)) {
|
|
950
|
+
pushEntry(linked.id, linked.node, {
|
|
951
|
+
children: linked.children,
|
|
952
|
+
siblings: [],
|
|
953
|
+
parent: activeLevelNode,
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
866
958
|
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
|
|
867
959
|
// as siblings of the level, not under it — the `visit(levelId)` DFS
|
|
868
960
|
// above doesn't reach them. Walk every node of those kinds whose
|
|
@@ -871,7 +963,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
871
963
|
// builders that gate on the current floor — e.g. elevator service
|
|
872
964
|
// range — keep working). Pure registry-driven dispatch: no kind
|
|
873
965
|
// name appears in this file.
|
|
874
|
-
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
|
875
966
|
const activeBuildingId = activeLevelNode
|
|
876
967
|
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
|
877
968
|
: null
|
|
@@ -907,7 +998,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
907
998
|
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
|
908
999
|
}
|
|
909
1000
|
return { entries: out, levelNodeIdsByType }
|
|
910
|
-
}, [installedPlugins, levelId, nodes
|
|
1001
|
+
}, [drawingType, installedPlugins, levelId, nodes])
|
|
911
1002
|
|
|
912
1003
|
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
|
913
1004
|
//
|
|
@@ -1011,7 +1102,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1011
1102
|
// the right chip during the edit AND `getActiveSnapContext()` resolves the
|
|
1012
1103
|
// polygon / wall mode-set the affordance's snap math reads. Torn down on
|
|
1013
1104
|
// release / cancel below. `null` for resize / rotate (no snapping chip).
|
|
1014
|
-
const reshapeScope =
|
|
1105
|
+
const reshapeScope = floorplanAffordanceReshapeScope(affordance, nodeId, payload)
|
|
1015
1106
|
if (reshapeScope) {
|
|
1016
1107
|
useInteractionScope.getState().begin(reshapeScope)
|
|
1017
1108
|
}
|
|
@@ -1103,9 +1194,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1103
1194
|
let delta = current - rot.initialAngle
|
|
1104
1195
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
1105
1196
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1106
|
-
|
|
1107
|
-
// degree chip read the committed rotation, not the raw pointer bearing.
|
|
1108
|
-
delta = snapDirectRotationDelta(delta, event.shiftKey)
|
|
1197
|
+
delta = snapDirectRotationDelta(delta, !isAngleSnapActive())
|
|
1109
1198
|
if (Math.abs(delta) < 0.0087) {
|
|
1110
1199
|
setRotationOverlay(null)
|
|
1111
1200
|
} else {
|
|
@@ -1257,7 +1346,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1257
1346
|
const entries = floorplanData.entries
|
|
1258
1347
|
if (entries.length === 0) return null
|
|
1259
1348
|
|
|
1260
|
-
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
|
|
1261
1349
|
const palette = renderCtx?.palette
|
|
1262
1350
|
|
|
1263
1351
|
return (
|
|
@@ -1277,7 +1365,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1277
1365
|
// still propagate normally inside the registry tree.
|
|
1278
1366
|
<g
|
|
1279
1367
|
className="floorplan-registry-layer"
|
|
1280
|
-
onClick={
|
|
1368
|
+
onClick={handleClickStop}
|
|
1281
1369
|
opacity={isAmbient ? 0.3 : undefined}
|
|
1282
1370
|
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
|
1283
1371
|
>
|
|
@@ -1289,16 +1377,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1289
1377
|
{entries.map((entry) => (
|
|
1290
1378
|
<FloorplanRegistryEntry
|
|
1291
1379
|
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1292
|
-
|
|
1380
|
+
annotationVisibility={annotationVisibility}
|
|
1381
|
+
floorplanMode={floorplanMode}
|
|
1293
1382
|
floorplanVisible={floorplanVisible}
|
|
1294
1383
|
geometryCacheRef={geometryCacheRef}
|
|
1295
1384
|
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1296
|
-
highlighted={highlightedIdSet.has(entry.id)}
|
|
1297
|
-
hovered={hoveredId === entry.id}
|
|
1298
1385
|
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1299
1386
|
interactiveElevators={interactiveElevators}
|
|
1300
1387
|
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1301
|
-
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
1302
1388
|
key={`base-${entry.id}`}
|
|
1303
1389
|
levelDataCacheRef={levelDataCacheRef}
|
|
1304
1390
|
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
@@ -1312,18 +1398,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1312
1398
|
onHandleHoverChange={setHoveredHandleId}
|
|
1313
1399
|
onHandleDoubleClick={commitAffordanceAction}
|
|
1314
1400
|
onHandlePointerDown={startAffordanceDrag}
|
|
1315
|
-
onHoveredIdChange={setHoveredId}
|
|
1316
1401
|
palette={palette}
|
|
1317
1402
|
pass="base"
|
|
1318
|
-
sceneRotationDeg={
|
|
1319
|
-
selected={selectedIdSet.has(entry.id)}
|
|
1320
|
-
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
|
1321
|
-
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
|
1403
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1322
1404
|
setMovingNode={setMovingNode}
|
|
1323
1405
|
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1324
1406
|
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1325
1407
|
unit={unit}
|
|
1326
|
-
|
|
1408
|
+
metricNotation={metricNotation}
|
|
1409
|
+
wallDimensionReference={effectiveWallDimensionReference}
|
|
1327
1410
|
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1328
1411
|
ctxOverrides={entry.ctxOverrides}
|
|
1329
1412
|
/>
|
|
@@ -1340,16 +1423,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1340
1423
|
{entries.map((entry) => (
|
|
1341
1424
|
<FloorplanRegistryEntry
|
|
1342
1425
|
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1343
|
-
|
|
1426
|
+
annotationVisibility={annotationVisibility}
|
|
1427
|
+
floorplanMode={floorplanMode}
|
|
1344
1428
|
floorplanVisible={floorplanVisible}
|
|
1345
1429
|
geometryCacheRef={geometryCacheRef}
|
|
1346
1430
|
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1347
|
-
highlighted={highlightedIdSet.has(entry.id)}
|
|
1348
|
-
hovered={hoveredId === entry.id}
|
|
1349
1431
|
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1350
1432
|
interactiveElevators={interactiveElevators}
|
|
1351
1433
|
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1352
|
-
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
1353
1434
|
key={`overlay-${entry.id}`}
|
|
1354
1435
|
levelDataCacheRef={levelDataCacheRef}
|
|
1355
1436
|
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
@@ -1363,31 +1444,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1363
1444
|
onHandleHoverChange={setHoveredHandleId}
|
|
1364
1445
|
onHandleDoubleClick={commitAffordanceAction}
|
|
1365
1446
|
onHandlePointerDown={startAffordanceDrag}
|
|
1366
|
-
onHoveredIdChange={setHoveredId}
|
|
1367
1447
|
palette={palette}
|
|
1368
1448
|
pass="overlay"
|
|
1369
|
-
sceneRotationDeg={
|
|
1370
|
-
selected={selectedIdSet.has(entry.id)}
|
|
1371
|
-
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
|
1372
|
-
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
|
1449
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1373
1450
|
setMovingNode={setMovingNode}
|
|
1374
1451
|
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1375
1452
|
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1376
1453
|
unit={unit}
|
|
1377
|
-
|
|
1454
|
+
metricNotation={metricNotation}
|
|
1455
|
+
wallDimensionReference={effectiveWallDimensionReference}
|
|
1378
1456
|
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1379
1457
|
ctxOverrides={entry.ctxOverrides}
|
|
1380
1458
|
/>
|
|
1381
1459
|
))}
|
|
1382
1460
|
</g>
|
|
1461
|
+
<FloorplanAnnotationLayoutResolver active={floorplanVisible} />
|
|
1383
1462
|
{/* Dashed group bbox — shows what a group drag carries along while a
|
|
1384
1463
|
multi-selection exists, rides the live delta mid-drag, and doubles
|
|
1385
1464
|
as the group's whole-area drag handle. */}
|
|
1386
|
-
<
|
|
1465
|
+
<ScaleAwareFloorplanGroupSelectionBox
|
|
1387
1466
|
onPointerDown={handleGroupBoxPointerDown}
|
|
1388
1467
|
onRotatePointerDown={handleGroupBoxRotatePointerDown}
|
|
1389
1468
|
palette={palette}
|
|
1390
|
-
unitsPerPixel={unitsPerPixel}
|
|
1391
1469
|
/>
|
|
1392
1470
|
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
|
1393
1471
|
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
|
@@ -1395,37 +1473,288 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1395
1473
|
<RotationAngleOverlay
|
|
1396
1474
|
overlay={rotationOverlay}
|
|
1397
1475
|
palette={palette}
|
|
1398
|
-
sceneRotationDeg={
|
|
1399
|
-
unitsPerPixel={unitsPerPixel}
|
|
1476
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1400
1477
|
/>
|
|
1401
1478
|
) : null}
|
|
1402
1479
|
</g>
|
|
1403
1480
|
)
|
|
1404
1481
|
})
|
|
1405
1482
|
|
|
1483
|
+
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
|
1484
|
+
const markerRef = useRef<SVGGElement>(null)
|
|
1485
|
+
const collisionLabelElementsRef = useRef<SVGGElement[]>([])
|
|
1486
|
+
const registryLabelElementsRef = useRef<SVGGElement[]>([])
|
|
1487
|
+
const registryOrientationElementsRef = useRef<SVGGElement[]>([])
|
|
1488
|
+
const appliedRotationDegRef = useRef<number | null>(null)
|
|
1489
|
+
const appliedLayoutInputsRef = useRef<object | null>(null)
|
|
1490
|
+
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
|
|
1491
|
+
const sceneRotationDeg = useFloorplanSceneRotation()
|
|
1492
|
+
const [settledLayoutEpoch, setSettledLayoutEpoch] = useState(0)
|
|
1493
|
+
const drawingType = useDrawingView((state) => state.drawingType)
|
|
1494
|
+
const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
|
|
1495
|
+
const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
|
|
1496
|
+
const wallDimensionReference = useFloorplanAnnotationVisibility(
|
|
1497
|
+
(state) => state.wallDimensionReference,
|
|
1498
|
+
)
|
|
1499
|
+
const floorplanMode = useFloorplanMode((state) => state.mode)
|
|
1500
|
+
const layoutInputs = useMemo(
|
|
1501
|
+
() => ({
|
|
1502
|
+
annotationVisibility,
|
|
1503
|
+
annotationLayoutOverrides,
|
|
1504
|
+
drawingType,
|
|
1505
|
+
interactionIdle,
|
|
1506
|
+
settledLayoutEpoch,
|
|
1507
|
+
wallDimensionReference,
|
|
1508
|
+
floorplanMode,
|
|
1509
|
+
}),
|
|
1510
|
+
[
|
|
1511
|
+
annotationVisibility,
|
|
1512
|
+
annotationLayoutOverrides,
|
|
1513
|
+
drawingType,
|
|
1514
|
+
interactionIdle,
|
|
1515
|
+
settledLayoutEpoch,
|
|
1516
|
+
wallDimensionReference,
|
|
1517
|
+
floorplanMode,
|
|
1518
|
+
],
|
|
1519
|
+
)
|
|
1520
|
+
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
|
|
1521
|
+
const layoutEnabled = active && interactionIdle
|
|
1522
|
+
|
|
1523
|
+
useEffect(() => {
|
|
1524
|
+
if (!layoutEnabled) return
|
|
1525
|
+
let frame: number | null = null
|
|
1526
|
+
const invalidateLayout = () => {
|
|
1527
|
+
if (frame !== null) return
|
|
1528
|
+
frame = window.requestAnimationFrame(() => {
|
|
1529
|
+
frame = null
|
|
1530
|
+
setSettledLayoutEpoch((epoch) => epoch + 1)
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1533
|
+
const unsubscribeScene = useScene.subscribe((state, previousState) => {
|
|
1534
|
+
if (
|
|
1535
|
+
state.nodes !== previousState.nodes ||
|
|
1536
|
+
state.installedPlugins !== previousState.installedPlugins
|
|
1537
|
+
) {
|
|
1538
|
+
invalidateLayout()
|
|
1539
|
+
}
|
|
1540
|
+
})
|
|
1541
|
+
const unsubscribeTransforms = useLiveTransforms.subscribe((state, previousState) => {
|
|
1542
|
+
if (state.transforms !== previousState.transforms) invalidateLayout()
|
|
1543
|
+
})
|
|
1544
|
+
const unsubscribeOverrides = useLiveNodeOverrides.subscribe((state, previousState) => {
|
|
1545
|
+
if (state.overrides !== previousState.overrides) invalidateLayout()
|
|
1546
|
+
})
|
|
1547
|
+
const unsubscribeInteractive = useInteractive.subscribe((state, previousState) => {
|
|
1548
|
+
if (state.elevators !== previousState.elevators) invalidateLayout()
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
return () => {
|
|
1552
|
+
unsubscribeScene()
|
|
1553
|
+
unsubscribeTransforms()
|
|
1554
|
+
unsubscribeOverrides()
|
|
1555
|
+
unsubscribeInteractive()
|
|
1556
|
+
if (frame !== null) window.cancelAnimationFrame(frame)
|
|
1557
|
+
}
|
|
1558
|
+
}, [layoutEnabled])
|
|
1559
|
+
|
|
1560
|
+
useLayoutEffect(() => {
|
|
1561
|
+
// Explicit layout inputs replace the former whole-subtree MutationObserver.
|
|
1562
|
+
if (!active) {
|
|
1563
|
+
appliedLayoutInputsRef.current = null
|
|
1564
|
+
return
|
|
1565
|
+
}
|
|
1566
|
+
if (!interactionIdle) return
|
|
1567
|
+
const registryLayer = markerRef.current?.parentElement
|
|
1568
|
+
if (!registryLayer) return
|
|
1569
|
+
const update = resolveFloorplanAnnotationUpdate({
|
|
1570
|
+
layoutInputsChanged: appliedLayoutInputsRef.current !== layoutInputs,
|
|
1571
|
+
nextRotationDeg: sceneRotationDeg,
|
|
1572
|
+
previousRotationDeg: appliedRotationDegRef.current,
|
|
1573
|
+
})
|
|
1574
|
+
if (update.resolveCollisions) {
|
|
1575
|
+
const svg = markerRef.current?.ownerSVGElement
|
|
1576
|
+
if (!svg) return
|
|
1577
|
+
collisionLabelElementsRef.current = Array.from(
|
|
1578
|
+
svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
|
|
1579
|
+
)
|
|
1580
|
+
registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
|
|
1581
|
+
registryLayer.contains(label),
|
|
1582
|
+
)
|
|
1583
|
+
registryOrientationElementsRef.current = Array.from(
|
|
1584
|
+
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-angle-radians]'),
|
|
1585
|
+
)
|
|
1586
|
+
}
|
|
1587
|
+
if (update.updateLabelPresentation) {
|
|
1588
|
+
updateSvgFloorplanLabelOrientations(registryOrientationElementsRef.current, sceneRotationDeg)
|
|
1589
|
+
appliedRotationDegRef.current = sceneRotationDeg
|
|
1590
|
+
}
|
|
1591
|
+
if (!update.resolveCollisions) return
|
|
1592
|
+
const svg = markerRef.current?.ownerSVGElement
|
|
1593
|
+
if (!svg) return
|
|
1594
|
+
resolveSvgAnnotationCollisions(svg, {
|
|
1595
|
+
labels: collisionLabelElementsRef.current,
|
|
1596
|
+
layoutOverrides: annotationLayoutOverrides,
|
|
1597
|
+
})
|
|
1598
|
+
appliedLayoutInputsRef.current = layoutInputs
|
|
1599
|
+
|
|
1600
|
+
for (const [index, label] of registryLabelElementsRef.current.entries()) {
|
|
1601
|
+
const id = svgAnnotationLabelId(label, index)
|
|
1602
|
+
label.dataset.floorplanAnnotationId = id
|
|
1603
|
+
label.style.pointerEvents = 'all'
|
|
1604
|
+
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
|
|
1605
|
+
}
|
|
1606
|
+
}, [active, annotationLayoutOverrides, interactionIdle, layoutInputs, sceneRotationDeg])
|
|
1607
|
+
|
|
1608
|
+
useEffect(() => {
|
|
1609
|
+
if (!layoutEnabled) return
|
|
1610
|
+
const registryLayer = markerRef.current?.parentElement
|
|
1611
|
+
if (!registryLayer) return
|
|
1612
|
+
let cleanupPointerDrag: (() => void) | null = null
|
|
1613
|
+
let cancelActivePointerDrag: (() => void) | null = null
|
|
1614
|
+
|
|
1615
|
+
const findLabel = (target: EventTarget | null): SVGGElement | null => {
|
|
1616
|
+
if (!(target instanceof Element)) return null
|
|
1617
|
+
const label = target.closest<SVGGElement>('[data-floorplan-annotation-label]')
|
|
1618
|
+
return label && registryLayer.contains(label) ? label : null
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const labelId = (label: SVGGElement): string => {
|
|
1622
|
+
const labels = registryLabelElementsRef.current
|
|
1623
|
+
return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1627
|
+
const label = findLabel(event.target)
|
|
1628
|
+
if (!(label && event.button === 0)) return
|
|
1629
|
+
const matrix = label.getScreenCTM()
|
|
1630
|
+
if (!matrix) return
|
|
1631
|
+
event.preventDefault()
|
|
1632
|
+
event.stopPropagation()
|
|
1633
|
+
cancelActivePointerDrag?.()
|
|
1634
|
+
label.style.cursor = 'grabbing'
|
|
1635
|
+
const id = labelId(label)
|
|
1636
|
+
const start = { x: event.clientX, y: event.clientY }
|
|
1637
|
+
const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? {
|
|
1638
|
+
...readFloorplanAnnotationLayoutOffset(label),
|
|
1639
|
+
pinned: true,
|
|
1640
|
+
}
|
|
1641
|
+
const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true
|
|
1642
|
+
let latest = existing
|
|
1643
|
+
let moved = false
|
|
1644
|
+
|
|
1645
|
+
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
1646
|
+
if (moveEvent.pointerId !== event.pointerId) return
|
|
1647
|
+
moved = true
|
|
1648
|
+
const local = screenVectorToFloorplanAnnotationLocal(
|
|
1649
|
+
matrix,
|
|
1650
|
+
moveEvent.clientX - start.x,
|
|
1651
|
+
moveEvent.clientY - start.y,
|
|
1652
|
+
)
|
|
1653
|
+
latest = {
|
|
1654
|
+
dx: existing.dx + local.x,
|
|
1655
|
+
dy: existing.dy + local.y,
|
|
1656
|
+
pinned: true,
|
|
1657
|
+
}
|
|
1658
|
+
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
|
|
1659
|
+
label.setAttribute(
|
|
1660
|
+
'transform',
|
|
1661
|
+
`${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
|
|
1662
|
+
)
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const finishPointerDrag = (endEvent: PointerEvent) => {
|
|
1666
|
+
if (endEvent.pointerId !== event.pointerId) return
|
|
1667
|
+
cleanupPointerDrag?.()
|
|
1668
|
+
label.style.cursor = moved || wasPinned ? 'grab' : 'move'
|
|
1669
|
+
if (moved) setAnnotationLayoutOverride(id, latest)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const cancelPointerDrag = (cancelEvent: PointerEvent) => {
|
|
1673
|
+
if (cancelEvent.pointerId !== event.pointerId) return
|
|
1674
|
+
cancelActivePointerDrag?.()
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
cancelActivePointerDrag = () => {
|
|
1678
|
+
cleanupPointerDrag?.()
|
|
1679
|
+
label.style.cursor = wasPinned ? 'grab' : 'move'
|
|
1680
|
+
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
|
|
1681
|
+
label.setAttribute(
|
|
1682
|
+
'transform',
|
|
1683
|
+
`${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(),
|
|
1684
|
+
)
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
cleanupPointerDrag = () => {
|
|
1688
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
1689
|
+
window.removeEventListener('pointerup', finishPointerDrag)
|
|
1690
|
+
window.removeEventListener('pointercancel', cancelPointerDrag)
|
|
1691
|
+
cleanupPointerDrag = null
|
|
1692
|
+
cancelActivePointerDrag = null
|
|
1693
|
+
}
|
|
1694
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
1695
|
+
window.addEventListener('pointerup', finishPointerDrag)
|
|
1696
|
+
window.addEventListener('pointercancel', cancelPointerDrag)
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
1700
|
+
const label = findLabel(event.target)
|
|
1701
|
+
if (!label) return
|
|
1702
|
+
event.preventDefault()
|
|
1703
|
+
event.stopPropagation()
|
|
1704
|
+
label.style.cursor = 'move'
|
|
1705
|
+
setAnnotationLayoutOverride(labelId(label), null)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
registryLayer.addEventListener('pointerdown', onPointerDown)
|
|
1709
|
+
registryLayer.addEventListener('dblclick', onDoubleClick)
|
|
1710
|
+
return () => {
|
|
1711
|
+
cancelActivePointerDrag?.()
|
|
1712
|
+
registryLayer.removeEventListener('pointerdown', onPointerDown)
|
|
1713
|
+
registryLayer.removeEventListener('dblclick', onDoubleClick)
|
|
1714
|
+
for (const label of registryLabelElementsRef.current) {
|
|
1715
|
+
label.style.pointerEvents = ''
|
|
1716
|
+
label.style.cursor = ''
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}, [layoutEnabled, setAnnotationLayoutOverride])
|
|
1720
|
+
return <g pointerEvents="none" ref={markerRef} />
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
|
|
1724
|
+
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
|
|
1725
|
+
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
|
|
1726
|
+
return {
|
|
1727
|
+
x: (matrix.d * dx - matrix.c * dy) / determinant,
|
|
1728
|
+
y: (-matrix.b * dx + matrix.a * dy) / determinant,
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
|
|
1733
|
+
const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
|
|
1734
|
+
const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
|
|
1735
|
+
return {
|
|
1736
|
+
dx: Number.isFinite(dx) ? dx : 0,
|
|
1737
|
+
dy: Number.isFinite(dy) ? dy : 0,
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1406
1741
|
type FloorplanRegistryEntryProps = {
|
|
1407
1742
|
activeDragId: string | null
|
|
1408
|
-
|
|
1743
|
+
annotationVisibility: FloorplanAnnotationVisibility
|
|
1744
|
+
floorplanMode: FloorplanMode
|
|
1409
1745
|
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1410
1746
|
floorplanVisible: boolean
|
|
1411
1747
|
geometryCacheRef: { current: Map<string, CacheEntry> }
|
|
1412
1748
|
hatchPatternId: string | undefined
|
|
1413
|
-
highlighted: boolean
|
|
1414
|
-
hovered: boolean
|
|
1415
1749
|
hoveredHandleId: string | null
|
|
1416
1750
|
interactiveElevators: unknown
|
|
1417
1751
|
isMarqueeSelectionActive: boolean
|
|
1418
|
-
isOpeningPlacementActive: boolean
|
|
1419
1752
|
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
|
1420
1753
|
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
1421
1754
|
moving: boolean
|
|
1422
1755
|
node: AnyNode
|
|
1423
1756
|
nodeId: AnyNodeId
|
|
1424
1757
|
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
1758
|
onClickStop: (event: React.MouseEvent<SVGGElement>) => void
|
|
1430
1759
|
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
|
|
1431
1760
|
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
|
|
@@ -1444,58 +1773,73 @@ type FloorplanRegistryEntryProps = {
|
|
|
1444
1773
|
event: ReactPointerEvent<SVGGElement>,
|
|
1445
1774
|
rotationPivot?: FloorplanPoint,
|
|
1446
1775
|
) => void
|
|
1447
|
-
onHoveredIdChange: (id: AnyNodeId | null) => void
|
|
1448
1776
|
palette: FloorplanPalette | undefined
|
|
1449
1777
|
pass: FloorplanRenderPass
|
|
1450
1778
|
sceneRotationDeg: number
|
|
1451
|
-
selected: boolean
|
|
1452
1779
|
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
|
1453
1780
|
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
|
1454
1781
|
siblingEpoch: number
|
|
1455
1782
|
unit: 'metric' | 'imperial'
|
|
1456
|
-
|
|
1783
|
+
metricNotation: 'meters' | 'millimeters'
|
|
1784
|
+
wallDimensionReference: FloorplanWallDimensionReference
|
|
1457
1785
|
visibilityRootId: AnyNodeId | undefined
|
|
1458
1786
|
}
|
|
1459
1787
|
|
|
1460
1788
|
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
1461
1789
|
activeDragId,
|
|
1462
|
-
|
|
1790
|
+
annotationVisibility,
|
|
1791
|
+
floorplanMode,
|
|
1463
1792
|
ctxOverrides,
|
|
1464
1793
|
floorplanVisible,
|
|
1465
1794
|
geometryCacheRef,
|
|
1466
1795
|
hatchPatternId,
|
|
1467
|
-
highlighted,
|
|
1468
|
-
hovered,
|
|
1469
1796
|
hoveredHandleId,
|
|
1470
1797
|
interactiveElevators,
|
|
1471
1798
|
isMarqueeSelectionActive,
|
|
1472
|
-
isOpeningPlacementActive,
|
|
1473
1799
|
levelDataCacheRef,
|
|
1474
1800
|
levelNodeIdsByType,
|
|
1475
1801
|
moving,
|
|
1476
1802
|
node,
|
|
1477
1803
|
nodeId,
|
|
1478
1804
|
nodes,
|
|
1479
|
-
suppressHandles,
|
|
1480
|
-
groupMoveCursor,
|
|
1481
1805
|
onClickStop,
|
|
1482
1806
|
onEntryPointerDown,
|
|
1483
1807
|
onGroupMovePointerDown,
|
|
1484
1808
|
onHandleHoverChange,
|
|
1485
1809
|
onHandleDoubleClick,
|
|
1486
1810
|
onHandlePointerDown,
|
|
1487
|
-
onHoveredIdChange,
|
|
1488
1811
|
palette,
|
|
1489
1812
|
pass,
|
|
1490
1813
|
sceneRotationDeg,
|
|
1491
|
-
selected,
|
|
1492
1814
|
setMovingNode,
|
|
1493
1815
|
setMovingNodeOrigin,
|
|
1494
1816
|
siblingEpoch,
|
|
1495
1817
|
unit,
|
|
1496
|
-
|
|
1818
|
+
metricNotation,
|
|
1819
|
+
wallDimensionReference,
|
|
1497
1820
|
visibilityRootId,
|
|
1498
1821
|
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
|
1822
|
+
const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
1823
|
+
const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
|
|
1824
|
+
const suppressHandles = useViewer(
|
|
1825
|
+
(state) =>
|
|
1826
|
+
state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
|
|
1827
|
+
)
|
|
1828
|
+
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
1829
|
+
const selectionProxyId = resolveSelectionProxyId(
|
|
1830
|
+
node,
|
|
1831
|
+
nodes as Record<string, AnyNode | undefined>,
|
|
1832
|
+
)
|
|
1833
|
+
const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
|
|
1834
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
1835
|
+
const referencedAnnotationRole = useViewer((state) =>
|
|
1836
|
+
floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
|
|
1837
|
+
)
|
|
1838
|
+
const activeRotateNodeId = useDirectManipulationFeedback((state) =>
|
|
1839
|
+
state.activeRotateNodeId === nodeId ? nodeId : null,
|
|
1840
|
+
)
|
|
1841
|
+
const groupMoveCursor =
|
|
1842
|
+
suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
|
|
1499
1843
|
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
|
1500
1844
|
const liveOverride = useLiveNodeOverrides((s) =>
|
|
1501
1845
|
floorplanVisible ? s.overrides.get(nodeId) : undefined,
|
|
@@ -1503,6 +1847,15 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1503
1847
|
const liveOverrides = floorplanVisible
|
|
1504
1848
|
? useLiveNodeOverrides.getState().overrides
|
|
1505
1849
|
: EMPTY_LIVE_OVERRIDES
|
|
1850
|
+
const presentationVisibility = resolveFloorplanAnnotationVisibility(
|
|
1851
|
+
floorplanMode,
|
|
1852
|
+
annotationVisibility,
|
|
1853
|
+
{
|
|
1854
|
+
referencedAnnotationRole,
|
|
1855
|
+
selected,
|
|
1856
|
+
target: 'editor',
|
|
1857
|
+
},
|
|
1858
|
+
)
|
|
1506
1859
|
|
|
1507
1860
|
const handlePointerDown = useCallback(
|
|
1508
1861
|
(event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
|
|
@@ -1512,16 +1865,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1512
1865
|
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
|
|
1513
1866
|
// the highlight halo in 3D as well as registry floor-plan hover strokes.
|
|
1514
1867
|
const handlePointerEnter = useCallback(() => {
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1868
|
+
const currentNode = useScene.getState().nodes[nodeId]
|
|
1869
|
+
setHoveredId(
|
|
1870
|
+
currentNode
|
|
1518
1871
|
? resolveSelectionProxyId(
|
|
1519
|
-
|
|
1872
|
+
currentNode,
|
|
1520
1873
|
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1521
1874
|
)
|
|
1522
1875
|
: nodeId,
|
|
1523
1876
|
)
|
|
1524
|
-
}, [nodeId,
|
|
1877
|
+
}, [nodeId, setHoveredId])
|
|
1525
1878
|
|
|
1526
1879
|
const handlePointerLeave = useCallback(() => {
|
|
1527
1880
|
const node = useScene.getState().nodes[nodeId]
|
|
@@ -1531,8 +1884,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1531
1884
|
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1532
1885
|
)
|
|
1533
1886
|
: nodeId
|
|
1534
|
-
if (useViewer.getState().hoveredId === targetId)
|
|
1535
|
-
}, [nodeId,
|
|
1887
|
+
if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
|
|
1888
|
+
}, [nodeId, setHoveredId])
|
|
1536
1889
|
|
|
1537
1890
|
const handleHandlePointerDown = useCallback(
|
|
1538
1891
|
(
|
|
@@ -1581,6 +1934,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1581
1934
|
)
|
|
1582
1935
|
|
|
1583
1936
|
const cacheEntry = buildFloorplanEntryGeometry({
|
|
1937
|
+
automaticDimensions: presentationVisibility.automaticDimensions,
|
|
1584
1938
|
ctxOverrides,
|
|
1585
1939
|
geometryCache: geometryCacheRef.current,
|
|
1586
1940
|
highlighted,
|
|
@@ -1599,21 +1953,25 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1599
1953
|
selected,
|
|
1600
1954
|
siblingEpoch,
|
|
1601
1955
|
unit,
|
|
1956
|
+
metricNotation,
|
|
1957
|
+
wallDimensionReference,
|
|
1602
1958
|
visibilityRootId,
|
|
1603
1959
|
})
|
|
1604
1960
|
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
|
1961
|
+
const visibleGeometry = rawGeometry
|
|
1962
|
+
? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
|
|
1963
|
+
: null
|
|
1605
1964
|
// Multi-selection shows highlight only: strip this member's edit handles /
|
|
1606
1965
|
// dimension chrome (all of which live in the overlay pass) while keeping
|
|
1607
1966
|
// its highlighted body geometry.
|
|
1608
1967
|
const geometry =
|
|
1609
|
-
|
|
1610
|
-
? stripHandleChrome(
|
|
1611
|
-
:
|
|
1968
|
+
visibleGeometry && suppressHandles && pass === 'overlay'
|
|
1969
|
+
? stripHandleChrome(visibleGeometry)
|
|
1970
|
+
: visibleGeometry
|
|
1612
1971
|
if (!geometry) return null
|
|
1613
1972
|
|
|
1614
|
-
const entryClick =
|
|
1615
|
-
const entryPointerDown =
|
|
1616
|
-
isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
|
|
1973
|
+
const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
|
|
1974
|
+
const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
|
|
1617
1975
|
|
|
1618
1976
|
return (
|
|
1619
1977
|
<g
|
|
@@ -1639,13 +1997,13 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1639
1997
|
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
|
1640
1998
|
palette={palette}
|
|
1641
1999
|
sceneRotationDeg={sceneRotationDeg}
|
|
1642
|
-
unitsPerPixel={unitsPerPixel}
|
|
1643
2000
|
/>
|
|
1644
2001
|
</g>
|
|
1645
2002
|
)
|
|
1646
2003
|
}, shallowPropsAreEqual)
|
|
1647
2004
|
|
|
1648
2005
|
type BuildFloorplanEntryGeometryArgs = {
|
|
2006
|
+
automaticDimensions: boolean
|
|
1649
2007
|
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1650
2008
|
geometryCache: Map<string, CacheEntry>
|
|
1651
2009
|
highlighted: boolean
|
|
@@ -1664,6 +2022,8 @@ type BuildFloorplanEntryGeometryArgs = {
|
|
|
1664
2022
|
selected: boolean
|
|
1665
2023
|
siblingEpoch: number
|
|
1666
2024
|
unit: 'metric' | 'imperial'
|
|
2025
|
+
metricNotation: 'meters' | 'millimeters'
|
|
2026
|
+
wallDimensionReference: FloorplanWallDimensionReference
|
|
1667
2027
|
visibilityRootId: AnyNodeId | undefined
|
|
1668
2028
|
}
|
|
1669
2029
|
|
|
@@ -1688,7 +2048,20 @@ export function collectFloorplanDependencyNodes(
|
|
|
1688
2048
|
})
|
|
1689
2049
|
}
|
|
1690
2050
|
|
|
2051
|
+
function floorplanEntryReferencedAnnotationRole(
|
|
2052
|
+
node: AnyNode,
|
|
2053
|
+
selectedIds: ReadonlySet<string>,
|
|
2054
|
+
): FloorplanAnnotationRole | undefined {
|
|
2055
|
+
if (selectedIds.size === 0) return undefined
|
|
2056
|
+
const definition = nodeRegistry.get(node.type)
|
|
2057
|
+
const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
|
|
2058
|
+
if (!role) return undefined
|
|
2059
|
+
const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
|
|
2060
|
+
return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1691
2063
|
function buildFloorplanEntryGeometry({
|
|
2064
|
+
automaticDimensions,
|
|
1692
2065
|
ctxOverrides,
|
|
1693
2066
|
geometryCache,
|
|
1694
2067
|
highlighted,
|
|
@@ -1707,6 +2080,8 @@ function buildFloorplanEntryGeometry({
|
|
|
1707
2080
|
selected,
|
|
1708
2081
|
siblingEpoch,
|
|
1709
2082
|
unit,
|
|
2083
|
+
metricNotation,
|
|
2084
|
+
wallDimensionReference,
|
|
1710
2085
|
visibilityRootId,
|
|
1711
2086
|
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
|
|
1712
2087
|
const def = nodeRegistry.get(node.type)
|
|
@@ -1728,9 +2103,12 @@ function buildFloorplanEntryGeometry({
|
|
|
1728
2103
|
)
|
|
1729
2104
|
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
|
|
1730
2105
|
const deps: NodeDeps = {
|
|
2106
|
+
automaticDimensions,
|
|
1731
2107
|
node,
|
|
1732
2108
|
live,
|
|
1733
2109
|
unit,
|
|
2110
|
+
metricNotation,
|
|
2111
|
+
wallDimensionReference,
|
|
1734
2112
|
selected,
|
|
1735
2113
|
highlighted,
|
|
1736
2114
|
hovered,
|
|
@@ -1806,8 +2184,11 @@ function buildFloorplanEntryGeometry({
|
|
|
1806
2184
|
levelDataCache,
|
|
1807
2185
|
)
|
|
1808
2186
|
const viewState = {
|
|
2187
|
+
automaticDimensions,
|
|
1809
2188
|
selected,
|
|
1810
2189
|
unit,
|
|
2190
|
+
metricNotation,
|
|
2191
|
+
wallDimensionReference,
|
|
1811
2192
|
highlighted,
|
|
1812
2193
|
hovered,
|
|
1813
2194
|
moving,
|
|
@@ -1826,6 +2207,12 @@ function buildFloorplanEntryGeometry({
|
|
|
1826
2207
|
siblings: ctxOverrides.siblings,
|
|
1827
2208
|
parent: ctxOverrides.parent,
|
|
1828
2209
|
levelData,
|
|
2210
|
+
extensions: createFloorplanContextExtensions({
|
|
2211
|
+
automaticDimensions,
|
|
2212
|
+
metricNotation,
|
|
2213
|
+
purpose: 'edit',
|
|
2214
|
+
wallDimensionReference,
|
|
2215
|
+
}),
|
|
1829
2216
|
viewState: palette
|
|
1830
2217
|
? {
|
|
1831
2218
|
selected,
|
|
@@ -1841,10 +2228,20 @@ function buildFloorplanEntryGeometry({
|
|
|
1841
2228
|
...buildContext(effectiveNode, contextNodes, viewState, levelData),
|
|
1842
2229
|
resolve: resolveContextNode,
|
|
1843
2230
|
}
|
|
1844
|
-
const
|
|
2231
|
+
const modelGeometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
1845
2232
|
effectiveNode,
|
|
1846
2233
|
ctx,
|
|
1847
2234
|
)
|
|
2235
|
+
const contextualGeometry = selected
|
|
2236
|
+
? (getFloorplanNodeExtension(def)?.contextualDimensions?.(effectiveNode, ctx) ?? null)
|
|
2237
|
+
: null
|
|
2238
|
+
const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
|
|
2239
|
+
annotationRole: 'contextual-dimension',
|
|
2240
|
+
})
|
|
2241
|
+
const geometry =
|
|
2242
|
+
modelGeometry && taggedContextualGeometry
|
|
2243
|
+
? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
|
|
2244
|
+
: (modelGeometry ?? taggedContextualGeometry)
|
|
1848
2245
|
const { base, overlay } = geometry
|
|
1849
2246
|
? splitFloorplanOverlay(geometry)
|
|
1850
2247
|
: { base: null, overlay: null }
|
|
@@ -1899,7 +2296,7 @@ export function getFloorplanLevelData(
|
|
|
1899
2296
|
|
|
1900
2297
|
type InteractiveGeometryProps = {
|
|
1901
2298
|
geometry: FloorplanGeometry
|
|
1902
|
-
unitsPerPixel
|
|
2299
|
+
unitsPerPixel?: number
|
|
1903
2300
|
palette: FloorplanPalette | undefined
|
|
1904
2301
|
hatchPatternId: string | undefined
|
|
1905
2302
|
hoveredHandleId: string | null
|
|
@@ -1925,9 +2322,9 @@ type InteractiveGeometryProps = {
|
|
|
1925
2322
|
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
|
1926
2323
|
}
|
|
1927
2324
|
|
|
1928
|
-
const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
2325
|
+
export const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
1929
2326
|
geometry,
|
|
1930
|
-
unitsPerPixel,
|
|
2327
|
+
unitsPerPixel: unitsPerPixelOverride,
|
|
1931
2328
|
palette,
|
|
1932
2329
|
hatchPatternId,
|
|
1933
2330
|
hoveredHandleId,
|
|
@@ -1941,6 +2338,12 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
1941
2338
|
onHandlePointerDown,
|
|
1942
2339
|
onMoveHandlePointerDown,
|
|
1943
2340
|
}: InteractiveGeometryProps): React.ReactElement {
|
|
2341
|
+
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
|
2342
|
+
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
|
2343
|
+
// Keep handles pixel-sized through normal navigation, then let them shrink
|
|
2344
|
+
// with the plan once zoom compensation would make them dominate the geometry.
|
|
2345
|
+
const handleUnitsPerPixel = resolveFloorplanHandleUnitsPerPixel(unitsPerPixel)
|
|
2346
|
+
|
|
1944
2347
|
return renderInteractive(geometry, 0)
|
|
1945
2348
|
|
|
1946
2349
|
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
|
@@ -1948,7 +2351,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
1948
2351
|
case 'group': {
|
|
1949
2352
|
const transform = formatGroupTransform(g.transform)
|
|
1950
2353
|
return (
|
|
1951
|
-
<g
|
|
2354
|
+
<g
|
|
2355
|
+
data-floorplan-annotation-obstacle={
|
|
2356
|
+
floorplanAnnotationObstacleMode(g) ??
|
|
2357
|
+
(isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
|
|
2358
|
+
}
|
|
2359
|
+
key={keyHint}
|
|
2360
|
+
transform={transform}
|
|
2361
|
+
>
|
|
1952
2362
|
{g.children.map((child, i) => renderInteractive(child, i))}
|
|
1953
2363
|
</g>
|
|
1954
2364
|
)
|
|
@@ -2009,10 +2419,10 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2009
2419
|
: palette.endpointHandleFill
|
|
2010
2420
|
const outerRadius =
|
|
2011
2421
|
(isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
|
|
2012
|
-
|
|
2422
|
+
handleUnitsPerPixel
|
|
2013
2423
|
const dotRadius =
|
|
2014
2424
|
(isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
|
|
2015
|
-
|
|
2425
|
+
handleUnitsPerPixel
|
|
2016
2426
|
return (
|
|
2017
2427
|
<g
|
|
2018
2428
|
key={keyHint}
|
|
@@ -2028,7 +2438,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2028
2438
|
r={outerRadius}
|
|
2029
2439
|
stroke={hoverStroke}
|
|
2030
2440
|
strokeOpacity={isActive ? 0.24 : 0.16}
|
|
2031
|
-
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX *
|
|
2441
|
+
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
|
2032
2442
|
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2033
2443
|
vectorEffect="non-scaling-stroke"
|
|
2034
2444
|
/>
|
|
@@ -2040,7 +2450,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2040
2450
|
r={outerRadius}
|
|
2041
2451
|
stroke={hoverStroke}
|
|
2042
2452
|
strokeOpacity={isActive ? 0.72 : 0.52}
|
|
2043
|
-
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX *
|
|
2453
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
|
2044
2454
|
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2045
2455
|
vectorEffect="non-scaling-stroke"
|
|
2046
2456
|
/>
|
|
@@ -2401,8 +2811,8 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2401
2811
|
// Slightly smaller than endpoint dots; hover-expanded.
|
|
2402
2812
|
const baseRadiusPx = 6
|
|
2403
2813
|
const hoverRadiusPx = 8
|
|
2404
|
-
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) *
|
|
2405
|
-
const plusHalf = 3 *
|
|
2814
|
+
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * handleUnitsPerPixel
|
|
2815
|
+
const plusHalf = 3 * handleUnitsPerPixel
|
|
2406
2816
|
return (
|
|
2407
2817
|
<g
|
|
2408
2818
|
key={keyHint}
|
|
@@ -2415,10 +2825,10 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2415
2825
|
cy={g.point[1]}
|
|
2416
2826
|
fill="none"
|
|
2417
2827
|
pointerEvents="none"
|
|
2418
|
-
r={radius + 2 *
|
|
2828
|
+
r={radius + 2 * handleUnitsPerPixel}
|
|
2419
2829
|
stroke={hoverStroke}
|
|
2420
2830
|
strokeOpacity={0.16}
|
|
2421
|
-
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX *
|
|
2831
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
|
2422
2832
|
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2423
2833
|
vectorEffect="non-scaling-stroke"
|
|
2424
2834
|
/>
|
|
@@ -2499,11 +2909,21 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2499
2909
|
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
|
|
2500
2910
|
const plateW = textWidth + padX * 2
|
|
2501
2911
|
const plateH = fontSize + padY * 2
|
|
2912
|
+
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
|
|
2502
2913
|
return (
|
|
2503
2914
|
<g
|
|
2915
|
+
data-floorplan-annotation-angle-radians={g.angle}
|
|
2916
|
+
data-floorplan-annotation-default-transform={labelTransform}
|
|
2917
|
+
data-floorplan-annotation-label=""
|
|
2918
|
+
data-floorplan-annotation-priority="20"
|
|
2919
|
+
data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
|
|
2920
|
+
data-floorplan-annotation-transform-after-rotation={`translate(0 ${
|
|
2921
|
+
-(g.offsetPx ?? 0) * labelUnitsPerPixel
|
|
2922
|
+
})`}
|
|
2923
|
+
data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
|
|
2504
2924
|
key={keyHint}
|
|
2505
2925
|
pointerEvents="none"
|
|
2506
|
-
transform={
|
|
2926
|
+
transform={labelTransform}
|
|
2507
2927
|
>
|
|
2508
2928
|
{outlined ? null : (
|
|
2509
2929
|
<rect
|
|
@@ -2598,147 +3018,13 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2598
3018
|
}
|
|
2599
3019
|
case 'dimension': {
|
|
2600
3020
|
if (!palette) return <></>
|
|
2601
|
-
const stroke = g.stroke ?? palette.measurementStroke
|
|
2602
|
-
// Offset endpoints along the outward normal — this is where the
|
|
2603
|
-
// dimension line sits, parallel to the edge.
|
|
2604
|
-
const ox = g.offsetNormal[0] * g.offsetDistance
|
|
2605
|
-
const oy = g.offsetNormal[1] * g.offsetDistance
|
|
2606
|
-
const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
|
|
2607
|
-
const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
|
|
2608
|
-
|
|
2609
|
-
// Extension line endpoints — extend past the dimension line by
|
|
2610
|
-
// `extensionOvershoot` so the tip clears the dimension stroke.
|
|
2611
|
-
const eOvershoot = g.extensionOvershoot
|
|
2612
|
-
const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
|
|
2613
|
-
const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
|
|
2614
|
-
const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
|
|
2615
|
-
const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
|
|
2616
|
-
|
|
2617
|
-
const dx = dEnd[0] - dStart[0]
|
|
2618
|
-
const dy = dEnd[1] - dStart[1]
|
|
2619
|
-
const length = Math.hypot(dx, dy)
|
|
2620
|
-
if (length < 1e-6) return <></>
|
|
2621
|
-
const dirX = dx / length
|
|
2622
|
-
const dirY = dy / length
|
|
2623
|
-
|
|
2624
|
-
// Plan-unit constants matching the legacy `floorplan-
|
|
2625
|
-
// measurements-layer.tsx`. `strokeWidth` is intentionally a
|
|
2626
|
-
// raw value (not multiplied by `unitsPerPixel`) because every
|
|
2627
|
-
// stroke here uses `vectorEffect: non-scaling-stroke` — the
|
|
2628
|
-
// browser interprets it as screen-pixel-stable. Multiplying
|
|
2629
|
-
// by `unitsPerPixel` would shrink the strokes by ~100× and
|
|
2630
|
-
// make them invisible. Tick length, dash pattern, font size,
|
|
2631
|
-
// and the label gap stay in plan units (they're geometry,
|
|
2632
|
-
// not stroke width).
|
|
2633
|
-
const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
|
|
2634
|
-
const perpX = -dirY * tickHalf
|
|
2635
|
-
const perpY = dirX * tickHalf
|
|
2636
|
-
|
|
2637
|
-
const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
|
|
2638
|
-
const labelGap = 0.5 // plan units — gap in the dimension line for the label
|
|
2639
|
-
const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
|
|
2640
|
-
|
|
2641
|
-
const midX = (dStart[0] + dEnd[0]) / 2
|
|
2642
|
-
const midY = (dStart[1] + dEnd[1]) / 2
|
|
2643
|
-
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
|
|
2644
|
-
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
|
|
2645
|
-
|
|
2646
|
-
// Keep the label parallel to the dimension line, but decide the
|
|
2647
|
-
// 180° flip from the on-SCREEN angle, not the local one. The parent
|
|
2648
|
-
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
|
|
2649
|
-
// plan), so a label kept upright in local coords still renders
|
|
2650
|
-
// upside down for half of the wall orientations. Same fix as the
|
|
2651
|
-
// `dimension-label` case above.
|
|
2652
|
-
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
|
|
2653
|
-
let screenDeg = labelDeg + sceneRotationDeg
|
|
2654
|
-
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
|
2655
|
-
if (screenDeg > 90) labelDeg -= 180
|
|
2656
|
-
else if (screenDeg <= -90) labelDeg += 180
|
|
2657
|
-
|
|
2658
3021
|
return (
|
|
2659
|
-
<
|
|
2660
|
-
{
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
strokeOpacity={0.95}
|
|
2666
|
-
strokeWidth={1.35}
|
|
2667
|
-
vectorEffect="non-scaling-stroke"
|
|
2668
|
-
x1={g.start[0]}
|
|
2669
|
-
x2={eStartTip[0]}
|
|
2670
|
-
y1={g.start[1]}
|
|
2671
|
-
y2={eStartTip[1]}
|
|
2672
|
-
/>
|
|
2673
|
-
<line
|
|
2674
|
-
stroke={stroke}
|
|
2675
|
-
strokeDasharray="0.08 0.12"
|
|
2676
|
-
strokeLinecap="round"
|
|
2677
|
-
strokeOpacity={0.95}
|
|
2678
|
-
strokeWidth={1.35}
|
|
2679
|
-
vectorEffect="non-scaling-stroke"
|
|
2680
|
-
x1={g.end[0]}
|
|
2681
|
-
x2={eEndTip[0]}
|
|
2682
|
-
y1={g.end[1]}
|
|
2683
|
-
y2={eEndTip[1]}
|
|
2684
|
-
/>
|
|
2685
|
-
{/* Dimension line: two halves with the label in between. */}
|
|
2686
|
-
<line
|
|
2687
|
-
stroke={stroke}
|
|
2688
|
-
strokeLinecap="round"
|
|
2689
|
-
strokeWidth={1.35}
|
|
2690
|
-
vectorEffect="non-scaling-stroke"
|
|
2691
|
-
x1={dStart[0]}
|
|
2692
|
-
x2={gapStart[0]}
|
|
2693
|
-
y1={dStart[1]}
|
|
2694
|
-
y2={gapStart[1]}
|
|
2695
|
-
/>
|
|
2696
|
-
<line
|
|
2697
|
-
stroke={stroke}
|
|
2698
|
-
strokeLinecap="round"
|
|
2699
|
-
strokeWidth={1.35}
|
|
2700
|
-
vectorEffect="non-scaling-stroke"
|
|
2701
|
-
x1={gapEnd[0]}
|
|
2702
|
-
x2={dEnd[0]}
|
|
2703
|
-
y1={gapEnd[1]}
|
|
2704
|
-
y2={dEnd[1]}
|
|
2705
|
-
/>
|
|
2706
|
-
{/* End ticks. */}
|
|
2707
|
-
<line
|
|
2708
|
-
stroke={stroke}
|
|
2709
|
-
strokeLinecap="round"
|
|
2710
|
-
strokeWidth={1.35}
|
|
2711
|
-
vectorEffect="non-scaling-stroke"
|
|
2712
|
-
x1={dStart[0] - perpX}
|
|
2713
|
-
x2={dStart[0] + perpX}
|
|
2714
|
-
y1={dStart[1] - perpY}
|
|
2715
|
-
y2={dStart[1] + perpY}
|
|
2716
|
-
/>
|
|
2717
|
-
<line
|
|
2718
|
-
stroke={stroke}
|
|
2719
|
-
strokeLinecap="round"
|
|
2720
|
-
strokeWidth={1.35}
|
|
2721
|
-
vectorEffect="non-scaling-stroke"
|
|
2722
|
-
x1={dEnd[0] - perpX}
|
|
2723
|
-
x2={dEnd[0] + perpX}
|
|
2724
|
-
y1={dEnd[1] - perpY}
|
|
2725
|
-
y2={dEnd[1] + perpY}
|
|
2726
|
-
/>
|
|
2727
|
-
{/* Rotated label centered in the gap. */}
|
|
2728
|
-
<text
|
|
2729
|
-
dominantBaseline="central"
|
|
2730
|
-
fill={stroke}
|
|
2731
|
-
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
2732
|
-
fontSize={fontSize}
|
|
2733
|
-
fontWeight={600}
|
|
2734
|
-
textAnchor="middle"
|
|
2735
|
-
transform={`rotate(${labelDeg} ${midX} ${midY})`}
|
|
2736
|
-
x={midX}
|
|
2737
|
-
y={midY}
|
|
2738
|
-
>
|
|
2739
|
-
{g.text}
|
|
2740
|
-
</text>
|
|
2741
|
-
</g>
|
|
3022
|
+
<FloorplanDimensionRenderer
|
|
3023
|
+
geometry={g}
|
|
3024
|
+
key={keyHint}
|
|
3025
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
3026
|
+
stroke={g.stroke ?? palette.measurementStroke}
|
|
3027
|
+
/>
|
|
2742
3028
|
)
|
|
2743
3029
|
}
|
|
2744
3030
|
case 'text': {
|
|
@@ -2746,8 +3032,18 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2746
3032
|
// Counter-rotate by the scene rotation so the label reads
|
|
2747
3033
|
// horizontally on screen even when the floor-plan view is
|
|
2748
3034
|
// rotated (default `sceneRotationDeg` is 90°).
|
|
3035
|
+
const beforeRotation = `translate(${g.x} ${g.y})`
|
|
3036
|
+
const transform = `${beforeRotation} rotate(${-sceneRotationDeg})`
|
|
2749
3037
|
return (
|
|
2750
|
-
<g
|
|
3038
|
+
<g
|
|
3039
|
+
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
|
|
3040
|
+
data-floorplan-annotation-angle-radians={0}
|
|
3041
|
+
data-floorplan-annotation-default-transform={transform}
|
|
3042
|
+
data-floorplan-annotation-screen-upright="true"
|
|
3043
|
+
data-floorplan-annotation-transform-before-rotation={beforeRotation}
|
|
3044
|
+
key={keyHint}
|
|
3045
|
+
transform={transform}
|
|
3046
|
+
>
|
|
2751
3047
|
<text
|
|
2752
3048
|
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
|
2753
3049
|
fill={g.fill ?? '#171717'}
|
|
@@ -2775,6 +3071,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
|
2775
3071
|
geometry={g}
|
|
2776
3072
|
key={keyHint}
|
|
2777
3073
|
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
|
|
3074
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
2778
3075
|
/>
|
|
2779
3076
|
)
|
|
2780
3077
|
}
|
|
@@ -2851,8 +3148,12 @@ export function buildContext(
|
|
|
2851
3148
|
node: AnyNode,
|
|
2852
3149
|
nodes: Record<string, AnyNode>,
|
|
2853
3150
|
viewState: {
|
|
3151
|
+
automaticDimensions?: boolean
|
|
2854
3152
|
selected: boolean
|
|
2855
3153
|
unit: 'metric' | 'imperial'
|
|
3154
|
+
metricNotation?: 'meters' | 'millimeters'
|
|
3155
|
+
purpose?: 'edit' | 'document'
|
|
3156
|
+
wallDimensionReference?: FloorplanWallDimensionReference
|
|
2856
3157
|
highlighted: boolean
|
|
2857
3158
|
hovered: boolean
|
|
2858
3159
|
moving: boolean
|
|
@@ -2892,6 +3193,12 @@ export function buildContext(
|
|
|
2892
3193
|
siblings,
|
|
2893
3194
|
parent,
|
|
2894
3195
|
levelData,
|
|
3196
|
+
extensions: createFloorplanContextExtensions({
|
|
3197
|
+
automaticDimensions: viewState.automaticDimensions,
|
|
3198
|
+
metricNotation: viewState.metricNotation ?? 'meters',
|
|
3199
|
+
purpose: viewState.purpose ?? 'edit',
|
|
3200
|
+
wallDimensionReference: viewState.wallDimensionReference,
|
|
3201
|
+
}),
|
|
2895
3202
|
viewState: viewState.palette
|
|
2896
3203
|
? {
|
|
2897
3204
|
selected: viewState.selected,
|
|
@@ -2905,6 +3212,29 @@ export function buildContext(
|
|
|
2905
3212
|
}
|
|
2906
3213
|
}
|
|
2907
3214
|
|
|
3215
|
+
export function collectFloorplanLinkedLevelNodes(
|
|
3216
|
+
nodes: Record<string, AnyNode>,
|
|
3217
|
+
levelId: AnyNodeId,
|
|
3218
|
+
excludedIds: ReadonlySet<AnyNodeId> = new Set(),
|
|
3219
|
+
): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
|
|
3220
|
+
const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
|
|
3221
|
+
for (const [rawId, node] of Object.entries(nodes)) {
|
|
3222
|
+
if (!node) continue
|
|
3223
|
+
const definition = nodeRegistry.get(node.type)
|
|
3224
|
+
const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
|
|
3225
|
+
if (!definition?.floorplan || !linkedLevelIds) continue
|
|
3226
|
+
const id = rawId as AnyNodeId
|
|
3227
|
+
if (excludedIds.has(id)) continue
|
|
3228
|
+
if (!linkedLevelIds(node).includes(levelId)) continue
|
|
3229
|
+
const childIds = (node as { children?: AnyNodeId[] }).children
|
|
3230
|
+
const children = Array.isArray(childIds)
|
|
3231
|
+
? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
|
|
3232
|
+
: []
|
|
3233
|
+
linked.push({ id, node, children })
|
|
3234
|
+
}
|
|
3235
|
+
return linked
|
|
3236
|
+
}
|
|
3237
|
+
|
|
2908
3238
|
/**
|
|
2909
3239
|
* Stable id for a handle on a node, derived from the node id + opaque
|
|
2910
3240
|
* payload. Used to track hover / active visual state when multiple
|
|
@@ -2951,6 +3281,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
|
2951
3281
|
'move-arrow',
|
|
2952
3282
|
'rotate-arrow',
|
|
2953
3283
|
'dimension',
|
|
3284
|
+
'dimension-string',
|
|
2954
3285
|
'dimension-label',
|
|
2955
3286
|
'equal-spacing-badge',
|
|
2956
3287
|
])
|
|
@@ -2969,6 +3300,12 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
|
2969
3300
|
base: FloorplanGeometry | null
|
|
2970
3301
|
overlay: FloorplanGeometry | null
|
|
2971
3302
|
} {
|
|
3303
|
+
if (readFloorplanGeometryMetadata(g).renderPass === 'overlay') {
|
|
3304
|
+
return { base: null, overlay: g }
|
|
3305
|
+
}
|
|
3306
|
+
if (isFloorplanAnnotationObstacleGeometry(g)) {
|
|
3307
|
+
return { base: null, overlay: g }
|
|
3308
|
+
}
|
|
2972
3309
|
if (OVERLAY_KINDS.has(g.kind)) {
|
|
2973
3310
|
return { base: null, overlay: g }
|
|
2974
3311
|
}
|
|
@@ -2981,12 +3318,10 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
|
2981
3318
|
if (split.overlay) overlayChildren.push(split.overlay)
|
|
2982
3319
|
}
|
|
2983
3320
|
const base: FloorplanGeometry | null =
|
|
2984
|
-
baseChildren.length > 0
|
|
2985
|
-
? { kind: 'group', children: baseChildren, transform: g.transform }
|
|
2986
|
-
: null
|
|
3321
|
+
baseChildren.length > 0 ? { ...g, children: baseChildren, transform: g.transform } : null
|
|
2987
3322
|
const overlay: FloorplanGeometry | null =
|
|
2988
3323
|
overlayChildren.length > 0
|
|
2989
|
-
? {
|
|
3324
|
+
? { ...g, children: overlayChildren, transform: g.transform }
|
|
2990
3325
|
: null
|
|
2991
3326
|
return { base, overlay }
|
|
2992
3327
|
}
|
|
@@ -3127,9 +3462,12 @@ export function computeAffectedSiblingIds(
|
|
|
3127
3462
|
|
|
3128
3463
|
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
|
3129
3464
|
const keys: Array<keyof NodeDeps> = [
|
|
3465
|
+
'automaticDimensions',
|
|
3130
3466
|
'node',
|
|
3131
3467
|
'live',
|
|
3132
3468
|
'unit',
|
|
3469
|
+
'metricNotation',
|
|
3470
|
+
'wallDimensionReference',
|
|
3133
3471
|
'selected',
|
|
3134
3472
|
'highlighted',
|
|
3135
3473
|
'hovered',
|
|
@@ -3223,7 +3561,7 @@ const ROTATION_WEDGE_SEGMENTS = 48
|
|
|
3223
3561
|
export function RotationAngleOverlay({
|
|
3224
3562
|
overlay,
|
|
3225
3563
|
palette,
|
|
3226
|
-
unitsPerPixel,
|
|
3564
|
+
unitsPerPixel: unitsPerPixelOverride,
|
|
3227
3565
|
sceneRotationDeg,
|
|
3228
3566
|
}: {
|
|
3229
3567
|
overlay: RotationOverlayState
|
|
@@ -3231,9 +3569,11 @@ export function RotationAngleOverlay({
|
|
|
3231
3569
|
FloorplanPalette,
|
|
3232
3570
|
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
|
3233
3571
|
>
|
|
3234
|
-
unitsPerPixel
|
|
3572
|
+
unitsPerPixel?: number
|
|
3235
3573
|
sceneRotationDeg: number
|
|
3236
3574
|
}): React.ReactElement {
|
|
3575
|
+
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
|
3576
|
+
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
|
3237
3577
|
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
|
3238
3578
|
const span = endAngle - startAngle
|
|
3239
3579
|
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|