@pascal-app/editor 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -6,9 +6,12 @@ import {
|
|
|
6
6
|
type AnyNodeId,
|
|
7
7
|
type BuildingNode,
|
|
8
8
|
type CeilingNode,
|
|
9
|
+
CeilingNode as CeilingNodeSchema,
|
|
9
10
|
type ColumnNode,
|
|
10
11
|
calculateLevelMiters,
|
|
12
|
+
DEFAULT_ANGLE_STEP,
|
|
11
13
|
type DoorNode,
|
|
14
|
+
DoorNode as DoorNodeSchema,
|
|
12
15
|
type ElevatorNode,
|
|
13
16
|
emitter,
|
|
14
17
|
type FenceNode,
|
|
@@ -31,6 +34,7 @@ import {
|
|
|
31
34
|
type RoofSegmentNode,
|
|
32
35
|
type SiteNode,
|
|
33
36
|
type SlabNode,
|
|
37
|
+
SlabNode as SlabNodeSchema,
|
|
34
38
|
type SpawnNode,
|
|
35
39
|
type StairNode,
|
|
36
40
|
StairNode as StairNodeSchema,
|
|
@@ -38,19 +42,24 @@ import {
|
|
|
38
42
|
StairSegmentNode as StairSegmentNodeSchema,
|
|
39
43
|
sampleWallCenterline,
|
|
40
44
|
sceneRegistry,
|
|
45
|
+
snapPointAlongAngleRay,
|
|
41
46
|
useInteractive,
|
|
42
47
|
useLiveNodeOverrides,
|
|
43
48
|
useLiveTransforms,
|
|
44
49
|
useScene,
|
|
45
50
|
type WallNode,
|
|
51
|
+
WallNode as WallNodeSchema,
|
|
46
52
|
type WindowNode,
|
|
53
|
+
WindowNode as WindowNodeSchema,
|
|
54
|
+
wallClosesRoom,
|
|
47
55
|
ZoneNode as ZoneNodeSchema,
|
|
48
56
|
type ZoneNode as ZoneNodeType,
|
|
49
57
|
} from '@pascal-app/core'
|
|
50
|
-
import {
|
|
58
|
+
import { useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
|
|
51
59
|
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
|
52
60
|
import { Command, Ruler } from 'lucide-react'
|
|
53
61
|
import {
|
|
62
|
+
type ComponentProps,
|
|
54
63
|
memo,
|
|
55
64
|
type MouseEvent as ReactMouseEvent,
|
|
56
65
|
type PointerEvent as ReactPointerEvent,
|
|
@@ -64,25 +73,50 @@ import {
|
|
|
64
73
|
import { createPortal } from 'react-dom'
|
|
65
74
|
import { Vector3 } from 'three'
|
|
66
75
|
import { useShallow } from 'zustand/react/shallow'
|
|
76
|
+
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
|
67
77
|
import {
|
|
68
78
|
alignFloorplanDraftPoint,
|
|
69
79
|
buildFloorplanItemEntry,
|
|
70
80
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
|
71
81
|
collectLevelDescendants,
|
|
82
|
+
FLOORPLAN_VIEW_ROTATION_DEG,
|
|
83
|
+
floorplanLocalToWorldPoint,
|
|
72
84
|
getFloorplanWall as getSharedFloorplanWall,
|
|
73
85
|
rotatePlanVector as rotateSharedPlanVector,
|
|
74
86
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
|
87
|
+
worldToFloorplanLocalPoint,
|
|
75
88
|
} from '../../lib/floorplan'
|
|
76
89
|
import { guideEmitter } from '../../lib/guide-events'
|
|
90
|
+
import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
|
|
91
|
+
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
|
77
92
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
78
93
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
|
94
|
+
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
|
79
95
|
import { cn } from '../../lib/utils'
|
|
80
96
|
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
|
97
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
81
98
|
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
|
82
|
-
import useEditor, {
|
|
99
|
+
import useEditor, {
|
|
100
|
+
isAngleSnapActive,
|
|
101
|
+
isMagneticSnapActive,
|
|
102
|
+
selectSiteFloorplanContext,
|
|
103
|
+
} from '../../store/use-editor'
|
|
104
|
+
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
|
|
105
|
+
import { useFloorplanMarquee } from '../../store/use-floorplan-marquee'
|
|
106
|
+
import useInteractionScope, {
|
|
107
|
+
useActiveHandleDrag,
|
|
108
|
+
useEndpointReshape,
|
|
109
|
+
useIsCurveReshape,
|
|
110
|
+
useMovingNode,
|
|
111
|
+
useReshapingNode,
|
|
112
|
+
} from '../../store/use-interaction-scope'
|
|
113
|
+
import usePlacementPreview from '../../store/use-placement-preview'
|
|
114
|
+
import { useStairBuildPreview } from '../../store/use-stair-build-preview'
|
|
83
115
|
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
|
84
116
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
|
117
|
+
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
|
|
85
118
|
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
|
119
|
+
import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
|
|
86
120
|
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
|
|
87
121
|
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
|
|
88
122
|
import {
|
|
@@ -95,8 +129,12 @@ import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-laye
|
|
|
95
129
|
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
|
|
96
130
|
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
|
97
131
|
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
|
98
|
-
import {
|
|
132
|
+
import {
|
|
133
|
+
FloorplanRegistryLayer,
|
|
134
|
+
RotationAngleOverlay,
|
|
135
|
+
} from '../editor-2d/renderers/floorplan-registry-layer'
|
|
99
136
|
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
|
137
|
+
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
|
|
100
138
|
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
|
101
139
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
|
102
140
|
import { snapToHalf } from '../tools/item/placement-math'
|
|
@@ -104,6 +142,11 @@ import {
|
|
|
104
142
|
isBoxSelectPointerSuppressed,
|
|
105
143
|
markBoxSelectHandled,
|
|
106
144
|
} from '../tools/select/box-select-state'
|
|
145
|
+
import {
|
|
146
|
+
type Point2 as MarqueePoint2,
|
|
147
|
+
polygonsIntersect as marqueePolygonsIntersect,
|
|
148
|
+
segmentIntersectsPolygon as marqueeSegmentIntersectsPolygon,
|
|
149
|
+
} from '../tools/select/marquee-geometry'
|
|
107
150
|
import {
|
|
108
151
|
createScreenRectangleSelectionElement,
|
|
109
152
|
hideScreenRectangleSelectionElement,
|
|
@@ -132,13 +175,14 @@ import {
|
|
|
132
175
|
DEFAULT_STAIR_WIDTH,
|
|
133
176
|
} from '../tools/stair/stair-defaults'
|
|
134
177
|
import {
|
|
178
|
+
chainEndJoinsExistingWall,
|
|
135
179
|
createWallOnCurrentLevel,
|
|
136
180
|
isSegmentLongEnough,
|
|
137
181
|
snapWallDraftPoint,
|
|
138
182
|
snapWallDraftPointDetailed,
|
|
139
183
|
snapPointToGrid as snapWallPointToGrid,
|
|
140
|
-
WALL_FINE_GRID_STEP,
|
|
141
184
|
WALL_GRID_STEP,
|
|
185
|
+
WALL_JOIN_SNAP_RADIUS,
|
|
142
186
|
type WallPlanPoint,
|
|
143
187
|
} from '../tools/wall/wall-drafting'
|
|
144
188
|
|
|
@@ -217,9 +261,7 @@ const FLOORPLAN_GUIDE_SELECTION_STROKE_WIDTH = 0.05
|
|
|
217
261
|
const FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET = 72
|
|
218
262
|
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92
|
|
219
263
|
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
|
|
220
|
-
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES =
|
|
221
|
-
const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
|
|
222
|
-
const FLOORPLAN_VIEW_ROTATION_DEG = 90
|
|
264
|
+
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15
|
|
223
265
|
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
|
|
224
266
|
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
|
|
225
267
|
const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005
|
|
@@ -342,14 +384,6 @@ type FloorplanSelectionBounds = {
|
|
|
342
384
|
maxY: number
|
|
343
385
|
}
|
|
344
386
|
|
|
345
|
-
type FloorplanMarqueeState = {
|
|
346
|
-
pointerId: number
|
|
347
|
-
startClientX: number
|
|
348
|
-
startClientY: number
|
|
349
|
-
startPlanPoint: WallPlanPoint
|
|
350
|
-
currentPlanPoint: WallPlanPoint
|
|
351
|
-
}
|
|
352
|
-
|
|
353
387
|
type LinkedWallSnapshot = {
|
|
354
388
|
id: WallNode['id']
|
|
355
389
|
start: WallPlanPoint
|
|
@@ -410,10 +444,13 @@ type GuideTransformDraft = {
|
|
|
410
444
|
|
|
411
445
|
type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches'
|
|
412
446
|
|
|
447
|
+
// The in-flight reference-scale measurement. Only the per-CLICK fields live
|
|
448
|
+
// here (guide + start anchor); the rubber-band's moving END is the shared
|
|
449
|
+
// `useFloorplanDraftPreview.cursorPoint` (set on every move anyway), so it
|
|
450
|
+
// never re-renders the panel — `FloorplanReferenceScaleDraftLine` reads it.
|
|
413
451
|
type ReferenceScaleDraft = {
|
|
414
452
|
guideId: GuideNode['id']
|
|
415
453
|
start: WallPlanPoint | null
|
|
416
|
-
cursor: WallPlanPoint | null
|
|
417
454
|
}
|
|
418
455
|
|
|
419
456
|
type PendingReferenceScale = {
|
|
@@ -433,9 +470,11 @@ type GuideHandleHintAnchor = {
|
|
|
433
470
|
function FloorplanCompassButton({
|
|
434
471
|
northRotationDeg,
|
|
435
472
|
onAlignNorth,
|
|
473
|
+
needleRef,
|
|
436
474
|
}: {
|
|
437
475
|
northRotationDeg: number
|
|
438
476
|
onAlignNorth: () => void
|
|
477
|
+
needleRef?: React.RefObject<SVGSVGElement | null>
|
|
439
478
|
}) {
|
|
440
479
|
return (
|
|
441
480
|
<Tooltip>
|
|
@@ -456,7 +495,8 @@ function FloorplanCompassButton({
|
|
|
456
495
|
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
|
|
457
496
|
<svg
|
|
458
497
|
aria-hidden="true"
|
|
459
|
-
className="h-6 w-6
|
|
498
|
+
className="h-6 w-6"
|
|
499
|
+
ref={needleRef}
|
|
460
500
|
style={{ transform: `rotate(${northRotationDeg}deg)` }}
|
|
461
501
|
viewBox="0 0 48 48"
|
|
462
502
|
>
|
|
@@ -866,13 +906,47 @@ function getElevatorResizeSign(handle: ElevatorResizeHandle) {
|
|
|
866
906
|
return handle.endsWith('positive') ? 1 : -1
|
|
867
907
|
}
|
|
868
908
|
|
|
869
|
-
function getSelectionModifierKeys(event?: {
|
|
909
|
+
function getSelectionModifierKeys(event?: {
|
|
910
|
+
metaKey?: boolean
|
|
911
|
+
ctrlKey?: boolean
|
|
912
|
+
shiftKey?: boolean
|
|
913
|
+
}) {
|
|
870
914
|
return {
|
|
871
915
|
meta: Boolean(event?.metaKey),
|
|
872
916
|
ctrl: Boolean(event?.ctrlKey),
|
|
917
|
+
shift: Boolean(event?.shiftKey),
|
|
873
918
|
}
|
|
874
919
|
}
|
|
875
920
|
|
|
921
|
+
const isMarqueeVec2 = (v: unknown): v is [number, number] =>
|
|
922
|
+
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
|
|
923
|
+
const isMarqueeVec2Array = (v: unknown): v is [number, number][] =>
|
|
924
|
+
Array.isArray(v) && v.length > 0 && v.every(isMarqueeVec2)
|
|
925
|
+
|
|
926
|
+
/** The screen marquee mapped into plan coordinates through the scene CTM —
|
|
927
|
+
* a quad (rotated views give a rotated quad, so tests stay exact). */
|
|
928
|
+
function screenRectToPlanQuad(rect: ScreenRect, scene: SVGGElement): MarqueePoint2[] | null {
|
|
929
|
+
const svg = scene.ownerSVGElement
|
|
930
|
+
const ctm = scene.getScreenCTM()
|
|
931
|
+
if (!(svg && ctm)) return null
|
|
932
|
+
const inverse = ctm.inverse()
|
|
933
|
+
const corners: [number, number][] = [
|
|
934
|
+
[rect.minX, rect.minY],
|
|
935
|
+
[rect.maxX, rect.minY],
|
|
936
|
+
[rect.maxX, rect.maxY],
|
|
937
|
+
[rect.minX, rect.maxY],
|
|
938
|
+
]
|
|
939
|
+
const quad: MarqueePoint2[] = []
|
|
940
|
+
for (const [x, y] of corners) {
|
|
941
|
+
const pt = svg.createSVGPoint()
|
|
942
|
+
pt.x = x
|
|
943
|
+
pt.y = y
|
|
944
|
+
const plan = pt.matrixTransform(inverse)
|
|
945
|
+
quad.push([plan.x, plan.y])
|
|
946
|
+
}
|
|
947
|
+
return quad
|
|
948
|
+
}
|
|
949
|
+
|
|
876
950
|
function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement): string[] {
|
|
877
951
|
const scene = svg.querySelector<SVGGElement>('[data-floorplan-scene]')
|
|
878
952
|
if (!scene) {
|
|
@@ -884,7 +958,32 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
|
|
|
884
958
|
return []
|
|
885
959
|
}
|
|
886
960
|
|
|
887
|
-
|
|
961
|
+
// Plan-footprint membership for the data kinds — walls/fences by their
|
|
962
|
+
// segment, slab/ceiling/zone by their polygon — exact under rotated
|
|
963
|
+
// geometry AND rotated views. The DOM-rect fallback below is an
|
|
964
|
+
// axis-aligned screen AABB, which inflates around anything diagonal.
|
|
965
|
+
const planQuad = screenRectToPlanQuad(rect, scene)
|
|
966
|
+
const sceneNodes = useScene.getState().nodes
|
|
967
|
+
const dataTested = new Set<string>()
|
|
968
|
+
const hitIdsFromData = new Set<string>()
|
|
969
|
+
if (planQuad) {
|
|
970
|
+
for (const id of candidateIds) {
|
|
971
|
+
const node = sceneNodes[id as AnyNodeId] as
|
|
972
|
+
| { start?: unknown; end?: unknown; polygon?: unknown }
|
|
973
|
+
| undefined
|
|
974
|
+
if (!node) continue
|
|
975
|
+
const { start, end, polygon } = node
|
|
976
|
+
if (isMarqueeVec2(start) && isMarqueeVec2(end)) {
|
|
977
|
+
dataTested.add(id)
|
|
978
|
+
if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id)
|
|
979
|
+
} else if (isMarqueeVec2Array(polygon)) {
|
|
980
|
+
dataTested.add(id)
|
|
981
|
+
if (marqueePolygonsIntersect(polygon, planQuad)) hitIdsFromData.add(id)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const candidateIdSet = new Set(candidateIds.filter((id) => !dataTested.has(id)))
|
|
888
987
|
const hitIds = new Set<string>()
|
|
889
988
|
const baseElementsById = new Map<string, SVGGraphicsElement[]>()
|
|
890
989
|
const fallbackElementsById = new Map<string, SVGGraphicsElement[]>()
|
|
@@ -905,7 +1004,7 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
|
|
|
905
1004
|
}
|
|
906
1005
|
}
|
|
907
1006
|
|
|
908
|
-
for (const id of
|
|
1007
|
+
for (const id of candidateIdSet) {
|
|
909
1008
|
const elements = baseElementsById.get(id) ?? fallbackElementsById.get(id) ?? []
|
|
910
1009
|
for (const element of elements) {
|
|
911
1010
|
const elementRect = element.getBoundingClientRect()
|
|
@@ -920,7 +1019,7 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
|
|
|
920
1019
|
}
|
|
921
1020
|
}
|
|
922
1021
|
|
|
923
|
-
return candidateIds.filter((id) => hitIds.has(id))
|
|
1022
|
+
return candidateIds.filter((id) => hitIds.has(id) || hitIdsFromData.has(id))
|
|
924
1023
|
}
|
|
925
1024
|
|
|
926
1025
|
function swallowNextFloorplanScreenSelectionClick() {
|
|
@@ -1096,9 +1195,28 @@ function getResizeCursorForAngle(angle: number) {
|
|
|
1096
1195
|
return 'nesw-resize'
|
|
1097
1196
|
}
|
|
1098
1197
|
|
|
1099
|
-
function
|
|
1198
|
+
function getGuideResizeCursorAngle(corner: GuideCorner, aspectRatio: number, rotationSvg: number) {
|
|
1100
1199
|
const signs = guideCornerSigns[corner]
|
|
1101
|
-
|
|
1200
|
+
// Screen-space direction from the guide center toward the dragged corner:
|
|
1201
|
+
// the corner diagonal depends on the image aspect, not a fixed 45°.
|
|
1202
|
+
return Math.atan2(signs.y, signs.x * aspectRatio) + rotationSvg
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function getGuideResizeCursor(angle: number, isDarkMode: boolean) {
|
|
1206
|
+
const strokeColor = isDarkMode ? '#ffffff' : '#09090b'
|
|
1207
|
+
const outlineColor = isDarkMode ? '#0a0e1b' : '#ffffff'
|
|
1208
|
+
const degrees = Math.round((angle * 180) / Math.PI)
|
|
1209
|
+
const arrowPath = 'M5 12h14M8.5 8.5 5 12l3.5 3.5M15.5 8.5 19 12l-3.5 3.5'
|
|
1210
|
+
const svgMarkup = `
|
|
1211
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
1212
|
+
<g transform="rotate(${degrees} 12 12)">
|
|
1213
|
+
<path d="${arrowPath}" stroke="${outlineColor}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1214
|
+
<path d="${arrowPath}" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1215
|
+
</g>
|
|
1216
|
+
</svg>
|
|
1217
|
+
`.trim()
|
|
1218
|
+
|
|
1219
|
+
return buildCursorUrl(svgMarkup, 12, 12, getResizeCursorForAngle(angle))
|
|
1102
1220
|
}
|
|
1103
1221
|
|
|
1104
1222
|
function buildCursorUrl(svgMarkup: string, hotspotX: number, hotspotY: number, fallback: string) {
|
|
@@ -1274,7 +1392,7 @@ function buildGuideResizeDraft(
|
|
|
1274
1392
|
function buildGuideRotationDraft(
|
|
1275
1393
|
interaction: GuideInteractionState,
|
|
1276
1394
|
pointerSvg: SvgPoint,
|
|
1277
|
-
|
|
1395
|
+
bypassSnap: boolean,
|
|
1278
1396
|
): GuideTransformDraft {
|
|
1279
1397
|
const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg)
|
|
1280
1398
|
|
|
@@ -1289,12 +1407,9 @@ function buildGuideRotationDraft(
|
|
|
1289
1407
|
|
|
1290
1408
|
const rawRotationSvg =
|
|
1291
1409
|
Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle
|
|
1292
|
-
const snappedRotationSvg =
|
|
1293
|
-
rawRotationSvg
|
|
1294
|
-
|
|
1295
|
-
? FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES
|
|
1296
|
-
: FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES,
|
|
1297
|
-
)
|
|
1410
|
+
const snappedRotationSvg = bypassSnap
|
|
1411
|
+
? rawRotationSvg
|
|
1412
|
+
: snapAngleToIncrement(rawRotationSvg, FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES)
|
|
1298
1413
|
|
|
1299
1414
|
return {
|
|
1300
1415
|
guideId: interaction.guideId,
|
|
@@ -1304,6 +1419,44 @@ function buildGuideRotationDraft(
|
|
|
1304
1419
|
}
|
|
1305
1420
|
}
|
|
1306
1421
|
|
|
1422
|
+
/** Live rotation readout for a guide rotate drag — feeds the registry
|
|
1423
|
+
* layer's wedge + degree chip so guides read the same as every other
|
|
1424
|
+
* rotate affordance. Sweeps from the grabbed corner's bearing at grab to
|
|
1425
|
+
* its current (snapped) bearing; suppressed below ~0.5° so a fresh grab
|
|
1426
|
+
* doesn't flash a zero-width sliver. */
|
|
1427
|
+
function buildGuideRotationReadout(
|
|
1428
|
+
interaction: GuideInteractionState | null,
|
|
1429
|
+
draft: GuideTransformDraft | null,
|
|
1430
|
+
) {
|
|
1431
|
+
if (
|
|
1432
|
+
!(
|
|
1433
|
+
interaction &&
|
|
1434
|
+
draft &&
|
|
1435
|
+
interaction.mode === 'rotate' &&
|
|
1436
|
+
draft.guideId === interaction.guideId
|
|
1437
|
+
)
|
|
1438
|
+
) {
|
|
1439
|
+
return null
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const delta = normalizeAngle(getGuideSvgRotation(draft.rotation) - interaction.rotationSvg)
|
|
1443
|
+
if (Math.abs(delta) < 0.0087) {
|
|
1444
|
+
return null
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const width = getGuideWidth(interaction.scale)
|
|
1448
|
+
const height = getGuideHeight(width, interaction.aspectRatio)
|
|
1449
|
+
const startAngle = interaction.rotationSvg + interaction.cornerBaseAngle
|
|
1450
|
+
|
|
1451
|
+
return {
|
|
1452
|
+
pivot: [interaction.centerSvg.x, interaction.centerSvg.y] as const,
|
|
1453
|
+
startAngle,
|
|
1454
|
+
endAngle: startAngle + delta,
|
|
1455
|
+
radius: Math.hypot(width, height) / 2,
|
|
1456
|
+
sweep: Math.abs(delta),
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1307
1460
|
function toSvgSelectionBounds(bounds: FloorplanSelectionBounds) {
|
|
1308
1461
|
return {
|
|
1309
1462
|
x: toSvgX(bounds.maxX),
|
|
@@ -1784,39 +1937,6 @@ function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
|
|
|
1784
1937
|
return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
|
|
1785
1938
|
}
|
|
1786
1939
|
|
|
1787
|
-
function floorplanLocalToWorldPoint(
|
|
1788
|
-
point: SvgPoint | WallPlanPoint,
|
|
1789
|
-
buildingPosition: readonly [number, number, number],
|
|
1790
|
-
buildingRotationY: number,
|
|
1791
|
-
): { x: number; z: number } {
|
|
1792
|
-
const localX = Array.isArray(point) ? point[0] : point.x
|
|
1793
|
-
const localY = Array.isArray(point) ? point[1] : point.y
|
|
1794
|
-
const cos = Math.cos(buildingRotationY)
|
|
1795
|
-
const sin = Math.sin(buildingRotationY)
|
|
1796
|
-
|
|
1797
|
-
return {
|
|
1798
|
-
x: buildingPosition[0] + localX * cos + localY * sin,
|
|
1799
|
-
z: buildingPosition[2] - localX * sin + localY * cos,
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
function worldToFloorplanLocalPoint(
|
|
1804
|
-
worldX: number,
|
|
1805
|
-
worldZ: number,
|
|
1806
|
-
buildingPosition: readonly [number, number, number],
|
|
1807
|
-
buildingRotationY: number,
|
|
1808
|
-
): SvgPoint {
|
|
1809
|
-
const dx = worldX - buildingPosition[0]
|
|
1810
|
-
const dz = worldZ - buildingPosition[2]
|
|
1811
|
-
const cos = Math.cos(buildingRotationY)
|
|
1812
|
-
const sin = Math.sin(buildingRotationY)
|
|
1813
|
-
|
|
1814
|
-
return {
|
|
1815
|
-
x: dx * cos - dz * sin,
|
|
1816
|
-
y: dx * sin + dz * cos,
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
1940
|
function projectSvgPointToSurface(
|
|
1821
1941
|
svgPoint: SvgPoint,
|
|
1822
1942
|
viewBox: { minX: number; minY: number; width: number; height: number },
|
|
@@ -2171,33 +2291,6 @@ function isPointNearPlanPoint(a: WallPlanPoint, b: WallPlanPoint, threshold = 0.
|
|
|
2171
2291
|
return Math.abs(a[0] - b[0]) < threshold && Math.abs(a[1] - b[1]) < threshold
|
|
2172
2292
|
}
|
|
2173
2293
|
|
|
2174
|
-
function calculatePolygonSnapPoint(
|
|
2175
|
-
lastPoint: WallPlanPoint,
|
|
2176
|
-
currentPoint: WallPlanPoint,
|
|
2177
|
-
): WallPlanPoint {
|
|
2178
|
-
const [x1, y1] = lastPoint
|
|
2179
|
-
const [x, y] = currentPoint
|
|
2180
|
-
const dx = x - x1
|
|
2181
|
-
const dy = y - y1
|
|
2182
|
-
const absDx = Math.abs(dx)
|
|
2183
|
-
const absDy = Math.abs(dy)
|
|
2184
|
-
const horizontalDist = absDy
|
|
2185
|
-
const verticalDist = absDx
|
|
2186
|
-
const diagonalDist = Math.abs(absDx - absDy)
|
|
2187
|
-
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
|
2188
|
-
|
|
2189
|
-
if (minDist === diagonalDist) {
|
|
2190
|
-
const diagonalLength = Math.min(absDx, absDy)
|
|
2191
|
-
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
|
2192
|
-
}
|
|
2193
|
-
|
|
2194
|
-
if (minDist === horizontalDist) {
|
|
2195
|
-
return [x, y1]
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
return [x1, y]
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
2294
|
function snapPolygonDraftPoint({
|
|
2202
2295
|
point,
|
|
2203
2296
|
start,
|
|
@@ -2207,13 +2300,19 @@ function snapPolygonDraftPoint({
|
|
|
2207
2300
|
start?: WallPlanPoint
|
|
2208
2301
|
angleSnap: boolean
|
|
2209
2302
|
}): WallPlanPoint {
|
|
2210
|
-
|
|
2211
|
-
|
|
2303
|
+
// `snapToHalf`'s default step is 0 in any non-`grid` mode, so the grid branch
|
|
2304
|
+
// passes the raw point through for `lines` / `off` (where wall-snap /
|
|
2305
|
+
// alignment, run by the caller, takes over) — no explicit bypass needed.
|
|
2212
2306
|
if (!(start && angleSnap)) {
|
|
2213
|
-
return
|
|
2307
|
+
return [snapToHalf(point[0]), snapToHalf(point[1])]
|
|
2214
2308
|
}
|
|
2215
2309
|
|
|
2216
|
-
|
|
2310
|
+
// 15° angle snap from the raw point, with the distance snapped along the
|
|
2311
|
+
// ray to the grid step — grid-snapping the point itself would pull the
|
|
2312
|
+
// vertex off non-axis rays (and matches the 3D slab / ceiling tools).
|
|
2313
|
+
return [
|
|
2314
|
+
...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, useEditor.getState().gridSnapStep),
|
|
2315
|
+
]
|
|
2217
2316
|
}
|
|
2218
2317
|
|
|
2219
2318
|
function pointMatchesWallPlanPoint(
|
|
@@ -2489,6 +2588,13 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
|
|
|
2489
2588
|
return a[0] === b[0] && a[1] === b[1]
|
|
2490
2589
|
}
|
|
2491
2590
|
|
|
2591
|
+
function isWithinWallJoinSnapRadius(point: WallPlanPoint, firstVertex: WallPlanPoint): boolean {
|
|
2592
|
+
const dx = point[0] - firstVertex[0]
|
|
2593
|
+
const dz = point[1] - firstVertex[1]
|
|
2594
|
+
|
|
2595
|
+
return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2492
2598
|
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
|
|
2493
2599
|
return (
|
|
2494
2600
|
currentIds.length === nextIds.length &&
|
|
@@ -2614,14 +2720,7 @@ function formatMeasurement(
|
|
|
2614
2720
|
metersPerUnit: number | null = null,
|
|
2615
2721
|
) {
|
|
2616
2722
|
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
|
|
2617
|
-
|
|
2618
|
-
const feet = measuredValue * 3.280_84
|
|
2619
|
-
const wholeFeet = Math.floor(feet)
|
|
2620
|
-
const inches = Math.round((feet - wholeFeet) * 12)
|
|
2621
|
-
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
2622
|
-
return `${wholeFeet}'${inches}"`
|
|
2623
|
-
}
|
|
2624
|
-
return `${Number.parseFloat(measuredValue.toFixed(2))}m`
|
|
2723
|
+
return formatLinearMeasurement(measuredValue, unit)
|
|
2625
2724
|
}
|
|
2626
2725
|
|
|
2627
2726
|
function formatNumber(value: number, fractionDigits = 2) {
|
|
@@ -2633,7 +2732,7 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
|
|
|
2633
2732
|
case 'centimeters':
|
|
2634
2733
|
return value / 100
|
|
2635
2734
|
case 'feet':
|
|
2636
|
-
return value
|
|
2735
|
+
return linearUnitToMeters(value, 'imperial')
|
|
2637
2736
|
case 'inches':
|
|
2638
2737
|
return value * 0.0254
|
|
2639
2738
|
default:
|
|
@@ -2641,6 +2740,39 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
|
|
|
2641
2740
|
}
|
|
2642
2741
|
}
|
|
2643
2742
|
|
|
2743
|
+
const REFERENCE_SCALE_LINGO_UNIT: Record<ReferenceScaleUnit, 'm' | 'cm' | 'ft' | 'in'> = {
|
|
2744
|
+
meters: 'm',
|
|
2745
|
+
centimeters: 'cm',
|
|
2746
|
+
feet: 'ft',
|
|
2747
|
+
inches: 'in',
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
/** Lingo-parse the free-text real-length input in the dropdown's unit — a
|
|
2751
|
+
* bare number means the dropdown unit, while `180cm`, `1m80` or `5'11"`
|
|
2752
|
+
* override it. Returns `null` when the text isn't a readable length. */
|
|
2753
|
+
function parseReferenceScaleLength(raw: string, unit: ReferenceScaleUnit): number | null {
|
|
2754
|
+
const unitId = REFERENCE_SCALE_LINGO_UNIT[unit]
|
|
2755
|
+
return parseMeasurement(
|
|
2756
|
+
raw,
|
|
2757
|
+
{ kind: 'length', unitId },
|
|
2758
|
+
{ bareUnit: unitId, system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric' },
|
|
2759
|
+
)
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
function referenceScaleLengthHint(raw: string, unit: ReferenceScaleUnit): string | null {
|
|
2763
|
+
const unitId = REFERENCE_SCALE_LINGO_UNIT[unit]
|
|
2764
|
+
return measurementHint(
|
|
2765
|
+
raw,
|
|
2766
|
+
{ kind: 'length', unitId },
|
|
2767
|
+
{
|
|
2768
|
+
bareUnit: unitId,
|
|
2769
|
+
system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric',
|
|
2770
|
+
displayUnit: unitId,
|
|
2771
|
+
precision: 2,
|
|
2772
|
+
},
|
|
2773
|
+
)
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2644
2776
|
function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) {
|
|
2645
2777
|
switch (unit) {
|
|
2646
2778
|
case 'centimeters':
|
|
@@ -3049,6 +3181,7 @@ function useGuideImageDimensions(url: string | null) {
|
|
|
3049
3181
|
function FloorplanGuideImage({
|
|
3050
3182
|
guide,
|
|
3051
3183
|
isInteractive,
|
|
3184
|
+
isLocked,
|
|
3052
3185
|
isSelected,
|
|
3053
3186
|
activeInteractionMode,
|
|
3054
3187
|
onGuideSelect,
|
|
@@ -3056,6 +3189,10 @@ function FloorplanGuideImage({
|
|
|
3056
3189
|
}: {
|
|
3057
3190
|
guide: GuideNode
|
|
3058
3191
|
isInteractive: boolean
|
|
3192
|
+
// Locked guides stay CLICKABLE (select → panel → unlock / edit scale) but
|
|
3193
|
+
// never start a translate drag. Removing the hit rect entirely made a
|
|
3194
|
+
// scale-calibrated (auto-locked) reference unselectable until reload.
|
|
3195
|
+
isLocked: boolean
|
|
3059
3196
|
isSelected: boolean
|
|
3060
3197
|
activeInteractionMode: GuideInteractionMode | null
|
|
3061
3198
|
onGuideSelect: (guideId: GuideNode['id']) => void
|
|
@@ -3090,16 +3227,26 @@ function FloorplanGuideImage({
|
|
|
3090
3227
|
}}
|
|
3091
3228
|
onPointerDown={(event) => {
|
|
3092
3229
|
if (event.button === 0) {
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3230
|
+
// Only a selected, unlocked guide consumes the pointer-down (it
|
|
3231
|
+
// starts a translate drag). Every other guide lets it bubble to
|
|
3232
|
+
// the <svg> root so box select arms exactly as on empty canvas.
|
|
3233
|
+
// A non-drag release still fires onClick (a committed box-select
|
|
3234
|
+
// drag swallows the trailing click), so click-to-select → panel
|
|
3235
|
+
// keeps working for locked and unselected guides alike.
|
|
3236
|
+
if (isLocked || !isSelected) {
|
|
3237
|
+
return
|
|
3096
3238
|
}
|
|
3239
|
+
event.stopPropagation()
|
|
3240
|
+
onGuideTranslateStart(guide, event)
|
|
3097
3241
|
}
|
|
3098
3242
|
}}
|
|
3099
3243
|
pointerEvents="all"
|
|
3100
3244
|
style={{
|
|
3101
|
-
cursor:
|
|
3102
|
-
isSelected
|
|
3245
|
+
cursor: isLocked
|
|
3246
|
+
? isSelected
|
|
3247
|
+
? 'default'
|
|
3248
|
+
: 'pointer'
|
|
3249
|
+
: isSelected && activeInteractionMode === 'translate'
|
|
3103
3250
|
? 'grabbing'
|
|
3104
3251
|
: isSelected
|
|
3105
3252
|
? 'grab'
|
|
@@ -3303,7 +3450,8 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({
|
|
|
3303
3450
|
activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null
|
|
3304
3451
|
}
|
|
3305
3452
|
guide={guide}
|
|
3306
|
-
isInteractive={isInteractive
|
|
3453
|
+
isInteractive={isInteractive}
|
|
3454
|
+
isLocked={guideUi[guide.id]?.locked === true}
|
|
3307
3455
|
isSelected={selectedGuideId === guide.id}
|
|
3308
3456
|
key={guide.id}
|
|
3309
3457
|
onGuideSelect={onGuideSelect}
|
|
@@ -3437,16 +3585,11 @@ function FloorplanReferenceScaleLayer({
|
|
|
3437
3585
|
unitsPerPixel={unitsPerPixel}
|
|
3438
3586
|
/>
|
|
3439
3587
|
))}
|
|
3440
|
-
{draft?.start &&
|
|
3441
|
-
<
|
|
3442
|
-
end={draft.cursor}
|
|
3443
|
-
isDraft
|
|
3444
|
-
label={`Ref ${formatMeasurement(
|
|
3445
|
-
Math.hypot(draft.cursor[0] - draft.start[0], draft.cursor[1] - draft.start[1]),
|
|
3446
|
-
unit,
|
|
3447
|
-
)}`}
|
|
3588
|
+
{draft?.start && (
|
|
3589
|
+
<FloorplanReferenceScaleDraftLine
|
|
3448
3590
|
palette={palette}
|
|
3449
3591
|
start={draft.start}
|
|
3592
|
+
unit={unit}
|
|
3450
3593
|
unitsPerPixel={unitsPerPixel}
|
|
3451
3594
|
/>
|
|
3452
3595
|
)}
|
|
@@ -3454,10 +3597,46 @@ function FloorplanReferenceScaleLayer({
|
|
|
3454
3597
|
)
|
|
3455
3598
|
}
|
|
3456
3599
|
|
|
3600
|
+
// The live reference-scale rubber-band — split out of the layer so it can
|
|
3601
|
+
// subscribe to the shared cursor store for its moving END. A per-move cursor
|
|
3602
|
+
// update re-renders ONLY this line, never FloorplanPanel; the START anchor +
|
|
3603
|
+
// render config arrive as props (set per click, never per move).
|
|
3604
|
+
function FloorplanReferenceScaleDraftLine({
|
|
3605
|
+
palette,
|
|
3606
|
+
start,
|
|
3607
|
+
unit,
|
|
3608
|
+
unitsPerPixel,
|
|
3609
|
+
}: {
|
|
3610
|
+
palette: FloorplanPalette
|
|
3611
|
+
start: WallPlanPoint
|
|
3612
|
+
unit: 'metric' | 'imperial'
|
|
3613
|
+
unitsPerPixel: number
|
|
3614
|
+
}) {
|
|
3615
|
+
const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
|
|
3616
|
+
if (!cursor) {
|
|
3617
|
+
return null
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
return (
|
|
3621
|
+
<FloorplanReferenceScaleLine
|
|
3622
|
+
end={cursor}
|
|
3623
|
+
isDraft
|
|
3624
|
+
label={`Ref ${formatMeasurement(
|
|
3625
|
+
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
|
|
3626
|
+
unit,
|
|
3627
|
+
)}`}
|
|
3628
|
+
palette={palette}
|
|
3629
|
+
start={start}
|
|
3630
|
+
unitsPerPixel={unitsPerPixel}
|
|
3631
|
+
/>
|
|
3632
|
+
)
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3457
3635
|
function FloorplanGuideSelectionOverlay({
|
|
3458
3636
|
guide,
|
|
3459
3637
|
isDarkMode,
|
|
3460
3638
|
rotationModifierPressed,
|
|
3639
|
+
sceneRotationDeg,
|
|
3461
3640
|
showHandles,
|
|
3462
3641
|
onCornerHoverChange,
|
|
3463
3642
|
onCornerPointerDown,
|
|
@@ -3465,6 +3644,7 @@ function FloorplanGuideSelectionOverlay({
|
|
|
3465
3644
|
guide: GuideNode | null
|
|
3466
3645
|
isDarkMode: boolean
|
|
3467
3646
|
rotationModifierPressed: boolean
|
|
3647
|
+
sceneRotationDeg: number
|
|
3468
3648
|
showHandles: boolean
|
|
3469
3649
|
onCornerHoverChange: (corner: GuideCorner | null) => void
|
|
3470
3650
|
onCornerPointerDown: (
|
|
@@ -3544,7 +3724,18 @@ function FloorplanGuideSelectionOverlay({
|
|
|
3544
3724
|
style={{
|
|
3545
3725
|
cursor: rotationModifierPressed
|
|
3546
3726
|
? getGuideRotateCursor(isDarkMode)
|
|
3547
|
-
: getGuideResizeCursor(
|
|
3727
|
+
: getGuideResizeCursor(
|
|
3728
|
+
getGuideResizeCursorAngle(
|
|
3729
|
+
corner,
|
|
3730
|
+
planWidth / planHeight,
|
|
3731
|
+
// The overlay renders inside the scene <g>, so the
|
|
3732
|
+
// on-screen corner direction carries the view
|
|
3733
|
+
// rotation on top of the guide's own rotation.
|
|
3734
|
+
getGuideSvgRotation(guide.rotation[1]) +
|
|
3735
|
+
(sceneRotationDeg * Math.PI) / 180,
|
|
3736
|
+
),
|
|
3737
|
+
isDarkMode,
|
|
3738
|
+
),
|
|
3548
3739
|
}}
|
|
3549
3740
|
vectorEffect="non-scaling-stroke"
|
|
3550
3741
|
/>
|
|
@@ -3561,11 +3752,13 @@ function FloorplanGuideHandleHint({
|
|
|
3561
3752
|
isDarkMode,
|
|
3562
3753
|
isMacPlatform,
|
|
3563
3754
|
rotationModifierPressed,
|
|
3755
|
+
showScaleHint,
|
|
3564
3756
|
}: {
|
|
3565
3757
|
anchor: GuideHandleHintAnchor | null
|
|
3566
3758
|
isDarkMode: boolean
|
|
3567
3759
|
isMacPlatform: boolean
|
|
3568
3760
|
rotationModifierPressed: boolean
|
|
3761
|
+
showScaleHint: boolean
|
|
3569
3762
|
}) {
|
|
3570
3763
|
if (!anchor) {
|
|
3571
3764
|
return null
|
|
@@ -3620,6 +3813,14 @@ function FloorplanGuideHandleHint({
|
|
|
3620
3813
|
icon="ph:mouse-left-click-fill"
|
|
3621
3814
|
/>
|
|
3622
3815
|
</div>
|
|
3816
|
+
|
|
3817
|
+
{showScaleHint && (
|
|
3818
|
+
<div className="flex items-center gap-1.5 opacity-40">
|
|
3819
|
+
<span className="font-medium text-[11px] lowercase leading-none">set scale</span>
|
|
3820
|
+
<Ruler aria-hidden="true" className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
|
|
3821
|
+
<span className="font-medium text-[11px] lowercase leading-none">panel</span>
|
|
3822
|
+
</div>
|
|
3823
|
+
)}
|
|
3623
3824
|
</div>
|
|
3624
3825
|
</div>
|
|
3625
3826
|
)
|
|
@@ -3732,10 +3933,12 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
|
|
|
3732
3933
|
})
|
|
3733
3934
|
|
|
3734
3935
|
const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
|
|
3936
|
+
dimmed,
|
|
3735
3937
|
isHighlighted,
|
|
3736
3938
|
palette,
|
|
3737
3939
|
sitePolygon,
|
|
3738
3940
|
}: {
|
|
3941
|
+
dimmed: boolean
|
|
3739
3942
|
isHighlighted: boolean
|
|
3740
3943
|
palette: FloorplanPalette
|
|
3741
3944
|
sitePolygon: SitePolygonEntry | null
|
|
@@ -3752,7 +3955,9 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
|
|
|
3752
3955
|
const dashPattern = `${dashLength} ${gapLength}`
|
|
3753
3956
|
|
|
3754
3957
|
return (
|
|
3755
|
-
|
|
3958
|
+
// The dashed property line reads like the dashed group selection box —
|
|
3959
|
+
// step it back while a multi (or in-flight marquee) selection exists.
|
|
3960
|
+
<g data-site-boundary opacity={dimmed ? 0.2 : undefined}>
|
|
3756
3961
|
<polygon
|
|
3757
3962
|
fill="none"
|
|
3758
3963
|
pointerEvents="none"
|
|
@@ -3777,7 +3982,7 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
|
|
|
3777
3982
|
strokeWidth={strokeWidth}
|
|
3778
3983
|
vectorEffect="non-scaling-stroke"
|
|
3779
3984
|
/>
|
|
3780
|
-
|
|
3985
|
+
</g>
|
|
3781
3986
|
)
|
|
3782
3987
|
})
|
|
3783
3988
|
|
|
@@ -4525,174 +4730,708 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
|
|
4525
4730
|
)
|
|
4526
4731
|
})
|
|
4527
4732
|
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
const panelInteractionRef = useRef<PanelInteractionState | null>(null)
|
|
4545
|
-
const panelBoundsRef = useRef<ViewportBounds | null>(null)
|
|
4546
|
-
const containerRef = useRef<HTMLDivElement>(null)
|
|
4547
|
-
const hasUserAdjustedViewportRef = useRef(false)
|
|
4548
|
-
const previousLevelIdRef = useRef<string | null>(null)
|
|
4549
|
-
const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
|
|
4550
|
-
const floorplanScreenSelectionRef = useRef<FloorplanScreenSelectionState | null>(null)
|
|
4551
|
-
const floorplanScreenSelectionElementRef = useRef<HTMLDivElement | null>(null)
|
|
4552
|
-
const floorplanScreenSelectionOwnsInputDraggingRef = useRef(false)
|
|
4553
|
-
const latestFloorplanUserRotationDegRef = useRef(0)
|
|
4554
|
-
const latestViewportRef = useRef<FloorplanViewport | null>(null)
|
|
4555
|
-
const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
|
|
4556
|
-
const floorplanViewAnimationFrameRef = useRef<number | null>(null)
|
|
4557
|
-
const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
|
|
4558
|
-
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
|
|
4559
|
-
useEditor.getState().navigationSyncPose,
|
|
4560
|
-
)
|
|
4561
|
-
const levelId = useViewer((state) => state.selection.levelId)
|
|
4562
|
-
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
4563
|
-
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
4564
|
-
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
4565
|
-
const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
|
|
4566
|
-
const setSelection = useViewer((state) => state.setSelection)
|
|
4567
|
-
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
|
4568
|
-
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
4569
|
-
const unit = useViewer((state) => state.unit)
|
|
4570
|
-
const showGrid = useViewer((state) => state.showGrid)
|
|
4571
|
-
const showGuides = useViewer((state) => state.showGuides)
|
|
4572
|
-
const setShowGuides = useViewer((state) => state.setShowGuides)
|
|
4573
|
-
const selectedItem = useEditor((state) => state.selectedItem)
|
|
4733
|
+
// Static segment for the in-flight stair build preview. No per-render
|
|
4734
|
+
// dependency (the geometry only moves / rotates), so it lives at module scope
|
|
4735
|
+
// instead of a `useMemo`.
|
|
4736
|
+
const FLOORPLAN_PREVIEW_STAIR_SEGMENT = StairSegmentNodeSchema.parse({
|
|
4737
|
+
id: 'sseg_floorplan_preview',
|
|
4738
|
+
segmentType: 'stair',
|
|
4739
|
+
width: DEFAULT_STAIR_WIDTH,
|
|
4740
|
+
length: DEFAULT_STAIR_LENGTH,
|
|
4741
|
+
height: DEFAULT_STAIR_HEIGHT,
|
|
4742
|
+
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
4743
|
+
attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
|
|
4744
|
+
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
4745
|
+
thickness: DEFAULT_STAIR_THICKNESS,
|
|
4746
|
+
position: [0, 0, 0],
|
|
4747
|
+
metadata: { isTransient: true, isFloorplanPreview: true },
|
|
4748
|
+
})
|
|
4574
4749
|
|
|
4575
|
-
|
|
4576
|
-
// Panel is permanently mounted and toggled via `display: none` in
|
|
4577
|
-
// editor/index.tsx — subscribing here lets us re-fit the viewport when
|
|
4578
|
-
// the user closes and re-opens the 2D editor instead of restoring the
|
|
4579
|
-
// stale viewport from before they closed it.
|
|
4580
|
-
const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
|
|
4581
|
-
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
4582
|
-
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
4583
|
-
const setMode = useEditor((state) => state.setMode)
|
|
4584
|
-
const movingNode = useEditor((state) => state.movingNode)
|
|
4585
|
-
const curvingWall = useEditor((state) => state.curvingWall)
|
|
4586
|
-
const curvingFence = useEditor((state) => state.curvingFence)
|
|
4587
|
-
const phase = useEditor((state) => state.phase)
|
|
4588
|
-
const mode = useEditor((state) => state.mode)
|
|
4589
|
-
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
|
4590
|
-
const setPhase = useEditor((state) => state.setPhase)
|
|
4591
|
-
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
|
4592
|
-
const setMovingNode = useEditor((state) => state.setMovingNode)
|
|
4593
|
-
const setCurvingWall = useEditor((state) => state.setCurvingWall)
|
|
4594
|
-
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
|
4595
|
-
const structureLayer = useEditor((state) => state.structureLayer)
|
|
4596
|
-
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
|
4597
|
-
const setTool = useEditor((state) => state.setTool)
|
|
4598
|
-
const tool = useEditor((state) => state.tool)
|
|
4599
|
-
const editingHole = useEditor((state) => state.editingHole)
|
|
4600
|
-
const setEditingHole = useEditor((state) => state.setEditingHole)
|
|
4601
|
-
const deleteNode = useScene((state) => state.deleteNode)
|
|
4602
|
-
const updateNode = useScene((state) => state.updateNode)
|
|
4603
|
-
const {
|
|
4604
|
-
buildingPosition,
|
|
4605
|
-
buildingRotationY,
|
|
4606
|
-
ceilings,
|
|
4607
|
-
currentBuildingId,
|
|
4608
|
-
fences,
|
|
4609
|
-
floorplanLevels,
|
|
4610
|
-
levelDescendantNodes,
|
|
4611
|
-
levelGuides,
|
|
4612
|
-
levelNode,
|
|
4613
|
-
openings,
|
|
4614
|
-
roofs,
|
|
4615
|
-
site,
|
|
4616
|
-
slabs,
|
|
4617
|
-
spawns,
|
|
4618
|
-
walls,
|
|
4619
|
-
zones,
|
|
4620
|
-
} = useFloorplanSceneData({ buildingId, levelId })
|
|
4621
|
-
// When only a building is selected (or we're mid-drag on a building),
|
|
4622
|
-
// the FloorplanRegistryLayer falls back to that building's level 0
|
|
4623
|
-
// (or lowest level) and renders it dimmed as context. We let the SVG
|
|
4624
|
-
// mount in that case so the dimmed-floor render path is reachable
|
|
4625
|
-
// instead of swapping in the "Switch to a building level" message.
|
|
4626
|
-
//
|
|
4627
|
-
// `currentBuildingId` covers both the user-selected building and the
|
|
4628
|
-
// building inferred from a selected level. During a building move the
|
|
4629
|
-
// `movingNode` carries the building's id even if the explicit
|
|
4630
|
-
// selection has been cleared as part of the move handoff.
|
|
4631
|
-
const movingBuildingId =
|
|
4632
|
-
useEditor((state) => {
|
|
4633
|
-
const moving = state.movingNode
|
|
4634
|
-
if (!moving) return null
|
|
4635
|
-
const def = nodeRegistry.get(moving.type)
|
|
4636
|
-
return def?.capabilities?.floorplanLevelContainer ? moving.id : null
|
|
4637
|
-
}) ?? null
|
|
4638
|
-
const ambientBuildingId = currentBuildingId ?? movingBuildingId
|
|
4639
|
-
const hasAmbientBuildingLevel = useScene((state) => {
|
|
4640
|
-
if (levelId || !ambientBuildingId) return false
|
|
4641
|
-
const building = state.nodes[ambientBuildingId]
|
|
4642
|
-
if (!building || building.type !== 'building') return false
|
|
4643
|
-
return building.children.some((cid) => state.nodes[cid]?.type === 'level')
|
|
4644
|
-
})
|
|
4645
|
-
const elevators = useScene(
|
|
4646
|
-
useShallow((state) => {
|
|
4647
|
-
const building = currentBuildingId ? state.nodes[currentBuildingId] : null
|
|
4648
|
-
if (!building || building.type !== 'building') {
|
|
4649
|
-
return [] as ElevatorNode[]
|
|
4650
|
-
}
|
|
4750
|
+
const EMPTY_FLOORPLAN_ID_SET: ReadonlySet<string> = new Set()
|
|
4651
4751
|
|
|
4652
|
-
|
|
4653
|
-
const node = state.nodes[childId]
|
|
4654
|
-
return node?.type === 'elevator' && node.visible !== false ? [node] : []
|
|
4655
|
-
})
|
|
4656
|
-
}),
|
|
4657
|
-
)
|
|
4658
|
-
const [floorplanUserRotationDeg, setFloorplanUserRotationDeg] = useState(0)
|
|
4659
|
-
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
|
4660
|
-
const floorplanSceneRotationDeg =
|
|
4661
|
-
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
|
|
4662
|
-
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
|
|
4752
|
+
type FloorplanStairLayerPalette = ComponentProps<typeof FloorplanStairLayer>['palette']
|
|
4663
4753
|
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
)
|
|
4680
|
-
const
|
|
4681
|
-
const
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
const
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4754
|
+
// Leaf layer for the stair tool's in-flight 2D build preview. Subscribes to the
|
|
4755
|
+
// `useStairBuildPreview` store directly so a per-`grid:move` point update (or an
|
|
4756
|
+
// R/T rotation) re-renders ONLY this tiny layer — never the ~120-220ms
|
|
4757
|
+
// `FloorplanPanel`. This mirrors how column / elevator placement stays smooth by
|
|
4758
|
+
// routing preview state through a store + leaf. Committed stairs render through
|
|
4759
|
+
// `FloorplanRegistryLayer`; this layer is non-interactive (noop handlers, empty
|
|
4760
|
+
// hit sets), so it never participates in hover / select.
|
|
4761
|
+
function FloorplanStairBuildPreviewLayer({
|
|
4762
|
+
palette,
|
|
4763
|
+
isDeleteMode,
|
|
4764
|
+
}: {
|
|
4765
|
+
palette: FloorplanStairLayerPalette
|
|
4766
|
+
isDeleteMode: boolean
|
|
4767
|
+
}) {
|
|
4768
|
+
const phase = useEditor((s) => s.phase)
|
|
4769
|
+
const mode = useEditor((s) => s.mode)
|
|
4770
|
+
const tool = useEditor((s) => s.tool)
|
|
4771
|
+
const point = useStairBuildPreview((s) => s.point)
|
|
4772
|
+
const rotation = useStairBuildPreview((s) => s.rotation)
|
|
4773
|
+
const isActive = phase === 'structure' && mode === 'build' && tool === 'stair'
|
|
4774
|
+
|
|
4775
|
+
const previewEntry = useMemo(() => {
|
|
4776
|
+
if (!(isActive && point)) {
|
|
4777
|
+
return null
|
|
4778
|
+
}
|
|
4779
|
+
const previewStair = StairNodeSchema.parse({
|
|
4780
|
+
id: 'stair_floorplan_preview',
|
|
4781
|
+
name: 'Staircase preview',
|
|
4782
|
+
position: [point[0], 0, point[1]],
|
|
4783
|
+
rotation,
|
|
4784
|
+
children: [FLOORPLAN_PREVIEW_STAIR_SEGMENT.id],
|
|
4785
|
+
metadata: { isTransient: true, isFloorplanPreview: true },
|
|
4786
|
+
})
|
|
4787
|
+
const entry = buildSharedFloorplanStairEntry(previewStair, [FLOORPLAN_PREVIEW_STAIR_SEGMENT])
|
|
4788
|
+
if (!entry) {
|
|
4789
|
+
return null
|
|
4790
|
+
}
|
|
4791
|
+
const hitPolygons =
|
|
4792
|
+
(previewStair.stairType ?? 'straight') === 'straight'
|
|
4793
|
+
? entry.segments.map((segmentEntry) => segmentEntry.polygon)
|
|
4794
|
+
: [getFloorplanCurvedStairHitPolygon(previewStair)]
|
|
4795
|
+
|
|
4796
|
+
return {
|
|
4797
|
+
...entry,
|
|
4798
|
+
hitPolygons,
|
|
4799
|
+
segments: entry.segments.map((segmentEntry) => ({
|
|
4800
|
+
...segmentEntry,
|
|
4801
|
+
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
|
|
4802
|
+
points: formatPolygonPoints(segmentEntry.polygon),
|
|
4803
|
+
treadBars: segmentEntry.treadBars.map((polygon) => ({
|
|
4804
|
+
points: formatPolygonPoints(polygon),
|
|
4805
|
+
polygon,
|
|
4806
|
+
})),
|
|
4807
|
+
})),
|
|
4808
|
+
}
|
|
4809
|
+
}, [isActive, point, rotation])
|
|
4810
|
+
|
|
4811
|
+
if (!previewEntry) {
|
|
4812
|
+
return null
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4815
|
+
return (
|
|
4816
|
+
<FloorplanStairLayer
|
|
4817
|
+
canFocusStairs={false}
|
|
4818
|
+
canSelectStairs={false}
|
|
4819
|
+
cursor={EDITOR_CURSOR}
|
|
4820
|
+
highlightedIdSet={EMPTY_FLOORPLAN_ID_SET}
|
|
4821
|
+
hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
|
4822
|
+
hoveredStairId={null}
|
|
4823
|
+
isDeleteMode={isDeleteMode}
|
|
4824
|
+
onStairDoubleClick={noopFloorplanStairHandler}
|
|
4825
|
+
onStairHoverChange={noopFloorplanStairHandler}
|
|
4826
|
+
onStairHoverEnter={noopFloorplanStairHandler}
|
|
4827
|
+
onStairPointerDown={noopFloorplanStairHandler}
|
|
4828
|
+
onStairSelect={noopFloorplanStairHandler}
|
|
4829
|
+
palette={palette}
|
|
4830
|
+
selectedIdSet={EMPTY_FLOORPLAN_ID_SET}
|
|
4831
|
+
stairEntries={[previewEntry]}
|
|
4832
|
+
/>
|
|
4833
|
+
)
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4836
|
+
// Leaf overlay for the cursor-following draft preview: the cursor crosshair plus
|
|
4837
|
+
// the live polygon-draft edge (slab / zone / ceiling). Subscribes to
|
|
4838
|
+
// `useFloorplanDraftPreview.cursorPoint` directly so a per-`grid:move` cursor
|
|
4839
|
+
// update re-renders ONLY this tiny layer — never the (~120-220ms) FloorplanPanel.
|
|
4840
|
+
// Everything else it needs is per-click panel state (the committed draft points)
|
|
4841
|
+
// passed as props, so it re-renders on click via the parent and on move via the
|
|
4842
|
+
// store. SVG mirrors the cursor-driven branches of `FloorplanDraftLayer`.
|
|
4843
|
+
function FloorplanDraftCursorLayer({
|
|
4844
|
+
activePolygonDraftPoints,
|
|
4845
|
+
isPolygonDraftBuildActive,
|
|
4846
|
+
cursorColor,
|
|
4847
|
+
draftFill,
|
|
4848
|
+
draftStroke,
|
|
4849
|
+
polygonDraftStroke,
|
|
4850
|
+
unitsPerPixel,
|
|
4851
|
+
}: {
|
|
4852
|
+
activePolygonDraftPoints: WallPlanPoint[]
|
|
4853
|
+
isPolygonDraftBuildActive: boolean
|
|
4854
|
+
cursorColor: string
|
|
4855
|
+
draftFill: string
|
|
4856
|
+
draftStroke: string
|
|
4857
|
+
polygonDraftStroke: string | undefined
|
|
4858
|
+
unitsPerPixel: number
|
|
4859
|
+
}) {
|
|
4860
|
+
const cursorPoint = useFloorplanDraftPreview((s) => s.cursorPoint)
|
|
4861
|
+
const activeStroke = polygonDraftStroke ?? draftStroke
|
|
4862
|
+
const strokeWidth = polygonDraftStroke ? FLOORPLAN_WALL_STROKE_WIDTH : '0.08'
|
|
4863
|
+
|
|
4864
|
+
const polygon = useMemo(() => {
|
|
4865
|
+
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
|
|
4866
|
+
return null
|
|
4867
|
+
}
|
|
4868
|
+
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
|
4869
|
+
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
4870
|
+
|
|
4871
|
+
const polyline = useMemo(() => {
|
|
4872
|
+
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) {
|
|
4873
|
+
return null
|
|
4874
|
+
}
|
|
4875
|
+
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
|
4876
|
+
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
4877
|
+
|
|
4878
|
+
const closingSegment = useMemo(() => {
|
|
4879
|
+
const firstPoint = activePolygonDraftPoints[0]
|
|
4880
|
+
if (
|
|
4881
|
+
!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2) ||
|
|
4882
|
+
!firstPoint
|
|
4883
|
+
) {
|
|
4884
|
+
return null
|
|
4885
|
+
}
|
|
4886
|
+
return {
|
|
4887
|
+
x1: toSvgX(cursorPoint[0]),
|
|
4888
|
+
y1: toSvgY(cursorPoint[1]),
|
|
4889
|
+
x2: toSvgX(firstPoint[0]),
|
|
4890
|
+
y2: toSvgY(firstPoint[1]),
|
|
4891
|
+
}
|
|
4892
|
+
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
4893
|
+
|
|
4894
|
+
return (
|
|
4895
|
+
<>
|
|
4896
|
+
{polygon && <polygon fill={draftFill} fillOpacity={0.2} points={polygon} stroke="none" />}
|
|
4897
|
+
|
|
4898
|
+
{polyline && (
|
|
4899
|
+
<polyline
|
|
4900
|
+
fill="none"
|
|
4901
|
+
points={polyline}
|
|
4902
|
+
stroke={activeStroke}
|
|
4903
|
+
strokeLinecap="round"
|
|
4904
|
+
strokeLinejoin="round"
|
|
4905
|
+
strokeWidth={strokeWidth}
|
|
4906
|
+
vectorEffect="non-scaling-stroke"
|
|
4907
|
+
/>
|
|
4908
|
+
)}
|
|
4909
|
+
|
|
4910
|
+
{closingSegment && (
|
|
4911
|
+
<line
|
|
4912
|
+
stroke={activeStroke}
|
|
4913
|
+
strokeDasharray="0.16 0.1"
|
|
4914
|
+
strokeLinecap="round"
|
|
4915
|
+
strokeOpacity={0.75}
|
|
4916
|
+
strokeWidth={strokeWidth}
|
|
4917
|
+
vectorEffect="non-scaling-stroke"
|
|
4918
|
+
x1={closingSegment.x1}
|
|
4919
|
+
x2={closingSegment.x2}
|
|
4920
|
+
y1={closingSegment.y1}
|
|
4921
|
+
y2={closingSegment.y2}
|
|
4922
|
+
/>
|
|
4923
|
+
)}
|
|
4924
|
+
|
|
4925
|
+
{cursorPoint && (
|
|
4926
|
+
<g>
|
|
4927
|
+
<circle
|
|
4928
|
+
cx={toSvgX(cursorPoint[0])}
|
|
4929
|
+
cy={toSvgY(cursorPoint[1])}
|
|
4930
|
+
fill={cursorColor}
|
|
4931
|
+
fillOpacity={0.25}
|
|
4932
|
+
r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX * unitsPerPixel}
|
|
4933
|
+
/>
|
|
4934
|
+
<circle
|
|
4935
|
+
cx={toSvgX(cursorPoint[0])}
|
|
4936
|
+
cy={toSvgY(cursorPoint[1])}
|
|
4937
|
+
fill={cursorColor}
|
|
4938
|
+
fillOpacity={0.9}
|
|
4939
|
+
r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX * unitsPerPixel}
|
|
4940
|
+
/>
|
|
4941
|
+
</g>
|
|
4942
|
+
)}
|
|
4943
|
+
</>
|
|
4944
|
+
)
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
// Leaf overlay for the marquee (box-select) rectangle. Subscribes to the
|
|
4948
|
+
// marquee store's moving corner so a per-move drag re-renders ONLY this layer,
|
|
4949
|
+
// never the (~120-220ms) FloorplanPanel. The bounds math is pure (the
|
|
4950
|
+
// module-scope `getFloorplanSelectionBounds` / `toSvgSelectionBounds`); the
|
|
4951
|
+
// cursor colour is the one bit of panel config, passed as a prop.
|
|
4952
|
+
function FloorplanMarqueeOverlay({ cursorColor }: { cursorColor: string }) {
|
|
4953
|
+
const drag = useFloorplanMarquee((s) => s.drag)
|
|
4954
|
+
const bounds = useMemo(() => {
|
|
4955
|
+
if (!drag) {
|
|
4956
|
+
return null
|
|
4957
|
+
}
|
|
4958
|
+
const dragDistance = Math.hypot(
|
|
4959
|
+
drag.currentPlanPoint[0] - drag.startPlanPoint[0],
|
|
4960
|
+
drag.currentPlanPoint[1] - drag.startPlanPoint[1],
|
|
4961
|
+
)
|
|
4962
|
+
if (dragDistance <= 0) {
|
|
4963
|
+
return null
|
|
4964
|
+
}
|
|
4965
|
+
return toSvgSelectionBounds(
|
|
4966
|
+
getFloorplanSelectionBounds(drag.startPlanPoint, drag.currentPlanPoint),
|
|
4967
|
+
)
|
|
4968
|
+
}, [drag])
|
|
4969
|
+
|
|
4970
|
+
return (
|
|
4971
|
+
<FloorplanMarqueeLayer
|
|
4972
|
+
bounds={bounds}
|
|
4973
|
+
cursorColor={cursorColor}
|
|
4974
|
+
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
|
|
4975
|
+
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
|
|
4976
|
+
/>
|
|
4977
|
+
)
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
// Thin subscriber wrapper for the coordinate-badge overlay: reads the hot
|
|
4981
|
+
// screen-space cursor position from the draft store so a per-`pointermove`
|
|
4982
|
+
// update re-renders only the badge, not FloorplanPanel. The remaining props
|
|
4983
|
+
// (tool / mode / colour) are per-interaction panel state passed through — they
|
|
4984
|
+
// change rarely, never per move.
|
|
4985
|
+
function FloorplanCursorIndicator(
|
|
4986
|
+
props: Omit<ComponentProps<typeof Editor2dFloorplanCursorIndicatorOverlay>, 'cursorPosition'>,
|
|
4987
|
+
) {
|
|
4988
|
+
const cursorPosition = useFloorplanDraftPreview((s) => s.cursorPosition)
|
|
4989
|
+
return <Editor2dFloorplanCursorIndicatorOverlay {...props} cursorPosition={cursorPosition} />
|
|
4990
|
+
}
|
|
4991
|
+
|
|
4992
|
+
// Leaf overlay for the live wall / fence / roof draft segment (the directional
|
|
4993
|
+
// draws). It subscribes to the per-move END points in the draft store so a
|
|
4994
|
+
// `grid:move` re-renders ONLY this layer, not FloorplanPanel; the per-click
|
|
4995
|
+
// START points + render config arrive as props. Owns the draft polygon (wall +
|
|
4996
|
+
// roof rect), the fence segment line, and the wall length/angle measurement —
|
|
4997
|
+
// the cursor-following pieces the shared `FloorplanDraftLayer` no longer carries.
|
|
4998
|
+
function FloorplanLinearDraftLayer({
|
|
4999
|
+
levelId,
|
|
5000
|
+
wallDraftStart,
|
|
5001
|
+
fenceDraftStart,
|
|
5002
|
+
roofDraftStart,
|
|
5003
|
+
isWallBuildActive,
|
|
5004
|
+
isFenceBuildActive,
|
|
5005
|
+
isRoofBuildActive,
|
|
5006
|
+
walls,
|
|
5007
|
+
unit,
|
|
5008
|
+
draftFill,
|
|
5009
|
+
draftStroke,
|
|
5010
|
+
measurementStroke,
|
|
5011
|
+
isDark,
|
|
5012
|
+
unitsPerPixel,
|
|
5013
|
+
sceneRotationDeg,
|
|
5014
|
+
}: {
|
|
5015
|
+
levelId: string | null
|
|
5016
|
+
wallDraftStart: WallPlanPoint | null
|
|
5017
|
+
fenceDraftStart: WallPlanPoint | null
|
|
5018
|
+
roofDraftStart: WallPlanPoint | null
|
|
5019
|
+
isWallBuildActive: boolean
|
|
5020
|
+
isFenceBuildActive: boolean
|
|
5021
|
+
isRoofBuildActive: boolean
|
|
5022
|
+
walls: WallNode[]
|
|
5023
|
+
unit: 'metric' | 'imperial'
|
|
5024
|
+
draftFill: string
|
|
5025
|
+
draftStroke: string
|
|
5026
|
+
measurementStroke: string
|
|
5027
|
+
isDark: boolean
|
|
5028
|
+
unitsPerPixel: number
|
|
5029
|
+
sceneRotationDeg: number
|
|
5030
|
+
}) {
|
|
5031
|
+
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
|
|
5032
|
+
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
|
|
5033
|
+
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
|
|
5034
|
+
|
|
5035
|
+
const draftPolygon = useMemo(() => {
|
|
5036
|
+
if (
|
|
5037
|
+
!(
|
|
5038
|
+
levelId &&
|
|
5039
|
+
wallDraftStart &&
|
|
5040
|
+
wallDraftEnd &&
|
|
5041
|
+
isSegmentLongEnough(wallDraftStart, wallDraftEnd)
|
|
5042
|
+
)
|
|
5043
|
+
) {
|
|
5044
|
+
return null
|
|
5045
|
+
}
|
|
5046
|
+
const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, wallDraftStart, wallDraftEnd))
|
|
5047
|
+
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
|
|
5048
|
+
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
|
5049
|
+
}, [levelId, wallDraftStart, wallDraftEnd])
|
|
5050
|
+
|
|
5051
|
+
const draftPolygonPoints = useMemo(() => {
|
|
5052
|
+
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
|
5053
|
+
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
|
5054
|
+
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
|
|
5055
|
+
const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
|
|
5056
|
+
const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
|
|
5057
|
+
|
|
5058
|
+
if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
|
|
5059
|
+
return formatPolygonPoints([
|
|
5060
|
+
{ x: minX, y: minY },
|
|
5061
|
+
{ x: maxX, y: minY },
|
|
5062
|
+
{ x: maxX, y: maxY },
|
|
5063
|
+
{ x: minX, y: maxY },
|
|
5064
|
+
])
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
|
|
5068
|
+
}, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
|
|
5069
|
+
|
|
5070
|
+
const fenceDraftSegment = useMemo(() => {
|
|
5071
|
+
if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
|
|
5072
|
+
return null
|
|
5073
|
+
}
|
|
5074
|
+
if (getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(fenceDraftEnd)) < 1e-6) {
|
|
5075
|
+
return null
|
|
5076
|
+
}
|
|
5077
|
+
return {
|
|
5078
|
+
x1: toSvgX(fenceDraftStart[0]),
|
|
5079
|
+
y1: toSvgY(fenceDraftStart[1]),
|
|
5080
|
+
x2: toSvgX(fenceDraftEnd[0]),
|
|
5081
|
+
y2: toSvgY(fenceDraftEnd[1]),
|
|
5082
|
+
}
|
|
5083
|
+
}, [fenceDraftEnd, fenceDraftStart, isFenceBuildActive])
|
|
5084
|
+
|
|
5085
|
+
// Live length + angle feedback for the wall draft — parity with the 3D
|
|
5086
|
+
// `WallTool`, ported to 2D plan space.
|
|
5087
|
+
const draftWallMeasurement = useMemo(() => {
|
|
5088
|
+
if (
|
|
5089
|
+
!(
|
|
5090
|
+
isWallBuildActive &&
|
|
5091
|
+
wallDraftStart &&
|
|
5092
|
+
wallDraftEnd &&
|
|
5093
|
+
isSegmentLongEnough(wallDraftStart, wallDraftEnd)
|
|
5094
|
+
)
|
|
5095
|
+
) {
|
|
5096
|
+
return null
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
const dx = wallDraftEnd[0] - wallDraftStart[0]
|
|
5100
|
+
const dy = wallDraftEnd[1] - wallDraftStart[1]
|
|
5101
|
+
const length = Math.hypot(dx, dy)
|
|
5102
|
+
|
|
5103
|
+
const draftFromStart: WallPlanPoint = [dx, dy]
|
|
5104
|
+
const draftFromEnd: WallPlanPoint = [-dx, -dy]
|
|
5105
|
+
const endpoints = [
|
|
5106
|
+
{ id: 'start', point: wallDraftStart, draftVector: draftFromStart },
|
|
5107
|
+
{ id: 'end', point: wallDraftEnd, draftVector: draftFromEnd },
|
|
5108
|
+
] as const
|
|
5109
|
+
|
|
5110
|
+
type AngleLabel = {
|
|
5111
|
+
id: string
|
|
5112
|
+
label: string
|
|
5113
|
+
center: WallPlanPoint
|
|
5114
|
+
radius: number
|
|
5115
|
+
startAngle: number
|
|
5116
|
+
endAngle: number
|
|
5117
|
+
midAngle: number
|
|
5118
|
+
}
|
|
5119
|
+
|
|
5120
|
+
const angleLabels: AngleLabel[] = []
|
|
5121
|
+
for (const endpoint of endpoints) {
|
|
5122
|
+
const connectedWall = walls.find((wall) =>
|
|
5123
|
+
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
|
5124
|
+
)
|
|
5125
|
+
if (!connectedWall) continue
|
|
5126
|
+
const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
|
5127
|
+
if (!ref) continue
|
|
5128
|
+
|
|
5129
|
+
const angle = getAngleToSegmentReference(endpoint.draftVector, ref)
|
|
5130
|
+
if (angle === null) continue
|
|
5131
|
+
const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref)
|
|
5132
|
+
if (!arc || arc.angle < 0.01) continue
|
|
5133
|
+
|
|
5134
|
+
const refLen = Math.hypot(ref.vector[0], ref.vector[1])
|
|
5135
|
+
const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28))
|
|
5136
|
+
|
|
5137
|
+
angleLabels.push({
|
|
5138
|
+
id: endpoint.id,
|
|
5139
|
+
label: formatAngleRadians(angle),
|
|
5140
|
+
center: endpoint.point,
|
|
5141
|
+
radius,
|
|
5142
|
+
startAngle: arc.startAngle,
|
|
5143
|
+
endAngle: arc.endAngle,
|
|
5144
|
+
midAngle: arc.midAngle,
|
|
5145
|
+
})
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
return {
|
|
5149
|
+
lengthLabel: formatMeasurement(length, unit),
|
|
5150
|
+
midpoint: [
|
|
5151
|
+
(wallDraftStart[0] + wallDraftEnd[0]) / 2,
|
|
5152
|
+
(wallDraftStart[1] + wallDraftEnd[1]) / 2,
|
|
5153
|
+
] as WallPlanPoint,
|
|
5154
|
+
direction: [dx / length, dy / length] as WallPlanPoint,
|
|
5155
|
+
angleLabels,
|
|
5156
|
+
}
|
|
5157
|
+
}, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls])
|
|
5158
|
+
|
|
5159
|
+
return (
|
|
5160
|
+
<>
|
|
5161
|
+
<FloorplanDraftLayer
|
|
5162
|
+
anchorFill={draftStroke}
|
|
5163
|
+
draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
|
|
5164
|
+
draftFill={draftFill}
|
|
5165
|
+
draftPolygonPoints={draftPolygonPoints}
|
|
5166
|
+
draftStroke={draftStroke}
|
|
5167
|
+
linearDraftSegment={fenceDraftSegment}
|
|
5168
|
+
polygonDraftClosingSegment={null}
|
|
5169
|
+
polygonDraftPolygonPoints={null}
|
|
5170
|
+
polygonDraftPolylinePoints={null}
|
|
5171
|
+
unitsPerPixel={unitsPerPixel}
|
|
5172
|
+
/>
|
|
5173
|
+
|
|
5174
|
+
{draftWallMeasurement && (
|
|
5175
|
+
<FloorplanDraftWallMeasurement
|
|
5176
|
+
labelBackground={isDark ? '#0f172a' : '#ffffff'}
|
|
5177
|
+
labelText={isDark ? '#e2e8f0' : '#171717'}
|
|
5178
|
+
measurement={draftWallMeasurement}
|
|
5179
|
+
measurementStroke={measurementStroke}
|
|
5180
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
5181
|
+
unitsPerPixel={unitsPerPixel}
|
|
5182
|
+
/>
|
|
5183
|
+
)}
|
|
5184
|
+
</>
|
|
5185
|
+
)
|
|
5186
|
+
}
|
|
5187
|
+
|
|
5188
|
+
const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = []
|
|
5189
|
+
|
|
5190
|
+
export function FloorplanPanel({
|
|
5191
|
+
/**
|
|
5192
|
+
* Element to portal the compass button into. The 2D/3D navigation poses stay
|
|
5193
|
+
* in sync (`navigationSyncPose`), so hosting the compass on the always-visible
|
|
5194
|
+
* viewer-area container keeps it correct — needle and align-to-north alike —
|
|
5195
|
+
* in 2d, 3d, and split modes, while this panel itself may be display:none.
|
|
5196
|
+
*/
|
|
5197
|
+
compassHost,
|
|
5198
|
+
}: {
|
|
5199
|
+
compassHost?: HTMLElement | null
|
|
5200
|
+
}) {
|
|
5201
|
+
const viewportHostRef = useRef<HTMLDivElement>(null)
|
|
5202
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
5203
|
+
const floorplanSceneRef = useRef<SVGGElement>(null)
|
|
5204
|
+
const floorplanContentRef = useRef<SVGGElement>(null)
|
|
5205
|
+
const panStateRef = useRef<PanState | null>(null)
|
|
5206
|
+
const floorplanRotationStateRef = useRef<FloorplanRotationState | null>(null)
|
|
5207
|
+
const floorplanSpacePanPressedRef = useRef(false)
|
|
5208
|
+
const floorplanNavigationClickSuppressedRef = useRef(false)
|
|
5209
|
+
const guideInteractionRef = useRef<GuideInteractionState | null>(null)
|
|
5210
|
+
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
|
|
5211
|
+
const pendingFenceDragRef = useRef<PendingFenceDragState | null>(null)
|
|
5212
|
+
const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
|
|
5213
|
+
const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
|
|
5214
|
+
const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
|
|
5215
|
+
const gestureScaleRef = useRef(1)
|
|
5216
|
+
const panelInteractionRef = useRef<PanelInteractionState | null>(null)
|
|
5217
|
+
const panelBoundsRef = useRef<ViewportBounds | null>(null)
|
|
5218
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
5219
|
+
const hasUserAdjustedViewportRef = useRef(false)
|
|
5220
|
+
const previousLevelIdRef = useRef<string | null>(null)
|
|
5221
|
+
const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
|
|
5222
|
+
const floorplanScreenSelectionRef = useRef<FloorplanScreenSelectionState | null>(null)
|
|
5223
|
+
const floorplanScreenSelectionElementRef = useRef<HTMLDivElement | null>(null)
|
|
5224
|
+
const floorplanScreenSelectionOwnsInputDraggingRef = useRef(false)
|
|
5225
|
+
const latestFloorplanUserRotationDegRef = useRef(0)
|
|
5226
|
+
const latestViewportRef = useRef<FloorplanViewport | null>(null)
|
|
5227
|
+
const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
|
|
5228
|
+
const floorplanViewAnimationFrameRef = useRef<number | null>(null)
|
|
5229
|
+
const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
|
|
5230
|
+
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
|
|
5231
|
+
useEditor.getState().navigationSyncPose,
|
|
5232
|
+
)
|
|
5233
|
+
const compassNeedleRef = useRef<SVGSVGElement | null>(null)
|
|
5234
|
+
const hiddenCompassAnimationRef = useRef<number | null>(null)
|
|
5235
|
+
const levelId = useViewer((state) => state.selection.levelId)
|
|
5236
|
+
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
5237
|
+
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
5238
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
5239
|
+
const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
|
|
5240
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
5241
|
+
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
|
5242
|
+
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
5243
|
+
const unit = useViewer((state) => state.unit)
|
|
5244
|
+
const showGrid = useViewer((state) => state.showGrid)
|
|
5245
|
+
const showGuides = useViewer((state) => state.showGuides)
|
|
5246
|
+
const setShowGuides = useViewer((state) => state.setShowGuides)
|
|
5247
|
+
const selectedItem = useEditor((state) => state.selectedItem)
|
|
5248
|
+
|
|
5249
|
+
const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
|
|
5250
|
+
// Panel is permanently mounted and toggled via `display: none` in
|
|
5251
|
+
// editor/index.tsx — subscribing here lets us re-fit the viewport when
|
|
5252
|
+
// the user closes and re-opens the 2D editor instead of restoring the
|
|
5253
|
+
// stale viewport from before they closed it.
|
|
5254
|
+
const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
|
|
5255
|
+
// Mirror for callbacks that fire outside React's render (the per-frame
|
|
5256
|
+
// navigation-pose subscriber): when the 2D panel is hidden (`display:none` in
|
|
5257
|
+
// 3D mode) it must NOT re-render on every camera-zoom frame.
|
|
5258
|
+
const isFloorplanOpenRef = useRef(isFloorplanOpen)
|
|
5259
|
+
isFloorplanOpenRef.current = isFloorplanOpen
|
|
5260
|
+
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
5261
|
+
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
5262
|
+
const setMode = useEditor((state) => state.setMode)
|
|
5263
|
+
const movingNode = useMovingNode()
|
|
5264
|
+
const isCurveReshape = useIsCurveReshape()
|
|
5265
|
+
const endpointReshape = useEndpointReshape()
|
|
5266
|
+
const reshapingNode = useReshapingNode()
|
|
5267
|
+
const phase = useEditor((state) => state.phase)
|
|
5268
|
+
const mode = useEditor((state) => state.mode)
|
|
5269
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
5270
|
+
const setPhase = useEditor((state) => state.setPhase)
|
|
5271
|
+
const setMovingNode = useEditor((state) => state.setMovingNode)
|
|
5272
|
+
const structureLayer = useEditor((state) => state.structureLayer)
|
|
5273
|
+
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
|
5274
|
+
const setTool = useEditor((state) => state.setTool)
|
|
5275
|
+
const tool = useEditor((state) => state.tool)
|
|
5276
|
+
const deleteNode = useScene((state) => state.deleteNode)
|
|
5277
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
5278
|
+
const {
|
|
5279
|
+
buildingPosition,
|
|
5280
|
+
buildingRotationY,
|
|
5281
|
+
ceilings,
|
|
5282
|
+
currentBuildingId,
|
|
5283
|
+
fences,
|
|
5284
|
+
floorplanLevels,
|
|
5285
|
+
levelDescendantNodes,
|
|
5286
|
+
levelGuides,
|
|
5287
|
+
levelNode,
|
|
5288
|
+
openings,
|
|
5289
|
+
roofs,
|
|
5290
|
+
site,
|
|
5291
|
+
slabs,
|
|
5292
|
+
spawns,
|
|
5293
|
+
walls,
|
|
5294
|
+
zones,
|
|
5295
|
+
} = useFloorplanSceneData({ buildingId, levelId })
|
|
5296
|
+
// When only a building is selected (or we're mid-drag on a building),
|
|
5297
|
+
// the FloorplanRegistryLayer falls back to that building's level 0
|
|
5298
|
+
// (or lowest level) and renders it dimmed as context. We let the SVG
|
|
5299
|
+
// mount in that case so the dimmed-floor render path is reachable
|
|
5300
|
+
// instead of swapping in the "Switch to a building level" message.
|
|
5301
|
+
//
|
|
5302
|
+
// `currentBuildingId` covers both the user-selected building and the
|
|
5303
|
+
// building inferred from a selected level. During a building move the
|
|
5304
|
+
// `movingNode` carries the building's id even if the explicit
|
|
5305
|
+
// selection has been cleared as part of the move handoff.
|
|
5306
|
+
const movingBuildingId =
|
|
5307
|
+
movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
|
|
5308
|
+
? movingNode.id
|
|
5309
|
+
: null
|
|
5310
|
+
const ambientBuildingId = currentBuildingId ?? movingBuildingId
|
|
5311
|
+
const hasAmbientBuildingLevel = useScene((state) => {
|
|
5312
|
+
if (levelId || !ambientBuildingId) return false
|
|
5313
|
+
const building = state.nodes[ambientBuildingId]
|
|
5314
|
+
if (building?.type !== 'building') return false
|
|
5315
|
+
return building.children.some((cid) => state.nodes[cid]?.type === 'level')
|
|
5316
|
+
})
|
|
5317
|
+
const elevators = useScene(
|
|
5318
|
+
useShallow((state) => {
|
|
5319
|
+
const building = currentBuildingId ? state.nodes[currentBuildingId] : null
|
|
5320
|
+
if (building?.type !== 'building') {
|
|
5321
|
+
return [] as ElevatorNode[]
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
return building.children.flatMap((childId) => {
|
|
5325
|
+
const node = state.nodes[childId]
|
|
5326
|
+
return node?.type === 'elevator' && node.visible !== false ? [node] : []
|
|
5327
|
+
})
|
|
5328
|
+
}),
|
|
5329
|
+
)
|
|
5330
|
+
const [floorplanUserRotationDeg, setFloorplanUserRotationDeg] = useState(0)
|
|
5331
|
+
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
|
5332
|
+
const floorplanSceneRotationDeg =
|
|
5333
|
+
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
|
|
5334
|
+
// Only sync ref from state when floorplan is open (state is source of truth).
|
|
5335
|
+
// When hidden, the imperative 3D path owns the ref and must not be clobbered.
|
|
5336
|
+
if (isFloorplanOpenRef.current) {
|
|
5337
|
+
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
// Draft START points stay in panel state (set per click). The live END points
|
|
5341
|
+
// are the per-move hot values — they live in `useFloorplanDraftPreview` so a
|
|
5342
|
+
// `grid:move` re-renders only `FloorplanLinearDraftLayer`, not this panel.
|
|
5343
|
+
// Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged.
|
|
5344
|
+
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
|
5345
|
+
const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(null)
|
|
5346
|
+
// Walls committed by the current 2D-only chain — exclusion set for the
|
|
5347
|
+
// T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`).
|
|
5348
|
+
const wallChainWallIdsRef = useRef<string[]>([])
|
|
5349
|
+
const setDraftEnd = useCallback(
|
|
5350
|
+
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
|
5351
|
+
const store = useFloorplanDraftPreview.getState()
|
|
5352
|
+
store.setWallDraftEnd(typeof next === 'function' ? next(store.wallDraftEnd) : next)
|
|
5353
|
+
},
|
|
5354
|
+
[],
|
|
5355
|
+
)
|
|
5356
|
+
const [fenceDraftStart, setFenceDraftStart] = useState<WallPlanPoint | null>(null)
|
|
5357
|
+
const setFenceDraftEnd = useCallback(
|
|
5358
|
+
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
|
5359
|
+
const store = useFloorplanDraftPreview.getState()
|
|
5360
|
+
store.setFenceDraftEnd(typeof next === 'function' ? next(store.fenceDraftEnd) : next)
|
|
5361
|
+
},
|
|
5362
|
+
[],
|
|
5363
|
+
)
|
|
5364
|
+
const [roofDraftStart, setRoofDraftStart] = useState<WallPlanPoint | null>(null)
|
|
5365
|
+
const setRoofDraftEnd = useCallback(
|
|
5366
|
+
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
|
5367
|
+
const store = useFloorplanDraftPreview.getState()
|
|
5368
|
+
store.setRoofDraftEnd(typeof next === 'function' ? next(store.roofDraftEnd) : next)
|
|
5369
|
+
},
|
|
5370
|
+
[],
|
|
5371
|
+
)
|
|
5372
|
+
const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([])
|
|
5373
|
+
const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])
|
|
5374
|
+
const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])
|
|
5375
|
+
const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)
|
|
5376
|
+
const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)
|
|
5377
|
+
const [guideTransformDraft, setGuideTransformDraft] = useState<GuideTransformDraft | null>(null)
|
|
5378
|
+
const [referenceScaleDraft, setReferenceScaleDraft] = useState<ReferenceScaleDraft | null>(null)
|
|
5379
|
+
const [pendingReferenceScale, setPendingReferenceScale] = useState<PendingReferenceScale | null>(
|
|
5380
|
+
null,
|
|
5381
|
+
)
|
|
5382
|
+
// Mirror the in-flight scale flow to the store — the reference panel's
|
|
5383
|
+
// Set Scale button flips into Cancel while it's active.
|
|
5384
|
+
useEffect(() => {
|
|
5385
|
+
useEditor
|
|
5386
|
+
.getState()
|
|
5387
|
+
.setReferenceScaleActiveGuideId(
|
|
5388
|
+
referenceScaleDraft?.guideId ?? pendingReferenceScale?.guideId ?? null,
|
|
5389
|
+
)
|
|
5390
|
+
}, [referenceScaleDraft, pendingReferenceScale])
|
|
5391
|
+
useEffect(() => {
|
|
5392
|
+
return () => useEditor.getState().setReferenceScaleActiveGuideId(null)
|
|
5393
|
+
}, [])
|
|
5394
|
+
const [referenceScaleValue, setReferenceScaleValue] = useState('1')
|
|
5395
|
+
const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
|
|
5396
|
+
unit === 'imperial' ? 'feet' : 'meters',
|
|
5397
|
+
)
|
|
5398
|
+
// The cursor point is the hottest 2D state — every build/edit tool republishes
|
|
5399
|
+
// it on `grid:move`. It lives in `useFloorplanDraftPreview` (not panel state)
|
|
5400
|
+
// so a per-move update re-renders only `FloorplanDraftCursorLayer`, not this
|
|
5401
|
+
// ~200ms panel. This shim keeps the `setCursorPoint(value)` /
|
|
5402
|
+
// `setCursorPoint(prev => …)` call sites (and their snap-SFX side effects)
|
|
5403
|
+
// unchanged while routing the write to the store; reads go through the store.
|
|
5404
|
+
const setCursorPoint = useCallback(
|
|
5405
|
+
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
|
5406
|
+
const store = useFloorplanDraftPreview.getState()
|
|
5407
|
+
const value = typeof next === 'function' ? next(store.cursorPoint) : next
|
|
5408
|
+
store.setCursorPoint(value)
|
|
5409
|
+
},
|
|
5410
|
+
[],
|
|
5411
|
+
)
|
|
5412
|
+
// The coordinate-badge cursor position is set on every SVG `pointermove` while
|
|
5413
|
+
// a build/select tool is active — the single hottest 2D update. It lives in
|
|
5414
|
+
// `useFloorplanDraftPreview` (not panel state) so a move re-renders only the
|
|
5415
|
+
// badge leaf, not this panel. Shim preserves the existing call sites (value +
|
|
5416
|
+
// functional-updater forms) while routing the write to the store.
|
|
5417
|
+
const setFloorplanCursorPosition = useCallback(
|
|
5418
|
+
(next: SvgPoint | null | ((prev: SvgPoint | null) => SvgPoint | null)) => {
|
|
5419
|
+
const store = useFloorplanDraftPreview.getState()
|
|
5420
|
+
const value = typeof next === 'function' ? next(store.cursorPosition) : next
|
|
5421
|
+
store.setCursorPosition(value)
|
|
5422
|
+
},
|
|
5423
|
+
[],
|
|
5424
|
+
)
|
|
5425
|
+
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
|
|
5426
|
+
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
|
|
5427
|
+
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
|
|
5428
|
+
const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
|
|
5429
|
+
const [hoveredFenceId, setHoveredFenceId] = useState<FenceNode['id'] | null>(null)
|
|
5430
|
+
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
|
|
5431
|
+
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
|
|
5432
|
+
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
|
|
5433
|
+
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
|
|
5434
|
+
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
|
|
4696
5435
|
const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['id'] | null>(null)
|
|
4697
5436
|
const [elevatorResizeDragState, setElevatorResizeDragState] =
|
|
4698
5437
|
useState<ElevatorResizeDragState | null>(null)
|
|
@@ -4713,9 +5452,6 @@ export function FloorplanPanel() {
|
|
|
4713
5452
|
const setGuideLocked = useEditor((s) => s.setGuideLocked)
|
|
4714
5453
|
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
|
|
4715
5454
|
const clearGuideUi = useEditor((s) => s.clearGuideUi)
|
|
4716
|
-
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
|
|
4717
|
-
null,
|
|
4718
|
-
)
|
|
4719
5455
|
const [shiftPressed, setShiftPressed] = useState(false)
|
|
4720
5456
|
const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
|
|
4721
5457
|
const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
|
|
@@ -4778,8 +5514,6 @@ export function FloorplanPanel() {
|
|
|
4778
5514
|
[site?.id],
|
|
4779
5515
|
),
|
|
4780
5516
|
)
|
|
4781
|
-
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
|
|
4782
|
-
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
|
|
4783
5517
|
const [isSpacePanPressed, setIsSpacePanPressed] = useState(false)
|
|
4784
5518
|
const [isPanning, setIsPanning] = useState(false)
|
|
4785
5519
|
const [isRotatingFloorplan, setIsRotatingFloorplan] = useState(false)
|
|
@@ -4971,6 +5705,10 @@ export function FloorplanPanel() {
|
|
|
4971
5705
|
const activeGuideInteractionMode = guideTransformDraft
|
|
4972
5706
|
? (guideInteractionRef.current?.mode ?? null)
|
|
4973
5707
|
: null
|
|
5708
|
+
const guideRotationReadout = buildGuideRotationReadout(
|
|
5709
|
+
guideInteractionRef.current,
|
|
5710
|
+
guideTransformDraft,
|
|
5711
|
+
)
|
|
4974
5712
|
const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
|
|
4975
5713
|
const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
|
|
4976
5714
|
const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
|
|
@@ -5190,7 +5928,12 @@ export function FloorplanPanel() {
|
|
|
5190
5928
|
const holes = (slab.holes ?? [])
|
|
5191
5929
|
.map((hole) => toFloorplanPolygon(hole))
|
|
5192
5930
|
.filter((hole) => hole.length >= 3)
|
|
5193
|
-
const visualPolygon = toFloorplanPolygon(
|
|
5931
|
+
const visualPolygon = toFloorplanPolygon(
|
|
5932
|
+
getRenderableSlabPolygon(slab, {
|
|
5933
|
+
walls: referenceWalls,
|
|
5934
|
+
siblingSlabs: referenceSlabs.filter((other) => other.id !== slab.id),
|
|
5935
|
+
}),
|
|
5936
|
+
)
|
|
5194
5937
|
const visualHoles = holes
|
|
5195
5938
|
|
|
5196
5939
|
return [
|
|
@@ -5380,6 +6123,7 @@ export function FloorplanPanel() {
|
|
|
5380
6123
|
const isOpeningMoveActive = movingOpeningType !== null
|
|
5381
6124
|
const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
|
|
5382
6125
|
const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence'
|
|
6126
|
+
const fenceContinuation = useEditor((state) => state.continuationByContext.fence)
|
|
5383
6127
|
const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof'
|
|
5384
6128
|
const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
|
|
5385
6129
|
const isStairMoveActive = movingNode?.type === 'stair'
|
|
@@ -5390,9 +6134,9 @@ export function FloorplanPanel() {
|
|
|
5390
6134
|
const isWallMoveActive = movingNode?.type === 'wall'
|
|
5391
6135
|
const isSpawnMoveActive = movingNode?.type === 'spawn'
|
|
5392
6136
|
const isElevatorMoveActive = movingNode?.type === 'elevator'
|
|
5393
|
-
const isWallCurveActive =
|
|
5394
|
-
const isFenceCurveActive =
|
|
5395
|
-
const isFenceEndpointMoveActive =
|
|
6137
|
+
const isWallCurveActive = isCurveReshape && reshapingNode?.type === 'wall'
|
|
6138
|
+
const isFenceCurveActive = isCurveReshape && reshapingNode?.type === 'fence'
|
|
6139
|
+
const isFenceEndpointMoveActive = endpointReshape !== null && reshapingNode?.type === 'fence'
|
|
5396
6140
|
const isItemPlacementPreviewActive =
|
|
5397
6141
|
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
|
|
5398
6142
|
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
|
|
@@ -5434,72 +6178,6 @@ export function FloorplanPanel() {
|
|
|
5434
6178
|
isFloorItemBuildActive ||
|
|
5435
6179
|
isFloorItemMoveActive ||
|
|
5436
6180
|
isRegistryToolBuildActive
|
|
5437
|
-
const floorplanPreviewStairSegment = useMemo(
|
|
5438
|
-
() =>
|
|
5439
|
-
StairSegmentNodeSchema.parse({
|
|
5440
|
-
id: 'sseg_floorplan_preview',
|
|
5441
|
-
segmentType: 'stair',
|
|
5442
|
-
width: DEFAULT_STAIR_WIDTH,
|
|
5443
|
-
length: DEFAULT_STAIR_LENGTH,
|
|
5444
|
-
height: DEFAULT_STAIR_HEIGHT,
|
|
5445
|
-
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
5446
|
-
attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
|
|
5447
|
-
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
5448
|
-
thickness: DEFAULT_STAIR_THICKNESS,
|
|
5449
|
-
position: [0, 0, 0],
|
|
5450
|
-
metadata: { isTransient: true, isFloorplanPreview: true },
|
|
5451
|
-
}),
|
|
5452
|
-
[],
|
|
5453
|
-
)
|
|
5454
|
-
const floorplanPreviewStairEntry = useMemo(() => {
|
|
5455
|
-
if (!(isStairBuildActive && stairBuildPreviewPoint)) {
|
|
5456
|
-
return null
|
|
5457
|
-
}
|
|
5458
|
-
|
|
5459
|
-
const previewStair = StairNodeSchema.parse({
|
|
5460
|
-
id: 'stair_floorplan_preview',
|
|
5461
|
-
name: 'Staircase preview',
|
|
5462
|
-
position: [stairBuildPreviewPoint[0], 0, stairBuildPreviewPoint[1]],
|
|
5463
|
-
rotation: stairBuildPreviewRotation,
|
|
5464
|
-
children: [floorplanPreviewStairSegment.id],
|
|
5465
|
-
metadata: { isTransient: true, isFloorplanPreview: true },
|
|
5466
|
-
})
|
|
5467
|
-
|
|
5468
|
-
const entry = buildSharedFloorplanStairEntry(previewStair, [floorplanPreviewStairSegment])
|
|
5469
|
-
if (!entry) {
|
|
5470
|
-
return null
|
|
5471
|
-
}
|
|
5472
|
-
const hitPolygons =
|
|
5473
|
-
(previewStair.stairType ?? 'straight') === 'straight'
|
|
5474
|
-
? entry.segments.map((segmentEntry) => segmentEntry.polygon)
|
|
5475
|
-
: [getFloorplanCurvedStairHitPolygon(previewStair)]
|
|
5476
|
-
|
|
5477
|
-
return {
|
|
5478
|
-
...entry,
|
|
5479
|
-
hitPolygons,
|
|
5480
|
-
segments: entry.segments.map((segmentEntry) => ({
|
|
5481
|
-
...segmentEntry,
|
|
5482
|
-
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
|
|
5483
|
-
points: formatPolygonPoints(segmentEntry.polygon),
|
|
5484
|
-
treadBars: segmentEntry.treadBars.map((polygon) => ({
|
|
5485
|
-
points: formatPolygonPoints(polygon),
|
|
5486
|
-
polygon,
|
|
5487
|
-
})),
|
|
5488
|
-
})),
|
|
5489
|
-
}
|
|
5490
|
-
}, [
|
|
5491
|
-
floorplanPreviewStairSegment,
|
|
5492
|
-
isStairBuildActive,
|
|
5493
|
-
stairBuildPreviewPoint,
|
|
5494
|
-
stairBuildPreviewRotation,
|
|
5495
|
-
])
|
|
5496
|
-
const renderedFloorplanStairEntries = useMemo(
|
|
5497
|
-
() =>
|
|
5498
|
-
floorplanPreviewStairEntry
|
|
5499
|
-
? [...floorplanStairEntries, floorplanPreviewStairEntry]
|
|
5500
|
-
: floorplanStairEntries,
|
|
5501
|
-
[floorplanPreviewStairEntry, floorplanStairEntries],
|
|
5502
|
-
)
|
|
5503
6181
|
const floorplanOpeningLocalY = useMemo(() => {
|
|
5504
6182
|
if (movingNode?.type === 'door' || movingNode?.type === 'window') {
|
|
5505
6183
|
return snapToHalf(movingNode.position[1])
|
|
@@ -5512,18 +6190,75 @@ export function FloorplanPanel() {
|
|
|
5512
6190
|
|
|
5513
6191
|
return 0
|
|
5514
6192
|
}, [isWindowBuildActive, movingNode])
|
|
6193
|
+
// Float the faithful door/window symbol at the cursor while it isn't over a
|
|
6194
|
+
// wall (the off-wall placement ghost), by publishing a transient opening on a
|
|
6195
|
+
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
|
|
6196
|
+
// renders it through the real `def.floorplan` builder (swing arc / panes), so
|
|
6197
|
+
// it reads as a real door/window, not a bare rectangle. Off any wall there's
|
|
6198
|
+
// no orientation to inherit, so the synthetic wall runs along plan-X.
|
|
6199
|
+
const showOpeningGhost = useCallback(
|
|
6200
|
+
(planPoint: WallPlanPoint) => {
|
|
6201
|
+
const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
|
|
6202
|
+
// Synthetic wall centred at the cursor; the opening sits at its midpoint.
|
|
6203
|
+
const half =
|
|
6204
|
+
(isDoor
|
|
6205
|
+
? movingNode?.type === 'door'
|
|
6206
|
+
? movingNode.width
|
|
6207
|
+
: 0.9
|
|
6208
|
+
: movingNode?.type === 'window'
|
|
6209
|
+
? movingNode.width
|
|
6210
|
+
: 1.5) /
|
|
6211
|
+
2 +
|
|
6212
|
+
0.5
|
|
6213
|
+
const wall = WallNodeSchema.parse({
|
|
6214
|
+
start: [planPoint[0] - half, planPoint[1]],
|
|
6215
|
+
end: [planPoint[0] + half, planPoint[1]],
|
|
6216
|
+
thickness: 0.1,
|
|
6217
|
+
})
|
|
6218
|
+
// Clone the moving opening (carries width / type / hinge / swing) onto the
|
|
6219
|
+
// synthetic wall, or parse a default for build mode. position[0] = the
|
|
6220
|
+
// along-wall midpoint so the symbol centres on the cursor.
|
|
6221
|
+
const base =
|
|
6222
|
+
movingNode?.type === 'door' || movingNode?.type === 'window'
|
|
6223
|
+
? { ...movingNode }
|
|
6224
|
+
: isDoor
|
|
6225
|
+
? DoorNodeSchema.parse({})
|
|
6226
|
+
: WindowNodeSchema.parse({})
|
|
6227
|
+
const ghost = {
|
|
6228
|
+
...base,
|
|
6229
|
+
parentId: wall.id,
|
|
6230
|
+
wallId: wall.id,
|
|
6231
|
+
roofSegmentId: undefined,
|
|
6232
|
+
roofFace: undefined,
|
|
6233
|
+
position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
|
|
6234
|
+
rotation: [0, 0, 0] as [number, number, number],
|
|
6235
|
+
} as AnyNode
|
|
6236
|
+
usePlacementPreview.getState().set(ghost, wall)
|
|
6237
|
+
},
|
|
6238
|
+
[floorplanOpeningLocalY, isDoorBuildActive, movingNode, movingOpeningType],
|
|
6239
|
+
)
|
|
6240
|
+
// Drop the floating opening ghost whenever opening placement ends (commit,
|
|
6241
|
+
// tool change, mode switch, cancel) or the active level changes, so a stale
|
|
6242
|
+
// ghost never lingers on the wrong level.
|
|
6243
|
+
useEffect(() => {
|
|
6244
|
+
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
|
|
6245
|
+
}, [isOpeningPlacementActive])
|
|
6246
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `levelId` is an intentional re-run trigger; the effect drops the placement ghost when the active level changes.
|
|
6247
|
+
useEffect(() => {
|
|
6248
|
+
usePlacementPreview.getState().clear()
|
|
6249
|
+
}, [levelId])
|
|
5515
6250
|
const isMarqueeSelectionToolActive =
|
|
5516
6251
|
mode === 'select' &&
|
|
5517
6252
|
floorplanSelectionTool === 'marquee' &&
|
|
5518
6253
|
!movingNode &&
|
|
5519
|
-
!
|
|
6254
|
+
!isFenceEndpointMoveActive &&
|
|
5520
6255
|
structureLayer !== 'zones'
|
|
5521
6256
|
const isScreenSelectionToolActive =
|
|
5522
6257
|
mode === 'select' &&
|
|
5523
6258
|
floorplanSelectionTool === 'click' &&
|
|
5524
6259
|
(phase === 'structure' || phase === 'furnish') &&
|
|
5525
6260
|
!movingNode &&
|
|
5526
|
-
!
|
|
6261
|
+
!isFenceEndpointMoveActive &&
|
|
5527
6262
|
!referenceScaleDraft &&
|
|
5528
6263
|
!pendingReferenceScale
|
|
5529
6264
|
const isDeleteMode = mode === 'delete' && !movingNode
|
|
@@ -5531,7 +6266,7 @@ export function FloorplanPanel() {
|
|
|
5531
6266
|
mode === 'select' &&
|
|
5532
6267
|
floorplanSelectionTool === 'click' &&
|
|
5533
6268
|
!movingNode &&
|
|
5534
|
-
!
|
|
6269
|
+
!isFenceEndpointMoveActive &&
|
|
5535
6270
|
structureLayer !== 'zones'
|
|
5536
6271
|
const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry
|
|
5537
6272
|
const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry
|
|
@@ -5544,7 +6279,7 @@ export function FloorplanPanel() {
|
|
|
5544
6279
|
mode === 'select' &&
|
|
5545
6280
|
floorplanSelectionTool === 'click' &&
|
|
5546
6281
|
!movingNode &&
|
|
5547
|
-
!
|
|
6282
|
+
!isFenceEndpointMoveActive &&
|
|
5548
6283
|
structureLayer === 'zones'
|
|
5549
6284
|
const canInteractFloorplanZones = isDeleteMode || canSelectFloorplanZones
|
|
5550
6285
|
const isFloorplanStructureContextActive = phase === 'structure' && structureLayer !== 'zones'
|
|
@@ -5555,7 +6290,7 @@ export function FloorplanPanel() {
|
|
|
5555
6290
|
(mode === 'select' &&
|
|
5556
6291
|
floorplanSelectionTool === 'click' &&
|
|
5557
6292
|
!movingNode &&
|
|
5558
|
-
!
|
|
6293
|
+
!isFenceEndpointMoveActive &&
|
|
5559
6294
|
isFloorplanStructureContextActive) ||
|
|
5560
6295
|
isDeleteMode
|
|
5561
6296
|
const canSelectFloorplanElevators = canSelectFloorplanStairs
|
|
@@ -5564,21 +6299,21 @@ export function FloorplanPanel() {
|
|
|
5564
6299
|
(mode === 'select' &&
|
|
5565
6300
|
floorplanSelectionTool === 'click' &&
|
|
5566
6301
|
!movingNode &&
|
|
5567
|
-
!
|
|
6302
|
+
!isFenceEndpointMoveActive &&
|
|
5568
6303
|
isFloorplanItemContextActive) ||
|
|
5569
6304
|
isDeleteMode
|
|
5570
6305
|
const canFocusFloorplanStairs =
|
|
5571
6306
|
mode === 'select' &&
|
|
5572
6307
|
floorplanSelectionTool === 'click' &&
|
|
5573
6308
|
!movingNode &&
|
|
5574
|
-
!
|
|
6309
|
+
!isFenceEndpointMoveActive &&
|
|
5575
6310
|
isFloorplanStructureContextActive
|
|
5576
6311
|
const canFocusFloorplanSpawns = canFocusFloorplanStairs
|
|
5577
6312
|
const canFocusFloorplanItems =
|
|
5578
6313
|
mode === 'select' &&
|
|
5579
6314
|
floorplanSelectionTool === 'click' &&
|
|
5580
6315
|
!movingNode &&
|
|
5581
|
-
!
|
|
6316
|
+
!isFenceEndpointMoveActive &&
|
|
5582
6317
|
isFloorplanItemContextActive
|
|
5583
6318
|
const visibleSitePolygon = displaySitePolygon
|
|
5584
6319
|
const canUseSiteBoundaryVertexHandles =
|
|
@@ -5594,35 +6329,6 @@ export function FloorplanPanel() {
|
|
|
5594
6329
|
() => new Set([...selectedIds, ...previewSelectedIds]),
|
|
5595
6330
|
[previewSelectedIds, selectedIds],
|
|
5596
6331
|
)
|
|
5597
|
-
const activeMarqueeBounds = useMemo(() => {
|
|
5598
|
-
if (!floorplanMarqueeState) {
|
|
5599
|
-
return null
|
|
5600
|
-
}
|
|
5601
|
-
|
|
5602
|
-
return getFloorplanSelectionBounds(
|
|
5603
|
-
floorplanMarqueeState.startPlanPoint,
|
|
5604
|
-
floorplanMarqueeState.currentPlanPoint,
|
|
5605
|
-
)
|
|
5606
|
-
}, [floorplanMarqueeState])
|
|
5607
|
-
const visibleMarqueeBounds = useMemo(() => {
|
|
5608
|
-
if (!(floorplanMarqueeState && activeMarqueeBounds)) {
|
|
5609
|
-
return null
|
|
5610
|
-
}
|
|
5611
|
-
|
|
5612
|
-
const dragDistance = Math.hypot(
|
|
5613
|
-
floorplanMarqueeState.currentPlanPoint[0] - floorplanMarqueeState.startPlanPoint[0],
|
|
5614
|
-
floorplanMarqueeState.currentPlanPoint[1] - floorplanMarqueeState.startPlanPoint[1],
|
|
5615
|
-
)
|
|
5616
|
-
|
|
5617
|
-
return dragDistance > 0 ? activeMarqueeBounds : null
|
|
5618
|
-
}, [activeMarqueeBounds, floorplanMarqueeState])
|
|
5619
|
-
const visibleSvgMarqueeBounds = useMemo(() => {
|
|
5620
|
-
if (!visibleMarqueeBounds) {
|
|
5621
|
-
return null
|
|
5622
|
-
}
|
|
5623
|
-
|
|
5624
|
-
return toSvgSelectionBounds(visibleMarqueeBounds)
|
|
5625
|
-
}, [visibleMarqueeBounds])
|
|
5626
6332
|
const siteVertexHandles = useMemo(() => {
|
|
5627
6333
|
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
|
|
5628
6334
|
return []
|
|
@@ -5639,151 +6345,41 @@ export function FloorplanPanel() {
|
|
|
5639
6345
|
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
|
5640
6346
|
const siteEdgeHandles = useMemo(() => {
|
|
5641
6347
|
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
|
|
5642
|
-
return []
|
|
5643
|
-
}
|
|
5644
|
-
|
|
5645
|
-
return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
|
|
5646
|
-
const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
|
|
5647
|
-
return {
|
|
5648
|
-
nodeId: visibleSitePolygon.site.id,
|
|
5649
|
-
edgeIndex,
|
|
5650
|
-
start: toWallPlanPoint(point),
|
|
5651
|
-
end: toWallPlanPoint(nextPoint ?? point),
|
|
5652
|
-
}
|
|
5653
|
-
})
|
|
5654
|
-
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
|
5655
|
-
const siteMidpointHandles = useMemo(() => {
|
|
5656
|
-
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
|
|
5657
|
-
return []
|
|
5658
|
-
}
|
|
5659
|
-
|
|
5660
|
-
return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
|
|
5661
|
-
const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
|
|
5662
|
-
return {
|
|
5663
|
-
nodeId: visibleSitePolygon.site.id,
|
|
5664
|
-
edgeIndex,
|
|
5665
|
-
point: [
|
|
5666
|
-
(point.x + (nextPoint?.x ?? point.x)) / 2,
|
|
5667
|
-
(point.y + (nextPoint?.y ?? point.y)) / 2,
|
|
5668
|
-
] as WallPlanPoint,
|
|
5669
|
-
}
|
|
5670
|
-
})
|
|
5671
|
-
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
|
5672
|
-
|
|
5673
|
-
const draftPolygon = useMemo(() => {
|
|
5674
|
-
if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
|
|
5675
|
-
return null
|
|
5676
|
-
}
|
|
5677
|
-
|
|
5678
|
-
const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, draftStart, draftEnd))
|
|
5679
|
-
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
|
|
5680
|
-
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
|
5681
|
-
}, [draftEnd, draftStart, levelId])
|
|
5682
|
-
// Live length + angle feedback for the wall draft — parity with the 3D
|
|
5683
|
-
// `WallTool` (`packages/nodes/src/wall/tool.tsx`), ported to 2D plan
|
|
5684
|
-
// space. Length renders at the segment midpoint; angle arcs sit at
|
|
5685
|
-
// each endpoint that meets an existing wall.
|
|
5686
|
-
const draftWallMeasurement = useMemo(() => {
|
|
5687
|
-
if (
|
|
5688
|
-
!(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))
|
|
5689
|
-
) {
|
|
5690
|
-
return null
|
|
5691
|
-
}
|
|
5692
|
-
|
|
5693
|
-
const dx = draftEnd[0] - draftStart[0]
|
|
5694
|
-
const dy = draftEnd[1] - draftStart[1]
|
|
5695
|
-
const length = Math.hypot(dx, dy)
|
|
5696
|
-
|
|
5697
|
-
const draftFromStart: WallPlanPoint = [dx, dy]
|
|
5698
|
-
const draftFromEnd: WallPlanPoint = [-dx, -dy]
|
|
5699
|
-
const endpoints = [
|
|
5700
|
-
{ id: 'start', point: draftStart, draftVector: draftFromStart },
|
|
5701
|
-
{ id: 'end', point: draftEnd, draftVector: draftFromEnd },
|
|
5702
|
-
] as const
|
|
5703
|
-
|
|
5704
|
-
type AngleLabel = {
|
|
5705
|
-
id: string
|
|
5706
|
-
label: string
|
|
5707
|
-
center: WallPlanPoint
|
|
5708
|
-
radius: number
|
|
5709
|
-
startAngle: number
|
|
5710
|
-
endAngle: number
|
|
5711
|
-
midAngle: number
|
|
5712
|
-
}
|
|
5713
|
-
|
|
5714
|
-
const angleLabels: AngleLabel[] = []
|
|
5715
|
-
for (const endpoint of endpoints) {
|
|
5716
|
-
const connectedWall = walls.find((wall) =>
|
|
5717
|
-
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
|
5718
|
-
)
|
|
5719
|
-
if (!connectedWall) continue
|
|
5720
|
-
const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
|
5721
|
-
if (!ref) continue
|
|
5722
|
-
|
|
5723
|
-
const angle = getAngleToSegmentReference(endpoint.draftVector, ref)
|
|
5724
|
-
if (angle === null) continue
|
|
5725
|
-
const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref)
|
|
5726
|
-
if (!arc || arc.angle < 0.01) continue
|
|
5727
|
-
|
|
5728
|
-
const refLen = Math.hypot(ref.vector[0], ref.vector[1])
|
|
5729
|
-
const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28))
|
|
5730
|
-
|
|
5731
|
-
angleLabels.push({
|
|
5732
|
-
id: endpoint.id,
|
|
5733
|
-
label: formatAngleRadians(angle),
|
|
5734
|
-
center: endpoint.point,
|
|
5735
|
-
radius,
|
|
5736
|
-
startAngle: arc.startAngle,
|
|
5737
|
-
endAngle: arc.endAngle,
|
|
5738
|
-
midAngle: arc.midAngle,
|
|
5739
|
-
})
|
|
5740
|
-
}
|
|
5741
|
-
|
|
5742
|
-
return {
|
|
5743
|
-
lengthLabel: formatMeasurement(length, unit),
|
|
5744
|
-
midpoint: [
|
|
5745
|
-
(draftStart[0] + draftEnd[0]) / 2,
|
|
5746
|
-
(draftStart[1] + draftEnd[1]) / 2,
|
|
5747
|
-
] as WallPlanPoint,
|
|
5748
|
-
direction: [dx / length, dy / length] as WallPlanPoint,
|
|
5749
|
-
angleLabels,
|
|
5750
|
-
}
|
|
5751
|
-
}, [draftEnd, draftStart, isWallBuildActive, unit, walls])
|
|
5752
|
-
const draftPolygonPoints = useMemo(() => {
|
|
5753
|
-
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
|
5754
|
-
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
|
5755
|
-
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
|
|
5756
|
-
const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
|
|
5757
|
-
const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
|
|
5758
|
-
|
|
5759
|
-
if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
|
|
5760
|
-
return formatPolygonPoints([
|
|
5761
|
-
{ x: minX, y: minY },
|
|
5762
|
-
{ x: maxX, y: minY },
|
|
5763
|
-
{ x: maxX, y: maxY },
|
|
5764
|
-
{ x: minX, y: maxY },
|
|
5765
|
-
])
|
|
5766
|
-
}
|
|
5767
|
-
}
|
|
5768
|
-
|
|
5769
|
-
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
|
|
5770
|
-
}, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
|
|
5771
|
-
const fenceDraftSegment = useMemo(() => {
|
|
5772
|
-
if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
|
|
5773
|
-
return null
|
|
6348
|
+
return []
|
|
5774
6349
|
}
|
|
5775
6350
|
|
|
5776
|
-
|
|
5777
|
-
|
|
6351
|
+
return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
|
|
6352
|
+
const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
|
|
6353
|
+
return {
|
|
6354
|
+
nodeId: visibleSitePolygon.site.id,
|
|
6355
|
+
edgeIndex,
|
|
6356
|
+
start: toWallPlanPoint(point),
|
|
6357
|
+
end: toWallPlanPoint(nextPoint ?? point),
|
|
6358
|
+
}
|
|
6359
|
+
})
|
|
6360
|
+
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
|
6361
|
+
const siteMidpointHandles = useMemo(() => {
|
|
6362
|
+
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
|
|
6363
|
+
return []
|
|
5778
6364
|
}
|
|
5779
6365
|
|
|
5780
|
-
return {
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
6366
|
+
return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
|
|
6367
|
+
const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
|
|
6368
|
+
return {
|
|
6369
|
+
nodeId: visibleSitePolygon.site.id,
|
|
6370
|
+
edgeIndex,
|
|
6371
|
+
point: [
|
|
6372
|
+
(point.x + (nextPoint?.x ?? point.x)) / 2,
|
|
6373
|
+
(point.y + (nextPoint?.y ?? point.y)) / 2,
|
|
6374
|
+
] as WallPlanPoint,
|
|
6375
|
+
}
|
|
6376
|
+
})
|
|
6377
|
+
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
|
6378
|
+
|
|
6379
|
+
// The live wall / fence / roof draft preview (polygon + fence segment + wall
|
|
6380
|
+
// measurement) moved into `FloorplanLinearDraftLayer`, which reads the per-
|
|
6381
|
+
// move END points from the draft store so it re-renders per move without
|
|
6382
|
+
// re-rendering this panel.
|
|
5787
6383
|
const activePolygonDraftPoints = useMemo(() => {
|
|
5788
6384
|
if (isCeilingBuildActive) {
|
|
5789
6385
|
return ceilingDraftPoints
|
|
@@ -5806,37 +6402,9 @@ export function FloorplanPanel() {
|
|
|
5806
6402
|
slabDraftPoints,
|
|
5807
6403
|
zoneDraftPoints,
|
|
5808
6404
|
])
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
}
|
|
5813
|
-
|
|
5814
|
-
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
|
5815
|
-
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
5816
|
-
const polygonDraftPolygonPoints = useMemo(() => {
|
|
5817
|
-
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
|
|
5818
|
-
return null
|
|
5819
|
-
}
|
|
5820
|
-
|
|
5821
|
-
return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
|
|
5822
|
-
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
5823
|
-
const polygonDraftClosingSegment = useMemo(() => {
|
|
5824
|
-
if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
|
|
5825
|
-
return null
|
|
5826
|
-
}
|
|
5827
|
-
|
|
5828
|
-
const firstPoint = activePolygonDraftPoints[0]
|
|
5829
|
-
if (!firstPoint) {
|
|
5830
|
-
return null
|
|
5831
|
-
}
|
|
5832
|
-
|
|
5833
|
-
return {
|
|
5834
|
-
x1: toSvgX(cursorPoint[0]),
|
|
5835
|
-
y1: toSvgY(cursorPoint[1]),
|
|
5836
|
-
x2: toSvgX(firstPoint[0]),
|
|
5837
|
-
y2: toSvgY(firstPoint[1]),
|
|
5838
|
-
}
|
|
5839
|
-
}, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
|
|
6405
|
+
// The cursor-following polygon-draft preview (slab / zone / ceiling) moved into
|
|
6406
|
+
// `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store
|
|
6407
|
+
// so it re-renders per move without re-rendering this panel.
|
|
5840
6408
|
|
|
5841
6409
|
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
|
|
5842
6410
|
|
|
@@ -6111,6 +6679,13 @@ export function FloorplanPanel() {
|
|
|
6111
6679
|
|
|
6112
6680
|
const syncFloorplanViewportToNavigationPose = useCallback(
|
|
6113
6681
|
(pose: NavigationSyncPose) => {
|
|
6682
|
+
// Skip the viewport sync while the 2D panel is hidden (3D mode). It writes
|
|
6683
|
+
// React state (`setViewport`) that re-renders the whole floorplan SVG, so
|
|
6684
|
+
// doing it every camera-zoom frame for an invisible panel was a needless
|
|
6685
|
+
// per-frame stall. The catch-up effect below re-syncs on reopen.
|
|
6686
|
+
if (!isFloorplanOpenRef.current) {
|
|
6687
|
+
return
|
|
6688
|
+
}
|
|
6114
6689
|
if (floorplanRotationStateRef.current) {
|
|
6115
6690
|
return
|
|
6116
6691
|
}
|
|
@@ -6134,6 +6709,8 @@ export function FloorplanPanel() {
|
|
|
6134
6709
|
)
|
|
6135
6710
|
|
|
6136
6711
|
useEffect(() => {
|
|
6712
|
+
if (!isFloorplanOpen) return
|
|
6713
|
+
|
|
6137
6714
|
const pose = useEditor.getState().navigationSyncPose
|
|
6138
6715
|
if (!pose) {
|
|
6139
6716
|
return
|
|
@@ -6143,10 +6720,52 @@ export function FloorplanPanel() {
|
|
|
6143
6720
|
if (pose.source === '3d') {
|
|
6144
6721
|
syncFloorplanViewportToNavigationPose(pose)
|
|
6145
6722
|
}
|
|
6146
|
-
}, [syncFloorplanViewportToNavigationPose])
|
|
6723
|
+
}, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
|
|
6724
|
+
|
|
6725
|
+
const cancelHiddenCompassAnimation = useCallback(() => {
|
|
6726
|
+
if (hiddenCompassAnimationRef.current !== null) {
|
|
6727
|
+
cancelAnimationFrame(hiddenCompassAnimationRef.current)
|
|
6728
|
+
hiddenCompassAnimationRef.current = null
|
|
6729
|
+
}
|
|
6730
|
+
}, [])
|
|
6731
|
+
|
|
6732
|
+
// Align-north while the panel is hidden publishes a single '2d' pose that
|
|
6733
|
+
// the 3D camera applies through the echo-suppressed pending-pose path — it
|
|
6734
|
+
// never publishes '3d' frames back, so the needle must animate itself.
|
|
6735
|
+
// Same time constant as the 2D view animation and the camera's effective
|
|
6736
|
+
// smoothTime, so all three stay visually in step.
|
|
6737
|
+
const animateHiddenCompassNeedle = useCallback(
|
|
6738
|
+
(targetDeg: number) => {
|
|
6739
|
+
cancelHiddenCompassAnimation()
|
|
6740
|
+
let last = performance.now()
|
|
6741
|
+
const tick = (now: number) => {
|
|
6742
|
+
hiddenCompassAnimationRef.current = null
|
|
6743
|
+
if (isFloorplanOpenRef.current) {
|
|
6744
|
+
return
|
|
6745
|
+
}
|
|
6746
|
+
const deltaMs = now - last
|
|
6747
|
+
last = now
|
|
6748
|
+
const currentDeg = latestFloorplanUserRotationDegRef.current
|
|
6749
|
+
const decay = Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS)
|
|
6750
|
+
let nextDeg = targetDeg - (targetDeg - currentDeg) * decay
|
|
6751
|
+
if (Math.abs(targetDeg - nextDeg) < 0.05) {
|
|
6752
|
+
nextDeg = targetDeg
|
|
6753
|
+
}
|
|
6754
|
+
latestFloorplanUserRotationDegRef.current = nextDeg
|
|
6755
|
+
if (compassNeedleRef.current) {
|
|
6756
|
+
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
|
|
6757
|
+
}
|
|
6758
|
+
if (nextDeg !== targetDeg) {
|
|
6759
|
+
hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
|
|
6760
|
+
}
|
|
6761
|
+
}
|
|
6762
|
+
hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
|
|
6763
|
+
},
|
|
6764
|
+
[cancelHiddenCompassAnimation],
|
|
6765
|
+
)
|
|
6147
6766
|
|
|
6148
6767
|
useEffect(() => {
|
|
6149
|
-
|
|
6768
|
+
const unsubscribe = useEditor.subscribe((state) => {
|
|
6150
6769
|
const pose = state.navigationSyncPose
|
|
6151
6770
|
if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) {
|
|
6152
6771
|
return
|
|
@@ -6154,11 +6773,50 @@ export function FloorplanPanel() {
|
|
|
6154
6773
|
|
|
6155
6774
|
latestNavigationSyncPoseRef.current = pose
|
|
6156
6775
|
|
|
6776
|
+
if (!isFloorplanOpenRef.current) {
|
|
6777
|
+
const nextDeg = floorplanRotationFromCameraAzimuth(
|
|
6778
|
+
pose.azimuth,
|
|
6779
|
+
latestFloorplanUserRotationDegRef.current,
|
|
6780
|
+
)
|
|
6781
|
+
if (pose.source === '3d') {
|
|
6782
|
+
// Panel hidden — drive the compass needle imperatively without
|
|
6783
|
+
// triggering React state (setViewport) that would re-render the
|
|
6784
|
+
// full floorplan SVG every camera frame. The live camera stream
|
|
6785
|
+
// owns the needle, so any local animation yields to it.
|
|
6786
|
+
cancelHiddenCompassAnimation()
|
|
6787
|
+
latestFloorplanUserRotationDegRef.current = nextDeg
|
|
6788
|
+
if (compassNeedleRef.current) {
|
|
6789
|
+
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
|
|
6790
|
+
}
|
|
6791
|
+
} else {
|
|
6792
|
+
animateHiddenCompassNeedle(nextDeg)
|
|
6793
|
+
}
|
|
6794
|
+
return
|
|
6795
|
+
}
|
|
6796
|
+
|
|
6157
6797
|
if (pose.source === '3d') {
|
|
6158
6798
|
syncFloorplanViewportToNavigationPose(pose)
|
|
6159
6799
|
}
|
|
6160
6800
|
})
|
|
6161
|
-
|
|
6801
|
+
return () => {
|
|
6802
|
+
unsubscribe()
|
|
6803
|
+
cancelHiddenCompassAnimation()
|
|
6804
|
+
}
|
|
6805
|
+
}, [
|
|
6806
|
+
syncFloorplanViewportToNavigationPose,
|
|
6807
|
+
animateHiddenCompassNeedle,
|
|
6808
|
+
cancelHiddenCompassAnimation,
|
|
6809
|
+
])
|
|
6810
|
+
|
|
6811
|
+
// When the panel is hidden the imperative path owns the compass needle.
|
|
6812
|
+
// React re-renders can overwrite the needle's inline transform with stale
|
|
6813
|
+
// state; this layout effect restores the authoritative ref value before
|
|
6814
|
+
// the browser paints so the needle never visibly snaps to a stale angle.
|
|
6815
|
+
useLayoutEffect(() => {
|
|
6816
|
+
if (!isFloorplanOpen && compassNeedleRef.current) {
|
|
6817
|
+
compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)`
|
|
6818
|
+
}
|
|
6819
|
+
})
|
|
6162
6820
|
|
|
6163
6821
|
useEffect(() => {
|
|
6164
6822
|
const host = viewportHostRef.current
|
|
@@ -6254,12 +6912,15 @@ export function FloorplanPanel() {
|
|
|
6254
6912
|
|
|
6255
6913
|
// While the cursor drives live geometry (items, drafts, moves), `fittedViewport` changes every
|
|
6256
6914
|
// pointermove. Syncing `viewport` here would call setState in a tight loop (max update depth).
|
|
6915
|
+
// `cursorPoint` now lives in the draft store; read it non-reactively (this
|
|
6916
|
+
// effect only re-runs when `fittedViewport` / the other transient signals
|
|
6917
|
+
// change, and the viewport never refits mid-draft because scene data is
|
|
6918
|
+
// stable then — so a live store read is sufficient and correct).
|
|
6257
6919
|
const transientFloorplanFit =
|
|
6258
|
-
cursorPoint != null ||
|
|
6920
|
+
useFloorplanDraftPreview.getState().cursorPoint != null ||
|
|
6259
6921
|
movingNode != null ||
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
curvingFence != null ||
|
|
6922
|
+
endpointReshape != null ||
|
|
6923
|
+
isCurveReshape ||
|
|
6263
6924
|
siteVertexDragState != null ||
|
|
6264
6925
|
isPolygonDraftBuildActive
|
|
6265
6926
|
|
|
@@ -6269,13 +6930,11 @@ export function FloorplanPanel() {
|
|
|
6269
6930
|
)
|
|
6270
6931
|
}
|
|
6271
6932
|
}, [
|
|
6272
|
-
|
|
6273
|
-
curvingWall,
|
|
6274
|
-
cursorPoint,
|
|
6933
|
+
endpointReshape,
|
|
6275
6934
|
fittedViewport,
|
|
6935
|
+
isCurveReshape,
|
|
6276
6936
|
isPolygonDraftBuildActive,
|
|
6277
6937
|
levelId,
|
|
6278
|
-
movingFenceEndpoint,
|
|
6279
6938
|
movingNode,
|
|
6280
6939
|
siteVertexDragState,
|
|
6281
6940
|
stopFloorplanViewAnimation,
|
|
@@ -6606,7 +7265,6 @@ export function FloorplanPanel() {
|
|
|
6606
7265
|
setReferenceScaleDraft({
|
|
6607
7266
|
guideId: guide.id,
|
|
6608
7267
|
start: null,
|
|
6609
|
-
cursor: null,
|
|
6610
7268
|
})
|
|
6611
7269
|
setPendingReferenceScale(null)
|
|
6612
7270
|
setMode('select')
|
|
@@ -6678,8 +7336,8 @@ export function FloorplanPanel() {
|
|
|
6678
7336
|
return
|
|
6679
7337
|
}
|
|
6680
7338
|
|
|
6681
|
-
const displayLength =
|
|
6682
|
-
if (!(displayLength > 0)) {
|
|
7339
|
+
const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit)
|
|
7340
|
+
if (!(displayLength && displayLength > 0)) {
|
|
6683
7341
|
return
|
|
6684
7342
|
}
|
|
6685
7343
|
|
|
@@ -6809,7 +7467,7 @@ export function FloorplanPanel() {
|
|
|
6809
7467
|
setCursorPoint(planPoint)
|
|
6810
7468
|
return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
|
|
6811
7469
|
},
|
|
6812
|
-
[],
|
|
7470
|
+
[setCursorPoint],
|
|
6813
7471
|
)
|
|
6814
7472
|
|
|
6815
7473
|
const handleElevatorResizePointerDown = useCallback(
|
|
@@ -6882,7 +7540,13 @@ export function FloorplanPanel() {
|
|
|
6882
7540
|
setElevatorResizeDragState(null)
|
|
6883
7541
|
setCursorPoint(null)
|
|
6884
7542
|
},
|
|
6885
|
-
[
|
|
7543
|
+
[
|
|
7544
|
+
elevatorResizeDragState,
|
|
7545
|
+
getPlanPointFromClientPoint,
|
|
7546
|
+
previewElevatorResize,
|
|
7547
|
+
updateNode,
|
|
7548
|
+
setCursorPoint,
|
|
7549
|
+
],
|
|
6886
7550
|
)
|
|
6887
7551
|
|
|
6888
7552
|
useEffect(() => {
|
|
@@ -6931,6 +7595,25 @@ export function FloorplanPanel() {
|
|
|
6931
7595
|
)
|
|
6932
7596
|
|
|
6933
7597
|
const alignFloorplanViewToNorth = useCallback(() => {
|
|
7598
|
+
if (!isFloorplanOpenRef.current) {
|
|
7599
|
+
// Panel hidden — derive from the live 3D camera pose and publish
|
|
7600
|
+
// directly. The pose subscription picks this '2d' pose up and animates
|
|
7601
|
+
// the needle locally (the camera transition suppresses '3d' echoes).
|
|
7602
|
+
const pose = latestNavigationSyncPoseRef.current
|
|
7603
|
+
if (!pose) return
|
|
7604
|
+
const currentRotation = latestFloorplanUserRotationDegRef.current
|
|
7605
|
+
const northAzimuth = cameraAzimuthFromFloorplanRotation(
|
|
7606
|
+
nearestEquivalentDegrees(0, currentRotation),
|
|
7607
|
+
)
|
|
7608
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
7609
|
+
source: '2d',
|
|
7610
|
+
target: [...pose.target],
|
|
7611
|
+
azimuth: northAzimuth,
|
|
7612
|
+
viewWidth: pose.viewWidth,
|
|
7613
|
+
})
|
|
7614
|
+
return
|
|
7615
|
+
}
|
|
7616
|
+
|
|
6934
7617
|
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
|
|
6935
7618
|
if (!currentViewport) {
|
|
6936
7619
|
return
|
|
@@ -7156,16 +7839,19 @@ export function FloorplanPanel() {
|
|
|
7156
7839
|
|
|
7157
7840
|
const clearWallPlacementDraft = useCallback(() => {
|
|
7158
7841
|
setDraftStart(null)
|
|
7842
|
+
setWallChainFirstVertex(null)
|
|
7843
|
+
wallChainWallIdsRef.current = []
|
|
7159
7844
|
setDraftEnd(null)
|
|
7160
|
-
|
|
7845
|
+
useSegmentDraftChain.getState().clear('wall')
|
|
7846
|
+
}, [setDraftEnd])
|
|
7161
7847
|
const clearFencePlacementDraft = useCallback(() => {
|
|
7162
7848
|
setFenceDraftStart(null)
|
|
7163
7849
|
setFenceDraftEnd(null)
|
|
7164
|
-
}, [])
|
|
7850
|
+
}, [setFenceDraftEnd])
|
|
7165
7851
|
const clearRoofPlacementDraft = useCallback(() => {
|
|
7166
7852
|
setRoofDraftStart(null)
|
|
7167
7853
|
setRoofDraftEnd(null)
|
|
7168
|
-
}, [])
|
|
7854
|
+
}, [setRoofDraftEnd])
|
|
7169
7855
|
const clearCeilingPlacementDraft = useCallback(() => {
|
|
7170
7856
|
setCeilingDraftPoints([])
|
|
7171
7857
|
}, [])
|
|
@@ -7191,12 +7877,14 @@ export function FloorplanPanel() {
|
|
|
7191
7877
|
const draft = siteBoundaryDraftRef.current
|
|
7192
7878
|
if (draft) {
|
|
7193
7879
|
clearSiteBoundaryLivePreview(draft.siteId)
|
|
7194
|
-
const
|
|
7880
|
+
const scope = useInteractionScope.getState().scope
|
|
7881
|
+
const activeHandleDrag =
|
|
7882
|
+
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
|
7195
7883
|
if (
|
|
7196
|
-
|
|
7197
|
-
|
|
7884
|
+
activeHandleDrag?.nodeId === draft.siteId &&
|
|
7885
|
+
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
|
7198
7886
|
) {
|
|
7199
|
-
|
|
7887
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
7200
7888
|
}
|
|
7201
7889
|
}
|
|
7202
7890
|
|
|
@@ -7236,6 +7924,7 @@ export function FloorplanPanel() {
|
|
|
7236
7924
|
clearWallEndpointDrag,
|
|
7237
7925
|
clearWallPlacementDraft,
|
|
7238
7926
|
clearZonePlacementDraft,
|
|
7927
|
+
setCursorPoint,
|
|
7239
7928
|
])
|
|
7240
7929
|
|
|
7241
7930
|
useEffect(() => {
|
|
@@ -7285,17 +7974,68 @@ export function FloorplanPanel() {
|
|
|
7285
7974
|
[levelId, setSelection],
|
|
7286
7975
|
)
|
|
7287
7976
|
|
|
7977
|
+
// Slab / ceiling are normally committed by their 3D registry tools (which
|
|
7978
|
+
// accumulate the same `grid:click` vertices the panel emits). That path is
|
|
7979
|
+
// dead in 2D-only view — the 3D canvas is `display:none`, so the tool never
|
|
7980
|
+
// commits. These local committers (mirroring `createZoneOnCurrentLevel`, and
|
|
7981
|
+
// the tools' own `commitSlab/CeilingDrawing`) are called ONLY in 2D-only view
|
|
7982
|
+
// so split / 3D keep their single-owner tool commit (no double-create).
|
|
7983
|
+
const createSlabOnCurrentLevel = useCallback(
|
|
7984
|
+
(points: WallPlanPoint[]) => {
|
|
7985
|
+
if (!levelId) {
|
|
7986
|
+
return null
|
|
7987
|
+
}
|
|
7988
|
+
const { createNode, nodes } = useScene.getState()
|
|
7989
|
+
const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length
|
|
7990
|
+
const defaults = useEditor.getState().toolDefaults.slab ?? {}
|
|
7991
|
+
const slab = SlabNodeSchema.parse({
|
|
7992
|
+
...defaults,
|
|
7993
|
+
name: `Slab ${slabCount + 1}`,
|
|
7994
|
+
polygon: points.map(([x, z]) => [x, z] as [number, number]),
|
|
7995
|
+
})
|
|
7996
|
+
createNode(slab, levelId)
|
|
7997
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
7998
|
+
setSelection({ selectedIds: [slab.id] })
|
|
7999
|
+
return slab.id
|
|
8000
|
+
},
|
|
8001
|
+
[levelId, setSelection],
|
|
8002
|
+
)
|
|
8003
|
+
|
|
8004
|
+
const createCeilingOnCurrentLevel = useCallback(
|
|
8005
|
+
(points: WallPlanPoint[]) => {
|
|
8006
|
+
if (!levelId) {
|
|
8007
|
+
return null
|
|
8008
|
+
}
|
|
8009
|
+
const { createNode, nodes } = useScene.getState()
|
|
8010
|
+
const ceilingCount = Object.values(nodes).filter((node) => node.type === 'ceiling').length
|
|
8011
|
+
const defaults = useEditor.getState().toolDefaults.ceiling ?? {}
|
|
8012
|
+
const ceiling = CeilingNodeSchema.parse({
|
|
8013
|
+
...defaults,
|
|
8014
|
+
name: `Ceiling ${ceilingCount + 1}`,
|
|
8015
|
+
polygon: points.map(([x, z]) => [x, z] as [number, number]),
|
|
8016
|
+
})
|
|
8017
|
+
createNode(ceiling, levelId)
|
|
8018
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
8019
|
+
setSelection({ selectedIds: [ceiling.id] })
|
|
8020
|
+
return ceiling.id
|
|
8021
|
+
},
|
|
8022
|
+
[levelId, setSelection],
|
|
8023
|
+
)
|
|
8024
|
+
|
|
7288
8025
|
useEffect(() => {
|
|
7289
8026
|
if (!isStairBuildActive) {
|
|
7290
|
-
|
|
7291
|
-
setStairBuildPreviewRotation(0)
|
|
8027
|
+
useStairBuildPreview.getState().reset()
|
|
7292
8028
|
return
|
|
7293
8029
|
}
|
|
7294
8030
|
|
|
7295
8031
|
const handleGridMove = (event: GridEvent) => {
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
8032
|
+
// Publish to the dedicated store (deduped on the snapped point), NOT panel
|
|
8033
|
+
// state: the stair preview lives in `FloorplanStairBuildPreviewLayer`, so a
|
|
8034
|
+
// per-move update re-renders only that tiny leaf instead of this entire
|
|
8035
|
+
// (~200ms) panel — the same pattern that keeps column/elevator smooth.
|
|
8036
|
+
useStairBuildPreview
|
|
8037
|
+
.getState()
|
|
8038
|
+
.setPoint(getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]]))
|
|
7299
8039
|
}
|
|
7300
8040
|
|
|
7301
8041
|
emitter.on('grid:move', handleGridMove)
|
|
@@ -7501,9 +8241,9 @@ export function FloorplanPanel() {
|
|
|
7501
8241
|
}
|
|
7502
8242
|
|
|
7503
8243
|
if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) {
|
|
7504
|
-
|
|
8244
|
+
useStairBuildPreview.getState().rotateBy(Math.PI / 4)
|
|
7505
8245
|
} else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) {
|
|
7506
|
-
|
|
8246
|
+
useStairBuildPreview.getState().rotateBy(-Math.PI / 4)
|
|
7507
8247
|
}
|
|
7508
8248
|
|
|
7509
8249
|
if (
|
|
@@ -7560,9 +8300,10 @@ export function FloorplanPanel() {
|
|
|
7560
8300
|
return
|
|
7561
8301
|
}
|
|
7562
8302
|
|
|
8303
|
+
const bypassSnap = shiftPressed || event.shiftKey
|
|
7563
8304
|
const nextDraft =
|
|
7564
8305
|
guideInteraction.mode === 'rotate'
|
|
7565
|
-
? buildGuideRotationDraft(guideInteraction, svgPoint,
|
|
8306
|
+
? buildGuideRotationDraft(guideInteraction, svgPoint, bypassSnap)
|
|
7566
8307
|
: guideInteraction.mode === 'translate'
|
|
7567
8308
|
? buildGuideTranslateDraft(guideInteraction, svgPoint)
|
|
7568
8309
|
: buildGuideResizeDraft(guideInteraction, svgPoint)
|
|
@@ -7619,15 +8360,13 @@ export function FloorplanPanel() {
|
|
|
7619
8360
|
return
|
|
7620
8361
|
}
|
|
7621
8362
|
|
|
7622
|
-
// Wall endpoint move:
|
|
7623
|
-
//
|
|
7624
|
-
// to the fine grid step for precision.
|
|
8363
|
+
// Wall endpoint move: snapping follows the active mode; there is no
|
|
8364
|
+
// held-key bypass.
|
|
7625
8365
|
const snapResult = snapWallDraftPointDetailed({
|
|
7626
8366
|
point: planPoint,
|
|
7627
8367
|
walls,
|
|
7628
8368
|
ignoreWallIds: [dragState.wallId],
|
|
7629
|
-
|
|
7630
|
-
magnetic: useEditor.getState().magneticSnap,
|
|
8369
|
+
magnetic: isMagneticSnapActive(),
|
|
7631
8370
|
})
|
|
7632
8371
|
const snappedPoint = snapResult.point
|
|
7633
8372
|
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
|
@@ -7696,17 +8435,12 @@ export function FloorplanPanel() {
|
|
|
7696
8435
|
}
|
|
7697
8436
|
|
|
7698
8437
|
const chord = getWallChordFrame(wall)
|
|
7699
|
-
const snappedPoint: WallPlanPoint =
|
|
7700
|
-
? planPoint
|
|
7701
|
-
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
|
8438
|
+
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
|
7702
8439
|
const rawCurveOffset = -(
|
|
7703
8440
|
(snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
|
|
7704
8441
|
(snappedPoint[1] - chord.midpoint.y) * chord.normal.y
|
|
7705
8442
|
)
|
|
7706
|
-
const nextCurveOffset = normalizeWallCurveOffset(
|
|
7707
|
-
wall,
|
|
7708
|
-
shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
|
|
7709
|
-
)
|
|
8443
|
+
const nextCurveOffset = normalizeWallCurveOffset(wall, snapToHalf(rawCurveOffset))
|
|
7710
8444
|
|
|
7711
8445
|
if (curveDragState.currentCurveOffset === nextCurveOffset) {
|
|
7712
8446
|
return
|
|
@@ -7733,9 +8467,10 @@ export function FloorplanPanel() {
|
|
|
7733
8467
|
}
|
|
7734
8468
|
|
|
7735
8469
|
const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)
|
|
8470
|
+
const bypassSnap = shiftPressed || event.shiftKey
|
|
7736
8471
|
const nextDraft = svgPoint
|
|
7737
8472
|
? interaction.mode === 'rotate'
|
|
7738
|
-
? buildGuideRotationDraft(interaction, svgPoint,
|
|
8473
|
+
? buildGuideRotationDraft(interaction, svgPoint, bypassSnap)
|
|
7739
8474
|
: interaction.mode === 'translate'
|
|
7740
8475
|
? buildGuideTranslateDraft(interaction, svgPoint)
|
|
7741
8476
|
: buildGuideResizeDraft(interaction, svgPoint)
|
|
@@ -7909,6 +8644,7 @@ export function FloorplanPanel() {
|
|
|
7909
8644
|
updateNode,
|
|
7910
8645
|
wallById,
|
|
7911
8646
|
walls,
|
|
8647
|
+
setCursorPoint,
|
|
7912
8648
|
])
|
|
7913
8649
|
|
|
7914
8650
|
useEffect(() => {
|
|
@@ -8035,6 +8771,7 @@ export function FloorplanPanel() {
|
|
|
8035
8771
|
siteBoundaryWorldPolygon,
|
|
8036
8772
|
siteVertexDragState,
|
|
8037
8773
|
updateNode,
|
|
8774
|
+
setCursorPoint,
|
|
8038
8775
|
])
|
|
8039
8776
|
|
|
8040
8777
|
useEffect(() => {
|
|
@@ -8093,7 +8830,14 @@ export function FloorplanPanel() {
|
|
|
8093
8830
|
|
|
8094
8831
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
8095
8832
|
},
|
|
8096
|
-
[
|
|
8833
|
+
[
|
|
8834
|
+
fittedViewport,
|
|
8835
|
+
floorplanSceneRotationDeg,
|
|
8836
|
+
floorplanUserRotationDeg,
|
|
8837
|
+
viewport,
|
|
8838
|
+
setFloorplanCursorPosition,
|
|
8839
|
+
setCursorPoint,
|
|
8840
|
+
],
|
|
8097
8841
|
)
|
|
8098
8842
|
|
|
8099
8843
|
const handlePointerDown = useCallback(
|
|
@@ -8159,7 +8903,7 @@ export function FloorplanPanel() {
|
|
|
8159
8903
|
}
|
|
8160
8904
|
|
|
8161
8905
|
const wallNode = useScene.getState().nodes[wallId as AnyNodeId]
|
|
8162
|
-
if (
|
|
8906
|
+
if (wallNode?.type !== 'wall') {
|
|
8163
8907
|
return
|
|
8164
8908
|
}
|
|
8165
8909
|
|
|
@@ -8170,25 +8914,26 @@ export function FloorplanPanel() {
|
|
|
8170
8914
|
stopPropagation: () => {},
|
|
8171
8915
|
} as any)
|
|
8172
8916
|
}, [])
|
|
8917
|
+
// Emits `planPoint` unchanged — callers own snapping. Re-quantizing here
|
|
8918
|
+
// (the old behaviour) destroyed magnetic corner / midpoint snaps the draft
|
|
8919
|
+
// branches had already resolved, desyncing the 2D pipeline from the 3D
|
|
8920
|
+
// tools that subscribe to these grid events.
|
|
8173
8921
|
const emitFloorplanGridEvent = useCallback(
|
|
8174
8922
|
(
|
|
8175
8923
|
eventType: 'move' | 'click' | 'double-click',
|
|
8176
8924
|
planPoint: WallPlanPoint,
|
|
8177
8925
|
nativeEvent: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>,
|
|
8178
8926
|
) => {
|
|
8179
|
-
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
|
8180
8927
|
const cos = Math.cos(buildingRotationY)
|
|
8181
8928
|
const sin = Math.sin(buildingRotationY)
|
|
8182
|
-
const worldX = buildingPosition[0] +
|
|
8183
|
-
const worldZ = buildingPosition[2] -
|
|
8929
|
+
const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin
|
|
8930
|
+
const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos
|
|
8184
8931
|
|
|
8185
8932
|
emitter.emit(`grid:${eventType}` as any, {
|
|
8186
8933
|
nativeEvent: nativeEvent.nativeEvent as any,
|
|
8187
8934
|
position: [worldX, floorplanGridWorldY, worldZ],
|
|
8188
|
-
localPosition: [
|
|
8935
|
+
localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]],
|
|
8189
8936
|
})
|
|
8190
|
-
|
|
8191
|
-
return snappedPoint
|
|
8192
8937
|
},
|
|
8193
8938
|
[buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
|
|
8194
8939
|
)
|
|
@@ -8231,7 +8976,7 @@ export function FloorplanPanel() {
|
|
|
8231
8976
|
const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => {
|
|
8232
8977
|
if (!ceilingId) return
|
|
8233
8978
|
const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId]
|
|
8234
|
-
if (
|
|
8979
|
+
if (ceilingNode?.type !== 'ceiling') return
|
|
8235
8980
|
|
|
8236
8981
|
emitter.emit('ceiling:leave', {
|
|
8237
8982
|
node: ceilingNode,
|
|
@@ -8407,35 +9152,34 @@ export function FloorplanPanel() {
|
|
|
8407
9152
|
}
|
|
8408
9153
|
|
|
8409
9154
|
if (referenceScaleDraft) {
|
|
8410
|
-
emitFloorplanGridEvent('move', planPoint, event)
|
|
9155
|
+
emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event)
|
|
8411
9156
|
|
|
9157
|
+
// The rubber-band's moving end IS this cursor point — the draft-line
|
|
9158
|
+
// leaf reads it from the store, so no per-move panel-state write.
|
|
8412
9159
|
setCursorPoint((previousPoint) =>
|
|
8413
9160
|
previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
|
|
8414
9161
|
)
|
|
8415
|
-
setReferenceScaleDraft((currentDraft) =>
|
|
8416
|
-
currentDraft
|
|
8417
|
-
? {
|
|
8418
|
-
...currentDraft,
|
|
8419
|
-
cursor: planPoint,
|
|
8420
|
-
}
|
|
8421
|
-
: currentDraft,
|
|
8422
|
-
)
|
|
8423
9162
|
return
|
|
8424
9163
|
}
|
|
8425
9164
|
|
|
8426
9165
|
if (isCeilingBuildActive) {
|
|
8427
|
-
// Polygon vertex
|
|
8428
|
-
// the
|
|
8429
|
-
//
|
|
8430
|
-
//
|
|
8431
|
-
|
|
8432
|
-
|
|
9166
|
+
// Polygon vertex snapping is governed by the active snapping mode (the
|
|
9167
|
+
// chip on the right): `grid` quantizes via `snapToHalf` (whose step is
|
|
9168
|
+
// 0 — i.e. off — in any non-grid mode), `angles` locks to 15° rays from
|
|
9169
|
+
// the previous vertex, `lines` pulls onto wall corners / alignment
|
|
9170
|
+
// guides, `off` is free. Alignment follows the magnetic snap mode.
|
|
9171
|
+
const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
|
|
9172
|
+
const fallbackPoint = snapPolygonDraftPoint({
|
|
8433
9173
|
point: planPoint,
|
|
8434
9174
|
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
|
8435
9175
|
angleSnap,
|
|
8436
9176
|
})
|
|
8437
|
-
|
|
8438
|
-
|
|
9177
|
+
const snappedPoint = resolveCeilingPlanPointSnap({
|
|
9178
|
+
rawPoint: planPoint,
|
|
9179
|
+
fallbackPoint,
|
|
9180
|
+
levelId,
|
|
9181
|
+
align: !angleSnap,
|
|
9182
|
+
}).point
|
|
8439
9183
|
|
|
8440
9184
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
|
8441
9185
|
setCursorPoint((previousPoint) =>
|
|
@@ -8445,8 +9189,13 @@ export function FloorplanPanel() {
|
|
|
8445
9189
|
}
|
|
8446
9190
|
|
|
8447
9191
|
if (isRoofBuildActive) {
|
|
8448
|
-
|
|
8449
|
-
|
|
9192
|
+
// Roof is placed as a footprint (no directional draw → polygon context:
|
|
9193
|
+
// grid / lines / off, no angle lock). Mode-driven, matching the chip:
|
|
9194
|
+
// `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
|
|
9195
|
+
// modes), `lines` pulls onto alignment, `off` is free.
|
|
9196
|
+
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
|
9197
|
+
applySnap: isMagneticSnapActive(),
|
|
9198
|
+
})
|
|
8450
9199
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
|
8451
9200
|
setCursorPoint((previousPoint) =>
|
|
8452
9201
|
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
|
@@ -8465,21 +9214,30 @@ export function FloorplanPanel() {
|
|
|
8465
9214
|
if (isFenceBuildActive) {
|
|
8466
9215
|
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
|
8467
9216
|
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
|
9217
|
+
// While a draft is open the segment locks to 15° rays from its start.
|
|
9218
|
+
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
|
9219
|
+
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
|
9220
|
+
// mode, not Alt (continuation is cycled through the HUD / C).
|
|
9221
|
+
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
|
8468
9222
|
const fenceSnapped = snapFenceDraftPoint({
|
|
8469
9223
|
point: planPoint,
|
|
8470
9224
|
walls,
|
|
8471
9225
|
fences,
|
|
8472
|
-
|
|
9226
|
+
start: fenceDraftStart ?? undefined,
|
|
9227
|
+
angleSnap: fenceAngleSnap,
|
|
9228
|
+
magnetic: isMagneticSnapActive(),
|
|
8473
9229
|
})
|
|
8474
|
-
const fenceGridBase = snapWallPointToGrid(
|
|
8475
|
-
planPoint,
|
|
8476
|
-
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
|
|
8477
|
-
)
|
|
9230
|
+
const fenceGridBase = snapWallPointToGrid(planPoint)
|
|
8478
9231
|
const fenceLocked =
|
|
8479
9232
|
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
|
8480
9233
|
let snappedPoint = fenceSnapped
|
|
8481
9234
|
if (fenceLocked) useAlignmentGuides.getState().clear()
|
|
8482
|
-
|
|
9235
|
+
// Alignment lines show in every mode; the pull applies only when
|
|
9236
|
+
// magnetic ('lines') and the segment isn't angle-locked.
|
|
9237
|
+
else
|
|
9238
|
+
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
|
9239
|
+
applySnap: isMagneticSnapActive() && !fenceAngleSnap,
|
|
9240
|
+
})
|
|
8483
9241
|
|
|
8484
9242
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
|
8485
9243
|
setCursorPoint((previousPoint) =>
|
|
@@ -8500,14 +9258,24 @@ export function FloorplanPanel() {
|
|
|
8500
9258
|
// the local polygon-draft state actually updates as the cursor
|
|
8501
9259
|
// moves (the catch-all would otherwise swallow the move event).
|
|
8502
9260
|
if (isPolygonBuildActive) {
|
|
8503
|
-
|
|
8504
|
-
|
|
9261
|
+
// Mode-driven (matches the chip): `grid` quantizes (`snapToHalf`'s step
|
|
9262
|
+
// is 0 in non-grid modes), `angles` locks 15° rays from the previous
|
|
9263
|
+
// vertex, `lines` snaps onto wall corners / alignment guides, `off` is
|
|
9264
|
+
// free. Alignment follows the magnetic snap mode.
|
|
9265
|
+
const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
|
|
9266
|
+
const fallbackPoint = snapPolygonDraftPoint({
|
|
8505
9267
|
point: planPoint,
|
|
8506
9268
|
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
|
8507
9269
|
angleSnap,
|
|
8508
9270
|
})
|
|
8509
|
-
|
|
8510
|
-
|
|
9271
|
+
// Zone shares the slab surface snap (wall corners / midpoints /
|
|
9272
|
+
// crossings + alignment) — it's the same polygon-on-a-level draw.
|
|
9273
|
+
const snappedPoint = resolveSlabPlanPointSnap({
|
|
9274
|
+
rawPoint: planPoint,
|
|
9275
|
+
fallbackPoint,
|
|
9276
|
+
levelId,
|
|
9277
|
+
align: !angleSnap,
|
|
9278
|
+
}).point
|
|
8511
9279
|
|
|
8512
9280
|
// Emit `grid:move` so the registry-driven slab tool also tracks
|
|
8513
9281
|
// the cursor (its 3D preview needs it).
|
|
@@ -8531,7 +9299,16 @@ export function FloorplanPanel() {
|
|
|
8531
9299
|
// `wall:move` events the door / window placement tools listen for.
|
|
8532
9300
|
// Same reason `handleBackgroundPlacementClick` runs its opening
|
|
8533
9301
|
// branch before its grid catch-all.
|
|
8534
|
-
|
|
9302
|
+
//
|
|
9303
|
+
// Only the pure BUILD case (a door/window tool armed with no
|
|
9304
|
+
// `movingNode`) drives placement through these synthesized `wall:*`
|
|
9305
|
+
// events. When a door/window `movingNode` is set — the community
|
|
9306
|
+
// preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
|
|
9307
|
+
// placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
|
|
9308
|
+
// plan-space snap, single-undo commit, R-flip). Running both at once
|
|
9309
|
+
// made them fight (R-flip overwritten on the next move, click-commit
|
|
9310
|
+
// dropped), so the move case is excluded here.
|
|
9311
|
+
if (isOpeningBuildActive && !isOpeningMoveActive) {
|
|
8535
9312
|
const closest = findClosestWallPoint(planPoint, walls, {
|
|
8536
9313
|
canUseWall: (wall) => !isCurvedWall(wall),
|
|
8537
9314
|
})
|
|
@@ -8558,9 +9335,22 @@ export function FloorplanPanel() {
|
|
|
8558
9335
|
} else {
|
|
8559
9336
|
emitter.emit('wall:move', wallEvent as any)
|
|
8560
9337
|
}
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
|
|
9338
|
+
// Snapped to a wall — the real on-wall draft is the preview; drop
|
|
9339
|
+
// the loose free-follow ghost.
|
|
9340
|
+
usePlacementPreview.getState().clear()
|
|
9341
|
+
} else {
|
|
9342
|
+
if (hoveredWallIdRef.current) {
|
|
9343
|
+
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
|
9344
|
+
hoveredWallIdRef.current = null
|
|
9345
|
+
}
|
|
9346
|
+
// Off any wall — float the FAITHFUL door/window symbol (swing arc /
|
|
9347
|
+
// panes) following the cursor, not a bare rectangle. The glyph
|
|
9348
|
+
// builder needs a wall for `ctx.parent`, so we publish the opening on
|
|
9349
|
+
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
|
|
9350
|
+
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
|
|
9351
|
+
// through the real `def.floorplan` builder.
|
|
9352
|
+
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
|
9353
|
+
showOpeningGhost(snappedPoint)
|
|
8564
9354
|
}
|
|
8565
9355
|
return
|
|
8566
9356
|
}
|
|
@@ -8584,8 +9374,15 @@ export function FloorplanPanel() {
|
|
|
8584
9374
|
// window are also registered kinds, but need wall events — see
|
|
8585
9375
|
// comment there). Wall build skips this so its own branch below
|
|
8586
9376
|
// updates local `draftEnd` state alongside the registry tool.
|
|
8587
|
-
|
|
8588
|
-
|
|
9377
|
+
//
|
|
9378
|
+
// A door/window MOVE (community preset) is owned by
|
|
9379
|
+
// `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
|
|
9380
|
+
// for it (build mode + a registered `door`/`window` tool), so without
|
|
9381
|
+
// this exclusion the catch-all would emit `grid:move` and re-drive the
|
|
9382
|
+
// 3D MoveDoorTool's free-follow, fighting the overlay again.
|
|
9383
|
+
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
|
|
9384
|
+
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
|
9385
|
+
emitFloorplanGridEvent('move', snappedPoint, event)
|
|
8589
9386
|
setCursorPoint((previousPoint) =>
|
|
8590
9387
|
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
|
8591
9388
|
)
|
|
@@ -8607,17 +9404,17 @@ export function FloorplanPanel() {
|
|
|
8607
9404
|
return
|
|
8608
9405
|
}
|
|
8609
9406
|
|
|
8610
|
-
// Wall draft: grid
|
|
8611
|
-
//
|
|
8612
|
-
//
|
|
8613
|
-
//
|
|
8614
|
-
|
|
8615
|
-
// point on the plain grid. Alt bypasses alignment.
|
|
9407
|
+
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
|
9408
|
+
// While a draft is open the segment locks to 15° rays from its start.
|
|
9409
|
+
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
|
9410
|
+
// there is no Shift hold-to-bypass.
|
|
9411
|
+
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
|
|
8616
9412
|
const wallSnap = snapWallDraftPointDetailed({
|
|
8617
9413
|
point: planPoint,
|
|
8618
9414
|
walls,
|
|
8619
|
-
|
|
8620
|
-
|
|
9415
|
+
start: draftStart ?? undefined,
|
|
9416
|
+
angleSnap: wallAngleSnap,
|
|
9417
|
+
magnetic: isMagneticSnapActive(),
|
|
8621
9418
|
})
|
|
8622
9419
|
const wallSnapped = wallSnap.point
|
|
8623
9420
|
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
|
|
@@ -8627,7 +9424,11 @@ export function FloorplanPanel() {
|
|
|
8627
9424
|
if (lockedToWall) {
|
|
8628
9425
|
useAlignmentGuides.getState().clear()
|
|
8629
9426
|
} else {
|
|
8630
|
-
|
|
9427
|
+
// Alignment lines show in every mode; the pull applies only when
|
|
9428
|
+
// magnetic ('lines') and the segment isn't angle-locked.
|
|
9429
|
+
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
|
9430
|
+
applySnap: isMagneticSnapActive() && !wallAngleSnap,
|
|
9431
|
+
})
|
|
8631
9432
|
}
|
|
8632
9433
|
useWallSnapIndicator
|
|
8633
9434
|
.getState()
|
|
@@ -8672,22 +9473,33 @@ export function FloorplanPanel() {
|
|
|
8672
9473
|
isFenceBuildActive,
|
|
8673
9474
|
isFloorplanGridInteractionActive,
|
|
8674
9475
|
isMarqueeSelectionToolActive,
|
|
8675
|
-
|
|
9476
|
+
isOpeningBuildActive,
|
|
9477
|
+
isOpeningMoveActive,
|
|
9478
|
+
// The off-wall opening ghost is published through this memoised
|
|
9479
|
+
// callback, whose glyph (door swing-arc vs window panes) is bound to
|
|
9480
|
+
// `isDoorBuildActive`. It must be a dependency or a door→window tool
|
|
9481
|
+
// switch (which changes none of the other listed deps) would keep the
|
|
9482
|
+
// stale closure and float a door symbol while the window tool is armed.
|
|
9483
|
+
showOpeningGhost,
|
|
8676
9484
|
isPolygonBuildActive,
|
|
8677
9485
|
isRoofBuildActive,
|
|
8678
9486
|
isWallBuildActive,
|
|
9487
|
+
levelId,
|
|
8679
9488
|
publishFloorplanNavigationPose,
|
|
8680
9489
|
smoothFloorplanNavigationView,
|
|
8681
9490
|
referenceScaleDraft,
|
|
8682
9491
|
roofDraftStart,
|
|
8683
9492
|
elevatorResizeDragState,
|
|
8684
9493
|
siteVertexDragState,
|
|
8685
|
-
shiftPressed,
|
|
8686
9494
|
surfaceSize.height,
|
|
8687
9495
|
surfaceSize.width,
|
|
8688
9496
|
viewBox.height,
|
|
8689
9497
|
viewBox.width,
|
|
8690
9498
|
walls,
|
|
9499
|
+
setCursorPoint,
|
|
9500
|
+
setDraftEnd,
|
|
9501
|
+
setRoofDraftEnd,
|
|
9502
|
+
setFenceDraftEnd,
|
|
8691
9503
|
],
|
|
8692
9504
|
)
|
|
8693
9505
|
|
|
@@ -8705,6 +9517,10 @@ export function FloorplanPanel() {
|
|
|
8705
9517
|
|
|
8706
9518
|
const firstPoint = slabDraftPoints[0]
|
|
8707
9519
|
if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
|
|
9520
|
+
// 2D-only view: the 3D tool can't commit, so close the polygon here.
|
|
9521
|
+
if (useEditor.getState().viewMode === '2d') {
|
|
9522
|
+
createSlabOnCurrentLevel(slabDraftPoints)
|
|
9523
|
+
}
|
|
8708
9524
|
clearDraft()
|
|
8709
9525
|
return
|
|
8710
9526
|
}
|
|
@@ -8712,7 +9528,7 @@ export function FloorplanPanel() {
|
|
|
8712
9528
|
setSlabDraftPoints((currentPoints) => [...currentPoints, point])
|
|
8713
9529
|
setCursorPoint(point)
|
|
8714
9530
|
},
|
|
8715
|
-
[clearDraft, slabDraftPoints],
|
|
9531
|
+
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint],
|
|
8716
9532
|
)
|
|
8717
9533
|
const handleSlabPlacementConfirm = useCallback(
|
|
8718
9534
|
(point?: WallPlanPoint) => {
|
|
@@ -8735,9 +9551,13 @@ export function FloorplanPanel() {
|
|
|
8735
9551
|
return
|
|
8736
9552
|
}
|
|
8737
9553
|
|
|
9554
|
+
// 2D-only view: the 3D tool can't commit, so create the slab here.
|
|
9555
|
+
if (useEditor.getState().viewMode === '2d') {
|
|
9556
|
+
createSlabOnCurrentLevel(nextPoints)
|
|
9557
|
+
}
|
|
8738
9558
|
clearDraft()
|
|
8739
9559
|
},
|
|
8740
|
-
[clearDraft, slabDraftPoints],
|
|
9560
|
+
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
|
|
8741
9561
|
)
|
|
8742
9562
|
const handleCeilingPlacementPoint = useCallback(
|
|
8743
9563
|
(point: WallPlanPoint) => {
|
|
@@ -8748,6 +9568,9 @@ export function FloorplanPanel() {
|
|
|
8748
9568
|
|
|
8749
9569
|
const firstPoint = ceilingDraftPoints[0]
|
|
8750
9570
|
if (firstPoint && ceilingDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
|
|
9571
|
+
if (useEditor.getState().viewMode === '2d') {
|
|
9572
|
+
createCeilingOnCurrentLevel(ceilingDraftPoints)
|
|
9573
|
+
}
|
|
8751
9574
|
clearCeilingPlacementDraft()
|
|
8752
9575
|
return
|
|
8753
9576
|
}
|
|
@@ -8755,7 +9578,7 @@ export function FloorplanPanel() {
|
|
|
8755
9578
|
setCeilingDraftPoints((currentPoints) => [...currentPoints, point])
|
|
8756
9579
|
setCursorPoint(point)
|
|
8757
9580
|
},
|
|
8758
|
-
[ceilingDraftPoints, clearCeilingPlacementDraft],
|
|
9581
|
+
[ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel, setCursorPoint],
|
|
8759
9582
|
)
|
|
8760
9583
|
const handleCeilingPlacementConfirm = useCallback(
|
|
8761
9584
|
(point?: WallPlanPoint) => {
|
|
@@ -8778,9 +9601,12 @@ export function FloorplanPanel() {
|
|
|
8778
9601
|
return
|
|
8779
9602
|
}
|
|
8780
9603
|
|
|
9604
|
+
if (useEditor.getState().viewMode === '2d') {
|
|
9605
|
+
createCeilingOnCurrentLevel(nextPoints)
|
|
9606
|
+
}
|
|
8781
9607
|
clearCeilingPlacementDraft()
|
|
8782
9608
|
},
|
|
8783
|
-
[ceilingDraftPoints, clearCeilingPlacementDraft],
|
|
9609
|
+
[ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel],
|
|
8784
9610
|
)
|
|
8785
9611
|
const handleZonePlacementPoint = useCallback(
|
|
8786
9612
|
(point: WallPlanPoint) => {
|
|
@@ -8802,7 +9628,7 @@ export function FloorplanPanel() {
|
|
|
8802
9628
|
setZoneDraftPoints((currentPoints) => [...currentPoints, point])
|
|
8803
9629
|
setCursorPoint(point)
|
|
8804
9630
|
},
|
|
8805
|
-
[clearDraft, createZoneOnCurrentLevel, zoneDraftPoints],
|
|
9631
|
+
[clearDraft, createZoneOnCurrentLevel, zoneDraftPoints, setCursorPoint],
|
|
8806
9632
|
)
|
|
8807
9633
|
const handleZonePlacementConfirm = useCallback(
|
|
8808
9634
|
(point?: WallPlanPoint) => {
|
|
@@ -8832,9 +9658,10 @@ export function FloorplanPanel() {
|
|
|
8832
9658
|
)
|
|
8833
9659
|
|
|
8834
9660
|
const handleWallPlacementPoint = useCallback(
|
|
8835
|
-
(point: WallPlanPoint
|
|
9661
|
+
(point: WallPlanPoint) => {
|
|
8836
9662
|
if (!draftStart) {
|
|
8837
9663
|
setDraftStart(point)
|
|
9664
|
+
setWallChainFirstVertex(point)
|
|
8838
9665
|
setDraftEnd(point)
|
|
8839
9666
|
setCursorPoint(point)
|
|
8840
9667
|
return
|
|
@@ -8849,35 +9676,81 @@ export function FloorplanPanel() {
|
|
|
8849
9676
|
// call. `emitFloorplanGridEvent('click', …)` in
|
|
8850
9677
|
// `useFloorplanBackgroundPlacement` fires it synchronously
|
|
8851
9678
|
// just before this callback runs, so by the time we get here
|
|
8852
|
-
// the wall already exists in the scene.
|
|
9679
|
+
// the wall already exists in the scene. Committing here as
|
|
9680
|
+
// well used to double-create walls whenever the two snap
|
|
9681
|
+
// pipelines resolved endpoints ≥1e-6 apart (the duplicate
|
|
9682
|
+
// check compares exact endpoints).
|
|
8853
9683
|
//
|
|
8854
|
-
//
|
|
8855
|
-
//
|
|
8856
|
-
//
|
|
8857
|
-
//
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
//
|
|
8865
|
-
//
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
9684
|
+
// That 3D path is dead in 2D-only view — the canvas is
|
|
9685
|
+
// `display:none`, so the tool never commits. Mirror the slab /
|
|
9686
|
+
// ceiling 2D-only committers: create locally here, gated on the
|
|
9687
|
+
// view, so split / 3D keep their single-owner tool commit.
|
|
9688
|
+
const viewIs2DOnly = useEditor.getState().viewMode === '2d'
|
|
9689
|
+
const createdWall = viewIs2DOnly ? createWallOnCurrentLevel(draftStart, point) : null
|
|
9690
|
+
if (createdWall) {
|
|
9691
|
+
wallChainWallIdsRef.current.push(createdWall.id)
|
|
9692
|
+
}
|
|
9693
|
+
|
|
9694
|
+
// Chain the next segment from the resolved commit endpoint (it may
|
|
9695
|
+
// have corner-snapped or split-adjusted): the wall we just made in
|
|
9696
|
+
// 2D-only, otherwise the 3D tool's published chain start. Both views
|
|
9697
|
+
// then draft from the same start.
|
|
9698
|
+
const publishedNextStart = useSegmentDraftChain.getState().wall
|
|
9699
|
+
const nextStart: WallPlanPoint = createdWall
|
|
9700
|
+
? (createdWall.end as WallPlanPoint)
|
|
9701
|
+
: (publishedNextStart ?? point)
|
|
9702
|
+
|
|
9703
|
+
if (
|
|
9704
|
+
useEditor.getState().getContinuation('wall') === 'single' ||
|
|
9705
|
+
(wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex))
|
|
9706
|
+
) {
|
|
9707
|
+
clearWallPlacementDraft()
|
|
9708
|
+
setCursorPoint(null)
|
|
9709
|
+
return
|
|
9710
|
+
}
|
|
9711
|
+
|
|
9712
|
+
if (createdWall) {
|
|
9713
|
+
// 2D-only committer: mirror the 3D tool's auto-close. Stop when the
|
|
9714
|
+
// segment seals a room against the wall network, or when its resolved
|
|
9715
|
+
// end tees into wall geometry outside the chain — continuing from a
|
|
9716
|
+
// T-junction only drafts on top of existing walls.
|
|
9717
|
+
const levelWalls = Object.values(useScene.getState().nodes).filter(
|
|
9718
|
+
(node): node is WallNode => node?.type === 'wall' && node.parentId === levelId,
|
|
9719
|
+
)
|
|
9720
|
+
if (
|
|
9721
|
+
chainEndJoinsExistingWall(
|
|
9722
|
+
createdWall.end as WallPlanPoint,
|
|
9723
|
+
levelWalls,
|
|
9724
|
+
wallChainWallIdsRef.current,
|
|
9725
|
+
) ||
|
|
9726
|
+
wallClosesRoom(levelWalls, createdWall)
|
|
9727
|
+
) {
|
|
9728
|
+
clearWallPlacementDraft()
|
|
9729
|
+
setCursorPoint(null)
|
|
9730
|
+
return
|
|
9731
|
+
}
|
|
9732
|
+
} else if (!(viewIs2DOnly || publishedNextStart)) {
|
|
9733
|
+
// Split view: the 3D tool owns both the commit and the continuation
|
|
9734
|
+
// decision, and it clears the published chain start whenever it stops
|
|
9735
|
+
// drafting (room close, T-junction, single). Mirror that here instead
|
|
9736
|
+
// of chaining the 2D draft from a dead point.
|
|
9737
|
+
clearWallPlacementDraft()
|
|
8869
9738
|
setCursorPoint(null)
|
|
8870
9739
|
return
|
|
8871
9740
|
}
|
|
8872
9741
|
|
|
8873
|
-
const nextStart: WallPlanPoint = createdWall
|
|
8874
|
-
? [createdWall.end[0], createdWall.end[1]]
|
|
8875
|
-
: point
|
|
8876
9742
|
setDraftStart(nextStart)
|
|
8877
9743
|
setDraftEnd(nextStart)
|
|
8878
9744
|
setCursorPoint(nextStart)
|
|
8879
9745
|
},
|
|
8880
|
-
[
|
|
9746
|
+
[
|
|
9747
|
+
clearWallPlacementDraft,
|
|
9748
|
+
draftStart,
|
|
9749
|
+
levelId,
|
|
9750
|
+
wallChainFirstVertex,
|
|
9751
|
+
setDraftEnd,
|
|
9752
|
+
setCursorPoint,
|
|
9753
|
+
],
|
|
8881
9754
|
)
|
|
8882
9755
|
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
|
8883
9756
|
ceilingPolygons: displayCeilingPolygons,
|
|
@@ -8905,8 +9778,9 @@ export function FloorplanPanel() {
|
|
|
8905
9778
|
walls: WallNode[]
|
|
8906
9779
|
start?: WallPlanPoint
|
|
8907
9780
|
angleSnap?: boolean
|
|
9781
|
+
bypassSnap?: boolean
|
|
8908
9782
|
step?: number
|
|
8909
|
-
}) => snapWallDraftPoint({ ...args, magnetic:
|
|
9783
|
+
}) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }),
|
|
8910
9784
|
[],
|
|
8911
9785
|
)
|
|
8912
9786
|
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
|
|
@@ -8914,6 +9788,7 @@ export function FloorplanPanel() {
|
|
|
8914
9788
|
ceilingDraftPoints,
|
|
8915
9789
|
clearFencePlacementDraft,
|
|
8916
9790
|
clearRoofPlacementDraft,
|
|
9791
|
+
clearWallPlacementDraft,
|
|
8917
9792
|
emitFloorplanGridEvent,
|
|
8918
9793
|
fenceDraftStart,
|
|
8919
9794
|
fences,
|
|
@@ -8928,19 +9803,26 @@ export function FloorplanPanel() {
|
|
|
8928
9803
|
isCeilingBuildActive,
|
|
8929
9804
|
isCeilingItemPlacementActive,
|
|
8930
9805
|
isFenceBuildActive,
|
|
8931
|
-
|
|
8932
|
-
|
|
9806
|
+
// Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
|
|
9807
|
+
// grid catch-all true for it, but the overlay owns its commit (its own
|
|
9808
|
+
// pointerup). Letting the catch-all emit `grid:click` here would consume
|
|
9809
|
+
// the commit click and fight the overlay.
|
|
9810
|
+
isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
|
|
9811
|
+
// Only the pure-build opening case (tool armed, no movingNode) commits via
|
|
9812
|
+
// the synthesized `wall:click`; the move case (community preset) is owned
|
|
9813
|
+
// by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
|
|
9814
|
+
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
|
|
8933
9815
|
isPolygonBuildActive,
|
|
8934
9816
|
isRoofBuildActive,
|
|
8935
9817
|
isWallBuildActive,
|
|
8936
9818
|
isZoneBuildActive,
|
|
9819
|
+
levelId,
|
|
8937
9820
|
roofDraftStart,
|
|
8938
9821
|
setCursorPoint,
|
|
8939
9822
|
setFenceDraftEnd,
|
|
8940
9823
|
setFenceDraftStart,
|
|
8941
9824
|
setRoofDraftEnd,
|
|
8942
9825
|
setRoofDraftStart,
|
|
8943
|
-
shiftPressed,
|
|
8944
9826
|
snapPolygonDraftPoint,
|
|
8945
9827
|
snapWallDraftPoint: snapWallDraftPointMagnetic,
|
|
8946
9828
|
toPoint2D,
|
|
@@ -8967,13 +9849,12 @@ export function FloorplanPanel() {
|
|
|
8967
9849
|
event.preventDefault()
|
|
8968
9850
|
event.stopPropagation()
|
|
8969
9851
|
|
|
8970
|
-
emitFloorplanGridEvent('click', planPoint, event)
|
|
9852
|
+
emitFloorplanGridEvent('click', getSnappedFloorplanPoint(planPoint), event)
|
|
8971
9853
|
|
|
8972
9854
|
if (!referenceScaleDraft.start) {
|
|
8973
9855
|
setReferenceScaleDraft({
|
|
8974
9856
|
...referenceScaleDraft,
|
|
8975
9857
|
start: planPoint,
|
|
8976
|
-
cursor: planPoint,
|
|
8977
9858
|
})
|
|
8978
9859
|
setCursorPoint(planPoint)
|
|
8979
9860
|
return
|
|
@@ -8994,7 +9875,17 @@ export function FloorplanPanel() {
|
|
|
8994
9875
|
end: planPoint,
|
|
8995
9876
|
measuredLengthUnits,
|
|
8996
9877
|
})
|
|
8997
|
-
|
|
9878
|
+
// Pre-fill with the drawn length in the pre-selected unit, so
|
|
9879
|
+
// confirming without editing is a no-op instead of a surprise
|
|
9880
|
+
// rescale (plan units are meters; convert when defaulting to feet).
|
|
9881
|
+
setReferenceScaleValue(
|
|
9882
|
+
formatNumber(
|
|
9883
|
+
unit === 'imperial'
|
|
9884
|
+
? measuredLengthUnits / linearUnitToMeters(1, 'imperial')
|
|
9885
|
+
: measuredLengthUnits,
|
|
9886
|
+
2,
|
|
9887
|
+
),
|
|
9888
|
+
)
|
|
8998
9889
|
setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters')
|
|
8999
9890
|
setReferenceScaleDraft(null)
|
|
9000
9891
|
setCursorPoint(null)
|
|
@@ -9084,6 +9975,7 @@ export function FloorplanPanel() {
|
|
|
9084
9975
|
unit,
|
|
9085
9976
|
visibleZonePolygons,
|
|
9086
9977
|
emitFloorplanGridEvent,
|
|
9978
|
+
setCursorPoint,
|
|
9087
9979
|
],
|
|
9088
9980
|
)
|
|
9089
9981
|
const handleSvgClick = useCallback(
|
|
@@ -9110,24 +10002,37 @@ export function FloorplanPanel() {
|
|
|
9110
10002
|
return
|
|
9111
10003
|
}
|
|
9112
10004
|
|
|
9113
|
-
const
|
|
10005
|
+
const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
|
|
10006
|
+
const fallbackPoint = snapPolygonDraftPoint({
|
|
9114
10007
|
point: planPoint,
|
|
9115
10008
|
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
|
9116
|
-
angleSnap
|
|
10009
|
+
angleSnap,
|
|
9117
10010
|
})
|
|
9118
10011
|
|
|
9119
10012
|
if (isCeilingBuildActive) {
|
|
9120
|
-
|
|
10013
|
+
const snappedPoint = resolveCeilingPlanPointSnap({
|
|
10014
|
+
rawPoint: planPoint,
|
|
10015
|
+
fallbackPoint,
|
|
10016
|
+
levelId,
|
|
10017
|
+
align: !angleSnap,
|
|
10018
|
+
}).point
|
|
10019
|
+
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
|
9121
10020
|
handleCeilingPlacementConfirm(snappedPoint)
|
|
9122
10021
|
return
|
|
9123
10022
|
}
|
|
9124
10023
|
|
|
10024
|
+
const snappedPoint = resolveSlabPlanPointSnap({
|
|
10025
|
+
rawPoint: planPoint,
|
|
10026
|
+
fallbackPoint,
|
|
10027
|
+
levelId,
|
|
10028
|
+
align: !angleSnap,
|
|
10029
|
+
}).point
|
|
9125
10030
|
if (isZoneBuildActive) {
|
|
9126
10031
|
handleZonePlacementConfirm(snappedPoint)
|
|
9127
10032
|
} else {
|
|
9128
10033
|
// Slab is registry-driven: forward the double-click so the 3D tool
|
|
9129
10034
|
// commits the node (zone has no registry tool, so it commits locally).
|
|
9130
|
-
emitFloorplanGridEvent('double-click',
|
|
10035
|
+
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
|
9131
10036
|
handleSlabPlacementConfirm(snappedPoint)
|
|
9132
10037
|
}
|
|
9133
10038
|
},
|
|
@@ -9142,7 +10047,7 @@ export function FloorplanPanel() {
|
|
|
9142
10047
|
isPolygonDraftBuildActive,
|
|
9143
10048
|
isRoofBuildActive,
|
|
9144
10049
|
isZoneBuildActive,
|
|
9145
|
-
|
|
10050
|
+
levelId,
|
|
9146
10051
|
],
|
|
9147
10052
|
)
|
|
9148
10053
|
|
|
@@ -9176,8 +10081,11 @@ export function FloorplanPanel() {
|
|
|
9176
10081
|
)
|
|
9177
10082
|
|
|
9178
10083
|
const addFloorplanSelection = useCallback(
|
|
9179
|
-
(
|
|
9180
|
-
|
|
10084
|
+
(
|
|
10085
|
+
nextSelectedIds: string[],
|
|
10086
|
+
modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean },
|
|
10087
|
+
) => {
|
|
10088
|
+
const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
|
|
9181
10089
|
|
|
9182
10090
|
if (shouldAppend) {
|
|
9183
10091
|
if (nextSelectedIds.length === 0) {
|
|
@@ -9195,8 +10103,8 @@ export function FloorplanPanel() {
|
|
|
9195
10103
|
)
|
|
9196
10104
|
|
|
9197
10105
|
const toggleFloorplanSelection = useCallback(
|
|
9198
|
-
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => {
|
|
9199
|
-
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
|
|
10106
|
+
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }) => {
|
|
10107
|
+
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
|
|
9200
10108
|
|
|
9201
10109
|
if (shouldToggle) {
|
|
9202
10110
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
@@ -9239,7 +10147,7 @@ export function FloorplanPanel() {
|
|
|
9239
10147
|
const commitFloorplanScreenSelection = useCallback(
|
|
9240
10148
|
(nextSelectedIds: string[], event: PointerEvent) => {
|
|
9241
10149
|
const modifierKeys = getSelectionModifierKeys(event)
|
|
9242
|
-
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl
|
|
10150
|
+
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
|
|
9243
10151
|
|
|
9244
10152
|
setSelectedReferenceId(null)
|
|
9245
10153
|
|
|
@@ -9477,7 +10385,16 @@ export function FloorplanPanel() {
|
|
|
9477
10385
|
document.body.style.userSelect = 'none'
|
|
9478
10386
|
document.body.style.cursor = shouldRotate
|
|
9479
10387
|
? getGuideRotateCursor(isDark)
|
|
9480
|
-
: getGuideResizeCursor(
|
|
10388
|
+
: getGuideResizeCursor(
|
|
10389
|
+
getGuideResizeCursorAngle(
|
|
10390
|
+
corner,
|
|
10391
|
+
aspectRatio,
|
|
10392
|
+
// Screen space includes the scene <g>'s view rotation on top of
|
|
10393
|
+
// the guide's own rotation.
|
|
10394
|
+
rotationSvg + (floorplanSceneRotationDeg * Math.PI) / 180,
|
|
10395
|
+
),
|
|
10396
|
+
isDark,
|
|
10397
|
+
)
|
|
9481
10398
|
|
|
9482
10399
|
const nextDraft: GuideTransformDraft = {
|
|
9483
10400
|
guideId: guide.id,
|
|
@@ -9489,7 +10406,7 @@ export function FloorplanPanel() {
|
|
|
9489
10406
|
guideTransformDraftRef.current = nextDraft
|
|
9490
10407
|
setGuideTransformDraft(nextDraft)
|
|
9491
10408
|
},
|
|
9492
|
-
[canInteractWithGuides, guideUi, handleGuideSelect, isDark],
|
|
10409
|
+
[canInteractWithGuides, floorplanSceneRotationDeg, guideUi, handleGuideSelect, isDark],
|
|
9493
10410
|
)
|
|
9494
10411
|
const handleGuideTranslateStart = useCallback(
|
|
9495
10412
|
(guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {
|
|
@@ -9573,7 +10490,9 @@ export function FloorplanPanel() {
|
|
|
9573
10490
|
siteBoundaryDraftRef.current = nextDraft
|
|
9574
10491
|
setSiteBoundaryDraft(nextDraft)
|
|
9575
10492
|
setSiteBoundaryLivePreview(siteId, nextDraft.polygon)
|
|
9576
|
-
|
|
10493
|
+
useInteractionScope
|
|
10494
|
+
.getState()
|
|
10495
|
+
.begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
|
|
9577
10496
|
setSiteVertexDragState({
|
|
9578
10497
|
pointerId: event.pointerId,
|
|
9579
10498
|
siteId,
|
|
@@ -9581,7 +10500,7 @@ export function FloorplanPanel() {
|
|
|
9581
10500
|
})
|
|
9582
10501
|
setCursorPoint(toWallPlanPoint(vertexPoint))
|
|
9583
10502
|
},
|
|
9584
|
-
[displaySitePolygon, setSiteBoundaryLivePreview],
|
|
10503
|
+
[displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
|
|
9585
10504
|
)
|
|
9586
10505
|
const handleSiteVertexDoubleClick = useCallback(
|
|
9587
10506
|
(
|
|
@@ -9655,7 +10574,9 @@ export function FloorplanPanel() {
|
|
|
9655
10574
|
siteBoundaryDraftRef.current = nextDraft
|
|
9656
10575
|
setSiteBoundaryDraft(nextDraft)
|
|
9657
10576
|
setSiteBoundaryLivePreview(siteId, nextPolygon)
|
|
9658
|
-
|
|
10577
|
+
useInteractionScope
|
|
10578
|
+
.getState()
|
|
10579
|
+
.begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
|
|
9659
10580
|
setSiteVertexDragState({
|
|
9660
10581
|
pointerId: event.pointerId,
|
|
9661
10582
|
siteId,
|
|
@@ -9663,7 +10584,7 @@ export function FloorplanPanel() {
|
|
|
9663
10584
|
})
|
|
9664
10585
|
setCursorPoint(insertedPoint)
|
|
9665
10586
|
},
|
|
9666
|
-
[displaySitePolygon, setSiteBoundaryLivePreview],
|
|
10587
|
+
[displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
|
|
9667
10588
|
)
|
|
9668
10589
|
|
|
9669
10590
|
const handlePointerLeave = useCallback(() => {
|
|
@@ -9682,7 +10603,7 @@ export function FloorplanPanel() {
|
|
|
9682
10603
|
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
|
9683
10604
|
hoveredWallIdRef.current = null
|
|
9684
10605
|
}
|
|
9685
|
-
}, [emitFloorplanWallLeave, siteVertexDragState])
|
|
10606
|
+
}, [emitFloorplanWallLeave, siteVertexDragState, setCursorPoint])
|
|
9686
10607
|
|
|
9687
10608
|
// Lightweight flag that mirrors the conditions under which
|
|
9688
10609
|
// FloorplanCursorIndicatorOverlay renders — used to gate cursor-position
|
|
@@ -9732,6 +10653,7 @@ export function FloorplanPanel() {
|
|
|
9732
10653
|
isSpacePanPressed,
|
|
9733
10654
|
elevatorResizeDragState,
|
|
9734
10655
|
siteVertexDragState,
|
|
10656
|
+
setFloorplanCursorPosition,
|
|
9735
10657
|
],
|
|
9736
10658
|
)
|
|
9737
10659
|
|
|
@@ -9739,7 +10661,7 @@ export function FloorplanPanel() {
|
|
|
9739
10661
|
setFloorplanCursorPosition(null)
|
|
9740
10662
|
setHoveredGuideCorner(null)
|
|
9741
10663
|
handlePointerLeave()
|
|
9742
|
-
}, [handlePointerLeave])
|
|
10664
|
+
}, [handlePointerLeave, setFloorplanCursorPosition])
|
|
9743
10665
|
|
|
9744
10666
|
const handleMarqueePointerDown = useCallback(
|
|
9745
10667
|
(event: ReactPointerEvent<SVGRectElement>) => {
|
|
@@ -9765,7 +10687,7 @@ export function FloorplanPanel() {
|
|
|
9765
10687
|
setCursorPoint(snappedPoint)
|
|
9766
10688
|
floorplanMarqueeSnapPointRef.current = snappedPoint
|
|
9767
10689
|
syncPreviewSelectedIds([])
|
|
9768
|
-
|
|
10690
|
+
useFloorplanMarquee.getState().begin({
|
|
9769
10691
|
pointerId: event.pointerId,
|
|
9770
10692
|
startClientX: event.clientX,
|
|
9771
10693
|
startClientY: event.clientY,
|
|
@@ -9775,7 +10697,12 @@ export function FloorplanPanel() {
|
|
|
9775
10697
|
|
|
9776
10698
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
9777
10699
|
},
|
|
9778
|
-
[
|
|
10700
|
+
[
|
|
10701
|
+
getPlanPointFromClientPoint,
|
|
10702
|
+
syncPreviewSelectedIds,
|
|
10703
|
+
setFloorplanCursorPosition,
|
|
10704
|
+
setCursorPoint,
|
|
10705
|
+
],
|
|
9779
10706
|
)
|
|
9780
10707
|
|
|
9781
10708
|
const handleMarqueePointerMove = useCallback(
|
|
@@ -9788,7 +10715,8 @@ export function FloorplanPanel() {
|
|
|
9788
10715
|
})
|
|
9789
10716
|
}
|
|
9790
10717
|
|
|
9791
|
-
|
|
10718
|
+
const marquee = useFloorplanMarquee.getState().drag
|
|
10719
|
+
if (marquee?.pointerId !== event.pointerId) {
|
|
9792
10720
|
return
|
|
9793
10721
|
}
|
|
9794
10722
|
|
|
@@ -9803,8 +10731,8 @@ export function FloorplanPanel() {
|
|
|
9803
10731
|
setCursorPoint(snappedPoint)
|
|
9804
10732
|
|
|
9805
10733
|
const dragDistance = Math.hypot(
|
|
9806
|
-
event.clientX -
|
|
9807
|
-
event.clientY -
|
|
10734
|
+
event.clientX - marquee.startClientX,
|
|
10735
|
+
event.clientY - marquee.startClientY,
|
|
9808
10736
|
)
|
|
9809
10737
|
|
|
9810
10738
|
if (
|
|
@@ -9817,37 +10745,28 @@ export function FloorplanPanel() {
|
|
|
9817
10745
|
floorplanMarqueeSnapPointRef.current = snappedPoint
|
|
9818
10746
|
|
|
9819
10747
|
if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) {
|
|
9820
|
-
const bounds = getFloorplanSelectionBounds(
|
|
9821
|
-
floorplanMarqueeState.startPlanPoint,
|
|
9822
|
-
snappedPoint,
|
|
9823
|
-
)
|
|
10748
|
+
const bounds = getFloorplanSelectionBounds(marquee.startPlanPoint, snappedPoint)
|
|
9824
10749
|
syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds))
|
|
9825
10750
|
} else {
|
|
9826
10751
|
syncPreviewSelectedIds([])
|
|
9827
10752
|
}
|
|
9828
10753
|
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
}
|
|
9833
|
-
|
|
9834
|
-
return {
|
|
9835
|
-
...currentState,
|
|
9836
|
-
currentPlanPoint: snappedPoint,
|
|
9837
|
-
}
|
|
9838
|
-
})
|
|
10754
|
+
// Advances the moving corner in the marquee store — re-renders only the
|
|
10755
|
+
// marquee overlay leaf, never this panel.
|
|
10756
|
+
useFloorplanMarquee.getState().setCurrent(snappedPoint)
|
|
9839
10757
|
},
|
|
9840
10758
|
[
|
|
9841
|
-
floorplanMarqueeState,
|
|
9842
10759
|
getFloorplanSelectionIdsInBounds,
|
|
9843
10760
|
getPlanPointFromClientPoint,
|
|
9844
10761
|
syncPreviewSelectedIds,
|
|
10762
|
+
setFloorplanCursorPosition,
|
|
10763
|
+
setCursorPoint,
|
|
9845
10764
|
],
|
|
9846
10765
|
)
|
|
9847
10766
|
|
|
9848
10767
|
const handleMarqueePointerUp = useCallback(
|
|
9849
10768
|
(event: ReactPointerEvent<SVGRectElement>) => {
|
|
9850
|
-
const marqueeState =
|
|
10769
|
+
const marqueeState = useFloorplanMarquee.getState().drag
|
|
9851
10770
|
if (!marqueeState || marqueeState.pointerId !== event.pointerId) {
|
|
9852
10771
|
return
|
|
9853
10772
|
}
|
|
@@ -9877,19 +10796,18 @@ export function FloorplanPanel() {
|
|
|
9877
10796
|
|
|
9878
10797
|
if (hitId) {
|
|
9879
10798
|
toggleFloorplanSelection(hitId, modifierKeys)
|
|
9880
|
-
} else if (!(modifierKeys.meta || modifierKeys.ctrl)) {
|
|
10799
|
+
} else if (!(modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift)) {
|
|
9881
10800
|
commitFloorplanSelection([])
|
|
9882
10801
|
}
|
|
9883
10802
|
}
|
|
9884
10803
|
|
|
9885
10804
|
syncPreviewSelectedIds([])
|
|
9886
|
-
|
|
10805
|
+
useFloorplanMarquee.getState().reset()
|
|
9887
10806
|
floorplanMarqueeSnapPointRef.current = null
|
|
9888
10807
|
},
|
|
9889
10808
|
[
|
|
9890
10809
|
addFloorplanSelection,
|
|
9891
10810
|
commitFloorplanSelection,
|
|
9892
|
-
floorplanMarqueeState,
|
|
9893
10811
|
getFloorplanHitIdAtPoint,
|
|
9894
10812
|
getFloorplanSelectionIdsInBounds,
|
|
9895
10813
|
getPlanPointFromClientPoint,
|
|
@@ -9900,7 +10818,7 @@ export function FloorplanPanel() {
|
|
|
9900
10818
|
|
|
9901
10819
|
const handleMarqueePointerCancel = useCallback(
|
|
9902
10820
|
(event: ReactPointerEvent<SVGRectElement>) => {
|
|
9903
|
-
if (
|
|
10821
|
+
if (useFloorplanMarquee.getState().drag?.pointerId !== event.pointerId) {
|
|
9904
10822
|
return
|
|
9905
10823
|
}
|
|
9906
10824
|
|
|
@@ -9908,18 +10826,18 @@ export function FloorplanPanel() {
|
|
|
9908
10826
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
9909
10827
|
}
|
|
9910
10828
|
|
|
9911
|
-
|
|
10829
|
+
useFloorplanMarquee.getState().reset()
|
|
9912
10830
|
setFloorplanCursorPosition(null)
|
|
9913
10831
|
floorplanMarqueeSnapPointRef.current = null
|
|
9914
10832
|
syncPreviewSelectedIds([])
|
|
9915
10833
|
setCursorPoint(null)
|
|
9916
10834
|
},
|
|
9917
|
-
[
|
|
10835
|
+
[syncPreviewSelectedIds, setFloorplanCursorPosition, setCursorPoint],
|
|
9918
10836
|
)
|
|
9919
10837
|
|
|
9920
10838
|
useEffect(() => {
|
|
9921
10839
|
if (!isMarqueeSelectionToolActive) {
|
|
9922
|
-
|
|
10840
|
+
useFloorplanMarquee.getState().reset()
|
|
9923
10841
|
floorplanMarqueeSnapPointRef.current = null
|
|
9924
10842
|
syncPreviewSelectedIds([])
|
|
9925
10843
|
if (mode === 'select') {
|
|
@@ -9929,7 +10847,13 @@ export function FloorplanPanel() {
|
|
|
9929
10847
|
}
|
|
9930
10848
|
|
|
9931
10849
|
setFloorplanCursorPosition(null)
|
|
9932
|
-
}, [
|
|
10850
|
+
}, [
|
|
10851
|
+
isMarqueeSelectionToolActive,
|
|
10852
|
+
mode,
|
|
10853
|
+
syncPreviewSelectedIds,
|
|
10854
|
+
setFloorplanCursorPosition,
|
|
10855
|
+
setCursorPoint,
|
|
10856
|
+
])
|
|
9933
10857
|
|
|
9934
10858
|
useEffect(() => {
|
|
9935
10859
|
if (mode !== 'delete') {
|
|
@@ -10027,7 +10951,7 @@ export function FloorplanPanel() {
|
|
|
10027
10951
|
}
|
|
10028
10952
|
|
|
10029
10953
|
const buildingNode = sceneNodes[nextBuildingId]
|
|
10030
|
-
if (
|
|
10954
|
+
if (buildingNode?.type !== 'building') {
|
|
10031
10955
|
return null
|
|
10032
10956
|
}
|
|
10033
10957
|
|
|
@@ -10087,7 +11011,8 @@ export function FloorplanPanel() {
|
|
|
10087
11011
|
const floorplanNavigationCursor =
|
|
10088
11012
|
isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null
|
|
10089
11013
|
const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan
|
|
10090
|
-
const pendingReferenceDisplayLength =
|
|
11014
|
+
const pendingReferenceDisplayLength =
|
|
11015
|
+
parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) ?? Number.NaN
|
|
10091
11016
|
const pendingReferenceRealLengthMeters =
|
|
10092
11017
|
pendingReferenceScale && pendingReferenceDisplayLength > 0
|
|
10093
11018
|
? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit)
|
|
@@ -10103,9 +11028,14 @@ export function FloorplanPanel() {
|
|
|
10103
11028
|
const referenceScaleInputError =
|
|
10104
11029
|
referenceScaleValue.trim() === ''
|
|
10105
11030
|
? 'Enter the real length of the line.'
|
|
10106
|
-
: pendingReferenceDisplayLength
|
|
10107
|
-
?
|
|
10108
|
-
:
|
|
11031
|
+
: Number.isNaN(pendingReferenceDisplayLength)
|
|
11032
|
+
? `Enter a length like 3.5, 180cm or 5'11".`
|
|
11033
|
+
: pendingReferenceDisplayLength > 0
|
|
11034
|
+
? null
|
|
11035
|
+
: 'Length must be greater than 0.'
|
|
11036
|
+
const referenceScaleHint = referenceScaleInputError
|
|
11037
|
+
? null
|
|
11038
|
+
: referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit)
|
|
10109
11039
|
return (
|
|
10110
11040
|
<div
|
|
10111
11041
|
className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
|
|
@@ -10118,9 +11048,8 @@ export function FloorplanPanel() {
|
|
|
10118
11048
|
>
|
|
10119
11049
|
<FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
|
|
10120
11050
|
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
|
10121
|
-
<
|
|
11051
|
+
<FloorplanCursorIndicator
|
|
10122
11052
|
cursorColor={floorplanCursorColor}
|
|
10123
|
-
cursorPosition={floorplanCursorPosition}
|
|
10124
11053
|
floorplanSelectionTool={floorplanSelectionTool}
|
|
10125
11054
|
indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X}
|
|
10126
11055
|
indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y}
|
|
@@ -10134,31 +11063,54 @@ export function FloorplanPanel() {
|
|
|
10134
11063
|
isDarkMode={isDark}
|
|
10135
11064
|
isMacPlatform={isMacPlatform}
|
|
10136
11065
|
rotationModifierPressed={rotationModifierPressed}
|
|
11066
|
+
showScaleHint={!selectedGuide.scaleReference}
|
|
10137
11067
|
/>
|
|
10138
11068
|
)}
|
|
10139
11069
|
{/* Floating Move / Duplicate / Delete buttons for registered
|
|
10140
|
-
kinds. All kinds are registry-driven now, so
|
|
10141
|
-
only action
|
|
11070
|
+
kinds. All kinds are registry-driven now, so these are the
|
|
11071
|
+
only action menus the floor plan mounts — the single-node
|
|
11072
|
+
pill, plus the group pill for multi-selections. */}
|
|
10142
11073
|
<FloorplanRegistryActionMenu />
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
11074
|
+
<FloorplanGroupActionMenu />
|
|
11075
|
+
|
|
11076
|
+
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
|
|
11077
|
+
(compassHost ? (
|
|
11078
|
+
createPortal(
|
|
11079
|
+
<FloorplanCompassButton
|
|
11080
|
+
needleRef={compassNeedleRef}
|
|
11081
|
+
northRotationDeg={floorplanUserRotationDeg}
|
|
11082
|
+
onAlignNorth={alignFloorplanViewToNorth}
|
|
11083
|
+
/>,
|
|
11084
|
+
compassHost,
|
|
11085
|
+
)
|
|
11086
|
+
) : (
|
|
11087
|
+
<FloorplanCompassButton
|
|
11088
|
+
needleRef={compassNeedleRef}
|
|
11089
|
+
northRotationDeg={floorplanUserRotationDeg}
|
|
11090
|
+
onAlignNorth={alignFloorplanViewToNorth}
|
|
11091
|
+
/>
|
|
11092
|
+
))}
|
|
10150
11093
|
|
|
10151
11094
|
{referenceScaleDraft && (
|
|
10152
11095
|
<div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
|
|
10153
11096
|
{referenceScaleDraft.start
|
|
10154
|
-
? 'Click the end of
|
|
10155
|
-
: 'Click
|
|
11097
|
+
? 'Click the other end of that distance'
|
|
11098
|
+
: 'Click one end of a distance you know — e.g. a dimension printed on the plan'}
|
|
10156
11099
|
</div>
|
|
10157
11100
|
)}
|
|
10158
11101
|
|
|
10159
11102
|
{pendingReferenceScale && (
|
|
10160
11103
|
<form
|
|
10161
11104
|
className="absolute top-1/2 left-1/2 z-40 w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background/95 p-3.5 text-foreground shadow-2xl backdrop-blur-md"
|
|
11105
|
+
onKeyDown={(event) => {
|
|
11106
|
+
// The focused length input keeps Escape from reaching the global
|
|
11107
|
+
// handler — cancel the flow from here too.
|
|
11108
|
+
if (event.key === 'Escape') {
|
|
11109
|
+
event.preventDefault()
|
|
11110
|
+
event.stopPropagation()
|
|
11111
|
+
guideEmitter.emit('guide:cancel-reference-scale')
|
|
11112
|
+
}
|
|
11113
|
+
}}
|
|
10162
11114
|
onSubmit={(event) => {
|
|
10163
11115
|
event.preventDefault()
|
|
10164
11116
|
handleReferenceScaleConfirm()
|
|
@@ -10197,16 +11149,9 @@ export function FloorplanPanel() {
|
|
|
10197
11149
|
'h-9 rounded-lg border bg-background px-3 text-sm outline-none transition focus:border-foreground/40',
|
|
10198
11150
|
referenceScaleInputError ? 'border-destructive/60' : 'border-border',
|
|
10199
11151
|
)}
|
|
10200
|
-
inputMode="decimal"
|
|
10201
|
-
onBlur={() => {
|
|
10202
|
-
const value = Number(referenceScaleValue)
|
|
10203
|
-
if (!(value > 0)) {
|
|
10204
|
-
setReferenceScaleValue('0.0001')
|
|
10205
|
-
}
|
|
10206
|
-
}}
|
|
10207
11152
|
onChange={(event) => setReferenceScaleValue(event.target.value)}
|
|
10208
|
-
|
|
10209
|
-
type="
|
|
11153
|
+
placeholder={`e.g. 3.5, 180cm or 5'11"`}
|
|
11154
|
+
type="text"
|
|
10210
11155
|
value={referenceScaleValue}
|
|
10211
11156
|
/>
|
|
10212
11157
|
<select
|
|
@@ -10229,6 +11174,7 @@ export function FloorplanPanel() {
|
|
|
10229
11174
|
)}
|
|
10230
11175
|
>
|
|
10231
11176
|
{referenceScaleInputError ??
|
|
11177
|
+
referenceScaleHint ??
|
|
10232
11178
|
'Any decimal works. Use the known real length, not the drawn value.'}
|
|
10233
11179
|
</span>
|
|
10234
11180
|
</label>
|
|
@@ -10258,11 +11204,20 @@ export function FloorplanPanel() {
|
|
|
10258
11204
|
</form>
|
|
10259
11205
|
)}
|
|
10260
11206
|
|
|
10261
|
-
{
|
|
11207
|
+
{levelNode?.type !== 'level' && !hasAmbientBuildingLevel ? (
|
|
10262
11208
|
<div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
|
|
10263
11209
|
Switch to a building level to view and edit the floorplan.
|
|
10264
11210
|
</div>
|
|
10265
|
-
) : (
|
|
11211
|
+
) : isFloorplanOpen ? (
|
|
11212
|
+
// The panel stays mounted in 3D mode (display:none) to keep the
|
|
11213
|
+
// portalled compass + viewport state warm, but the heavy 2D scene
|
|
11214
|
+
// (registry layer → one InteractiveGeometry per node, geometry
|
|
11215
|
+
// renderer, handle layers) must NOT render/reconcile while hidden —
|
|
11216
|
+
// otherwise every scene/selection change in pure 3D re-rendered the
|
|
11217
|
+
// whole floorplan tree (profiler: 150–200ms on a wall-endpoint drag).
|
|
11218
|
+
// `isFloorplanOpen` is `viewMode !== '3d'`, so this still renders fully
|
|
11219
|
+
// in both 2D and split. Viewport state lives on the still-mounted
|
|
11220
|
+
// panel, so pan/zoom is preserved across the toggle.
|
|
10266
11221
|
<svg
|
|
10267
11222
|
className="h-full w-full touch-none"
|
|
10268
11223
|
onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
|
|
@@ -10337,6 +11292,13 @@ export function FloorplanPanel() {
|
|
|
10337
11292
|
showGrid={showGrid}
|
|
10338
11293
|
/>
|
|
10339
11294
|
|
|
11295
|
+
{/* Dev-only: draw each wall's opening-snap hit area (the
|
|
11296
|
+
capsule of points within the snap radius of its centerline).
|
|
11297
|
+
Gated on the developer-menu toggle. Painted right after the
|
|
11298
|
+
grid so the translucent capsules sit under the wall / opening
|
|
11299
|
+
glyphs. */}
|
|
11300
|
+
<FloorplanVoronoiLayer />
|
|
11301
|
+
|
|
10340
11302
|
<FloorplanReferenceFloorLayer
|
|
10341
11303
|
data={referenceFloorData}
|
|
10342
11304
|
opacity={referenceFloorOpacity}
|
|
@@ -10354,31 +11316,14 @@ export function FloorplanPanel() {
|
|
|
10354
11316
|
/>
|
|
10355
11317
|
|
|
10356
11318
|
{/* Stair is fully registry-driven for committed nodes
|
|
10357
|
-
(`def.floorplan` on the stair kind).
|
|
10358
|
-
|
|
10359
|
-
the scene graph
|
|
10360
|
-
`
|
|
10361
|
-
|
|
10362
|
-
|
|
10363
|
-
|
|
10364
|
-
|
|
10365
|
-
<FloorplanStairLayer
|
|
10366
|
-
canFocusStairs={false}
|
|
10367
|
-
canSelectStairs={false}
|
|
10368
|
-
cursor={EDITOR_CURSOR}
|
|
10369
|
-
highlightedIdSet={highlightedFloorplanIdSet}
|
|
10370
|
-
hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
|
10371
|
-
hoveredStairId={null}
|
|
10372
|
-
isDeleteMode={isDeleteMode}
|
|
10373
|
-
onStairDoubleClick={noopFloorplanStairHandler}
|
|
10374
|
-
onStairHoverChange={noopFloorplanStairHandler}
|
|
10375
|
-
onStairHoverEnter={noopFloorplanStairHandler}
|
|
10376
|
-
onStairPointerDown={noopFloorplanStairHandler}
|
|
10377
|
-
onStairSelect={noopFloorplanStairHandler}
|
|
10378
|
-
palette={palette}
|
|
10379
|
-
selectedIdSet={selectedIdSet}
|
|
10380
|
-
stairEntries={renderedFloorplanStairEntries}
|
|
10381
|
-
/>
|
|
11319
|
+
(`def.floorplan` on the stair kind). The only thing left for
|
|
11320
|
+
this view is the in-flight build preview, which lives outside
|
|
11321
|
+
the scene graph (so `FloorplanRegistryLayer` can't see it).
|
|
11322
|
+
`FloorplanStairBuildPreviewLayer` owns it as a leaf that
|
|
11323
|
+
subscribes to the `useStairBuildPreview` store directly, so a
|
|
11324
|
+
per-`grid:move` cursor update re-renders only that tiny layer
|
|
11325
|
+
rather than this whole panel. */}
|
|
11326
|
+
<FloorplanStairBuildPreviewLayer isDeleteMode={isDeleteMode} palette={palette} />
|
|
10382
11327
|
|
|
10383
11328
|
<FloorplanReferenceScaleLayer
|
|
10384
11329
|
draft={referenceScaleDraft}
|
|
@@ -10450,6 +11395,7 @@ export function FloorplanPanel() {
|
|
|
10450
11395
|
`floorplan-wall-move-ghost-layer.tsx`. */}
|
|
10451
11396
|
<FloorplanWallMoveGhostLayer />
|
|
10452
11397
|
</g>
|
|
11398
|
+
<FloorplanMeasurementToolLayer />
|
|
10453
11399
|
</FloorplanRenderProvider>
|
|
10454
11400
|
{/* Cursor-driven placement ghost for movingNode when the
|
|
10455
11401
|
active kind is registry-driven. Renders via a portal
|
|
@@ -10464,6 +11410,7 @@ export function FloorplanPanel() {
|
|
|
10464
11410
|
<FloorplanAlignmentGuideLayer />
|
|
10465
11411
|
|
|
10466
11412
|
<FloorplanSiteLayer
|
|
11413
|
+
dimmed={selectedIds.length > 1 || previewSelectedIds.length > 1}
|
|
10467
11414
|
isHighlighted={isSiteBoundaryHighlighted}
|
|
10468
11415
|
palette={palette}
|
|
10469
11416
|
sitePolygon={visibleSitePolygon}
|
|
@@ -10504,13 +11451,15 @@ export function FloorplanPanel() {
|
|
|
10504
11451
|
the alignment guides. */}
|
|
10505
11452
|
<FloorplanSnapBeaconLayer />
|
|
10506
11453
|
|
|
10507
|
-
<
|
|
10508
|
-
bounds={visibleSvgMarqueeBounds}
|
|
10509
|
-
cursorColor={palette.cursor}
|
|
10510
|
-
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
|
|
10511
|
-
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
|
|
10512
|
-
/>
|
|
11454
|
+
<FloorplanMarqueeOverlay cursorColor={palette.cursor} />
|
|
10513
11455
|
|
|
11456
|
+
{/* This shared layer now carries only the per-CLICK draft anchors
|
|
11457
|
+
(reference-scale start + committed polygon vertices). The
|
|
11458
|
+
cursor-following draft geometry moved to the leaves below
|
|
11459
|
+
(`FloorplanLinearDraftLayer` for wall/fence/roof,
|
|
11460
|
+
`FloorplanDraftCursorLayer` for polygon previews), which read
|
|
11461
|
+
the live END points from the draft store so a per-move update
|
|
11462
|
+
never re-renders this panel. */}
|
|
10514
11463
|
<FloorplanDraftLayer
|
|
10515
11464
|
anchorFill={palette.anchor}
|
|
10516
11465
|
draftAnchorPoints={[
|
|
@@ -10530,33 +11479,32 @@ export function FloorplanPanel() {
|
|
|
10530
11479
|
})),
|
|
10531
11480
|
]}
|
|
10532
11481
|
draftFill={palette.draftFill}
|
|
10533
|
-
draftPolygonPoints={
|
|
11482
|
+
draftPolygonPoints={null}
|
|
10534
11483
|
draftStroke={palette.draftStroke}
|
|
10535
|
-
linearDraftSegment={
|
|
10536
|
-
polygonDraftClosingSegment={
|
|
10537
|
-
polygonDraftPolygonPoints={
|
|
10538
|
-
polygonDraftPolylinePoints={
|
|
10539
|
-
polygonDraftStroke={
|
|
10540
|
-
isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined
|
|
10541
|
-
}
|
|
10542
|
-
polygonDraftStrokeWidth={
|
|
10543
|
-
isSlabBuildActive || isCeilingBuildActive
|
|
10544
|
-
? FLOORPLAN_WALL_STROKE_WIDTH
|
|
10545
|
-
: undefined
|
|
10546
|
-
}
|
|
11484
|
+
linearDraftSegment={null}
|
|
11485
|
+
polygonDraftClosingSegment={null}
|
|
11486
|
+
polygonDraftPolygonPoints={null}
|
|
11487
|
+
polygonDraftPolylinePoints={null}
|
|
10547
11488
|
unitsPerPixel={floorplanUnitsPerPixel}
|
|
10548
11489
|
/>
|
|
10549
11490
|
|
|
10550
|
-
|
|
10551
|
-
|
|
10552
|
-
|
|
10553
|
-
|
|
10554
|
-
|
|
10555
|
-
|
|
10556
|
-
|
|
10557
|
-
|
|
10558
|
-
|
|
10559
|
-
|
|
11491
|
+
<FloorplanLinearDraftLayer
|
|
11492
|
+
draftFill={palette.draftFill}
|
|
11493
|
+
draftStroke={palette.draftStroke}
|
|
11494
|
+
fenceDraftStart={fenceDraftStart}
|
|
11495
|
+
isDark={isDark}
|
|
11496
|
+
isFenceBuildActive={isFenceBuildActive}
|
|
11497
|
+
isRoofBuildActive={isRoofBuildActive}
|
|
11498
|
+
isWallBuildActive={isWallBuildActive}
|
|
11499
|
+
levelId={levelId}
|
|
11500
|
+
measurementStroke={palette.measurementStroke}
|
|
11501
|
+
roofDraftStart={roofDraftStart}
|
|
11502
|
+
sceneRotationDeg={floorplanSceneRotationDeg}
|
|
11503
|
+
unit={unit}
|
|
11504
|
+
unitsPerPixel={floorplanUnitsPerPixel}
|
|
11505
|
+
wallDraftStart={draftStart}
|
|
11506
|
+
walls={walls}
|
|
11507
|
+
/>
|
|
10560
11508
|
|
|
10561
11509
|
{/* Wall / fence endpoint, wall curve, slab / ceiling /
|
|
10562
11510
|
zone vertex+midpoint+edge handles are all driven by the
|
|
@@ -10572,29 +11520,36 @@ export function FloorplanPanel() {
|
|
|
10572
11520
|
onCornerHoverChange={setHoveredGuideCorner}
|
|
10573
11521
|
onCornerPointerDown={handleGuideCornerPointerDown}
|
|
10574
11522
|
rotationModifierPressed={rotationModifierPressed}
|
|
11523
|
+
sceneRotationDeg={floorplanSceneRotationDeg}
|
|
10575
11524
|
showHandles={canInteractWithGuides && guideUi[selectedGuide.id]?.locked !== true}
|
|
10576
11525
|
/>
|
|
10577
11526
|
)}
|
|
10578
11527
|
|
|
10579
|
-
{
|
|
10580
|
-
<
|
|
10581
|
-
|
|
10582
|
-
|
|
10583
|
-
|
|
10584
|
-
|
|
10585
|
-
|
|
10586
|
-
|
|
10587
|
-
|
|
10588
|
-
|
|
10589
|
-
|
|
10590
|
-
cy={toSvgY(cursorPoint[1])}
|
|
10591
|
-
fill={floorplanCursorColor}
|
|
10592
|
-
fillOpacity={0.9}
|
|
10593
|
-
r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX * floorplanUnitsPerPixel}
|
|
10594
|
-
/>
|
|
10595
|
-
</g>
|
|
11528
|
+
{guideRotationReadout && (
|
|
11529
|
+
<RotationAngleOverlay
|
|
11530
|
+
overlay={guideRotationReadout}
|
|
11531
|
+
palette={{
|
|
11532
|
+
measurementLabelBackground: isDark ? '#0f172a' : '#ffffff',
|
|
11533
|
+
measurementLabelText: isDark ? '#e2e8f0' : '#171717',
|
|
11534
|
+
measurementStroke: palette.measurementStroke,
|
|
11535
|
+
}}
|
|
11536
|
+
sceneRotationDeg={floorplanSceneRotationDeg}
|
|
11537
|
+
unitsPerPixel={floorplanUnitsPerPixel}
|
|
11538
|
+
/>
|
|
10596
11539
|
)}
|
|
10597
11540
|
|
|
11541
|
+
<FloorplanDraftCursorLayer
|
|
11542
|
+
activePolygonDraftPoints={activePolygonDraftPoints}
|
|
11543
|
+
cursorColor={floorplanCursorColor}
|
|
11544
|
+
draftFill={palette.draftFill}
|
|
11545
|
+
draftStroke={palette.draftStroke}
|
|
11546
|
+
isPolygonDraftBuildActive={isPolygonDraftBuildActive}
|
|
11547
|
+
polygonDraftStroke={
|
|
11548
|
+
isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined
|
|
11549
|
+
}
|
|
11550
|
+
unitsPerPixel={floorplanUnitsPerPixel}
|
|
11551
|
+
/>
|
|
11552
|
+
|
|
10598
11553
|
{activeDraftAnchorPoint && (
|
|
10599
11554
|
<circle
|
|
10600
11555
|
cx={toSvgX(activeDraftAnchorPoint[0])}
|
|
@@ -10618,7 +11573,7 @@ export function FloorplanPanel() {
|
|
|
10618
11573
|
/>
|
|
10619
11574
|
)}
|
|
10620
11575
|
</svg>
|
|
10621
|
-
)}
|
|
11576
|
+
) : null}
|
|
10622
11577
|
</div>
|
|
10623
11578
|
</div>
|
|
10624
11579
|
)
|