@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
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
type FloorplanMoveTargetSession,
|
|
11
11
|
nodeRegistry,
|
|
12
12
|
pauseSceneHistory,
|
|
13
|
-
resolveAlignment,
|
|
14
13
|
resumeSceneHistory,
|
|
15
14
|
useLiveNodeOverrides,
|
|
16
15
|
useLiveTransforms,
|
|
@@ -19,11 +18,19 @@ import {
|
|
|
19
18
|
import { useViewer } from '@pascal-app/viewer'
|
|
20
19
|
import { useEffect } from 'react'
|
|
21
20
|
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
|
21
|
+
import { isHistoryShortcut } from '../../lib/history'
|
|
22
22
|
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
|
23
23
|
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
|
24
|
+
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
|
|
24
25
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
26
|
+
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
|
25
27
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
26
|
-
import useEditor
|
|
28
|
+
import useEditor, {
|
|
29
|
+
isAlignmentGuideActive,
|
|
30
|
+
isGridSnapActive,
|
|
31
|
+
isMagneticSnapActive,
|
|
32
|
+
} from '../../store/use-editor'
|
|
33
|
+
import { useMovingNode } from '../../store/use-interaction-scope'
|
|
27
34
|
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
|
28
35
|
|
|
29
36
|
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
|
|
@@ -53,7 +60,7 @@ const ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
53
60
|
* cursor → meters accounts for pan / zoom / building rotation.
|
|
54
61
|
*/
|
|
55
62
|
export function FloorplanRegistryMoveOverlay() {
|
|
56
|
-
const movingNode =
|
|
63
|
+
const movingNode = useMovingNode()
|
|
57
64
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
58
65
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
59
66
|
|
|
@@ -114,6 +121,17 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
114
121
|
pauseSceneHistory(useScene)
|
|
115
122
|
let historyPaused = true
|
|
116
123
|
|
|
124
|
+
const clearLivePreviews = () => {
|
|
125
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
126
|
+
const liveOverrides = useLiveNodeOverrides.getState()
|
|
127
|
+
const scene = useScene.getState()
|
|
128
|
+
for (const id of session.affectedIds) {
|
|
129
|
+
liveTransforms.clear(id)
|
|
130
|
+
liveOverrides.clear(id)
|
|
131
|
+
scene.markDirty(id)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
// The registry action menu's Move button portals to `document.body`,
|
|
118
136
|
// so the trigger click's pointer-up happens OUTSIDE the floor-plan
|
|
119
137
|
// scene and never reaches `onPointerUp` here. That means: the very
|
|
@@ -124,6 +142,18 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
124
142
|
// to be consumed. That legacy flow is gone in the registry layer;
|
|
125
143
|
// all entries use the action menu now.
|
|
126
144
|
let hasMovedSinceStart = false
|
|
145
|
+
// Last resolved position of the moved node — drives the move "tick" SFX.
|
|
146
|
+
// Parity with the 3D move, which emits on any change of the resolved
|
|
147
|
+
// position (every snapping mode, not only grid).
|
|
148
|
+
let lastSnapKey: string | null = null
|
|
149
|
+
// Live cursor location — updated on EVERY pointermove (even over the 3D
|
|
150
|
+
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
|
|
151
|
+
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
|
|
152
|
+
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
|
|
153
|
+
let pointerOverFloorplan = false
|
|
154
|
+
const onPointerTrack = (event: PointerEvent) => {
|
|
155
|
+
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
|
|
156
|
+
}
|
|
127
157
|
|
|
128
158
|
const onMove = (event: PointerEvent) => {
|
|
129
159
|
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
|
@@ -146,6 +176,23 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
146
176
|
metaKey: event.metaKey,
|
|
147
177
|
},
|
|
148
178
|
})
|
|
179
|
+
// Move "tick" — same feedback the 3D move gives, which fires whenever the
|
|
180
|
+
// resolved position changes (any snapping mode, not just grid), so it
|
|
181
|
+
// ticks as the item lands on each new snapped/free position.
|
|
182
|
+
const movedId = session.affectedIds[0]
|
|
183
|
+
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
|
|
184
|
+
const pos = (moved as { position?: [number, number, number] } | undefined)?.position
|
|
185
|
+
if (pos) {
|
|
186
|
+
const key = movementSfxStepKey({
|
|
187
|
+
coords: [pos[0], pos[2]],
|
|
188
|
+
gridSnapActive: isGridSnapActive(),
|
|
189
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
190
|
+
})
|
|
191
|
+
if (key !== lastSnapKey) {
|
|
192
|
+
lastSnapKey = key
|
|
193
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
149
196
|
}
|
|
150
197
|
|
|
151
198
|
const commitFinalStateOrRevert = () => {
|
|
@@ -283,7 +330,42 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
283
330
|
}
|
|
284
331
|
|
|
285
332
|
const onKey = (event: KeyboardEvent) => {
|
|
286
|
-
|
|
333
|
+
// R flips a directional kind's facing mid-placement (door / window:
|
|
334
|
+
// front ↔ back). The session records the flip and re-runs its last
|
|
335
|
+
// apply so the 2D symbol updates immediately; kinds without a facing
|
|
336
|
+
// leave `flipSide` unset and R falls through to the global handler.
|
|
337
|
+
//
|
|
338
|
+
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
|
|
339
|
+
// move tool ALSO listens for R on `window`. We own R only while the
|
|
340
|
+
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
|
|
341
|
+
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
|
|
342
|
+
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
|
|
343
|
+
// first) so the 3D tool can't also flip. When the cursor is over the 3D
|
|
344
|
+
// pane we yield — the 3D tool owns R there. (The old sticky
|
|
345
|
+
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
|
|
346
|
+
// the first 2D move, killing the 3D flip.)
|
|
347
|
+
if (event.key === 'r' || event.key === 'R') {
|
|
348
|
+
// Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
|
|
349
|
+
if (event.metaKey || event.ctrlKey) return
|
|
350
|
+
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
|
|
351
|
+
if (event.repeat) return
|
|
352
|
+
const t = event.target as HTMLElement | null
|
|
353
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
event.preventDefault()
|
|
357
|
+
event.stopImmediatePropagation()
|
|
358
|
+
session.flipSide()
|
|
359
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
|
363
|
+
if (isHistoryShortcut(event)) {
|
|
364
|
+
// ⌘Z mid-move cancels like Escape — keep it from reaching the
|
|
365
|
+
// global undo arm (this handler is capture-phase, that one bubbles).
|
|
366
|
+
event.preventDefault()
|
|
367
|
+
event.stopImmediatePropagation()
|
|
368
|
+
}
|
|
287
369
|
// Claim teardown ownership so the 3D move tool's cleanup skips
|
|
288
370
|
// its own restore — without this, both sides would race to
|
|
289
371
|
// write the same baseline, harmless but wasteful.
|
|
@@ -295,12 +377,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
295
377
|
resumeSceneHistory(useScene)
|
|
296
378
|
historyPaused = false
|
|
297
379
|
}
|
|
298
|
-
|
|
299
|
-
const liveOverrides = useLiveNodeOverrides.getState()
|
|
300
|
-
for (const id of session.affectedIds) {
|
|
301
|
-
liveTransforms.clear(id)
|
|
302
|
-
liveOverrides.clear(id)
|
|
303
|
-
}
|
|
380
|
+
clearLivePreviews()
|
|
304
381
|
useAlignmentGuides.getState().clear()
|
|
305
382
|
setMovingNode(null)
|
|
306
383
|
return
|
|
@@ -317,24 +394,24 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
317
394
|
// `useLiveNodeOverrides`. Either way, leaving them in place
|
|
318
395
|
// after Esc would freeze the 2D / 3D view at the cancelled
|
|
319
396
|
// position.
|
|
320
|
-
|
|
321
|
-
const liveOverrides = useLiveNodeOverrides.getState()
|
|
322
|
-
for (const id of session.affectedIds) {
|
|
323
|
-
liveTransforms.clear(id)
|
|
324
|
-
liveOverrides.clear(id)
|
|
325
|
-
}
|
|
397
|
+
clearLivePreviews()
|
|
326
398
|
// Restore selection cleared by the action menu's Move click.
|
|
327
399
|
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
|
328
400
|
setMovingNode(null)
|
|
329
401
|
}
|
|
330
402
|
|
|
403
|
+
window.addEventListener('pointermove', onPointerTrack)
|
|
331
404
|
window.addEventListener('pointermove', onMove)
|
|
332
405
|
window.addEventListener('pointerup', onPointerUp)
|
|
333
|
-
|
|
406
|
+
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
|
|
407
|
+
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
|
|
408
|
+
// then pre-empts it so only one handler flips.
|
|
409
|
+
window.addEventListener('keydown', onKey, true)
|
|
334
410
|
return () => {
|
|
411
|
+
window.removeEventListener('pointermove', onPointerTrack)
|
|
335
412
|
window.removeEventListener('pointermove', onMove)
|
|
336
413
|
window.removeEventListener('pointerup', onPointerUp)
|
|
337
|
-
window.removeEventListener('keydown', onKey)
|
|
414
|
+
window.removeEventListener('keydown', onKey, true)
|
|
338
415
|
// Unmount cleanup. `historyPaused === true` here means none of
|
|
339
416
|
// our terminal paths (commit, Esc) ran in this overlay — they
|
|
340
417
|
// each call `resumeSceneHistory` and flip the flag.
|
|
@@ -365,12 +442,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
365
442
|
// `useLiveTransforms`; wall sessions write to
|
|
366
443
|
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
|
|
367
444
|
// tool's cleanup isn't there to clear them for us.
|
|
368
|
-
|
|
369
|
-
const liveOverrides = useLiveNodeOverrides.getState()
|
|
370
|
-
for (const id of session.affectedIds) {
|
|
371
|
-
liveTransforms.clear(id)
|
|
372
|
-
liveOverrides.clear(id)
|
|
373
|
-
}
|
|
445
|
+
clearLivePreviews()
|
|
374
446
|
// Sessions that publish Figma-style alignment guides during `apply`
|
|
375
447
|
// (item / shelf / column) leave them in the store; this cleanup runs
|
|
376
448
|
// after every terminal path (commit + Esc both unmount via
|
|
@@ -388,12 +460,40 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
388
460
|
// ── Path 2 — generic free-floating translate ────────────────────
|
|
389
461
|
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
|
390
462
|
if (!entry) return
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
463
|
+
const sceneNodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
464
|
+
const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes)
|
|
465
|
+
const relatedEntries = Array.from(relatedEntryIds)
|
|
466
|
+
.map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null)
|
|
467
|
+
.filter((value): value is SVGGElement => value != null)
|
|
468
|
+
|
|
469
|
+
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
|
|
470
|
+
// `position` — translating a `position` here would write a field their
|
|
471
|
+
// schema ignores and snap the run back. For those we move every path
|
|
472
|
+
// point by the cursor delta and commit the translated `path` instead.
|
|
473
|
+
// The reference origin is the path centre so the SVG `translate` delta
|
|
474
|
+
// matches the geometry's actual location (which isn't at [0,0,0]).
|
|
475
|
+
// Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
|
|
476
|
+
// here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
|
|
477
|
+
// moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
|
|
478
|
+
const rawPath = (movingNode as { path?: unknown }).path
|
|
479
|
+
const originalPath =
|
|
480
|
+
Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
|
|
481
|
+
? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
|
|
482
|
+
: null
|
|
483
|
+
const originalPosition: [number, number, number] = originalPath
|
|
484
|
+
? (() => {
|
|
485
|
+
let cx = 0
|
|
486
|
+
let cz = 0
|
|
487
|
+
for (const p of originalPath) {
|
|
488
|
+
cx += p[0]
|
|
489
|
+
cz += p[2]
|
|
490
|
+
}
|
|
491
|
+
const n = originalPath.length || 1
|
|
492
|
+
return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
|
|
493
|
+
})()
|
|
494
|
+
: (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
|
|
495
|
+
0, 0, 0,
|
|
496
|
+
]) as [number, number, number])
|
|
397
497
|
const isFreshPlacement = isFreshPlacementMetadata(
|
|
398
498
|
(movingNode as { metadata?: unknown }).metadata,
|
|
399
499
|
)
|
|
@@ -403,20 +503,41 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
403
503
|
// so its untransformed bbox IS the world-space footprint. Cache the
|
|
404
504
|
// moving entry's local bbox once (relative to originalPosition) and
|
|
405
505
|
// derive anchors at any proposed (sx, sz) by translating it.
|
|
406
|
-
const movingLocalBBox =
|
|
506
|
+
const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries)
|
|
407
507
|
const candidateAnchors: AlignmentAnchor[] = []
|
|
408
508
|
const allEntries = scene.querySelectorAll('[data-node-id]')
|
|
409
509
|
for (const el of Array.from(allEntries)) {
|
|
410
510
|
const otherId = el.getAttribute('data-node-id')
|
|
411
|
-
if (!otherId || otherId
|
|
511
|
+
if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue
|
|
412
512
|
const b = (el as SVGGraphicsElement).getBBox()
|
|
413
|
-
|
|
513
|
+
// Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
|
|
514
|
+
// lineset drawn as a line) has one zero dimension but is still a valid
|
|
515
|
+
// alignment target — its endpoints become line anchors.
|
|
516
|
+
if (b.width <= 0 && b.height <= 0) continue
|
|
414
517
|
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
|
|
415
518
|
}
|
|
416
519
|
|
|
417
520
|
let lastSnapped: [number, number] | null = null
|
|
418
521
|
let dragAnchor: [number, number] | null = null
|
|
419
522
|
|
|
523
|
+
// Footprint bounding box drawn around the dragged entry — the 2D
|
|
524
|
+
// counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
|
|
525
|
+
// reads the same in both views. Green wireframe rect over the entry's
|
|
526
|
+
// own bbox, translated in lockstep with it. The entry stays visible the
|
|
527
|
+
// whole drag (no hide-until-move) so it never appears to vanish.
|
|
528
|
+
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
529
|
+
const boxEl = document.createElementNS(SVG_NS, 'rect')
|
|
530
|
+
boxEl.setAttribute('x', String(movingLocalBBox.x))
|
|
531
|
+
boxEl.setAttribute('y', String(movingLocalBBox.y))
|
|
532
|
+
boxEl.setAttribute('width', String(movingLocalBBox.width))
|
|
533
|
+
boxEl.setAttribute('height', String(movingLocalBBox.height))
|
|
534
|
+
boxEl.setAttribute('fill', 'none')
|
|
535
|
+
boxEl.setAttribute('stroke', '#22c55e')
|
|
536
|
+
boxEl.setAttribute('stroke-width', '1.5')
|
|
537
|
+
boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
|
|
538
|
+
boxEl.setAttribute('pointer-events', 'none')
|
|
539
|
+
scene.appendChild(boxEl)
|
|
540
|
+
|
|
420
541
|
const onMove = (event: PointerEvent) => {
|
|
421
542
|
// Same target guard as Path 1 — pointer must be over the floor
|
|
422
543
|
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
|
@@ -426,9 +547,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
426
547
|
if (!m) return
|
|
427
548
|
|
|
428
549
|
// 1) Grid snap baseline. Fresh catalog placement is absolute under
|
|
429
|
-
// the cursor; existing moves preserve the cursor's grab offset.
|
|
550
|
+
// the cursor; existing moves preserve the cursor's grab offset. Grid
|
|
551
|
+
// follows the active snapping mode (Shift cycles it); raw cursor in
|
|
552
|
+
// any non-grid mode.
|
|
430
553
|
const gridStep = useEditor.getState().gridSnapStep
|
|
431
|
-
const snap = (value: number) =>
|
|
554
|
+
const snap = (value: number) =>
|
|
555
|
+
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
|
|
432
556
|
const resolved = resolvePlanarCursorPosition({
|
|
433
557
|
cursor: [m[0], m[1]],
|
|
434
558
|
original: [originalPosition[0], originalPosition[2]],
|
|
@@ -441,12 +565,13 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
441
565
|
|
|
442
566
|
// 2) Alignment snap layered on top. Treat the grid-snapped point
|
|
443
567
|
// as the "proposed" position so alignment competes from a stable
|
|
444
|
-
// base rather than the raw cursor jitter.
|
|
445
|
-
//
|
|
446
|
-
//
|
|
568
|
+
// base rather than the raw cursor jitter. Alignment "lines" are
|
|
569
|
+
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
|
|
570
|
+
// magnetic pull toward them applies only in 'lines' mode. Alt is
|
|
571
|
+
// force-place, not a snap bypass.
|
|
447
572
|
let finalX = gridX
|
|
448
573
|
let finalZ = gridZ
|
|
449
|
-
if (
|
|
574
|
+
if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
|
|
450
575
|
// Translate the cached local bbox to the proposed pos to get the
|
|
451
576
|
// moving anchors at that location. The entry's untransformed
|
|
452
577
|
// bbox is in world meters relative to the node's origin, so a
|
|
@@ -469,12 +594,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
469
594
|
// store, which the 2D FloorplanAlignmentGuideLayer renders
|
|
470
595
|
// inside the rotated scene <g>. The 3D pipeline uses a
|
|
471
596
|
// separate store, so frames stay isolated per surface.
|
|
472
|
-
const result =
|
|
597
|
+
const result = resolveAlignmentForFloorplanView({
|
|
473
598
|
moving: movingAnchors,
|
|
474
599
|
candidates: candidateAnchors,
|
|
475
600
|
threshold: ALIGNMENT_THRESHOLD_M,
|
|
476
601
|
})
|
|
477
|
-
if (result.snap) {
|
|
602
|
+
if (result.snap && isMagneticSnapActive()) {
|
|
478
603
|
finalX += result.snap.dx
|
|
479
604
|
finalZ += result.snap.dz
|
|
480
605
|
}
|
|
@@ -483,9 +608,34 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
483
608
|
useAlignmentGuides.getState().clear()
|
|
484
609
|
}
|
|
485
610
|
|
|
611
|
+
// 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
|
|
612
|
+
// 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
|
|
613
|
+
// alignment guide, so it runs in every snapping mode except Off.
|
|
614
|
+
const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
|
|
615
|
+
if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
|
|
616
|
+
const snappedPosition = groupMoveSnap({
|
|
617
|
+
node: movingNode,
|
|
618
|
+
candidatePosition: [finalX, originalPosition[1], finalZ],
|
|
619
|
+
movingIds: [movingNode.id as AnyNodeId],
|
|
620
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
621
|
+
levelId:
|
|
622
|
+
(useViewer.getState().selection.levelId as AnyNodeId | null) ??
|
|
623
|
+
(movingNode.parentId as AnyNodeId | undefined) ??
|
|
624
|
+
null,
|
|
625
|
+
})
|
|
626
|
+
if (snappedPosition) {
|
|
627
|
+
finalX = snappedPosition[0]
|
|
628
|
+
finalZ = snappedPosition[2]
|
|
629
|
+
useAlignmentGuides.getState().clear()
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
486
633
|
const dx = finalX - originalPosition[0]
|
|
487
634
|
const dz = finalZ - originalPosition[2]
|
|
488
|
-
|
|
635
|
+
for (const relatedEntry of relatedEntries) {
|
|
636
|
+
relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
|
|
637
|
+
}
|
|
638
|
+
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
|
|
489
639
|
lastSnapped = [finalX, finalZ]
|
|
490
640
|
}
|
|
491
641
|
|
|
@@ -499,6 +649,35 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
499
649
|
const [, oldY] = originalPosition
|
|
500
650
|
setMovingNodeOrigin('2d')
|
|
501
651
|
let selectedId = movingNode.id as AnyNodeId
|
|
652
|
+
if (originalPath) {
|
|
653
|
+
// Polyline kinds: shift every point by the committed delta and
|
|
654
|
+
// write `path`. Strip the fresh-placement flags on first drop.
|
|
655
|
+
const dx = sx - originalPosition[0]
|
|
656
|
+
const dz = sz - originalPosition[2]
|
|
657
|
+
const nextPath = originalPath.map(
|
|
658
|
+
([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
|
|
659
|
+
)
|
|
660
|
+
useScene.getState().updateNode(
|
|
661
|
+
movingNode.id as AnyNodeId,
|
|
662
|
+
(isFreshPlacement
|
|
663
|
+
? {
|
|
664
|
+
path: nextPath,
|
|
665
|
+
metadata: stripPlacementMetadataFlags(
|
|
666
|
+
(movingNode as { metadata?: unknown }).metadata,
|
|
667
|
+
),
|
|
668
|
+
visible: true,
|
|
669
|
+
}
|
|
670
|
+
: { path: nextPath }) as Partial<AnyNode>,
|
|
671
|
+
)
|
|
672
|
+
useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
|
|
673
|
+
for (const relatedEntry of relatedEntries) {
|
|
674
|
+
relatedEntry.removeAttribute('transform')
|
|
675
|
+
}
|
|
676
|
+
useAlignmentGuides.getState().clear()
|
|
677
|
+
setMovingNode(null)
|
|
678
|
+
swallowNextClick()
|
|
679
|
+
return
|
|
680
|
+
}
|
|
502
681
|
if (isFreshPlacement) {
|
|
503
682
|
selectedId =
|
|
504
683
|
commitFreshPlacementSubtree(
|
|
@@ -520,37 +699,54 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
520
699
|
)
|
|
521
700
|
}
|
|
522
701
|
useViewer.getState().setSelection({ selectedIds: [selectedId] })
|
|
523
|
-
|
|
702
|
+
for (const relatedEntry of relatedEntries) {
|
|
703
|
+
relatedEntry.removeAttribute('transform')
|
|
704
|
+
}
|
|
524
705
|
useAlignmentGuides.getState().clear()
|
|
525
706
|
setMovingNode(null)
|
|
526
707
|
swallowNextClick()
|
|
527
708
|
}
|
|
528
709
|
|
|
529
710
|
const onKey = (event: KeyboardEvent) => {
|
|
530
|
-
if (event.key
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (wasTracking) temporal.pause()
|
|
537
|
-
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
|
|
538
|
-
if (wasTracking) temporal.resume()
|
|
539
|
-
}
|
|
540
|
-
entry.removeAttribute('transform')
|
|
541
|
-
useAlignmentGuides.getState().clear()
|
|
542
|
-
setMovingNode(null)
|
|
711
|
+
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
|
712
|
+
if (isHistoryShortcut(event)) {
|
|
713
|
+
// ⌘Z mid-move cancels like Escape — keep it from reaching the
|
|
714
|
+
// global undo arm (this handler is capture-phase, that one bubbles).
|
|
715
|
+
event.preventDefault()
|
|
716
|
+
event.stopImmediatePropagation()
|
|
543
717
|
}
|
|
718
|
+
setMovingNodeOrigin('2d')
|
|
719
|
+
if (isFreshPlacement) {
|
|
720
|
+
emitter.emit('tool:cancel')
|
|
721
|
+
const temporal = useScene.temporal.getState()
|
|
722
|
+
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
|
|
723
|
+
if (wasTracking) temporal.pause()
|
|
724
|
+
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
|
|
725
|
+
if (wasTracking) temporal.resume()
|
|
726
|
+
}
|
|
727
|
+
for (const relatedEntry of relatedEntries) {
|
|
728
|
+
relatedEntry.removeAttribute('transform')
|
|
729
|
+
}
|
|
730
|
+
useAlignmentGuides.getState().clear()
|
|
731
|
+
setMovingNode(null)
|
|
544
732
|
}
|
|
545
733
|
|
|
546
734
|
window.addEventListener('pointermove', onMove)
|
|
547
735
|
window.addEventListener('pointerup', onPointerUp)
|
|
548
|
-
window.addEventListener('keydown', onKey)
|
|
736
|
+
window.addEventListener('keydown', onKey, true)
|
|
549
737
|
return () => {
|
|
550
738
|
window.removeEventListener('pointermove', onMove)
|
|
551
739
|
window.removeEventListener('pointerup', onPointerUp)
|
|
552
|
-
window.removeEventListener('keydown', onKey)
|
|
553
|
-
|
|
740
|
+
window.removeEventListener('keydown', onKey, true)
|
|
741
|
+
for (const relatedEntry of relatedEntries) {
|
|
742
|
+
relatedEntry.removeAttribute('transform')
|
|
743
|
+
}
|
|
744
|
+
// Always un-hide on teardown so a committed copy shows and a
|
|
745
|
+
// never-revealed entry doesn't leak a hidden style onto a reused node.
|
|
746
|
+
for (const relatedEntry of relatedEntries) {
|
|
747
|
+
relatedEntry.style.visibility = ''
|
|
748
|
+
}
|
|
749
|
+
boxEl.remove()
|
|
554
750
|
useAlignmentGuides.getState().clear()
|
|
555
751
|
}
|
|
556
752
|
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
|
@@ -601,6 +797,51 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
|
|
601
797
|
return false
|
|
602
798
|
}
|
|
603
799
|
|
|
800
|
+
function collectFloorplanMoveEntryIds(
|
|
801
|
+
rootId: AnyNodeId,
|
|
802
|
+
nodes: Record<string, AnyNode>,
|
|
803
|
+
): Set<AnyNodeId> {
|
|
804
|
+
const root = nodes[rootId]
|
|
805
|
+
if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') {
|
|
806
|
+
return new Set([rootId])
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const ids = new Set<AnyNodeId>()
|
|
810
|
+
const queue: AnyNodeId[] = [rootId]
|
|
811
|
+
while (queue.length > 0) {
|
|
812
|
+
const id = queue.pop()!
|
|
813
|
+
if (ids.has(id)) continue
|
|
814
|
+
const node = nodes[id]
|
|
815
|
+
if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue
|
|
816
|
+
ids.add(id)
|
|
817
|
+
for (const childId of node.children ?? []) {
|
|
818
|
+
const child = nodes[childId as AnyNodeId]
|
|
819
|
+
if (child?.type === 'cabinet' || child?.type === 'cabinet-module') {
|
|
820
|
+
queue.push(childId as AnyNodeId)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return ids
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect {
|
|
828
|
+
const first = entries[0]?.getBBox()
|
|
829
|
+
if (!first) return new DOMRect(0, 0, 0, 0)
|
|
830
|
+
|
|
831
|
+
let minX = first.x
|
|
832
|
+
let minY = first.y
|
|
833
|
+
let maxX = first.x + first.width
|
|
834
|
+
let maxY = first.y + first.height
|
|
835
|
+
for (const entry of entries.slice(1)) {
|
|
836
|
+
const box = entry.getBBox()
|
|
837
|
+
minX = Math.min(minX, box.x)
|
|
838
|
+
minY = Math.min(minY, box.y)
|
|
839
|
+
maxX = Math.max(maxX, box.x + box.width)
|
|
840
|
+
maxY = Math.max(maxY, box.y + box.height)
|
|
841
|
+
}
|
|
842
|
+
return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY))
|
|
843
|
+
}
|
|
844
|
+
|
|
604
845
|
function swallowNextClick() {
|
|
605
846
|
const swallowClick = (e: MouseEvent) => {
|
|
606
847
|
e.stopPropagation()
|
|
@@ -14,12 +14,14 @@ import { useFloorplanRender } from './floorplan-render-context'
|
|
|
14
14
|
* endpoint (corner) → square midpoint → triangle
|
|
15
15
|
* intersection → ✕ cross wall body (edge) → circle
|
|
16
16
|
*
|
|
17
|
-
* Indigo to match the 3D beacon
|
|
18
|
-
* guides. Sizes are pixel-budgeted via
|
|
17
|
+
* Indigo to match the 3D beacon, except the corner (endpoint) square which is
|
|
18
|
+
* green to match the alignment guides. Sizes are pixel-budgeted via
|
|
19
|
+
* `unitsPerPixel` so the marker stays a
|
|
19
20
|
* constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
|
|
20
21
|
* group so coordinates are world meters (XZ) 1:1, like the alignment guides.
|
|
21
22
|
*/
|
|
22
23
|
const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
|
|
24
|
+
const ENDPOINT_COLOR = '#22c55e' // green-500 — corner (endpoint) snap accent
|
|
23
25
|
|
|
24
26
|
export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
|
|
25
27
|
const point = useWallSnapIndicator((s) => s.point)
|
|
@@ -54,7 +56,7 @@ function SnapMarker({
|
|
|
54
56
|
z: number
|
|
55
57
|
}) {
|
|
56
58
|
if (kind === 'endpoint') {
|
|
57
|
-
return <rect fill={
|
|
59
|
+
return <rect fill={ENDPOINT_COLOR} height={m * 2} width={m * 2} x={x - m} y={z - m} />
|
|
58
60
|
}
|
|
59
61
|
if (kind === 'midpoint') {
|
|
60
62
|
const t = m * 1.3
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
|
3
|
+
|
|
4
|
+
describe('resolveFloorplanLabelAngle', () => {
|
|
5
|
+
test('keeps segment labels readable while preserving their screen direction', () => {
|
|
6
|
+
expect(resolveFloorplanLabelAngle(0, 0)).toBe(0)
|
|
7
|
+
expect(resolveFloorplanLabelAngle(Math.PI, 0)).toBe(0)
|
|
8
|
+
expect(resolveFloorplanLabelAngle(Math.PI / 2, 90)).toBe(-90)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('counter-rotates aggregate labels to remain horizontal', () => {
|
|
12
|
+
expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
|
|
13
|
+
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function resolveFloorplanLabelAngle(
|
|
2
|
+
angleRadians: number,
|
|
3
|
+
sceneRotationDeg: number,
|
|
4
|
+
screenUpright = false,
|
|
5
|
+
): number {
|
|
6
|
+
if (screenUpright) return -sceneRotationDeg
|
|
7
|
+
|
|
8
|
+
let localAngleDeg = (angleRadians * 180) / Math.PI
|
|
9
|
+
let screenAngleDeg = localAngleDeg + sceneRotationDeg
|
|
10
|
+
screenAngleDeg = ((((screenAngleDeg + 180) % 360) + 360) % 360) - 180
|
|
11
|
+
if (screenAngleDeg > 90) localAngleDeg -= 180
|
|
12
|
+
else if (screenAngleDeg <= -90) localAngleDeg += 180
|
|
13
|
+
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
|
|
14
|
+
}
|
|
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
|
27
27
|
*/
|
|
28
28
|
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
|
|
29
29
|
const node = usePlacementPreview((s) => s.node)
|
|
30
|
+
const parentNode = usePlacementPreview((s) => s.parentNode)
|
|
30
31
|
if (!node) return null
|
|
31
32
|
|
|
32
33
|
const builder = nodeRegistry.get(node.type)?.floorplan
|
|
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
|
|
|
37
38
|
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
|
|
38
39
|
// and `parent: null` short-circuits the elevator's level walk) so the layer
|
|
39
40
|
// never subscribes to / bulk-reads the nodes map during render.
|
|
41
|
+
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
|
|
42
|
+
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
|
|
40
43
|
const ctx = {
|
|
41
44
|
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
|
|
42
45
|
children: [],
|
|
43
46
|
siblings: [],
|
|
44
|
-
parent: null,
|
|
47
|
+
parent: parentNode ?? null,
|
|
45
48
|
viewState: undefined,
|
|
46
49
|
} as unknown as GeometryContext
|
|
47
50
|
|