@pascal-app/editor 1.0.0 → 1.0.3
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 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -6,10 +6,13 @@ import {
|
|
|
6
6
|
type AnyNodeId,
|
|
7
7
|
bboxAnchors,
|
|
8
8
|
bboxCornerAnchors,
|
|
9
|
+
cascadeDirty,
|
|
10
|
+
collectDescendants,
|
|
9
11
|
createSceneApi,
|
|
10
12
|
emitter,
|
|
11
13
|
type FloorplanMoveTargetSession,
|
|
12
14
|
type GroupMoveSnapResult,
|
|
15
|
+
getEffectiveNode,
|
|
13
16
|
type MovableConfig,
|
|
14
17
|
nodeRegistry,
|
|
15
18
|
pauseSceneHistory,
|
|
@@ -18,8 +21,9 @@ import {
|
|
|
18
21
|
useLiveTransforms,
|
|
19
22
|
useScene,
|
|
20
23
|
} from '@pascal-app/core'
|
|
24
|
+
import { nodeLevelFrame } from '@pascal-app/core/procedural-items'
|
|
21
25
|
import { useViewer } from '@pascal-app/viewer'
|
|
22
|
-
import { useEffect } from 'react'
|
|
26
|
+
import { useEffect, useMemo } from 'react'
|
|
23
27
|
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
|
24
28
|
import { isHistoryShortcut } from '../../lib/history'
|
|
25
29
|
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
} from '../../lib/rigid-plan-svg-transform'
|
|
31
35
|
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
|
|
32
36
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
37
|
+
import { surfaceAttachmentId } from '../../lib/surface-attachment'
|
|
33
38
|
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
|
34
39
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
35
40
|
import useEditor, {
|
|
@@ -37,7 +42,11 @@ import useEditor, {
|
|
|
37
42
|
isGridSnapActive,
|
|
38
43
|
isMagneticSnapActive,
|
|
39
44
|
} from '../../store/use-editor'
|
|
40
|
-
import {
|
|
45
|
+
import useInteractionScope, {
|
|
46
|
+
isInteractionSubtreeDraft,
|
|
47
|
+
useMovingNode,
|
|
48
|
+
} from '../../store/use-interaction-scope'
|
|
49
|
+
import usePlacementPreview from '../../store/use-placement-preview'
|
|
41
50
|
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
|
42
51
|
|
|
43
52
|
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
|
|
@@ -67,7 +76,14 @@ const ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
67
76
|
* cursor → meters accounts for pan / zoom / building rotation.
|
|
68
77
|
*/
|
|
69
78
|
export function FloorplanRegistryMoveOverlay() {
|
|
70
|
-
const
|
|
79
|
+
const source = useMovingNode()
|
|
80
|
+
const movingNode = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
source && isInteractionSubtreeDraft(source.id)
|
|
83
|
+
? (useScene.getState().nodes[source.id] ?? source)
|
|
84
|
+
: source,
|
|
85
|
+
[source],
|
|
86
|
+
)
|
|
71
87
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
72
88
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
73
89
|
|
|
@@ -93,7 +109,15 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
93
109
|
return [m.x, m.y]
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
const isPointerOverFloorplanScene = (
|
|
112
|
+
const isPointerOverFloorplanScene = (event: PointerEvent): boolean => {
|
|
113
|
+
// View controls overlap the SVG viewport; clicking one must not place the draft.
|
|
114
|
+
if (
|
|
115
|
+
(event.target as Element | null)?.closest?.(
|
|
116
|
+
'button, input, select, textarea, [role="button"]',
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return false
|
|
120
|
+
const { clientX, clientY } = event
|
|
97
121
|
// The scene's `<g>` only covers painted SVG elements, so hovers over
|
|
98
122
|
// empty grid background often target the parent SVG. Bounds keep the
|
|
99
123
|
// cursor active anywhere inside the floor-plan viewport.
|
|
@@ -108,6 +132,8 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
108
132
|
)
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
const ownsSubtree = useInteractionScope.getState().adoptSubtree(movingNode.id)
|
|
136
|
+
|
|
111
137
|
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
|
|
112
138
|
if (hasMoveTarget && def?.floorplanMoveTarget) {
|
|
113
139
|
const sceneNodes = useScene.getState().nodes
|
|
@@ -137,7 +163,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
137
163
|
for (const id of session.affectedIds) {
|
|
138
164
|
liveTransforms.clear(id)
|
|
139
165
|
liveOverrides.clear(id)
|
|
140
|
-
|
|
166
|
+
const api = createSceneApi(useScene)
|
|
167
|
+
for (const dirtyId of new Set([
|
|
168
|
+
...cascadeDirty(id, { scene: api }),
|
|
169
|
+
...collectDescendants(id, { scene: api }),
|
|
170
|
+
]))
|
|
171
|
+
scene.markDirty(dirtyId)
|
|
141
172
|
}
|
|
142
173
|
}
|
|
143
174
|
|
|
@@ -161,9 +192,29 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
161
192
|
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
|
|
162
193
|
let pointerOverFloorplan = false
|
|
163
194
|
const onPointerTrack = (event: PointerEvent) => {
|
|
164
|
-
pointerOverFloorplan = isPointerOverFloorplanScene(event
|
|
195
|
+
pointerOverFloorplan = isPointerOverFloorplanScene(event)
|
|
165
196
|
}
|
|
166
197
|
|
|
198
|
+
let rejectionIndicator: SVGCircleElement | null = null
|
|
199
|
+
let lastPlanPoint: [number, number] | null = null
|
|
200
|
+
const clearRejection = () => {
|
|
201
|
+
rejectionIndicator?.remove()
|
|
202
|
+
rejectionIndicator = null
|
|
203
|
+
}
|
|
204
|
+
const showRejection = () => {
|
|
205
|
+
if (!lastPlanPoint) return
|
|
206
|
+
clearRejection()
|
|
207
|
+
rejectionIndicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
208
|
+
rejectionIndicator.setAttribute('cx', String(lastPlanPoint[0]))
|
|
209
|
+
rejectionIndicator.setAttribute('cy', String(lastPlanPoint[1]))
|
|
210
|
+
rejectionIndicator.setAttribute('r', '0.15')
|
|
211
|
+
rejectionIndicator.setAttribute('fill', 'none')
|
|
212
|
+
rejectionIndicator.setAttribute('stroke', '#ef4444')
|
|
213
|
+
rejectionIndicator.setAttribute('stroke-width', '1.5')
|
|
214
|
+
rejectionIndicator.setAttribute('vector-effect', 'non-scaling-stroke')
|
|
215
|
+
rejectionIndicator.setAttribute('pointer-events', 'none')
|
|
216
|
+
scene.appendChild(rejectionIndicator)
|
|
217
|
+
}
|
|
167
218
|
const onMove = (event: PointerEvent) => {
|
|
168
219
|
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
|
169
220
|
// tracks pointer events that actually correspond to a floor-plan
|
|
@@ -172,10 +223,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
172
223
|
// hovers over empty grid background — without it, the cursor
|
|
173
224
|
// only updated the shelf when it happened to brush over an
|
|
174
225
|
// existing SVG entry, leaving the move feeling "stuck" elsewhere.
|
|
175
|
-
if (!isPointerOverFloorplanScene(event
|
|
226
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
176
227
|
const planPoint = toMeters(event.clientX, event.clientY)
|
|
177
228
|
if (!planPoint) return
|
|
178
229
|
hasMovedSinceStart = true
|
|
230
|
+
lastPlanPoint = planPoint
|
|
231
|
+
clearRejection()
|
|
179
232
|
session.apply({
|
|
180
233
|
planPoint,
|
|
181
234
|
modifiers: {
|
|
@@ -190,7 +243,14 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
190
243
|
// ticks as the item lands on each new snapped/free position.
|
|
191
244
|
const movedId = session.affectedIds[0]
|
|
192
245
|
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
|
|
193
|
-
const
|
|
246
|
+
const effective = moved ? getEffectiveNode(moved) : undefined
|
|
247
|
+
const pos =
|
|
248
|
+
effective?.type === 'item' || effective?.type === 'procedural-item'
|
|
249
|
+
? nodeLevelFrame(effective.id, {
|
|
250
|
+
...useScene.getState().nodes,
|
|
251
|
+
[effective.id]: effective,
|
|
252
|
+
}).position
|
|
253
|
+
: (effective as { position?: [number, number, number] } | undefined)?.position
|
|
194
254
|
if (pos) {
|
|
195
255
|
const key = movementSfxStepKey({
|
|
196
256
|
coords: [pos[0], pos[2]],
|
|
@@ -211,12 +271,19 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
211
271
|
?.metadata,
|
|
212
272
|
)
|
|
213
273
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
274
|
+
const atomicPreview =
|
|
275
|
+
freshPlacement &&
|
|
276
|
+
(ownsSubtree ||
|
|
277
|
+
('children' in movingNode && movingNode.children.length > 0) ||
|
|
278
|
+
surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
|
|
279
|
+
if (atomicPreview && !commitValid) {
|
|
280
|
+
showRejection()
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Keep ordinary teardown order. Validated replacements can refuse, so they
|
|
285
|
+
// claim teardown only after success, before clearing the moving node.
|
|
286
|
+
if (!atomicPreview) setMovingNodeOrigin('2d')
|
|
220
287
|
|
|
221
288
|
// Sessions with a `commit` hook own their atomic write (e.g.
|
|
222
289
|
// wall move emits creates + deletes + updates via the junction
|
|
@@ -224,17 +291,33 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
224
291
|
// and Phase 2's resume — but Phase 2's write is delegated, and
|
|
225
292
|
// we skip the snapshot-diff finalUpdates path.
|
|
226
293
|
if (commitValid && freshPlacement) {
|
|
227
|
-
session
|
|
294
|
+
// Subtrees finalize from the preview. Staging a session commit would publish
|
|
295
|
+
// an unvalidated graph, including for presets created outside our factory.
|
|
296
|
+
if (!atomicPreview) session.commit?.()
|
|
228
297
|
const stagedNode = useScene.getState().nodes[movingNode.id]
|
|
298
|
+
const preview = usePlacementPreview.getState().node
|
|
299
|
+
const effective = stagedNode ? getEffectiveNode(stagedNode) : null
|
|
300
|
+
const candidate =
|
|
301
|
+
effective?.visible === false && preview?.id === movingNode.id ? preview : effective
|
|
229
302
|
const committedId = stagedNode
|
|
230
303
|
? commitFreshPlacementSubtree(
|
|
231
304
|
movingNode.id as AnyNodeId,
|
|
232
305
|
{
|
|
306
|
+
...(atomicPreview ? candidate : {}),
|
|
233
307
|
metadata: stripPlacementMetadataFlags(stagedNode.metadata),
|
|
234
308
|
visible: true,
|
|
235
309
|
} as Partial<AnyNode>,
|
|
310
|
+
showRejection,
|
|
236
311
|
)
|
|
237
312
|
: null
|
|
313
|
+
if (!committedId && atomicPreview) return false
|
|
314
|
+
if (atomicPreview) {
|
|
315
|
+
setMovingNodeOrigin('2d')
|
|
316
|
+
for (const id of session.affectedIds) {
|
|
317
|
+
useLiveTransforms.getState().clear(id)
|
|
318
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
238
321
|
if (historyPaused) {
|
|
239
322
|
resumeSceneHistory(useScene)
|
|
240
323
|
historyPaused = false
|
|
@@ -247,7 +330,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
247
330
|
}
|
|
248
331
|
|
|
249
332
|
if (commitValid && session.commit) {
|
|
250
|
-
|
|
333
|
+
restoreSnapshots(snapshots)
|
|
251
334
|
if (historyPaused) {
|
|
252
335
|
resumeSceneHistory(useScene)
|
|
253
336
|
historyPaused = false
|
|
@@ -298,7 +381,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
298
381
|
// 1. Revert to baseline while history is still paused.
|
|
299
382
|
// 2. Resume history.
|
|
300
383
|
// 3. Re-apply the final state — recorded as one tracked change.
|
|
301
|
-
|
|
384
|
+
restoreSnapshots(snapshots)
|
|
302
385
|
if (historyPaused) {
|
|
303
386
|
resumeSceneHistory(useScene)
|
|
304
387
|
historyPaused = false
|
|
@@ -312,7 +395,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
312
395
|
// brings them back at the new position.
|
|
313
396
|
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
|
314
397
|
} else {
|
|
315
|
-
|
|
398
|
+
restoreSnapshots(snapshots)
|
|
316
399
|
if (historyPaused) {
|
|
317
400
|
resumeSceneHistory(useScene)
|
|
318
401
|
historyPaused = false
|
|
@@ -325,7 +408,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
325
408
|
// Bounding-rect check (see `isPointerOverFloorplanScene`) — same
|
|
326
409
|
// reason as `onMove`: commits should land for any pointer-up
|
|
327
410
|
// inside the SVG viewport, including empty grid background.
|
|
328
|
-
if (!isPointerOverFloorplanScene(event
|
|
411
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
329
412
|
if (!hasMovedSinceStart) return
|
|
330
413
|
|
|
331
414
|
// Commit using the LAST pointermove's state — no re-apply at
|
|
@@ -344,7 +427,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
344
427
|
// pointermove right before pointerup, so the trade-off lands
|
|
345
428
|
// on the side of WYSIWYG.
|
|
346
429
|
|
|
347
|
-
commitFinalStateOrRevert()
|
|
430
|
+
if (commitFinalStateOrRevert() === false) return
|
|
348
431
|
setMovingNode(null)
|
|
349
432
|
|
|
350
433
|
// Swallow the click event that follows this pointer-up — the
|
|
@@ -408,7 +491,8 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
408
491
|
setMovingNodeOrigin('2d')
|
|
409
492
|
if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
|
|
410
493
|
emitter.emit('tool:cancel')
|
|
411
|
-
|
|
494
|
+
if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id))
|
|
495
|
+
useScene.getState().deleteNode(movingNode.id)
|
|
412
496
|
if (historyPaused) {
|
|
413
497
|
resumeSceneHistory(useScene)
|
|
414
498
|
historyPaused = false
|
|
@@ -419,7 +503,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
419
503
|
return
|
|
420
504
|
}
|
|
421
505
|
// Revert untracked, then resume — no history entry.
|
|
422
|
-
|
|
506
|
+
restoreSnapshots(snapshots)
|
|
423
507
|
if (historyPaused) {
|
|
424
508
|
resumeSceneHistory(useScene)
|
|
425
509
|
historyPaused = false
|
|
@@ -444,6 +528,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
444
528
|
// then pre-empts it so only one handler flips.
|
|
445
529
|
window.addEventListener('keydown', onKey, true)
|
|
446
530
|
return () => {
|
|
531
|
+
clearRejection()
|
|
447
532
|
window.removeEventListener('pointermove', onPointerTrack)
|
|
448
533
|
window.removeEventListener('pointermove', onMove)
|
|
449
534
|
window.removeEventListener('pointerup', onPointerUp)
|
|
@@ -464,11 +549,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
464
549
|
// our unmount
|
|
465
550
|
// are now distinguished by the origin flag — no scene-state
|
|
466
551
|
// diff heuristic required.
|
|
552
|
+
const freshPlacement = ownsSubtree
|
|
467
553
|
if (historyPaused) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
554
|
+
const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
|
|
555
|
+
if (!finalisedBy3D && !freshPlacement) {
|
|
556
|
+
if (hasMovedSinceStart) {
|
|
557
|
+
restoreSnapshots(snapshots)
|
|
472
558
|
}
|
|
473
559
|
}
|
|
474
560
|
resumeSceneHistory(useScene)
|
|
@@ -478,7 +564,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
478
564
|
// `useLiveTransforms`; wall sessions write to
|
|
479
565
|
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
|
|
480
566
|
// tool's cleanup isn't there to clear them for us.
|
|
481
|
-
clearLivePreviews()
|
|
567
|
+
if (!freshPlacement) clearLivePreviews()
|
|
482
568
|
// Sessions that publish Figma-style alignment guides during `apply`
|
|
483
569
|
// (item / shelf / column) leave them in the store; this cleanup runs
|
|
484
570
|
// after every terminal path (commit + Esc both unmount via
|
|
@@ -589,7 +675,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
589
675
|
// Same target guard as Path 1 — pointer must be over the floor
|
|
590
676
|
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
|
591
677
|
// plan coords.
|
|
592
|
-
if (!isPointerOverFloorplanScene(event
|
|
678
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
593
679
|
const m = toMeters(event.clientX, event.clientY)
|
|
594
680
|
if (!m) return
|
|
595
681
|
forcePlace = event.altKey
|
|
@@ -740,7 +826,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
740
826
|
|
|
741
827
|
const onPointerUp = (event: PointerEvent) => {
|
|
742
828
|
if (event.button !== 0) return
|
|
743
|
-
if (!isPointerOverFloorplanScene(event
|
|
829
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
744
830
|
|
|
745
831
|
const snapped = lastSnapped
|
|
746
832
|
if (!snapped) return
|
|
@@ -754,8 +840,14 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
754
840
|
]
|
|
755
841
|
: snapped.rotation
|
|
756
842
|
const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
|
|
757
|
-
|
|
843
|
+
const atomicPreview =
|
|
844
|
+
isFreshPlacement &&
|
|
845
|
+
(ownsSubtree ||
|
|
846
|
+
('children' in movingNode && movingNode.children.length > 0) ||
|
|
847
|
+
surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
|
|
848
|
+
if (!atomicPreview) setMovingNodeOrigin('2d')
|
|
758
849
|
if (!lastPositionValid && !forcePlace) {
|
|
850
|
+
if (atomicPreview) setMovingNodeOrigin('2d')
|
|
759
851
|
for (const relatedEntry of relatedEntries) {
|
|
760
852
|
relatedEntry.removeAttribute('transform')
|
|
761
853
|
}
|
|
@@ -766,6 +858,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
766
858
|
}
|
|
767
859
|
let selectedId = movingNode.id as AnyNodeId
|
|
768
860
|
if (originalPath) {
|
|
861
|
+
if (atomicPreview) setMovingNodeOrigin('2d')
|
|
769
862
|
// Polyline kinds: shift every point by the committed delta and
|
|
770
863
|
// write `path`. Strip the fresh-placement flags on first drop.
|
|
771
864
|
const dx = sx - originalPosition[0]
|
|
@@ -795,18 +888,21 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
795
888
|
return
|
|
796
889
|
}
|
|
797
890
|
if (isFreshPlacement) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
891
|
+
const committedId = commitFreshPlacementSubtree(
|
|
892
|
+
movingNode.id as AnyNodeId,
|
|
893
|
+
{
|
|
894
|
+
position: [sx, oldY, sz],
|
|
895
|
+
...rotationPatch,
|
|
896
|
+
metadata: stripPlacementMetadataFlags((movingNode as { metadata?: unknown }).metadata),
|
|
897
|
+
visible: true,
|
|
898
|
+
} as Partial<AnyNode>,
|
|
899
|
+
)
|
|
900
|
+
if (!committedId && atomicPreview) {
|
|
901
|
+
boxEl.setAttribute('stroke', '#ef4444')
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
if (atomicPreview) setMovingNodeOrigin('2d')
|
|
905
|
+
selectedId = committedId ?? selectedId
|
|
810
906
|
} else {
|
|
811
907
|
useScene.getState().updateNode(
|
|
812
908
|
movingNode.id as AnyNodeId,
|
|
@@ -836,11 +932,13 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
836
932
|
setMovingNodeOrigin('2d')
|
|
837
933
|
if (isFreshPlacement) {
|
|
838
934
|
emitter.emit('tool:cancel')
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
935
|
+
if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id)) {
|
|
936
|
+
const temporal = useScene.temporal.getState()
|
|
937
|
+
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
|
|
938
|
+
if (wasTracking) temporal.pause()
|
|
939
|
+
useScene.getState().deleteNode(movingNode.id)
|
|
940
|
+
if (wasTracking) temporal.resume()
|
|
941
|
+
}
|
|
844
942
|
}
|
|
845
943
|
for (const relatedEntry of relatedEntries) {
|
|
846
944
|
relatedEntry.removeAttribute('transform')
|
|
@@ -888,8 +986,15 @@ function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
|
888
986
|
return { id: node.id, data }
|
|
889
987
|
}
|
|
890
988
|
|
|
891
|
-
function
|
|
892
|
-
|
|
989
|
+
function restoreSnapshots(snapshots: NodeSnapshot[]) {
|
|
990
|
+
const updates = snapshots
|
|
991
|
+
.filter(
|
|
992
|
+
(s) =>
|
|
993
|
+
!useScene.getState().nodes[s.id] ||
|
|
994
|
+
!deepEqual(s.data, snapshotNode(useScene.getState().nodes[s.id]!).data),
|
|
995
|
+
)
|
|
996
|
+
.map((s) => ({ id: s.id, data: s.data }))
|
|
997
|
+
if (updates.length) useScene.getState().updateNodes(updates)
|
|
893
998
|
}
|
|
894
999
|
|
|
895
1000
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { WallPlanPoint } from '@pascal-app/core'
|
|
4
|
+
|
|
5
|
+
/** Length plate + angle arcs for a drafted wall segment, in plan space. */
|
|
6
|
+
export type DraftWallMeasurement = {
|
|
7
|
+
lengthLabel: string
|
|
8
|
+
midpoint: WallPlanPoint
|
|
9
|
+
direction: WallPlanPoint
|
|
10
|
+
angleLabels: {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
center: WallPlanPoint
|
|
14
|
+
radius: number
|
|
15
|
+
startAngle: number
|
|
16
|
+
endAngle: number
|
|
17
|
+
midAngle: number
|
|
18
|
+
}[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function FloorplanDraftWallMeasurement({
|
|
22
|
+
measurement,
|
|
23
|
+
measurementStroke,
|
|
24
|
+
labelBackground,
|
|
25
|
+
labelText,
|
|
26
|
+
sceneRotationDeg,
|
|
27
|
+
unitsPerPixel,
|
|
28
|
+
}: {
|
|
29
|
+
measurement: DraftWallMeasurement
|
|
30
|
+
measurementStroke: string
|
|
31
|
+
labelBackground: string
|
|
32
|
+
labelText: string
|
|
33
|
+
sceneRotationDeg: number
|
|
34
|
+
unitsPerPixel: number
|
|
35
|
+
}) {
|
|
36
|
+
const stroke = measurementStroke
|
|
37
|
+
const labelBg = labelBackground
|
|
38
|
+
|
|
39
|
+
const upx = unitsPerPixel
|
|
40
|
+
const fontSize = Math.max(upx * 10, 0.08)
|
|
41
|
+
const padX = upx * 6
|
|
42
|
+
const padY = upx * 3
|
|
43
|
+
|
|
44
|
+
// Length plate: rotates to follow the wall direction, but flips 180°
|
|
45
|
+
// when its on-screen orientation would read upside-down (same trick as
|
|
46
|
+
// `floorplan-registry-layer.tsx` for dimension labels).
|
|
47
|
+
const wallAngleDeg =
|
|
48
|
+
(Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
|
|
49
|
+
let labelAngleDeg = wallAngleDeg
|
|
50
|
+
let screenDeg = wallAngleDeg + sceneRotationDeg
|
|
51
|
+
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
|
52
|
+
if (screenDeg > 90) labelAngleDeg -= 180
|
|
53
|
+
else if (screenDeg <= -90) labelAngleDeg += 180
|
|
54
|
+
|
|
55
|
+
// Push the plate perpendicular to the wall so the dashed footprint
|
|
56
|
+
// stays visible underneath.
|
|
57
|
+
const perpX = -measurement.direction[1]
|
|
58
|
+
const perpY = measurement.direction[0]
|
|
59
|
+
const offset = upx * 18
|
|
60
|
+
const cx = measurement.midpoint[0] + perpX * offset
|
|
61
|
+
const cy = measurement.midpoint[1] + perpY * offset
|
|
62
|
+
|
|
63
|
+
const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
|
|
64
|
+
const lengthPlateW = lengthTextWidth + padX * 2
|
|
65
|
+
const lengthPlateH = fontSize + padY * 2
|
|
66
|
+
|
|
67
|
+
const arcSampleCount = 32
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<g pointerEvents="none">
|
|
71
|
+
<g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
|
|
72
|
+
<rect
|
|
73
|
+
fill={labelBg}
|
|
74
|
+
height={lengthPlateH}
|
|
75
|
+
opacity={0.92}
|
|
76
|
+
rx={upx * 3}
|
|
77
|
+
ry={upx * 3}
|
|
78
|
+
stroke={stroke}
|
|
79
|
+
strokeWidth={upx * 0.5}
|
|
80
|
+
vectorEffect="non-scaling-stroke"
|
|
81
|
+
width={lengthPlateW}
|
|
82
|
+
x={-lengthPlateW / 2}
|
|
83
|
+
y={-lengthPlateH / 2}
|
|
84
|
+
/>
|
|
85
|
+
<text
|
|
86
|
+
dominantBaseline="middle"
|
|
87
|
+
fill={labelText}
|
|
88
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
89
|
+
fontSize={fontSize}
|
|
90
|
+
fontWeight={600}
|
|
91
|
+
textAnchor="middle"
|
|
92
|
+
x={0}
|
|
93
|
+
y={0}
|
|
94
|
+
>
|
|
95
|
+
{measurement.lengthLabel}
|
|
96
|
+
</text>
|
|
97
|
+
</g>
|
|
98
|
+
|
|
99
|
+
{measurement.angleLabels.map((arc) => {
|
|
100
|
+
// Sample the arc as a polyline — avoids the SVG arc command's
|
|
101
|
+
// sweep-flag direction quirks across negative/positive sweeps.
|
|
102
|
+
const points: string[] = []
|
|
103
|
+
for (let i = 0; i <= arcSampleCount; i += 1) {
|
|
104
|
+
const t = i / arcSampleCount
|
|
105
|
+
const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
|
|
106
|
+
const px = arc.center[0] + Math.cos(a) * arc.radius
|
|
107
|
+
const py = arc.center[1] + Math.sin(a) * arc.radius
|
|
108
|
+
points.push(`${px},${py}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const aFontSize = Math.max(upx * 9, 0.075)
|
|
112
|
+
const aPadX = upx * 5
|
|
113
|
+
const aPadY = upx * 2.5
|
|
114
|
+
const aTextWidth = arc.label.length * upx * 6.2
|
|
115
|
+
const aPlateW = aTextWidth + aPadX * 2
|
|
116
|
+
const aPlateH = aFontSize + aPadY * 2
|
|
117
|
+
|
|
118
|
+
const labelDist = arc.radius + upx * 16
|
|
119
|
+
const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
|
|
120
|
+
const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<g key={`draft-angle-${arc.id}`}>
|
|
124
|
+
<polyline
|
|
125
|
+
fill="none"
|
|
126
|
+
points={points.join(' ')}
|
|
127
|
+
stroke={stroke}
|
|
128
|
+
strokeLinecap="round"
|
|
129
|
+
strokeLinejoin="round"
|
|
130
|
+
strokeOpacity={0.95}
|
|
131
|
+
strokeWidth={upx * 1.2}
|
|
132
|
+
vectorEffect="non-scaling-stroke"
|
|
133
|
+
/>
|
|
134
|
+
<g transform={`translate(${lx} ${ly})`}>
|
|
135
|
+
<rect
|
|
136
|
+
fill={labelBg}
|
|
137
|
+
height={aPlateH}
|
|
138
|
+
opacity={0.92}
|
|
139
|
+
rx={upx * 3}
|
|
140
|
+
ry={upx * 3}
|
|
141
|
+
stroke={stroke}
|
|
142
|
+
strokeWidth={upx * 0.5}
|
|
143
|
+
vectorEffect="non-scaling-stroke"
|
|
144
|
+
width={aPlateW}
|
|
145
|
+
x={-aPlateW / 2}
|
|
146
|
+
y={-aPlateH / 2}
|
|
147
|
+
/>
|
|
148
|
+
<text
|
|
149
|
+
dominantBaseline="middle"
|
|
150
|
+
fill={labelText}
|
|
151
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
152
|
+
fontSize={aFontSize}
|
|
153
|
+
fontWeight={600}
|
|
154
|
+
textAnchor="middle"
|
|
155
|
+
x={0}
|
|
156
|
+
y={0}
|
|
157
|
+
>
|
|
158
|
+
{arc.label}
|
|
159
|
+
</text>
|
|
160
|
+
</g>
|
|
161
|
+
</g>
|
|
162
|
+
)
|
|
163
|
+
})}
|
|
164
|
+
</g>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
collectFloorplanLinkedLevelNodes,
|
|
24
24
|
computeAffectedSiblingIds,
|
|
25
25
|
floorplanAffordanceReshapeScope,
|
|
26
|
+
floorplanEntryYieldsToTool,
|
|
26
27
|
floorplanHandleDoubleClickAffordance,
|
|
27
28
|
InteractiveGeometry,
|
|
29
|
+
isFloorplanHierarchyVisible,
|
|
28
30
|
isFloorplanOpeningPlacementState,
|
|
29
31
|
resolveFloorplanHandleUnitsPerPixel,
|
|
30
32
|
siteToFloorplanTransform,
|
|
@@ -780,3 +782,50 @@ describe('collectFloorplanLinkedLevelNodes', () => {
|
|
|
780
782
|
).toEqual([])
|
|
781
783
|
})
|
|
782
784
|
})
|
|
785
|
+
|
|
786
|
+
describe('floorplan entry routing while a tool is active', () => {
|
|
787
|
+
test('build tools get presses on entries, as in 3D; select and delete keep selecting', () => {
|
|
788
|
+
expect(floorplanEntryYieldsToTool({ mode: 'build', openingPlacement: false })).toBe(true)
|
|
789
|
+
expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: true })).toBe(true)
|
|
790
|
+
expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: false })).toBe(false)
|
|
791
|
+
expect(floorplanEntryYieldsToTool({ mode: 'delete', openingPlacement: false })).toBe(false)
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
describe('isFloorplanHierarchyVisible', () => {
|
|
796
|
+
const node = (id: string, type: string, parentId: string | null, visible = true) =>
|
|
797
|
+
({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode
|
|
798
|
+
const noOverrides = new Map<string, LiveNodeOverrides>()
|
|
799
|
+
const visibleUnder = (nodes: Record<string, AnyNode>, rootId: string, id: string) =>
|
|
800
|
+
isFloorplanHierarchyVisible(nodes[id]!, nodes, noOverrides, rootId as AnyNodeId)
|
|
801
|
+
|
|
802
|
+
test('a hidden Site root keeps the nodes on it, linked or detached', () => {
|
|
803
|
+
const nodes: Record<string, AnyNode> = {
|
|
804
|
+
site_a: node('site_a', 'site', null, false),
|
|
805
|
+
building_a: node('building_a', 'building', 'site_a'),
|
|
806
|
+
level_a: node('level_a', 'level', 'building_a'),
|
|
807
|
+
wall_a: node('wall_a', 'wall', 'level_a'),
|
|
808
|
+
wall_b: node('wall_b', 'wall', 'level_a', false),
|
|
809
|
+
tree_a: node('tree_a', 'trees:tree', null),
|
|
810
|
+
}
|
|
811
|
+
expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(true)
|
|
812
|
+
expect(visibleUnder(nodes, 'site_a', 'tree_a')).toBe(true)
|
|
813
|
+
expect(visibleUnder(nodes, 'site_a', 'wall_b')).toBe(false)
|
|
814
|
+
expect(visibleUnder(nodes, 'site_a', 'site_a')).toBe(false)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test('a hidden building or level root still hides what it hosts', () => {
|
|
818
|
+
const nodes: Record<string, AnyNode> = {
|
|
819
|
+
site_a: node('site_a', 'site', null),
|
|
820
|
+
building_a: node('building_a', 'building', 'site_a', false),
|
|
821
|
+
level_a: node('level_a', 'level', 'building_a'),
|
|
822
|
+
wall_a: node('wall_a', 'wall', 'level_a'),
|
|
823
|
+
elevator_a: node('elevator_a', 'elevator', null),
|
|
824
|
+
}
|
|
825
|
+
expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(false)
|
|
826
|
+
expect(visibleUnder(nodes, 'building_a', 'elevator_a')).toBe(false)
|
|
827
|
+
// A level plan is scoped to its level: the walk stops at the root and
|
|
828
|
+
// never consults the building above it.
|
|
829
|
+
expect(visibleUnder(nodes, 'level_a', 'wall_a')).toBe(true)
|
|
830
|
+
})
|
|
831
|
+
})
|