@pascal-app/editor 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,1963 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type FloorplanAffordancePoint,
|
|
7
|
+
type FloorplanAffordanceSession,
|
|
8
|
+
type FloorplanGeometry,
|
|
9
|
+
type FloorplanPalette,
|
|
10
|
+
type FloorplanPoint,
|
|
11
|
+
type GeometryContext,
|
|
12
|
+
kindsWithFloorplanScope,
|
|
13
|
+
nodeRegistry,
|
|
14
|
+
pauseSceneHistory,
|
|
15
|
+
resolveBuildingForLevel,
|
|
16
|
+
resumeSceneHistory,
|
|
17
|
+
useInteractive,
|
|
18
|
+
useLiveNodeOverrides,
|
|
19
|
+
useLiveTransforms,
|
|
20
|
+
useScene,
|
|
21
|
+
} from '@pascal-app/core'
|
|
22
|
+
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
23
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
24
|
+
import {
|
|
25
|
+
memo,
|
|
26
|
+
type PointerEvent as ReactPointerEvent,
|
|
27
|
+
useCallback,
|
|
28
|
+
useEffect,
|
|
29
|
+
useMemo,
|
|
30
|
+
useRef,
|
|
31
|
+
useState,
|
|
32
|
+
} from 'react'
|
|
33
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
34
|
+
import useEditor from '../../../store/use-editor'
|
|
35
|
+
import { useFloorplanRender } from '../floorplan-render-context'
|
|
36
|
+
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Registry-driven floor-plan layer.
|
|
40
|
+
*
|
|
41
|
+
* For every node in the active level whose definition exposes
|
|
42
|
+
* `def.floorplan`, builds a `GeometryContext` (with `viewState` so the
|
|
43
|
+
* kind can theme its output), calls the builder, and walks the resulting
|
|
44
|
+
* tree. Static primitives (polygon / line / circle / etc.) defer to
|
|
45
|
+
* `<FloorplanGeometryRenderer>`. Interactive primitives — `hatch`,
|
|
46
|
+
* `hit-line`, `endpoint-handle`, `dimension-label` — render here so they
|
|
47
|
+
* can access the SVG context for pointer events + units-per-pixel.
|
|
48
|
+
*
|
|
49
|
+
* Selection: clicking the entry's `<g>` selects the node. The wall
|
|
50
|
+
* `def.floorplan` also emits a `hit-line` along the centerline so the
|
|
51
|
+
* user can grab the wall body even at zoom levels where the polygon is
|
|
52
|
+
* skinny.
|
|
53
|
+
*
|
|
54
|
+
* 2D endpoint drag: when an `endpoint-handle` is pointer-downed and its
|
|
55
|
+
* `affordance === 'move-endpoint'`, this layer drives the legacy wall
|
|
56
|
+
* endpoint flow inline — snap pointer to walls/grid, run linked-wall
|
|
57
|
+
* cascade, live-update positions with history paused, single undo on
|
|
58
|
+
* commit. The kind-generic abstraction lands once fence + slab + ceiling
|
|
59
|
+
* pick up their 2D drags too (next iteration).
|
|
60
|
+
*/
|
|
61
|
+
// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
|
|
62
|
+
// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
|
|
63
|
+
// dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
|
|
64
|
+
// screen size at any zoom.
|
|
65
|
+
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
|
|
66
|
+
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
|
|
67
|
+
const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
|
|
68
|
+
const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4
|
|
69
|
+
const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
|
|
70
|
+
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
|
|
71
|
+
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
|
|
72
|
+
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Snapshot of node fields captured at drag-start, used by the single-undo
|
|
76
|
+
* dance to revert untracked before re-applying as a single tracked
|
|
77
|
+
* change. The dispatcher only knows about the `affectedIds` the
|
|
78
|
+
* affordance declares; it captures whatever fields exist on each node by
|
|
79
|
+
* cloning the full record minus the registry-managed `id` / `type`.
|
|
80
|
+
*/
|
|
81
|
+
type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
|
82
|
+
|
|
83
|
+
type ActiveDrag = {
|
|
84
|
+
pointerId: number
|
|
85
|
+
/** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
|
|
86
|
+
handleId: string
|
|
87
|
+
session: FloorplanAffordanceSession
|
|
88
|
+
snapshots: NodeSnapshot[]
|
|
89
|
+
historyPaused: boolean
|
|
90
|
+
/**
|
|
91
|
+
* Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
|
|
92
|
+
* the live angle wedge + degree readout — the 2D twin of the 3D rotate
|
|
93
|
+
* gizmo's readout. The bearing sweep is measured the same way every
|
|
94
|
+
* rotate affordance measures it: `atan2(pointer − pivot)`.
|
|
95
|
+
*/
|
|
96
|
+
rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Transient live-rotation readout state. Rebuilt each pointer-move while a
|
|
101
|
+
* rotate-arrow is dragged and cleared on release. World-plan coords.
|
|
102
|
+
*/
|
|
103
|
+
type RotationOverlayState = {
|
|
104
|
+
pivot: FloorplanPoint
|
|
105
|
+
startAngle: number
|
|
106
|
+
endAngle: number
|
|
107
|
+
radius: number
|
|
108
|
+
/** Swept magnitude in radians, for the degree chip. */
|
|
109
|
+
sweep: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
113
|
+
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
|
114
|
+
// deep-cloned to detach from the live store reference.
|
|
115
|
+
const data: Record<string, unknown> = {}
|
|
116
|
+
for (const [key, value] of Object.entries(node)) {
|
|
117
|
+
if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue
|
|
118
|
+
data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
|
|
119
|
+
}
|
|
120
|
+
return { id: node.id, data }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|
124
|
+
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
128
|
+
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
|
129
|
+
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
|
130
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
131
|
+
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
132
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
133
|
+
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
134
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
135
|
+
const nodes = useScene((s) => s.nodes)
|
|
136
|
+
// When a building is being moved, its explicit selection may be
|
|
137
|
+
// cleared as part of the move handoff. Fall back to the
|
|
138
|
+
// mid-drag building id so the dimmed floor keeps rendering
|
|
139
|
+
// throughout the gesture.
|
|
140
|
+
const movingBuildingId = useEditor((state) => {
|
|
141
|
+
const moving = state.movingNode
|
|
142
|
+
if (!moving) return null
|
|
143
|
+
const def = nodeRegistry.get(moving.type)
|
|
144
|
+
return def?.capabilities?.floorplanLevelContainer ? moving.id : null
|
|
145
|
+
})
|
|
146
|
+
const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
|
|
147
|
+
|
|
148
|
+
// When only a building is in scope (no specific level), fall back to
|
|
149
|
+
// its level 0 (or the lowest-indexed level) so the floor still
|
|
150
|
+
// renders as context — dimmed and non-interactive — instead of
|
|
151
|
+
// disappearing entirely.
|
|
152
|
+
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
|
|
153
|
+
if (selectedLevelId || !ambientBuildingSourceId) return null
|
|
154
|
+
const building = nodes[ambientBuildingSourceId]
|
|
155
|
+
if (!building || building.type !== 'building') return null
|
|
156
|
+
let zero: AnyNodeId | null = null
|
|
157
|
+
let lowestId: AnyNodeId | null = null
|
|
158
|
+
let lowestIdx = Number.POSITIVE_INFINITY
|
|
159
|
+
const childIds = (building as unknown as { children?: AnyNodeId[] }).children ?? []
|
|
160
|
+
for (const childId of childIds) {
|
|
161
|
+
const child = nodes[childId]
|
|
162
|
+
if (child?.type !== 'level') continue
|
|
163
|
+
if (child.level === 0) {
|
|
164
|
+
zero = child.id
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
if (child.level < lowestIdx) {
|
|
168
|
+
lowestIdx = child.level
|
|
169
|
+
lowestId = child.id
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return zero ?? lowestId
|
|
173
|
+
}, [selectedLevelId, ambientBuildingSourceId, nodes])
|
|
174
|
+
|
|
175
|
+
const levelId = selectedLevelId ?? ambientLevelId
|
|
176
|
+
const isAmbient = !selectedLevelId && !!ambientLevelId
|
|
177
|
+
const renderCtx = useFloorplanRender()
|
|
178
|
+
const movingNode = useEditor((s) => s.movingNode)
|
|
179
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
180
|
+
// Door / window placement (both build and move) needs the SVG's
|
|
181
|
+
// background click handler to run — it finds the closest wall via
|
|
182
|
+
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
|
183
|
+
// tool. When the user clicks *on top of* a wall in this mode, the
|
|
184
|
+
// wall's registry entry would otherwise swallow the click via
|
|
185
|
+
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
|
186
|
+
// Pass clicks through in that case.
|
|
187
|
+
const editorPhase = useEditor((s) => s.phase)
|
|
188
|
+
const editorMode = useEditor((s) => s.mode)
|
|
189
|
+
const editorTool = useEditor((s) => s.tool)
|
|
190
|
+
const structureLayer = useEditor((s) => s.structureLayer)
|
|
191
|
+
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
192
|
+
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
|
193
|
+
const isOpeningPlacementActive =
|
|
194
|
+
(editorPhase === 'structure' &&
|
|
195
|
+
editorMode === 'build' &&
|
|
196
|
+
(editorTool === 'door' || editorTool === 'window')) ||
|
|
197
|
+
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
|
198
|
+
const isMarqueeSelectionActive =
|
|
199
|
+
editorMode === 'select' &&
|
|
200
|
+
floorplanSelectionTool === 'marquee' &&
|
|
201
|
+
structureLayer !== 'zones' &&
|
|
202
|
+
!movingNode &&
|
|
203
|
+
!movingFenceEndpoint
|
|
204
|
+
// Subscribe to the live-transforms map ref so the layer re-renders
|
|
205
|
+
// whenever a 3D mover publishes a per-frame position (see
|
|
206
|
+
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
|
207
|
+
// updates after 3D commit — the 3D drag would look frozen in 2D.
|
|
208
|
+
const liveTransforms = useLiveTransforms((s) => s.transforms)
|
|
209
|
+
// Same reactivity hook for elevator runtime state — `useInteractive`
|
|
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)
|
|
215
|
+
const interactiveElevators = useInteractive((s) => s.elevators)
|
|
216
|
+
|
|
217
|
+
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
218
|
+
// Marquee preview selection — matches the legacy `highlightedIdSet` use
|
|
219
|
+
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
|
|
220
|
+
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
|
|
221
|
+
|
|
222
|
+
// Interactive state lives in refs; only the visible feedback bits go
|
|
223
|
+
// into React state to keep re-renders cheap during drag.
|
|
224
|
+
const dragRef = useRef<ActiveDrag | null>(null)
|
|
225
|
+
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
|
226
|
+
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
227
|
+
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
|
228
|
+
|
|
229
|
+
const handleSelect = useCallback(
|
|
230
|
+
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
|
231
|
+
if (event.button !== 0) return
|
|
232
|
+
event.stopPropagation()
|
|
233
|
+
setSelection({ selectedIds: [id] })
|
|
234
|
+
// Setting selection re-renders the entry — the overlay pass mounts
|
|
235
|
+
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
|
236
|
+
// pointerdown and click. If the click target ends up on the SVG
|
|
237
|
+
// background, `<g floorplan-registry-layer onClick=handleClickStop>`
|
|
238
|
+
// never sees it, and the SVG's `handleBackgroundClick` clears the
|
|
239
|
+
// selection we just set. Swallow the next click globally to break
|
|
240
|
+
// that race; the listener removes itself after firing (or after a
|
|
241
|
+
// safety timeout if no click follows).
|
|
242
|
+
const swallowClick = (ev: Event) => {
|
|
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)
|
|
249
|
+
},
|
|
250
|
+
[setSelection],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
|
254
|
+
event.stopPropagation()
|
|
255
|
+
}, [])
|
|
256
|
+
|
|
257
|
+
// Build the geometry list. `viewState` flows into ctx so kinds can
|
|
258
|
+
// theme their output and conditionally emit selection chrome.
|
|
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
|
+
}[] = []
|
|
284
|
+
|
|
285
|
+
const visit = (id: AnyNodeId) => {
|
|
286
|
+
const node = nodes[id]
|
|
287
|
+
if (!node) return
|
|
288
|
+
if ((node as { visible?: boolean }).visible === false) return
|
|
289
|
+
const def = nodeRegistry.get(node.type)
|
|
290
|
+
const builder = def?.floorplan
|
|
291
|
+
if (builder) {
|
|
292
|
+
const selected = selectedIdSet.has(id)
|
|
293
|
+
const highlighted = highlightedIdSet.has(id)
|
|
294
|
+
const hovered = hoveredId === id
|
|
295
|
+
const moving = movingNode?.id === id
|
|
296
|
+
// Live-transform override — when a mover is publishing per-frame
|
|
297
|
+
// position/rotation, render that here instead of the committed
|
|
298
|
+
// scene state. Without this the 2D floor plan would only update
|
|
299
|
+
// after commit, making the drag look frozen.
|
|
300
|
+
//
|
|
301
|
+
// The live-transform contract varies per kind (see
|
|
302
|
+
// wiki/architecture/tools.md "useLiveTransforms contract is
|
|
303
|
+
// per-kind, not generic"); position-carrying floor-placed kinds
|
|
304
|
+
// publish canonical X/Z, while slab / ceiling publish a polygon
|
|
305
|
+
// translation delta.
|
|
306
|
+
const live = liveTransforms.get(id)
|
|
307
|
+
let effectiveNode: AnyNode = node
|
|
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
|
|
347
|
+
}
|
|
348
|
+
const ctx = buildContext(effectiveNode, contextNodes, {
|
|
349
|
+
selected,
|
|
350
|
+
highlighted,
|
|
351
|
+
hovered,
|
|
352
|
+
moving,
|
|
353
|
+
palette: renderCtx?.palette,
|
|
354
|
+
})
|
|
355
|
+
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
356
|
+
effectiveNode,
|
|
357
|
+
ctx,
|
|
358
|
+
)
|
|
359
|
+
if (geometry) {
|
|
360
|
+
const { base, overlay } = splitFloorplanOverlay(geometry)
|
|
361
|
+
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
365
|
+
if (Array.isArray(childIds)) {
|
|
366
|
+
for (const cid of childIds) visit(cid)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
visit(levelId as AnyNodeId)
|
|
371
|
+
|
|
372
|
+
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
|
|
373
|
+
// as siblings of the level, not under it — the `visit(levelId)` DFS
|
|
374
|
+
// above doesn't reach them. Walk every node of those kinds whose
|
|
375
|
+
// parent matches the active level's building, and synthesise a
|
|
376
|
+
// `GeometryContext` whose `parent` is the active level (so kind
|
|
377
|
+
// builders that gate on the current floor — e.g. elevator service
|
|
378
|
+
// range — keep working). Pure registry-driven dispatch: no kind
|
|
379
|
+
// name appears in this file.
|
|
380
|
+
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
|
381
|
+
const activeBuildingId = activeLevelNode
|
|
382
|
+
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
|
383
|
+
: null
|
|
384
|
+
if (activeLevelNode && activeBuildingId) {
|
|
385
|
+
const buildingScopedKinds = kindsWithFloorplanScope('building')
|
|
386
|
+
const buildingScopedKindSet = new Set(buildingScopedKinds)
|
|
387
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
388
|
+
if (!node || !buildingScopedKindSet.has(node.type)) continue
|
|
389
|
+
if ((node as { visible?: boolean }).visible === false) continue
|
|
390
|
+
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
|
391
|
+
if (parentId !== activeBuildingId) continue
|
|
392
|
+
const cid = id as AnyNodeId
|
|
393
|
+
const def = nodeRegistry.get(node.type)
|
|
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,
|
|
416
|
+
children: [],
|
|
417
|
+
siblings: [],
|
|
418
|
+
parent: activeLevelNode,
|
|
419
|
+
viewState: renderCtx?.palette
|
|
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
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Stable z-order sort. SVG renders in document order — later siblings
|
|
441
|
+
// paint on top of earlier ones — so anything that should sit *under*
|
|
442
|
+
// other floor-plan geometry has to come first in the entries array.
|
|
443
|
+
// Zones are conceptual room/area regions; walls / slabs / furniture
|
|
444
|
+
// all belong on top of them. Within a layer bucket we preserve the
|
|
445
|
+
// DFS visit order (stable sort) so siblings keep their relative
|
|
446
|
+
// priority.
|
|
447
|
+
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
448
|
+
return out
|
|
449
|
+
}, [
|
|
450
|
+
levelId,
|
|
451
|
+
nodes,
|
|
452
|
+
liveTransforms,
|
|
453
|
+
liveOverrides,
|
|
454
|
+
selectedIdSet,
|
|
455
|
+
highlightedIdSet,
|
|
456
|
+
hoveredId,
|
|
457
|
+
movingNode?.id,
|
|
458
|
+
renderCtx?.palette,
|
|
459
|
+
interactiveElevators,
|
|
460
|
+
])
|
|
461
|
+
|
|
462
|
+
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
|
463
|
+
//
|
|
464
|
+
// Pointer-down on an interactive handle resolves the kind's
|
|
465
|
+
// `def.floorplanAffordances?.[affordance]` and starts a session. The
|
|
466
|
+
// dispatcher then owns: history pause/resume, snapshot capture,
|
|
467
|
+
// pointer-move/up/cancel routing, and the single-undo dance on
|
|
468
|
+
// commit. Each kind owns the actual mutation logic inside `apply`.
|
|
469
|
+
const startAffordanceDrag = useCallback(
|
|
470
|
+
(
|
|
471
|
+
nodeId: AnyNodeId,
|
|
472
|
+
handleId: string,
|
|
473
|
+
affordance: string,
|
|
474
|
+
payload: unknown,
|
|
475
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
476
|
+
// Present only for rotate-arrow handles — the pivot the node turns
|
|
477
|
+
// around, used to drive the live angle wedge + degree readout.
|
|
478
|
+
rotationPivot?: FloorplanPoint,
|
|
479
|
+
) => {
|
|
480
|
+
if (event.button !== 0) return
|
|
481
|
+
if (movingNode) return
|
|
482
|
+
|
|
483
|
+
const sceneNodes = useScene.getState().nodes
|
|
484
|
+
const node = sceneNodes[nodeId]
|
|
485
|
+
if (!node) return
|
|
486
|
+
|
|
487
|
+
const def = nodeRegistry.get(node.type)
|
|
488
|
+
const handler = def?.floorplanAffordances?.[affordance]
|
|
489
|
+
if (!handler) return
|
|
490
|
+
|
|
491
|
+
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
|
|
492
|
+
if (!initialPlanPoint) return
|
|
493
|
+
|
|
494
|
+
event.preventDefault()
|
|
495
|
+
event.stopPropagation()
|
|
496
|
+
|
|
497
|
+
const session = handler.start({
|
|
498
|
+
node,
|
|
499
|
+
payload,
|
|
500
|
+
nodes: sceneNodes,
|
|
501
|
+
initialPlanPoint,
|
|
502
|
+
gridSnapStep: useEditor.getState().gridSnapStep,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const snapshots: NodeSnapshot[] = []
|
|
506
|
+
for (const id of session.affectedIds) {
|
|
507
|
+
const n = sceneNodes[id]
|
|
508
|
+
if (n) snapshots.push(snapshotNode(n))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
pauseSceneHistory(useScene)
|
|
512
|
+
|
|
513
|
+
// Rotation readout setup. The wedge radius tracks the grab distance
|
|
514
|
+
// from the pivot (≈ the handle's orbit), nudged inward so the swept
|
|
515
|
+
// fill reads as the handle swinging round rather than overlapping it,
|
|
516
|
+
// and floored so a tight footprint still shows a legible wedge.
|
|
517
|
+
let rotation: ActiveDrag['rotation']
|
|
518
|
+
if (rotationPivot) {
|
|
519
|
+
const dx = initialPlanPoint[0] - rotationPivot[0]
|
|
520
|
+
const dz = initialPlanPoint[1] - rotationPivot[1]
|
|
521
|
+
rotation = {
|
|
522
|
+
pivot: rotationPivot,
|
|
523
|
+
initialAngle: Math.atan2(dz, dx),
|
|
524
|
+
radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25),
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
dragRef.current = {
|
|
529
|
+
pointerId: event.pointerId,
|
|
530
|
+
handleId,
|
|
531
|
+
session,
|
|
532
|
+
snapshots,
|
|
533
|
+
historyPaused: true,
|
|
534
|
+
rotation,
|
|
535
|
+
}
|
|
536
|
+
setActiveDragId(handleId)
|
|
537
|
+
setSelection({ selectedIds: [nodeId] })
|
|
538
|
+
;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)
|
|
539
|
+
},
|
|
540
|
+
[movingNode, setSelection],
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
useEffect(() => {
|
|
544
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
545
|
+
const drag = dragRef.current
|
|
546
|
+
if (!drag || event.pointerId !== drag.pointerId) return
|
|
547
|
+
|
|
548
|
+
const planPoint = clientToPlan(event.clientX, event.clientY)
|
|
549
|
+
if (!planPoint) return
|
|
550
|
+
|
|
551
|
+
drag.session.apply({
|
|
552
|
+
planPoint,
|
|
553
|
+
modifiers: {
|
|
554
|
+
shiftKey: event.shiftKey,
|
|
555
|
+
altKey: event.altKey,
|
|
556
|
+
ctrlKey: event.ctrlKey,
|
|
557
|
+
metaKey: event.metaKey,
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Live rotation readout. Sweep from the bearing at grab to the
|
|
562
|
+
// current pointer bearing around the pivot — the same measurement
|
|
563
|
+
// every rotate affordance applies — and surface it as a wedge +
|
|
564
|
+
// degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash
|
|
565
|
+
// a zero-width sliver.
|
|
566
|
+
const rot = drag.rotation
|
|
567
|
+
if (rot) {
|
|
568
|
+
const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0])
|
|
569
|
+
let delta = current - rot.initialAngle
|
|
570
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
571
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
572
|
+
if (Math.abs(delta) < 0.0087) {
|
|
573
|
+
setRotationOverlay(null)
|
|
574
|
+
} else {
|
|
575
|
+
setRotationOverlay({
|
|
576
|
+
pivot: rot.pivot,
|
|
577
|
+
startAngle: rot.initialAngle,
|
|
578
|
+
endAngle: rot.initialAngle + delta,
|
|
579
|
+
radius: rot.radius,
|
|
580
|
+
sweep: Math.abs(delta),
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
587
|
+
const drag = dragRef.current
|
|
588
|
+
if (!drag || event.pointerId !== drag.pointerId) return
|
|
589
|
+
|
|
590
|
+
const commitValid = drag.session.canCommit()
|
|
591
|
+
|
|
592
|
+
// Sessions with a `commit` hook own their atomic write (e.g.
|
|
593
|
+
// affordances that publish to `useLiveNodeOverrides` during
|
|
594
|
+
// `apply()` and never touch scene mid-drag). Mirrors the move
|
|
595
|
+
// overlay's `session.commit` path — revert untracked (no-op when
|
|
596
|
+
// the session never wrote to scene), resume history, then let
|
|
597
|
+
// the session do the tracked write.
|
|
598
|
+
if (commitValid && drag.session.commit) {
|
|
599
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
600
|
+
if (drag.historyPaused) {
|
|
601
|
+
resumeSceneHistory(useScene)
|
|
602
|
+
drag.historyPaused = false
|
|
603
|
+
}
|
|
604
|
+
drag.session.commit()
|
|
605
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
606
|
+
dragRef.current = null
|
|
607
|
+
setActiveDragId(null)
|
|
608
|
+
setRotationOverlay(null)
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Capture the final state BEFORE the revert so we know what to
|
|
613
|
+
// re-apply post-resume.
|
|
614
|
+
const sceneNodes = useScene.getState().nodes
|
|
615
|
+
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
|
616
|
+
for (const snap of drag.snapshots) {
|
|
617
|
+
const current = sceneNodes[snap.id]
|
|
618
|
+
if (!current) continue
|
|
619
|
+
const data: Record<string, unknown> = {}
|
|
620
|
+
let changed = false
|
|
621
|
+
for (const [key, before] of Object.entries(snap.data)) {
|
|
622
|
+
const after = (current as unknown as Record<string, unknown>)[key]
|
|
623
|
+
if (!deepEqual(before, after)) {
|
|
624
|
+
data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
|
|
625
|
+
changed = true
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (changed) finalUpdates.push({ id: snap.id, data })
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (commitValid && finalUpdates.length > 0) {
|
|
632
|
+
// Single-undo dance (mirrors the 3D move-endpoint-tool):
|
|
633
|
+
// 1. Revert to baseline while history is still paused (untracked).
|
|
634
|
+
// 2. Resume history.
|
|
635
|
+
// 3. Re-apply the final state — recorded as one tracked change.
|
|
636
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
637
|
+
if (drag.historyPaused) {
|
|
638
|
+
resumeSceneHistory(useScene)
|
|
639
|
+
drag.historyPaused = false
|
|
640
|
+
}
|
|
641
|
+
useScene.getState().updateNodes(finalUpdates)
|
|
642
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
643
|
+
} else {
|
|
644
|
+
// Either no net change or canCommit() rejected — revert and
|
|
645
|
+
// resume without committing. Also clear any live overrides
|
|
646
|
+
// the session published (no-op when the session writes to
|
|
647
|
+
// scene directly).
|
|
648
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
649
|
+
if (drag.historyPaused) {
|
|
650
|
+
resumeSceneHistory(useScene)
|
|
651
|
+
drag.historyPaused = false
|
|
652
|
+
}
|
|
653
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
654
|
+
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
dragRef.current = null
|
|
658
|
+
setActiveDragId(null)
|
|
659
|
+
setRotationOverlay(null)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const onPointerCancel = (event: PointerEvent) => {
|
|
663
|
+
const drag = dragRef.current
|
|
664
|
+
if (!drag || event.pointerId !== drag.pointerId) return
|
|
665
|
+
|
|
666
|
+
// Revert untracked, then resume — no history entry is recorded.
|
|
667
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
668
|
+
if (drag.historyPaused) {
|
|
669
|
+
resumeSceneHistory(useScene)
|
|
670
|
+
drag.historyPaused = false
|
|
671
|
+
}
|
|
672
|
+
// Affordances that publish Figma alignment guides during `apply`
|
|
673
|
+
// (fence endpoint) leave them in the store on cancel — `canCommit`
|
|
674
|
+
// (the pointer-up clear) never runs on a cancel.
|
|
675
|
+
useAlignmentGuides.getState().clear()
|
|
676
|
+
// Drop any live overrides the session may have published. No-op
|
|
677
|
+
// for affordances whose `apply()` writes straight to scene; the
|
|
678
|
+
// override-routed sessions (wall endpoint, wall curve) rely on
|
|
679
|
+
// this to revert cleanly.
|
|
680
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
681
|
+
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
682
|
+
|
|
683
|
+
dragRef.current = null
|
|
684
|
+
setActiveDragId(null)
|
|
685
|
+
setRotationOverlay(null)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
689
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
690
|
+
window.addEventListener('pointercancel', onPointerCancel)
|
|
691
|
+
return () => {
|
|
692
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
693
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
694
|
+
window.removeEventListener('pointercancel', onPointerCancel)
|
|
695
|
+
// Component unmounted mid-drag — restore the baseline and unpause
|
|
696
|
+
// history so we don't leak a paused store across mounts. Also
|
|
697
|
+
// drop any live overrides the session published so the next
|
|
698
|
+
// mount doesn't render at the cancelled position.
|
|
699
|
+
const drag = dragRef.current
|
|
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
|
|
708
|
+
}
|
|
709
|
+
// Clear any alignment guide a session left behind on mid-drag unmount.
|
|
710
|
+
useAlignmentGuides.getState().clear()
|
|
711
|
+
}
|
|
712
|
+
}, [])
|
|
713
|
+
|
|
714
|
+
if (entries.length === 0) return null
|
|
715
|
+
|
|
716
|
+
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
|
|
717
|
+
const palette = renderCtx?.palette
|
|
718
|
+
|
|
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
|
+
return (
|
|
781
|
+
// The outer wrapper stops `click` events that escape an entry's
|
|
782
|
+
// `onClick={handleClickStop}`. The base+overlay split means
|
|
783
|
+
// pointer-down can land on the base `<g>` and pointer-up on the
|
|
784
|
+
// overlay `<g>` (selection mounts the overlay on top mid-gesture).
|
|
785
|
+
// When the down/up targets differ, the browser dispatches `click`
|
|
786
|
+
// to the lowest common ancestor — which sits ABOVE the entry-level
|
|
787
|
+
// handler. Without this guard the click reaches the SVG's
|
|
788
|
+
// `handleBackgroundClick`, which calls
|
|
789
|
+
// `resolveFloorplanBackgroundSelection` → `clear-elements` (because
|
|
790
|
+
// registry-driven items aren't in the legacy hit-test set) →
|
|
791
|
+
// clearing the selection that pointer-down just set, so items
|
|
792
|
+
// appear to "deselect themselves a fraction of a second after
|
|
793
|
+
// clicking." Scoped to `onClick` so hover / drag / pointer events
|
|
794
|
+
// still propagate normally inside the registry tree.
|
|
795
|
+
<g
|
|
796
|
+
className="floorplan-registry-layer"
|
|
797
|
+
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
|
798
|
+
opacity={isAmbient ? 0.3 : undefined}
|
|
799
|
+
style={isAmbient ? { pointerEvents: 'none' } : undefined}
|
|
800
|
+
>
|
|
801
|
+
{/* Base pass — rank-sorted body geometry (polygons, paths, fills,
|
|
802
|
+
strokes, hatches). Lower-rank kinds (zones) paint first so
|
|
803
|
+
higher-rank kinds (slabs, then walls / items / shelves) layer
|
|
804
|
+
on top in the expected document-order z-stack. */}
|
|
805
|
+
<g className="floorplan-registry-base">
|
|
806
|
+
{entries.map(({ id, base }) => (base ? renderEntry(id, base, `base-${id}`) : null))}
|
|
807
|
+
</g>
|
|
808
|
+
{/* Overlay pass — interactive handles (vertex / midpoint / edge /
|
|
809
|
+
move) and labels (text / dimensions). Painted after every base
|
|
810
|
+
entry so polygon-editor chrome on a selected slab stays above
|
|
811
|
+
neighbouring walls, and a zone name stays readable above the
|
|
812
|
+
slab + wall geometry sitting on top of the zone. Each overlay
|
|
813
|
+
still routes through the same selection-handling `<g>` so a
|
|
814
|
+
click on a zone's name selects the zone. */}
|
|
815
|
+
<g className="floorplan-registry-overlay">
|
|
816
|
+
{entries.map(({ id, overlay }) =>
|
|
817
|
+
overlay ? renderEntry(id, overlay, `overlay-${id}`) : null,
|
|
818
|
+
)}
|
|
819
|
+
</g>
|
|
820
|
+
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
|
821
|
+
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
|
822
|
+
{rotationOverlay && palette ? (
|
|
823
|
+
<RotationAngleOverlay
|
|
824
|
+
overlay={rotationOverlay}
|
|
825
|
+
palette={palette}
|
|
826
|
+
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
827
|
+
unitsPerPixel={unitsPerPixel}
|
|
828
|
+
/>
|
|
829
|
+
) : null}
|
|
830
|
+
</g>
|
|
831
|
+
)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
// ── Interactive geometry walker ──────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
function InteractiveGeometry({
|
|
837
|
+
geometry,
|
|
838
|
+
unitsPerPixel,
|
|
839
|
+
palette,
|
|
840
|
+
hatchPatternId,
|
|
841
|
+
hoveredHandleId,
|
|
842
|
+
activeDragId,
|
|
843
|
+
isMarqueeSelectionActive,
|
|
844
|
+
nodeId,
|
|
845
|
+
sceneRotationDeg,
|
|
846
|
+
onHandleHoverChange,
|
|
847
|
+
onHandlePointerDown,
|
|
848
|
+
onMoveHandlePointerDown,
|
|
849
|
+
}: {
|
|
850
|
+
geometry: FloorplanGeometry
|
|
851
|
+
unitsPerPixel: number
|
|
852
|
+
palette: FloorplanPalette | undefined
|
|
853
|
+
hatchPatternId: string | undefined
|
|
854
|
+
hoveredHandleId: string | null
|
|
855
|
+
activeDragId: string | null
|
|
856
|
+
isMarqueeSelectionActive: boolean
|
|
857
|
+
nodeId: AnyNodeId
|
|
858
|
+
sceneRotationDeg: number
|
|
859
|
+
onHandleHoverChange: (id: string | null) => void
|
|
860
|
+
onHandlePointerDown: (
|
|
861
|
+
affordance: string,
|
|
862
|
+
payload: unknown,
|
|
863
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
864
|
+
// Forwarded only by rotate-arrow handles — the pivot the drag turns
|
|
865
|
+
// the node around, used to drive the live angle wedge + degree chip.
|
|
866
|
+
rotationPivot?: FloorplanPoint,
|
|
867
|
+
) => void
|
|
868
|
+
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
|
869
|
+
}): React.ReactElement {
|
|
870
|
+
return renderInteractive(geometry, 0)
|
|
871
|
+
|
|
872
|
+
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
|
873
|
+
switch (g.kind) {
|
|
874
|
+
case 'group': {
|
|
875
|
+
const transform = formatGroupTransform(g.transform)
|
|
876
|
+
return (
|
|
877
|
+
<g key={keyHint} transform={transform}>
|
|
878
|
+
{g.children.map((child, i) => renderInteractive(child, i))}
|
|
879
|
+
</g>
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
case 'hatch': {
|
|
883
|
+
if (!hatchPatternId) return <></>
|
|
884
|
+
return (
|
|
885
|
+
<polygon
|
|
886
|
+
fill={`url(#${hatchPatternId})`}
|
|
887
|
+
key={keyHint}
|
|
888
|
+
opacity={g.opacity}
|
|
889
|
+
pointerEvents="none"
|
|
890
|
+
points={g.points.map(([x, y]) => `${x},${y}`).join(' ')}
|
|
891
|
+
/>
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
case 'hit-line': {
|
|
895
|
+
return (
|
|
896
|
+
<line
|
|
897
|
+
key={keyHint}
|
|
898
|
+
pointerEvents={isMarqueeSelectionActive ? 'none' : (g.pointerEvents ?? 'stroke')}
|
|
899
|
+
stroke="transparent"
|
|
900
|
+
strokeLinecap="round"
|
|
901
|
+
strokeWidth={g.strokeWidthPx * unitsPerPixel}
|
|
902
|
+
style={{ cursor: g.cursor ?? 'pointer' }}
|
|
903
|
+
vectorEffect="non-scaling-stroke"
|
|
904
|
+
x1={g.x1}
|
|
905
|
+
x2={g.x2}
|
|
906
|
+
y1={g.y1}
|
|
907
|
+
y2={g.y2}
|
|
908
|
+
/>
|
|
909
|
+
)
|
|
910
|
+
}
|
|
911
|
+
case 'endpoint-handle': {
|
|
912
|
+
if (!palette) return <></>
|
|
913
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
914
|
+
const isHovered = hoveredHandleId === handleId
|
|
915
|
+
const isActive = activeDragId === handleId
|
|
916
|
+
// Variant picks the colour-set. Endpoint dots use the orange
|
|
917
|
+
// legacy palette; curve sagitta dots use the teal set so users
|
|
918
|
+
// can tell them apart at a glance.
|
|
919
|
+
const isCurve = g.variant === 'curve'
|
|
920
|
+
const stroke = isCurve
|
|
921
|
+
? palette.curveHandleStroke
|
|
922
|
+
: isActive
|
|
923
|
+
? palette.endpointHandleActiveStroke
|
|
924
|
+
: palette.endpointHandleStroke
|
|
925
|
+
const hoverStroke = isCurve
|
|
926
|
+
? palette.curveHandleHoverStroke
|
|
927
|
+
: isActive
|
|
928
|
+
? palette.endpointHandleActiveStroke
|
|
929
|
+
: palette.endpointHandleHoverStroke
|
|
930
|
+
const fill = isCurve
|
|
931
|
+
? palette.curveHandleFill
|
|
932
|
+
: isActive
|
|
933
|
+
? palette.endpointHandleActiveFill
|
|
934
|
+
: palette.endpointHandleFill
|
|
935
|
+
const outerRadius =
|
|
936
|
+
(isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
|
|
937
|
+
unitsPerPixel
|
|
938
|
+
const dotRadius =
|
|
939
|
+
(isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
|
|
940
|
+
unitsPerPixel
|
|
941
|
+
return (
|
|
942
|
+
<g
|
|
943
|
+
key={keyHint}
|
|
944
|
+
onClick={(e) => e.stopPropagation()}
|
|
945
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
946
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
947
|
+
>
|
|
948
|
+
<circle
|
|
949
|
+
cx={g.point[0]}
|
|
950
|
+
cy={g.point[1]}
|
|
951
|
+
fill="none"
|
|
952
|
+
pointerEvents="none"
|
|
953
|
+
r={outerRadius}
|
|
954
|
+
stroke={hoverStroke}
|
|
955
|
+
strokeOpacity={isActive ? 0.24 : 0.16}
|
|
956
|
+
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
|
|
957
|
+
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
958
|
+
vectorEffect="non-scaling-stroke"
|
|
959
|
+
/>
|
|
960
|
+
<circle
|
|
961
|
+
cx={g.point[0]}
|
|
962
|
+
cy={g.point[1]}
|
|
963
|
+
fill="none"
|
|
964
|
+
pointerEvents="none"
|
|
965
|
+
r={outerRadius}
|
|
966
|
+
stroke={hoverStroke}
|
|
967
|
+
strokeOpacity={isActive ? 0.72 : 0.52}
|
|
968
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
|
969
|
+
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
970
|
+
vectorEffect="non-scaling-stroke"
|
|
971
|
+
/>
|
|
972
|
+
<circle
|
|
973
|
+
cx={g.point[0]}
|
|
974
|
+
cy={g.point[1]}
|
|
975
|
+
fill={fill}
|
|
976
|
+
fillOpacity={0.96}
|
|
977
|
+
pointerEvents="none"
|
|
978
|
+
r={outerRadius}
|
|
979
|
+
stroke={stroke}
|
|
980
|
+
strokeWidth="0.05"
|
|
981
|
+
vectorEffect="non-scaling-stroke"
|
|
982
|
+
/>
|
|
983
|
+
<circle
|
|
984
|
+
cx={g.point[0]}
|
|
985
|
+
cy={g.point[1]}
|
|
986
|
+
fill={stroke}
|
|
987
|
+
pointerEvents="none"
|
|
988
|
+
r={dotRadius}
|
|
989
|
+
vectorEffect="non-scaling-stroke"
|
|
990
|
+
/>
|
|
991
|
+
<circle
|
|
992
|
+
cx={g.point[0]}
|
|
993
|
+
cy={g.point[1]}
|
|
994
|
+
fill="transparent"
|
|
995
|
+
onPointerDown={(e) =>
|
|
996
|
+
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
997
|
+
}
|
|
998
|
+
pointerEvents="all"
|
|
999
|
+
r={outerRadius}
|
|
1000
|
+
stroke="transparent"
|
|
1001
|
+
strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
|
|
1002
|
+
style={{ cursor: 'pointer' }}
|
|
1003
|
+
vectorEffect="non-scaling-stroke"
|
|
1004
|
+
/>
|
|
1005
|
+
</g>
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
case 'move-handle': {
|
|
1009
|
+
if (!palette) return <></>
|
|
1010
|
+
const moveHandleId = `${nodeId}:move`
|
|
1011
|
+
const isHovered = hoveredHandleId === moveHandleId
|
|
1012
|
+
// World-relative sizing: the move dot is anchored to the door,
|
|
1013
|
+
// not to the screen, so it grows when the user zooms in and
|
|
1014
|
+
// shrinks when they zoom out — same scaling rule as the door
|
|
1015
|
+
// footprint itself. Sizes are tuned for a ~0.9 m door at default
|
|
1016
|
+
// zoom; the ratios match the legacy 13/15/6/16/7/18 px stack.
|
|
1017
|
+
const baseRadius = 0.1
|
|
1018
|
+
const hoverRadius = 0.115
|
|
1019
|
+
const outerRadius = isHovered ? hoverRadius : baseRadius
|
|
1020
|
+
const dotRadius = 0.045
|
|
1021
|
+
const fillStroke = 0.005
|
|
1022
|
+
const glowStroke = 0.12
|
|
1023
|
+
const ringStroke = 0.055
|
|
1024
|
+
const hitStroke = 0.14
|
|
1025
|
+
// Same 5-circle stack as the orange endpoint dot — hover glow +
|
|
1026
|
+
// hover ring + filled outer + inner dot + transparent hit. On
|
|
1027
|
+
// pointer-down, the layer calls `setMovingNode(node)`, which
|
|
1028
|
+
// FloorplanRegistryMoveOverlay picks up and routes to the
|
|
1029
|
+
// kind's `def.floorplanMoveTarget`.
|
|
1030
|
+
return (
|
|
1031
|
+
<g
|
|
1032
|
+
key={keyHint}
|
|
1033
|
+
onClick={(e) => e.stopPropagation()}
|
|
1034
|
+
onPointerEnter={() => onHandleHoverChange(moveHandleId)}
|
|
1035
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
1036
|
+
>
|
|
1037
|
+
<circle
|
|
1038
|
+
cx={g.point[0]}
|
|
1039
|
+
cy={g.point[1]}
|
|
1040
|
+
fill="none"
|
|
1041
|
+
pointerEvents="none"
|
|
1042
|
+
r={outerRadius}
|
|
1043
|
+
stroke={palette.endpointHandleHoverStroke}
|
|
1044
|
+
strokeOpacity={0.16}
|
|
1045
|
+
strokeWidth={glowStroke}
|
|
1046
|
+
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
1047
|
+
/>
|
|
1048
|
+
<circle
|
|
1049
|
+
cx={g.point[0]}
|
|
1050
|
+
cy={g.point[1]}
|
|
1051
|
+
fill="none"
|
|
1052
|
+
pointerEvents="none"
|
|
1053
|
+
r={outerRadius}
|
|
1054
|
+
stroke={palette.endpointHandleHoverStroke}
|
|
1055
|
+
strokeOpacity={0.52}
|
|
1056
|
+
strokeWidth={ringStroke}
|
|
1057
|
+
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
1058
|
+
/>
|
|
1059
|
+
<circle
|
|
1060
|
+
cx={g.point[0]}
|
|
1061
|
+
cy={g.point[1]}
|
|
1062
|
+
fill={palette.endpointHandleFill}
|
|
1063
|
+
fillOpacity={0.96}
|
|
1064
|
+
pointerEvents="none"
|
|
1065
|
+
r={outerRadius}
|
|
1066
|
+
stroke={palette.endpointHandleStroke}
|
|
1067
|
+
strokeWidth={fillStroke}
|
|
1068
|
+
/>
|
|
1069
|
+
<circle
|
|
1070
|
+
cx={g.point[0]}
|
|
1071
|
+
cy={g.point[1]}
|
|
1072
|
+
fill={palette.endpointHandleStroke}
|
|
1073
|
+
pointerEvents="none"
|
|
1074
|
+
r={dotRadius}
|
|
1075
|
+
/>
|
|
1076
|
+
<circle
|
|
1077
|
+
cx={g.point[0]}
|
|
1078
|
+
cy={g.point[1]}
|
|
1079
|
+
fill="transparent"
|
|
1080
|
+
onPointerDown={(e) => onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)}
|
|
1081
|
+
pointerEvents="all"
|
|
1082
|
+
r={outerRadius}
|
|
1083
|
+
stroke="transparent"
|
|
1084
|
+
strokeWidth={hitStroke}
|
|
1085
|
+
style={{ cursor: 'move' }}
|
|
1086
|
+
/>
|
|
1087
|
+
</g>
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
case 'rotate-arrow': {
|
|
1091
|
+
if (!palette) return <></>
|
|
1092
|
+
// 2D counterpart of the 3D `arc-resize` rotate gizmo. Local
|
|
1093
|
+
// frame: +X is the radial-outward direction (away from the
|
|
1094
|
+
// pivot); the arc bows in that direction with arrowheads on
|
|
1095
|
+
// each end pointing tangentially in opposite directions —
|
|
1096
|
+
// "rotate either way."
|
|
1097
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
1098
|
+
const isHovered = hoveredHandleId === handleId
|
|
1099
|
+
// Arc geometry (all values precomputed for a 72° arc of
|
|
1100
|
+
// radius 0.13 — comparable footprint to `move-arrow`).
|
|
1101
|
+
const R = 0.13
|
|
1102
|
+
const halfSpan = Math.PI / 5
|
|
1103
|
+
const cosH = Math.cos(halfSpan)
|
|
1104
|
+
const sinH = Math.sin(halfSpan)
|
|
1105
|
+
const endY = R * sinH
|
|
1106
|
+
const headLen = 0.06
|
|
1107
|
+
const headHalfBase = 0.045
|
|
1108
|
+
// End-1 (top) arrowhead — tip along CCW tangent.
|
|
1109
|
+
const t1x = -sinH * headLen
|
|
1110
|
+
const t1y = endY + cosH * headLen
|
|
1111
|
+
const b1ax = cosH * headHalfBase
|
|
1112
|
+
const b1ay = endY + sinH * headHalfBase
|
|
1113
|
+
const b1bx = -cosH * headHalfBase
|
|
1114
|
+
const b1by = endY - sinH * headHalfBase
|
|
1115
|
+
// End-2 (bottom) arrowhead — mirror of End-1.
|
|
1116
|
+
const t2x = -sinH * headLen
|
|
1117
|
+
const t2y = -endY - cosH * headLen
|
|
1118
|
+
const b2ax = cosH * headHalfBase
|
|
1119
|
+
const b2ay = -endY - sinH * headHalfBase
|
|
1120
|
+
const b2bx = -cosH * headHalfBase
|
|
1121
|
+
const b2by = -endY + sinH * headHalfBase
|
|
1122
|
+
const arcPath = `M 0 ${-endY} A ${R} ${R} 0 0 1 0 ${endY}`
|
|
1123
|
+
const head1 = `M ${t1x} ${t1y} L ${b1ax} ${b1ay} L ${b1bx} ${b1by} Z`
|
|
1124
|
+
const head2 = `M ${t2x} ${t2y} L ${b2ax} ${b2ay} L ${b2bx} ${b2by} Z`
|
|
1125
|
+
const fill = isHovered ? '#a5b4fc' : '#8381ed'
|
|
1126
|
+
const strokeWidthPx = isHovered ? 2.4 : 1.8
|
|
1127
|
+
const angleDeg = (g.angle * 180) / Math.PI
|
|
1128
|
+
const affordance = g.affordance
|
|
1129
|
+
const payload = g.payload
|
|
1130
|
+
const pivot = g.pivot
|
|
1131
|
+
return (
|
|
1132
|
+
<g
|
|
1133
|
+
key={keyHint}
|
|
1134
|
+
onClick={(e) => e.stopPropagation()}
|
|
1135
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
|
|
1136
|
+
>
|
|
1137
|
+
<path
|
|
1138
|
+
d={arcPath}
|
|
1139
|
+
fill="none"
|
|
1140
|
+
pointerEvents="none"
|
|
1141
|
+
stroke={fill}
|
|
1142
|
+
strokeLinecap="round"
|
|
1143
|
+
strokeWidth={strokeWidthPx}
|
|
1144
|
+
vectorEffect="non-scaling-stroke"
|
|
1145
|
+
/>
|
|
1146
|
+
<path d={head1} fill={fill} pointerEvents="none" />
|
|
1147
|
+
<path d={head2} fill={fill} pointerEvents="none" />
|
|
1148
|
+
{/* Hit target — fat invisible stroke along the arc + filled
|
|
1149
|
+
triangles at the heads so the user can grab anywhere on
|
|
1150
|
+
the visible icon. */}
|
|
1151
|
+
<path
|
|
1152
|
+
d={arcPath}
|
|
1153
|
+
fill="none"
|
|
1154
|
+
onPointerDown={(e) =>
|
|
1155
|
+
onHandlePointerDown(
|
|
1156
|
+
affordance,
|
|
1157
|
+
payload,
|
|
1158
|
+
e as ReactPointerEvent<SVGPathElement>,
|
|
1159
|
+
pivot,
|
|
1160
|
+
)
|
|
1161
|
+
}
|
|
1162
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
1163
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
1164
|
+
pointerEvents="stroke"
|
|
1165
|
+
stroke="transparent"
|
|
1166
|
+
strokeWidth={0.06}
|
|
1167
|
+
style={{ cursor: 'grab' }}
|
|
1168
|
+
/>
|
|
1169
|
+
<path
|
|
1170
|
+
d={`${head1} ${head2}`}
|
|
1171
|
+
fill="transparent"
|
|
1172
|
+
onPointerDown={(e) =>
|
|
1173
|
+
onHandlePointerDown(
|
|
1174
|
+
affordance,
|
|
1175
|
+
payload,
|
|
1176
|
+
e as ReactPointerEvent<SVGPathElement>,
|
|
1177
|
+
pivot,
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
1181
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
1182
|
+
pointerEvents="fill"
|
|
1183
|
+
style={{ cursor: 'grab' }}
|
|
1184
|
+
/>
|
|
1185
|
+
</g>
|
|
1186
|
+
)
|
|
1187
|
+
}
|
|
1188
|
+
case 'move-arrow': {
|
|
1189
|
+
if (!palette) return <></>
|
|
1190
|
+
// Affordance-routed arrows (door width-resize) get a per-payload
|
|
1191
|
+
// handle id so each side can hover independently; default
|
|
1192
|
+
// (move-flow) arrows share the node's :move id like the dot.
|
|
1193
|
+
const handleId = g.affordance ? makeHandleId(nodeId, g.payload) : `${nodeId}:move`
|
|
1194
|
+
const isHovered = hoveredHandleId === handleId
|
|
1195
|
+
// Arrow geometry in plan units (meters) — scales with the scene
|
|
1196
|
+
// so it shrinks on zoom-out and grows on zoom-in, matching the
|
|
1197
|
+
// wall it accompanies. Composed of a rectangular shaft + triangular
|
|
1198
|
+
// head, drawn as a single path for a clean fill + stroke outline.
|
|
1199
|
+
const sl = 0.1 // shaft length (shortened body)
|
|
1200
|
+
const hl = 0.12 // head length
|
|
1201
|
+
const sh = 0.04 // shaft half-height
|
|
1202
|
+
const hh = 0.1 // head half-height
|
|
1203
|
+
// Inset the shaft start so the arrow sits a little off the wall
|
|
1204
|
+
// body (matches the 3D `HANDLE_OFFSET`).
|
|
1205
|
+
const bi = 0.03 // base inset
|
|
1206
|
+
const arrowD = `M ${bi},${-sh} L ${bi + sl},${-sh} L ${bi + sl},${-hh} L ${bi + sl + hl},0 L ${bi + sl},${hh} L ${bi + sl},${sh} L ${bi},${sh} Z`
|
|
1207
|
+
// Indigo palette to match the 3D `WallMoveSideHandles` arrows
|
|
1208
|
+
// (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
|
|
1209
|
+
// accent in `floating-action-menu.tsx`.
|
|
1210
|
+
const fill = isHovered ? '#a5b4fc' : '#8381ed'
|
|
1211
|
+
const angleDeg = (g.angle * 180) / Math.PI
|
|
1212
|
+
const cursor = g.affordance ? 'ew-resize' : 'move'
|
|
1213
|
+
const affordance = g.affordance
|
|
1214
|
+
const payload = g.payload
|
|
1215
|
+
// No hover-grow: a scaling transform would enlarge the hit area
|
|
1216
|
+
// too, letting clicks just outside the visible arrow still start
|
|
1217
|
+
// a drag. Hover feedback is colour-only so the click region
|
|
1218
|
+
// always matches the painted arrow shape exactly.
|
|
1219
|
+
return (
|
|
1220
|
+
<g
|
|
1221
|
+
key={keyHint}
|
|
1222
|
+
onClick={(e) => e.stopPropagation()}
|
|
1223
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
|
|
1224
|
+
>
|
|
1225
|
+
<path d={arrowD} fill={fill} pointerEvents="none" />
|
|
1226
|
+
<path
|
|
1227
|
+
d={arrowD}
|
|
1228
|
+
fill="transparent"
|
|
1229
|
+
onPointerDown={(e) => {
|
|
1230
|
+
if (affordance) {
|
|
1231
|
+
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGGElement>)
|
|
1232
|
+
} else {
|
|
1233
|
+
onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
|
|
1234
|
+
}
|
|
1235
|
+
}}
|
|
1236
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
1237
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
1238
|
+
pointerEvents="fill"
|
|
1239
|
+
style={{ cursor }}
|
|
1240
|
+
/>
|
|
1241
|
+
</g>
|
|
1242
|
+
)
|
|
1243
|
+
}
|
|
1244
|
+
case 'edge-handle': {
|
|
1245
|
+
if (!palette) return <></>
|
|
1246
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
1247
|
+
const isHovered = hoveredHandleId === handleId
|
|
1248
|
+
const isActive = activeDragId === handleId
|
|
1249
|
+
const showVisible = isHovered || isActive
|
|
1250
|
+
const stroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke
|
|
1251
|
+
// Stroke widths in screen pixels — non-scaling-stroke keeps the
|
|
1252
|
+
// hit area + glow consistent at every zoom.
|
|
1253
|
+
const glowWidthPx = 14
|
|
1254
|
+
const visibleWidthPx = 3
|
|
1255
|
+
const hitWidthPx = 18
|
|
1256
|
+
return (
|
|
1257
|
+
<g
|
|
1258
|
+
key={keyHint}
|
|
1259
|
+
onClick={(e) => e.stopPropagation()}
|
|
1260
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
1261
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
1262
|
+
>
|
|
1263
|
+
{/* Soft glow — visible only on hover / active. */}
|
|
1264
|
+
<line
|
|
1265
|
+
pointerEvents="none"
|
|
1266
|
+
stroke={stroke}
|
|
1267
|
+
strokeLinecap="round"
|
|
1268
|
+
strokeOpacity={0.18}
|
|
1269
|
+
strokeWidth={glowWidthPx * unitsPerPixel}
|
|
1270
|
+
style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
1271
|
+
vectorEffect="non-scaling-stroke"
|
|
1272
|
+
x1={g.x1}
|
|
1273
|
+
x2={g.x2}
|
|
1274
|
+
y1={g.y1}
|
|
1275
|
+
y2={g.y2}
|
|
1276
|
+
/>
|
|
1277
|
+
{/* Solid stroke on top — slightly more opaque when active. */}
|
|
1278
|
+
<line
|
|
1279
|
+
pointerEvents="none"
|
|
1280
|
+
stroke={stroke}
|
|
1281
|
+
strokeLinecap="round"
|
|
1282
|
+
strokeOpacity={isActive ? 0.95 : 0.82}
|
|
1283
|
+
strokeWidth={visibleWidthPx * unitsPerPixel}
|
|
1284
|
+
style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
1285
|
+
vectorEffect="non-scaling-stroke"
|
|
1286
|
+
x1={g.x1}
|
|
1287
|
+
x2={g.x2}
|
|
1288
|
+
y1={g.y1}
|
|
1289
|
+
y2={g.y2}
|
|
1290
|
+
/>
|
|
1291
|
+
{/* Transparent hit area along the edge. */}
|
|
1292
|
+
<line
|
|
1293
|
+
onPointerDown={(e) =>
|
|
1294
|
+
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
1295
|
+
}
|
|
1296
|
+
pointerEvents="stroke"
|
|
1297
|
+
stroke="transparent"
|
|
1298
|
+
strokeLinecap="round"
|
|
1299
|
+
strokeWidth={hitWidthPx * unitsPerPixel}
|
|
1300
|
+
style={{ cursor: 'pointer' }}
|
|
1301
|
+
vectorEffect="non-scaling-stroke"
|
|
1302
|
+
x1={g.x1}
|
|
1303
|
+
x2={g.x2}
|
|
1304
|
+
y1={g.y1}
|
|
1305
|
+
y2={g.y2}
|
|
1306
|
+
/>
|
|
1307
|
+
</g>
|
|
1308
|
+
)
|
|
1309
|
+
}
|
|
1310
|
+
case 'midpoint-handle': {
|
|
1311
|
+
if (!palette) return <></>
|
|
1312
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
1313
|
+
const isHovered = hoveredHandleId === handleId
|
|
1314
|
+
const isActive = activeDragId === handleId
|
|
1315
|
+
const stroke = palette.endpointHandleStroke
|
|
1316
|
+
const hoverStroke = palette.endpointHandleHoverStroke
|
|
1317
|
+
// Slightly smaller than endpoint dots; hover-expanded.
|
|
1318
|
+
const baseRadiusPx = 6
|
|
1319
|
+
const hoverRadiusPx = 8
|
|
1320
|
+
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
|
|
1321
|
+
const plusHalf = 3 * unitsPerPixel
|
|
1322
|
+
return (
|
|
1323
|
+
<g
|
|
1324
|
+
key={keyHint}
|
|
1325
|
+
onClick={(e) => e.stopPropagation()}
|
|
1326
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
1327
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
1328
|
+
>
|
|
1329
|
+
<circle
|
|
1330
|
+
cx={g.point[0]}
|
|
1331
|
+
cy={g.point[1]}
|
|
1332
|
+
fill="none"
|
|
1333
|
+
pointerEvents="none"
|
|
1334
|
+
r={radius + 2 * unitsPerPixel}
|
|
1335
|
+
stroke={hoverStroke}
|
|
1336
|
+
strokeOpacity={0.16}
|
|
1337
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
|
1338
|
+
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
1339
|
+
vectorEffect="non-scaling-stroke"
|
|
1340
|
+
/>
|
|
1341
|
+
<circle
|
|
1342
|
+
cx={g.point[0]}
|
|
1343
|
+
cy={g.point[1]}
|
|
1344
|
+
fill="#ffffff"
|
|
1345
|
+
fillOpacity={1}
|
|
1346
|
+
pointerEvents="none"
|
|
1347
|
+
r={radius}
|
|
1348
|
+
stroke={stroke}
|
|
1349
|
+
strokeOpacity={0.9}
|
|
1350
|
+
strokeWidth={1.4}
|
|
1351
|
+
vectorEffect="non-scaling-stroke"
|
|
1352
|
+
/>
|
|
1353
|
+
{/* `+` icon — only when the user is close enough to see it
|
|
1354
|
+
clearly (hover or active state). Keeps the resting state
|
|
1355
|
+
visually quiet on busy polygons. */}
|
|
1356
|
+
<line
|
|
1357
|
+
pointerEvents="none"
|
|
1358
|
+
stroke={stroke}
|
|
1359
|
+
strokeLinecap="round"
|
|
1360
|
+
strokeWidth={1.6}
|
|
1361
|
+
vectorEffect="non-scaling-stroke"
|
|
1362
|
+
x1={g.point[0] - plusHalf}
|
|
1363
|
+
x2={g.point[0] + plusHalf}
|
|
1364
|
+
y1={g.point[1]}
|
|
1365
|
+
y2={g.point[1]}
|
|
1366
|
+
/>
|
|
1367
|
+
<line
|
|
1368
|
+
pointerEvents="none"
|
|
1369
|
+
stroke={stroke}
|
|
1370
|
+
strokeLinecap="round"
|
|
1371
|
+
strokeWidth={1.6}
|
|
1372
|
+
vectorEffect="non-scaling-stroke"
|
|
1373
|
+
x1={g.point[0]}
|
|
1374
|
+
x2={g.point[0]}
|
|
1375
|
+
y1={g.point[1] - plusHalf}
|
|
1376
|
+
y2={g.point[1] + plusHalf}
|
|
1377
|
+
/>
|
|
1378
|
+
<circle
|
|
1379
|
+
cx={g.point[0]}
|
|
1380
|
+
cy={g.point[1]}
|
|
1381
|
+
fill="transparent"
|
|
1382
|
+
onPointerDown={(e) =>
|
|
1383
|
+
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
1384
|
+
}
|
|
1385
|
+
pointerEvents="all"
|
|
1386
|
+
r={radius + unitsPerPixel * 2}
|
|
1387
|
+
stroke="transparent"
|
|
1388
|
+
strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
|
|
1389
|
+
style={{ cursor: 'pointer' }}
|
|
1390
|
+
vectorEffect="non-scaling-stroke"
|
|
1391
|
+
/>
|
|
1392
|
+
</g>
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
case 'dimension-label': {
|
|
1396
|
+
if (!palette) return <></>
|
|
1397
|
+
// Flip the label upright relative to the SCREEN, not the local
|
|
1398
|
+
// coord system. The registry layer's parent `<g>` is rotated by
|
|
1399
|
+
// `sceneRotationDeg` (default 90° in the floor plan), so a label
|
|
1400
|
+
// we draw "upright" in local coords ends up sideways on screen.
|
|
1401
|
+
// Combine local angle + scene rotation, normalise to (-180, 180],
|
|
1402
|
+
// and flip by 180° if it falls outside (-90, 90] — that keeps
|
|
1403
|
+
// text reading left-to-right, top-to-bottom regardless of the
|
|
1404
|
+
// building's orientation.
|
|
1405
|
+
let degrees = (g.angle * 180) / Math.PI
|
|
1406
|
+
let screenDegrees = degrees + sceneRotationDeg
|
|
1407
|
+
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
|
1408
|
+
if (screenDegrees > 90) degrees -= 180
|
|
1409
|
+
else if (screenDegrees <= -90) degrees += 180
|
|
1410
|
+
|
|
1411
|
+
const padX = unitsPerPixel * 6
|
|
1412
|
+
const padY = unitsPerPixel * 3
|
|
1413
|
+
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
|
1414
|
+
// Rough text width approximation — SVG can't measure text without
|
|
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
|
|
1418
|
+
const plateW = textWidth + padX * 2
|
|
1419
|
+
const plateH = fontSize + padY * 2
|
|
1420
|
+
return (
|
|
1421
|
+
<g
|
|
1422
|
+
key={keyHint}
|
|
1423
|
+
pointerEvents="none"
|
|
1424
|
+
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees})`}
|
|
1425
|
+
>
|
|
1426
|
+
<rect
|
|
1427
|
+
fill={palette.measurementLabelBackground}
|
|
1428
|
+
height={plateH}
|
|
1429
|
+
opacity={0.92}
|
|
1430
|
+
rx={unitsPerPixel * 3}
|
|
1431
|
+
ry={unitsPerPixel * 3}
|
|
1432
|
+
stroke={palette.measurementStroke}
|
|
1433
|
+
strokeWidth={unitsPerPixel * 0.5}
|
|
1434
|
+
vectorEffect="non-scaling-stroke"
|
|
1435
|
+
width={plateW}
|
|
1436
|
+
x={-plateW / 2}
|
|
1437
|
+
y={-plateH / 2}
|
|
1438
|
+
/>
|
|
1439
|
+
<text
|
|
1440
|
+
dominantBaseline="middle"
|
|
1441
|
+
fill={palette.measurementLabelText}
|
|
1442
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
1443
|
+
fontSize={fontSize}
|
|
1444
|
+
fontWeight={600}
|
|
1445
|
+
textAnchor="middle"
|
|
1446
|
+
x={0}
|
|
1447
|
+
y={0}
|
|
1448
|
+
>
|
|
1449
|
+
{g.text}
|
|
1450
|
+
</text>
|
|
1451
|
+
</g>
|
|
1452
|
+
)
|
|
1453
|
+
}
|
|
1454
|
+
case 'dimension': {
|
|
1455
|
+
if (!palette) return <></>
|
|
1456
|
+
const stroke = g.stroke ?? palette.measurementStroke
|
|
1457
|
+
// Offset endpoints along the outward normal — this is where the
|
|
1458
|
+
// dimension line sits, parallel to the edge.
|
|
1459
|
+
const ox = g.offsetNormal[0] * g.offsetDistance
|
|
1460
|
+
const oy = g.offsetNormal[1] * g.offsetDistance
|
|
1461
|
+
const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
|
|
1462
|
+
const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
|
|
1463
|
+
|
|
1464
|
+
// Extension line endpoints — extend past the dimension line by
|
|
1465
|
+
// `extensionOvershoot` so the tip clears the dimension stroke.
|
|
1466
|
+
const eOvershoot = g.extensionOvershoot
|
|
1467
|
+
const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
|
|
1468
|
+
const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
|
|
1469
|
+
const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
|
|
1470
|
+
const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
|
|
1471
|
+
|
|
1472
|
+
const dx = dEnd[0] - dStart[0]
|
|
1473
|
+
const dy = dEnd[1] - dStart[1]
|
|
1474
|
+
const length = Math.hypot(dx, dy)
|
|
1475
|
+
if (length < 1e-6) return <></>
|
|
1476
|
+
const dirX = dx / length
|
|
1477
|
+
const dirY = dy / length
|
|
1478
|
+
|
|
1479
|
+
// Plan-unit constants matching the legacy `floorplan-
|
|
1480
|
+
// measurements-layer.tsx`. `strokeWidth` is intentionally a
|
|
1481
|
+
// raw value (not multiplied by `unitsPerPixel`) because every
|
|
1482
|
+
// stroke here uses `vectorEffect: non-scaling-stroke` — the
|
|
1483
|
+
// browser interprets it as screen-pixel-stable. Multiplying
|
|
1484
|
+
// by `unitsPerPixel` would shrink the strokes by ~100× and
|
|
1485
|
+
// make them invisible. Tick length, dash pattern, font size,
|
|
1486
|
+
// and the label gap stay in plan units (they're geometry,
|
|
1487
|
+
// not stroke width).
|
|
1488
|
+
const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
|
|
1489
|
+
const perpX = -dirY * tickHalf
|
|
1490
|
+
const perpY = dirX * tickHalf
|
|
1491
|
+
|
|
1492
|
+
const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
|
|
1493
|
+
const labelGap = 0.5 // plan units — gap in the dimension line for the label
|
|
1494
|
+
const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
|
|
1495
|
+
|
|
1496
|
+
const midX = (dStart[0] + dEnd[0]) / 2
|
|
1497
|
+
const midY = (dStart[1] + dEnd[1]) / 2
|
|
1498
|
+
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
|
|
1499
|
+
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
|
|
1500
|
+
|
|
1501
|
+
// Keep the label parallel to the dimension line, but decide the
|
|
1502
|
+
// 180° flip from the on-SCREEN angle, not the local one. The parent
|
|
1503
|
+
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
|
|
1504
|
+
// plan), so a label kept upright in local coords still renders
|
|
1505
|
+
// upside down for half of the wall orientations. Same fix as the
|
|
1506
|
+
// `dimension-label` case above.
|
|
1507
|
+
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
|
|
1508
|
+
let screenDeg = labelDeg + sceneRotationDeg
|
|
1509
|
+
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
|
1510
|
+
if (screenDeg > 90) labelDeg -= 180
|
|
1511
|
+
else if (screenDeg <= -90) labelDeg += 180
|
|
1512
|
+
|
|
1513
|
+
return (
|
|
1514
|
+
<g key={keyHint} pointerEvents="none">
|
|
1515
|
+
{/* Extension lines (dashed). */}
|
|
1516
|
+
<line
|
|
1517
|
+
stroke={stroke}
|
|
1518
|
+
strokeDasharray="0.08 0.12"
|
|
1519
|
+
strokeLinecap="round"
|
|
1520
|
+
strokeOpacity={0.95}
|
|
1521
|
+
strokeWidth={1.35}
|
|
1522
|
+
vectorEffect="non-scaling-stroke"
|
|
1523
|
+
x1={g.start[0]}
|
|
1524
|
+
x2={eStartTip[0]}
|
|
1525
|
+
y1={g.start[1]}
|
|
1526
|
+
y2={eStartTip[1]}
|
|
1527
|
+
/>
|
|
1528
|
+
<line
|
|
1529
|
+
stroke={stroke}
|
|
1530
|
+
strokeDasharray="0.08 0.12"
|
|
1531
|
+
strokeLinecap="round"
|
|
1532
|
+
strokeOpacity={0.95}
|
|
1533
|
+
strokeWidth={1.35}
|
|
1534
|
+
vectorEffect="non-scaling-stroke"
|
|
1535
|
+
x1={g.end[0]}
|
|
1536
|
+
x2={eEndTip[0]}
|
|
1537
|
+
y1={g.end[1]}
|
|
1538
|
+
y2={eEndTip[1]}
|
|
1539
|
+
/>
|
|
1540
|
+
{/* Dimension line: two halves with the label in between. */}
|
|
1541
|
+
<line
|
|
1542
|
+
stroke={stroke}
|
|
1543
|
+
strokeLinecap="round"
|
|
1544
|
+
strokeWidth={1.35}
|
|
1545
|
+
vectorEffect="non-scaling-stroke"
|
|
1546
|
+
x1={dStart[0]}
|
|
1547
|
+
x2={gapStart[0]}
|
|
1548
|
+
y1={dStart[1]}
|
|
1549
|
+
y2={gapStart[1]}
|
|
1550
|
+
/>
|
|
1551
|
+
<line
|
|
1552
|
+
stroke={stroke}
|
|
1553
|
+
strokeLinecap="round"
|
|
1554
|
+
strokeWidth={1.35}
|
|
1555
|
+
vectorEffect="non-scaling-stroke"
|
|
1556
|
+
x1={gapEnd[0]}
|
|
1557
|
+
x2={dEnd[0]}
|
|
1558
|
+
y1={gapEnd[1]}
|
|
1559
|
+
y2={dEnd[1]}
|
|
1560
|
+
/>
|
|
1561
|
+
{/* End ticks. */}
|
|
1562
|
+
<line
|
|
1563
|
+
stroke={stroke}
|
|
1564
|
+
strokeLinecap="round"
|
|
1565
|
+
strokeWidth={1.35}
|
|
1566
|
+
vectorEffect="non-scaling-stroke"
|
|
1567
|
+
x1={dStart[0] - perpX}
|
|
1568
|
+
x2={dStart[0] + perpX}
|
|
1569
|
+
y1={dStart[1] - perpY}
|
|
1570
|
+
y2={dStart[1] + perpY}
|
|
1571
|
+
/>
|
|
1572
|
+
<line
|
|
1573
|
+
stroke={stroke}
|
|
1574
|
+
strokeLinecap="round"
|
|
1575
|
+
strokeWidth={1.35}
|
|
1576
|
+
vectorEffect="non-scaling-stroke"
|
|
1577
|
+
x1={dEnd[0] - perpX}
|
|
1578
|
+
x2={dEnd[0] + perpX}
|
|
1579
|
+
y1={dEnd[1] - perpY}
|
|
1580
|
+
y2={dEnd[1] + perpY}
|
|
1581
|
+
/>
|
|
1582
|
+
{/* Rotated label centered in the gap. */}
|
|
1583
|
+
<text
|
|
1584
|
+
dominantBaseline="central"
|
|
1585
|
+
fill={stroke}
|
|
1586
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
1587
|
+
fontSize={fontSize}
|
|
1588
|
+
fontWeight={600}
|
|
1589
|
+
textAnchor="middle"
|
|
1590
|
+
transform={`rotate(${labelDeg} ${midX} ${midY})`}
|
|
1591
|
+
x={midX}
|
|
1592
|
+
y={midY}
|
|
1593
|
+
>
|
|
1594
|
+
{g.text}
|
|
1595
|
+
</text>
|
|
1596
|
+
</g>
|
|
1597
|
+
)
|
|
1598
|
+
}
|
|
1599
|
+
case 'text': {
|
|
1600
|
+
if (!g.upright) return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
|
|
1601
|
+
// Counter-rotate by the scene rotation so the label reads
|
|
1602
|
+
// horizontally on screen even when the floor-plan view is
|
|
1603
|
+
// rotated (default `sceneRotationDeg` is 90°).
|
|
1604
|
+
return (
|
|
1605
|
+
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
|
|
1606
|
+
<text
|
|
1607
|
+
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
|
1608
|
+
fill={g.fill ?? '#171717'}
|
|
1609
|
+
fontFamily={g.fontFamily}
|
|
1610
|
+
fontSize={g.fontSize}
|
|
1611
|
+
fontWeight={g.fontWeight}
|
|
1612
|
+
opacity={g.opacity}
|
|
1613
|
+
paintOrder={g.paintOrder}
|
|
1614
|
+
stroke={g.stroke}
|
|
1615
|
+
strokeLinecap={g.stroke ? 'round' : undefined}
|
|
1616
|
+
strokeLinejoin={g.stroke ? 'round' : undefined}
|
|
1617
|
+
strokeWidth={g.strokeWidth}
|
|
1618
|
+
textAnchor={g.textAnchor ?? 'start'}
|
|
1619
|
+
x={0}
|
|
1620
|
+
y={0}
|
|
1621
|
+
>
|
|
1622
|
+
{g.text}
|
|
1623
|
+
</text>
|
|
1624
|
+
</g>
|
|
1625
|
+
)
|
|
1626
|
+
}
|
|
1627
|
+
default:
|
|
1628
|
+
return (
|
|
1629
|
+
<FloorplanGeometryRenderer
|
|
1630
|
+
geometry={g}
|
|
1631
|
+
key={keyHint}
|
|
1632
|
+
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
|
|
1633
|
+
/>
|
|
1634
|
+
)
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1640
|
+
|
|
1641
|
+
function applyPositionLiveTransform(
|
|
1642
|
+
node: AnyNode,
|
|
1643
|
+
live: { position: [number, number, number]; rotation: number },
|
|
1644
|
+
): AnyNode {
|
|
1645
|
+
const currentRotation = (node as { rotation?: unknown }).rotation
|
|
1646
|
+
const rotation = Array.isArray(currentRotation)
|
|
1647
|
+
? ([
|
|
1648
|
+
(currentRotation[0] as number) ?? 0,
|
|
1649
|
+
live.rotation,
|
|
1650
|
+
(currentRotation[2] as number) ?? 0,
|
|
1651
|
+
] as [number, number, number])
|
|
1652
|
+
: typeof currentRotation === 'number'
|
|
1653
|
+
? live.rotation
|
|
1654
|
+
: currentRotation
|
|
1655
|
+
|
|
1656
|
+
return {
|
|
1657
|
+
...node,
|
|
1658
|
+
position: live.position,
|
|
1659
|
+
...(rotation !== undefined ? { rotation } : {}),
|
|
1660
|
+
parentId: null,
|
|
1661
|
+
} as AnyNode
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function buildContext(
|
|
1665
|
+
node: AnyNode,
|
|
1666
|
+
nodes: Record<string, AnyNode>,
|
|
1667
|
+
viewState: {
|
|
1668
|
+
selected: boolean
|
|
1669
|
+
highlighted: boolean
|
|
1670
|
+
hovered: boolean
|
|
1671
|
+
moving: boolean
|
|
1672
|
+
palette: FloorplanPalette | undefined
|
|
1673
|
+
},
|
|
1674
|
+
): GeometryContext {
|
|
1675
|
+
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
1676
|
+
|
|
1677
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
1678
|
+
const children: AnyNode[] = Array.isArray(childIds)
|
|
1679
|
+
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
|
|
1680
|
+
: []
|
|
1681
|
+
|
|
1682
|
+
const parentId = node.parentId as AnyNodeId | null
|
|
1683
|
+
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
|
|
1684
|
+
|
|
1685
|
+
let siblings: AnyNode[] = []
|
|
1686
|
+
if (parent) {
|
|
1687
|
+
const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
|
|
1688
|
+
if (Array.isArray(parentChildIds)) {
|
|
1689
|
+
for (const sid of parentChildIds) {
|
|
1690
|
+
if (sid === node.id) continue
|
|
1691
|
+
const s = nodes[sid]
|
|
1692
|
+
if (s && s.type === node.type) siblings.push(s)
|
|
1693
|
+
}
|
|
1694
|
+
} else {
|
|
1695
|
+
siblings = Object.values(nodes).filter(
|
|
1696
|
+
(n) => n !== node && n.type === node.type && n.parentId === parentId,
|
|
1697
|
+
)
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
return {
|
|
1702
|
+
resolve,
|
|
1703
|
+
children,
|
|
1704
|
+
siblings,
|
|
1705
|
+
parent,
|
|
1706
|
+
viewState: viewState.palette
|
|
1707
|
+
? {
|
|
1708
|
+
selected: viewState.selected,
|
|
1709
|
+
highlighted: viewState.highlighted,
|
|
1710
|
+
hovered: viewState.hovered,
|
|
1711
|
+
moving: viewState.moving,
|
|
1712
|
+
palette: viewState.palette,
|
|
1713
|
+
}
|
|
1714
|
+
: undefined,
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* Stable id for a handle on a node, derived from the node id + opaque
|
|
1720
|
+
* payload. Used to track hover / active visual state when multiple
|
|
1721
|
+
* handles belong to the same node (start vs end endpoint, multiple
|
|
1722
|
+
* vertices of a polygon, etc.).
|
|
1723
|
+
*/
|
|
1724
|
+
function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
|
|
1725
|
+
if (payload == null) return `${nodeId}`
|
|
1726
|
+
if (typeof payload === 'object') {
|
|
1727
|
+
// Stable JSON serialisation of common shapes — endpoint discriminator,
|
|
1728
|
+
// vertex index, etc. Don't try to handle arbitrarily-deep payloads.
|
|
1729
|
+
try {
|
|
1730
|
+
return `${nodeId}:${JSON.stringify(payload)}`
|
|
1731
|
+
} catch {
|
|
1732
|
+
return `${nodeId}`
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return `${nodeId}:${String(payload)}`
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Geometry kinds that always render in the overlay pass — interactive
|
|
1740
|
+
* handles and node labels. These need to sit above every kind's base
|
|
1741
|
+
* geometry regardless of the owning node's z-bucket so that:
|
|
1742
|
+
* - polygon edit handles on a selected slab don't get hidden by the
|
|
1743
|
+
* walls / items resting on top of the slab,
|
|
1744
|
+
* - a zone's name stays legible above the slab covering the zone, and
|
|
1745
|
+
* - measurement labels never get clipped by structural fills.
|
|
1746
|
+
*/
|
|
1747
|
+
const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
1748
|
+
'text',
|
|
1749
|
+
'endpoint-handle',
|
|
1750
|
+
'midpoint-handle',
|
|
1751
|
+
'edge-handle',
|
|
1752
|
+
'move-handle',
|
|
1753
|
+
'move-arrow',
|
|
1754
|
+
'rotate-arrow',
|
|
1755
|
+
'dimension',
|
|
1756
|
+
'dimension-label',
|
|
1757
|
+
])
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Walk a `FloorplanGeometry` tree and split it into two trees: one with
|
|
1761
|
+
* only "base" primitives (polygons, paths, fills, strokes) and one with
|
|
1762
|
+
* only "overlay" primitives (handles, labels — see `OVERLAY_KINDS`).
|
|
1763
|
+
*
|
|
1764
|
+
* Groups recurse: a `kind: 'group'` is split into a base group and an
|
|
1765
|
+
* overlay group, both carrying the same `transform` so nested rotations
|
|
1766
|
+
* / translations apply in both passes. Empty groups collapse to `null`
|
|
1767
|
+
* so the caller can skip emitting an `<g>` when there's nothing to draw.
|
|
1768
|
+
*/
|
|
1769
|
+
function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
1770
|
+
base: FloorplanGeometry | null
|
|
1771
|
+
overlay: FloorplanGeometry | null
|
|
1772
|
+
} {
|
|
1773
|
+
if (OVERLAY_KINDS.has(g.kind)) {
|
|
1774
|
+
return { base: null, overlay: g }
|
|
1775
|
+
}
|
|
1776
|
+
if (g.kind === 'group') {
|
|
1777
|
+
const baseChildren: FloorplanGeometry[] = []
|
|
1778
|
+
const overlayChildren: FloorplanGeometry[] = []
|
|
1779
|
+
for (const child of g.children) {
|
|
1780
|
+
const split = splitFloorplanOverlay(child)
|
|
1781
|
+
if (split.base) baseChildren.push(split.base)
|
|
1782
|
+
if (split.overlay) overlayChildren.push(split.overlay)
|
|
1783
|
+
}
|
|
1784
|
+
const base: FloorplanGeometry | null =
|
|
1785
|
+
baseChildren.length > 0
|
|
1786
|
+
? { kind: 'group', children: baseChildren, transform: g.transform }
|
|
1787
|
+
: null
|
|
1788
|
+
const overlay: FloorplanGeometry | null =
|
|
1789
|
+
overlayChildren.length > 0
|
|
1790
|
+
? { kind: 'group', children: overlayChildren, transform: g.transform }
|
|
1791
|
+
: null
|
|
1792
|
+
return { base, overlay }
|
|
1793
|
+
}
|
|
1794
|
+
return { base: g, overlay: null }
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
|
1799
|
+
* sits under everything with a higher rank. SVG renders in document
|
|
1800
|
+
* order, so an earlier entry in the array ends up beneath a later one.
|
|
1801
|
+
*
|
|
1802
|
+
* Three buckets today:
|
|
1803
|
+
* 0 — `zone`: conceptual area regions, always under everything else.
|
|
1804
|
+
* 1 — `slab` / `ceiling`: the floor / ceiling surface; sits over the
|
|
1805
|
+
* zone but under any structural / furniture geometry placed on it.
|
|
1806
|
+
* 2 — every other kind (walls, items, shelves, columns, stairs, …):
|
|
1807
|
+
* structure + furniture, painted on top.
|
|
1808
|
+
*
|
|
1809
|
+
* Sort is stable in modern JS engines, so siblings within the same
|
|
1810
|
+
* bucket keep their DFS order (= scene tree order).
|
|
1811
|
+
*/
|
|
1812
|
+
function floorplanLayerRank(type: string): number {
|
|
1813
|
+
switch (type) {
|
|
1814
|
+
case 'zone':
|
|
1815
|
+
return 0
|
|
1816
|
+
case 'slab':
|
|
1817
|
+
case 'ceiling':
|
|
1818
|
+
return 1
|
|
1819
|
+
default:
|
|
1820
|
+
return 2
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
1825
|
+
if (a === b) return true
|
|
1826
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
1827
|
+
if (a.length !== b.length) return false
|
|
1828
|
+
for (let i = 0; i < a.length; i++) {
|
|
1829
|
+
if (!deepEqual(a[i], b[i])) return false
|
|
1830
|
+
}
|
|
1831
|
+
return true
|
|
1832
|
+
}
|
|
1833
|
+
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
|
|
1834
|
+
const aKeys = Object.keys(a as Record<string, unknown>)
|
|
1835
|
+
const bKeys = Object.keys(b as Record<string, unknown>)
|
|
1836
|
+
if (aKeys.length !== bKeys.length) return false
|
|
1837
|
+
for (const key of aKeys) {
|
|
1838
|
+
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
|
1839
|
+
return false
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return true
|
|
1843
|
+
}
|
|
1844
|
+
return false
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const ROTATION_WEDGE_COLOR = '#8381ed'
|
|
1848
|
+
const ROTATION_WEDGE_SEGMENTS = 48
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Live rotation readout for the floor plan — the 2D twin of the 3D rotate
|
|
1852
|
+
* gizmo's wedge. Draws a filled sector + outline swept from the pointer's
|
|
1853
|
+
* bearing at grab (`startAngle`) to its current bearing (`endAngle`) around
|
|
1854
|
+
* the pivot, plus an upright degree chip at the wedge midpoint. All geometry
|
|
1855
|
+
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
|
1856
|
+
* horizontally regardless of the building's on-screen orientation.
|
|
1857
|
+
*/
|
|
1858
|
+
function RotationAngleOverlay({
|
|
1859
|
+
overlay,
|
|
1860
|
+
palette,
|
|
1861
|
+
unitsPerPixel,
|
|
1862
|
+
sceneRotationDeg,
|
|
1863
|
+
}: {
|
|
1864
|
+
overlay: RotationOverlayState
|
|
1865
|
+
palette: FloorplanPalette
|
|
1866
|
+
unitsPerPixel: number
|
|
1867
|
+
sceneRotationDeg: number
|
|
1868
|
+
}): React.ReactElement {
|
|
1869
|
+
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
|
1870
|
+
const span = endAngle - startAngle
|
|
1871
|
+
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|
|
1872
|
+
let d = `M ${pivot[0]} ${pivot[1]}`
|
|
1873
|
+
for (let i = 0; i <= count; i++) {
|
|
1874
|
+
const a = startAngle + (span * i) / count
|
|
1875
|
+
d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}`
|
|
1876
|
+
}
|
|
1877
|
+
d += ' Z'
|
|
1878
|
+
|
|
1879
|
+
const midAngle = startAngle + span / 2
|
|
1880
|
+
const labelDist = radius + unitsPerPixel * 14
|
|
1881
|
+
const lx = pivot[0] + Math.cos(midAngle) * labelDist
|
|
1882
|
+
const ly = pivot[1] + Math.sin(midAngle) * labelDist
|
|
1883
|
+
|
|
1884
|
+
const text = `${Math.round((sweep * 180) / Math.PI)}°`
|
|
1885
|
+
const padX = unitsPerPixel * 6
|
|
1886
|
+
const padY = unitsPerPixel * 3
|
|
1887
|
+
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
|
1888
|
+
const textWidth = text.length * unitsPerPixel * 6.2
|
|
1889
|
+
const plateW = textWidth + padX * 2
|
|
1890
|
+
const plateH = fontSize + padY * 2
|
|
1891
|
+
|
|
1892
|
+
return (
|
|
1893
|
+
<g className="floorplan-rotation-readout" pointerEvents="none">
|
|
1894
|
+
<path d={d} fill={ROTATION_WEDGE_COLOR} fillOpacity={0.18} stroke="none" />
|
|
1895
|
+
<path
|
|
1896
|
+
d={d}
|
|
1897
|
+
fill="none"
|
|
1898
|
+
stroke={ROTATION_WEDGE_COLOR}
|
|
1899
|
+
strokeLinejoin="round"
|
|
1900
|
+
strokeOpacity={0.95}
|
|
1901
|
+
strokeWidth={1.8}
|
|
1902
|
+
vectorEffect="non-scaling-stroke"
|
|
1903
|
+
/>
|
|
1904
|
+
{/* Counter-rotate the scene transform so the chip stays horizontal. */}
|
|
1905
|
+
<g transform={`translate(${lx} ${ly}) rotate(${-sceneRotationDeg})`}>
|
|
1906
|
+
<rect
|
|
1907
|
+
fill={palette.measurementLabelBackground}
|
|
1908
|
+
height={plateH}
|
|
1909
|
+
opacity={0.92}
|
|
1910
|
+
rx={unitsPerPixel * 3}
|
|
1911
|
+
ry={unitsPerPixel * 3}
|
|
1912
|
+
stroke={palette.measurementStroke}
|
|
1913
|
+
strokeWidth={unitsPerPixel * 0.5}
|
|
1914
|
+
vectorEffect="non-scaling-stroke"
|
|
1915
|
+
width={plateW}
|
|
1916
|
+
x={-plateW / 2}
|
|
1917
|
+
y={-plateH / 2}
|
|
1918
|
+
/>
|
|
1919
|
+
<text
|
|
1920
|
+
dominantBaseline="middle"
|
|
1921
|
+
fill={palette.measurementLabelText}
|
|
1922
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
1923
|
+
fontSize={fontSize}
|
|
1924
|
+
fontWeight={600}
|
|
1925
|
+
textAnchor="middle"
|
|
1926
|
+
x={0}
|
|
1927
|
+
y={0}
|
|
1928
|
+
>
|
|
1929
|
+
{text}
|
|
1930
|
+
</text>
|
|
1931
|
+
</g>
|
|
1932
|
+
</g>
|
|
1933
|
+
)
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function formatGroupTransform(t?: {
|
|
1937
|
+
translate?: readonly [number, number]
|
|
1938
|
+
rotate?: number
|
|
1939
|
+
}): string | undefined {
|
|
1940
|
+
if (!t) return undefined
|
|
1941
|
+
const parts: string[] = []
|
|
1942
|
+
if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
|
|
1943
|
+
if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
|
|
1944
|
+
return parts.length > 0 ? parts.join(' ') : undefined
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
|
|
1948
|
+
// The registry layer lives under the floor-plan scene `<g>`. The
|
|
1949
|
+
// legacy panel computes the same conversion via floorplanSceneRef +
|
|
1950
|
+
// getScreenCTM; we replicate it by walking up to the SVG owner.
|
|
1951
|
+
const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
|
|
1952
|
+
const svg = target?.ownerSVGElement
|
|
1953
|
+
if (!(svg && target)) return null
|
|
1954
|
+
const ctm = target.getScreenCTM()
|
|
1955
|
+
if (!ctm) return null
|
|
1956
|
+
const point = svg.createSVGPoint()
|
|
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]
|
|
1963
|
+
}
|