@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
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
generateId,
|
|
5
5
|
type LevelNode,
|
|
6
|
+
remapMeasurementReferences,
|
|
6
7
|
type StairNode,
|
|
7
8
|
useScene,
|
|
8
9
|
} from '@pascal-app/core'
|
|
@@ -30,6 +31,9 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
|
|
|
30
31
|
'stair',
|
|
31
32
|
'spawn',
|
|
32
33
|
'zone',
|
|
34
|
+
'cabinet',
|
|
35
|
+
'cabinet-module',
|
|
36
|
+
'measurement',
|
|
33
37
|
])
|
|
34
38
|
|
|
35
39
|
let clipboardPayload: ClipboardPayload | null = null
|
|
@@ -99,6 +103,30 @@ function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
|
|
|
99
103
|
return nodes[parentId]?.type === 'level'
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
function getPromotedCabinetRunId(
|
|
107
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
108
|
+
node: AnyNode,
|
|
109
|
+
selectedIds: Set<AnyNodeId>,
|
|
110
|
+
) {
|
|
111
|
+
if (node.type !== 'cabinet-module') return null
|
|
112
|
+
|
|
113
|
+
const parentId = node.parentId as AnyNodeId | null
|
|
114
|
+
const parent = parentId ? nodes[parentId] : null
|
|
115
|
+
if (parent?.type !== 'cabinet') return null
|
|
116
|
+
if (!parentId) return null
|
|
117
|
+
if (selectedIds.has(parentId)) return parentId
|
|
118
|
+
|
|
119
|
+
const siblingIds = Array.isArray(parent.children) ? (parent.children as AnyNodeId[]) : []
|
|
120
|
+
const hasOnlySelectedModules =
|
|
121
|
+
siblingIds.length > 0 &&
|
|
122
|
+
siblingIds.every((childId) => {
|
|
123
|
+
const child = nodes[childId]
|
|
124
|
+
return child?.type === 'cabinet-module' && selectedIds.has(childId)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return hasOnlySelectedModules ? parentId : null
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
|
|
103
131
|
const scene = useScene.getState()
|
|
104
132
|
const resolvedLevelId =
|
|
@@ -114,7 +142,7 @@ function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
|
|
|
114
142
|
if (!parentId) return null
|
|
115
143
|
|
|
116
144
|
const building = nodes[parentId]
|
|
117
|
-
if (
|
|
145
|
+
if (building?.type !== 'building') return null
|
|
118
146
|
|
|
119
147
|
const siblingLevels = building.children
|
|
120
148
|
.map((childId) => nodes[childId as AnyNodeId])
|
|
@@ -165,6 +193,10 @@ function remapNodeReferences(
|
|
|
165
193
|
;(clone as StairNode).toLevelId = nextLevelId
|
|
166
194
|
}
|
|
167
195
|
|
|
196
|
+
if (clone.type === 'measurement') {
|
|
197
|
+
clone.measurement = remapMeasurementReferences(clone.measurement, idMap)
|
|
198
|
+
}
|
|
199
|
+
|
|
168
200
|
const metadata =
|
|
169
201
|
clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
|
|
170
202
|
? { ...(clone.metadata as Record<string, unknown>) }
|
|
@@ -176,11 +208,14 @@ function remapNodeReferences(
|
|
|
176
208
|
return AnyNode.parse(clone)
|
|
177
209
|
}
|
|
178
210
|
|
|
179
|
-
|
|
211
|
+
function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
|
|
180
212
|
const scene = useScene.getState()
|
|
181
|
-
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
|
|
182
213
|
const selectedIdSet = new Set(ids)
|
|
183
|
-
const
|
|
214
|
+
const promotedIds = ids.map((id) => {
|
|
215
|
+
const node = scene.nodes[id]
|
|
216
|
+
return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id
|
|
217
|
+
})
|
|
218
|
+
const rootIds = Array.from(new Set(promotedIds)).filter((id) => {
|
|
184
219
|
const node = scene.nodes[id]
|
|
185
220
|
return (
|
|
186
221
|
node &&
|
|
@@ -191,7 +226,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
|
191
226
|
})
|
|
192
227
|
|
|
193
228
|
if (rootIds.length === 0) {
|
|
194
|
-
return
|
|
229
|
+
return null
|
|
195
230
|
}
|
|
196
231
|
|
|
197
232
|
const subtreeIds = new Set<AnyNodeId>()
|
|
@@ -199,7 +234,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
|
199
234
|
collectSubtreeIds(scene.nodes, rootId, subtreeIds)
|
|
200
235
|
}
|
|
201
236
|
|
|
202
|
-
|
|
237
|
+
return {
|
|
203
238
|
copiedAt: Date.now(),
|
|
204
239
|
nodes: [...subtreeIds]
|
|
205
240
|
.map((id) => scene.nodes[id])
|
|
@@ -207,15 +242,45 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
|
207
242
|
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
|
|
208
243
|
rootIds,
|
|
209
244
|
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
248
|
+
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
|
|
249
|
+
const payload = buildClipboardPayload(ids)
|
|
250
|
+
if (!payload) return false
|
|
251
|
+
|
|
252
|
+
clipboardPayload = payload
|
|
210
253
|
notifySubscribers()
|
|
211
254
|
|
|
212
255
|
return true
|
|
213
256
|
}
|
|
214
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Clone the given nodes (subtrees included, ids remapped) onto the target /
|
|
260
|
+
* active level in place — the same copy + paste pipeline in one step, WITHOUT
|
|
261
|
+
* touching the user's editor clipboard. Selects the clones. Used by the group
|
|
262
|
+
* action menu's Duplicate.
|
|
263
|
+
*/
|
|
264
|
+
export function duplicateNodesToLevel(
|
|
265
|
+
ids: AnyNodeId[],
|
|
266
|
+
targetLevelId?: AnyNodeId,
|
|
267
|
+
): PasteResult | null {
|
|
268
|
+
const payload = buildClipboardPayload(ids)
|
|
269
|
+
if (!payload) return null
|
|
270
|
+
return applyClipboardPayloadToLevel(payload, targetLevelId)
|
|
271
|
+
}
|
|
272
|
+
|
|
215
273
|
export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
|
|
216
|
-
|
|
274
|
+
if (!clipboardPayload) return null
|
|
275
|
+
return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function applyClipboardPayloadToLevel(
|
|
279
|
+
payload: ClipboardPayload,
|
|
280
|
+
targetLevelId?: AnyNodeId,
|
|
281
|
+
): PasteResult | null {
|
|
217
282
|
const targetLevel = getPasteTargetLevel(targetLevelId)
|
|
218
|
-
if (!
|
|
283
|
+
if (!targetLevel) return null
|
|
219
284
|
|
|
220
285
|
const scene = useScene.getState()
|
|
221
286
|
const idMap = new Map<AnyNodeId, AnyNodeId>()
|
package/src/lib/scene.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
clearSceneHistory,
|
|
5
|
+
nodeRegistry,
|
|
6
|
+
resolveLevelId,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
4
10
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
11
|
import useEditor, {
|
|
6
12
|
hasCustomPersistedEditorUiState,
|
|
7
13
|
normalizePersistedEditorUiState,
|
|
8
14
|
type PersistedEditorUiState,
|
|
9
15
|
} from '../store/use-editor'
|
|
16
|
+
import { editorHostPanelRegistry } from './plugin-panels'
|
|
10
17
|
|
|
11
18
|
export type SceneGraph = {
|
|
12
19
|
nodes: Record<string, unknown>
|
|
13
20
|
rootNodeIds: string[]
|
|
21
|
+
// Document-level scene state that travels with the graph. Optional so older
|
|
22
|
+
// payloads (and callers that only build nodes) stay valid.
|
|
23
|
+
collections?: Record<string, unknown>
|
|
24
|
+
materials?: Record<string, unknown>
|
|
25
|
+
installedPlugins?: string[]
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
type PersistedSelectionPath = {
|
|
@@ -355,10 +367,8 @@ function resetEditorInteractionState() {
|
|
|
355
367
|
structureLayer: 'elements',
|
|
356
368
|
catalogCategory: null,
|
|
357
369
|
selectedItem: null,
|
|
358
|
-
movingNode: null,
|
|
359
370
|
selectedReferenceId: null,
|
|
360
371
|
spaces: {},
|
|
361
|
-
editingHole: null,
|
|
362
372
|
hoveredHole: null,
|
|
363
373
|
isPreviewMode: false,
|
|
364
374
|
})
|
|
@@ -373,13 +383,26 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
|
|
|
373
383
|
}
|
|
374
384
|
|
|
375
385
|
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
|
386
|
+
const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
|
|
376
387
|
if (hasUsableSceneGraph(sceneGraph)) {
|
|
377
|
-
const { nodes, rootNodeIds } = sceneGraph
|
|
378
|
-
useScene.getState().setScene(nodes as any, rootNodeIds as any
|
|
388
|
+
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph
|
|
389
|
+
useScene.getState().setScene(nodes as any, rootNodeIds as any, {
|
|
390
|
+
collections: collections as any,
|
|
391
|
+
materials: materials as any,
|
|
392
|
+
installedPlugins: installedPlugins ?? defaultInstalledPlugins,
|
|
393
|
+
hasExplicitPluginInstallState: installedPlugins !== undefined,
|
|
394
|
+
})
|
|
379
395
|
} else {
|
|
380
396
|
useScene.getState().clearScene()
|
|
397
|
+
useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false })
|
|
381
398
|
}
|
|
382
399
|
|
|
400
|
+
// The loaded scene is the undo floor. Loading records history entries of
|
|
401
|
+
// its own (`unloadScene` + `setScene`/`clearScene` are tracked writes), so
|
|
402
|
+
// without this reset a few Ctrl+Z presses could step past the load into the
|
|
403
|
+
// pre-load — often empty — state and wipe the whole project.
|
|
404
|
+
clearSceneHistory()
|
|
405
|
+
|
|
383
406
|
syncEditorSelectionFromCurrentScene()
|
|
384
407
|
}
|
|
385
408
|
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import {
|
|
5
|
+
resolveCanvasSelectionNode,
|
|
6
|
+
resolveNodeSelectionTarget,
|
|
7
|
+
resolveSelectedIdsForNodeClick,
|
|
8
|
+
selectionModifiersFromEvent,
|
|
9
|
+
shouldPreserveSelectedRoofHostTarget,
|
|
10
|
+
} from './selection-routing'
|
|
11
|
+
|
|
12
|
+
function registerTestDefinition(kind: string, overrides: Record<string, unknown> = {}) {
|
|
13
|
+
if (nodeRegistry.has(kind)) return
|
|
14
|
+
registerNode({
|
|
15
|
+
kind,
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
schema: z.object({ type: z.literal(kind) }) as never,
|
|
18
|
+
category: 'furnish',
|
|
19
|
+
defaults: () => ({ type: kind }) as never,
|
|
20
|
+
capabilities: {},
|
|
21
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
22
|
+
...overrides,
|
|
23
|
+
} as never)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('resolveSelectedIdsForNodeClick', () => {
|
|
27
|
+
test('preserves the pre-routing selection when a phase switch clears current ids', () => {
|
|
28
|
+
expect(
|
|
29
|
+
resolveSelectedIdsForNodeClick({
|
|
30
|
+
baseSelectedIds: ['wall_1'],
|
|
31
|
+
currentSelectedIds: [],
|
|
32
|
+
modifierKeys: { meta: true, ctrl: false, shift: false },
|
|
33
|
+
nodeId: 'item_1',
|
|
34
|
+
}),
|
|
35
|
+
).toEqual(['wall_1', 'item_1'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('toggles from the pre-routing selection while a modifier is held', () => {
|
|
39
|
+
expect(
|
|
40
|
+
resolveSelectedIdsForNodeClick({
|
|
41
|
+
baseSelectedIds: ['wall_1', 'item_1'],
|
|
42
|
+
currentSelectedIds: [],
|
|
43
|
+
modifierKeys: { meta: false, ctrl: false, shift: true },
|
|
44
|
+
nodeId: 'item_1',
|
|
45
|
+
}),
|
|
46
|
+
).toEqual(['wall_1'])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('selectionModifiersFromEvent', () => {
|
|
51
|
+
test('falls back to tracked modifier state when the click event omits keys', () => {
|
|
52
|
+
expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
|
|
53
|
+
meta: false,
|
|
54
|
+
ctrl: true,
|
|
55
|
+
shift: false,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('prefers explicit event key state over stale tracked modifiers', () => {
|
|
60
|
+
expect(
|
|
61
|
+
selectionModifiersFromEvent(
|
|
62
|
+
{ metaKey: false, ctrlKey: false, shiftKey: false },
|
|
63
|
+
{ meta: true, ctrl: true, shift: true },
|
|
64
|
+
),
|
|
65
|
+
).toEqual({
|
|
66
|
+
meta: false,
|
|
67
|
+
ctrl: false,
|
|
68
|
+
shift: false,
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('resolveNodeSelectionTarget', () => {
|
|
74
|
+
test('routes furniture items to furnish', () => {
|
|
75
|
+
const node = {
|
|
76
|
+
id: 'item_1',
|
|
77
|
+
type: 'item',
|
|
78
|
+
asset: { category: 'furniture' },
|
|
79
|
+
} as unknown as AnyNode
|
|
80
|
+
|
|
81
|
+
expect(resolveNodeSelectionTarget(node)).toEqual({ phase: 'furnish' })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('routes door and window catalog items to structure', () => {
|
|
85
|
+
const node = {
|
|
86
|
+
id: 'item_1',
|
|
87
|
+
type: 'item',
|
|
88
|
+
asset: { category: 'door' },
|
|
89
|
+
} as unknown as AnyNode
|
|
90
|
+
|
|
91
|
+
expect(resolveNodeSelectionTarget(node)).toEqual({
|
|
92
|
+
phase: 'structure',
|
|
93
|
+
structureLayer: 'elements',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('resolveCanvasSelectionNode', () => {
|
|
99
|
+
// Mirrors the cabinet-module setup: modules proxy to their run for grouped
|
|
100
|
+
// move / rotate, but declare `selectionProxy.bypassDirectPick` so a direct
|
|
101
|
+
// body click selects the clicked module.
|
|
102
|
+
const groupKind = 'selection-routing-proxy-group-test'
|
|
103
|
+
const memberKind = 'selection-routing-proxy-member-test'
|
|
104
|
+
|
|
105
|
+
function registerBypassKinds() {
|
|
106
|
+
registerTestDefinition(groupKind)
|
|
107
|
+
registerTestDefinition(memberKind, {
|
|
108
|
+
selectionProxy: {
|
|
109
|
+
bypassDirectPick: (node: AnyNode, proxyTarget: AnyNode) =>
|
|
110
|
+
(node.type as string) === memberKind && (proxyTarget.type as string) === groupKind,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test('keeps proxied members individually selectable when the kind declares bypassDirectPick', () => {
|
|
116
|
+
registerBypassKinds()
|
|
117
|
+
const run = { id: 'group_run', type: groupKind, metadata: {} } as unknown as AnyNode
|
|
118
|
+
const module = {
|
|
119
|
+
id: 'group_member',
|
|
120
|
+
type: memberKind,
|
|
121
|
+
parentId: run.id,
|
|
122
|
+
metadata: { nodeSelectionProxyId: run.id },
|
|
123
|
+
} as unknown as AnyNode
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
resolveCanvasSelectionNode({
|
|
127
|
+
node: module,
|
|
128
|
+
nodes: {
|
|
129
|
+
[run.id]: run,
|
|
130
|
+
[module.id]: module,
|
|
131
|
+
},
|
|
132
|
+
selectedIds: [],
|
|
133
|
+
}),
|
|
134
|
+
).toBe(module)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('keeps nested proxied members leaf-selectable by default', () => {
|
|
138
|
+
registerBypassKinds()
|
|
139
|
+
const rootRun = { id: 'group_root_run', type: groupKind, metadata: {} } as unknown as AnyNode
|
|
140
|
+
const legRun = {
|
|
141
|
+
id: 'group_leg_run',
|
|
142
|
+
type: groupKind,
|
|
143
|
+
parentId: rootRun.id,
|
|
144
|
+
metadata: { nodeSelectionProxyId: rootRun.id },
|
|
145
|
+
} as unknown as AnyNode
|
|
146
|
+
const nestedCornerBase = {
|
|
147
|
+
id: 'group_nested_member',
|
|
148
|
+
type: memberKind,
|
|
149
|
+
parentId: legRun.id,
|
|
150
|
+
metadata: { nodeSelectionProxyId: legRun.id },
|
|
151
|
+
} as unknown as AnyNode
|
|
152
|
+
|
|
153
|
+
expect(
|
|
154
|
+
resolveCanvasSelectionNode({
|
|
155
|
+
node: nestedCornerBase,
|
|
156
|
+
nodes: {
|
|
157
|
+
[rootRun.id]: rootRun,
|
|
158
|
+
[legRun.id]: legRun,
|
|
159
|
+
[nestedCornerBase.id]: nestedCornerBase,
|
|
160
|
+
},
|
|
161
|
+
selectedIds: [],
|
|
162
|
+
}),
|
|
163
|
+
).toBe(nestedCornerBase)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('follows the proxy when the kind declares no bypass', () => {
|
|
167
|
+
const kind = 'selection-routing-proxy-no-bypass-test'
|
|
168
|
+
registerTestDefinition(kind)
|
|
169
|
+
const group = { id: 'plain_group', type: kind, metadata: {} } as unknown as AnyNode
|
|
170
|
+
const member = {
|
|
171
|
+
id: 'plain_member',
|
|
172
|
+
type: kind,
|
|
173
|
+
parentId: group.id,
|
|
174
|
+
metadata: { nodeSelectionProxyId: group.id },
|
|
175
|
+
} as unknown as AnyNode
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
resolveCanvasSelectionNode({
|
|
179
|
+
node: member,
|
|
180
|
+
nodes: {
|
|
181
|
+
[group.id]: group,
|
|
182
|
+
[member.id]: member,
|
|
183
|
+
},
|
|
184
|
+
selectedIds: [],
|
|
185
|
+
}),
|
|
186
|
+
).toBe(group)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('keeps parent-frame children routed to their parent when that parent is solely selected', () => {
|
|
190
|
+
const kind = 'selection-routing-parent-frame-test'
|
|
191
|
+
registerTestDefinition(kind, {
|
|
192
|
+
capabilities: {
|
|
193
|
+
movable: {
|
|
194
|
+
axes: ['x', 'z'],
|
|
195
|
+
gridSnap: true,
|
|
196
|
+
parentFrame: {
|
|
197
|
+
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
|
|
198
|
+
(node.parentId ? nodes[node.parentId] : null) ?? null,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const parent = { id: 'parent_1', type: groupKind, metadata: {} } as unknown as AnyNode
|
|
205
|
+
const child = {
|
|
206
|
+
id: 'child_1',
|
|
207
|
+
type: kind,
|
|
208
|
+
parentId: parent.id,
|
|
209
|
+
metadata: {},
|
|
210
|
+
} as unknown as AnyNode
|
|
211
|
+
|
|
212
|
+
expect(
|
|
213
|
+
resolveCanvasSelectionNode({
|
|
214
|
+
node: child,
|
|
215
|
+
nodes: {
|
|
216
|
+
[parent.id]: parent,
|
|
217
|
+
[child.id]: child,
|
|
218
|
+
},
|
|
219
|
+
selectedIds: [parent.id],
|
|
220
|
+
}),
|
|
221
|
+
).toBe(parent)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('prefers an explicit selection proxy before parent-frame routing', () => {
|
|
225
|
+
const kind = 'selection-routing-proxy-before-parent-frame-test'
|
|
226
|
+
registerTestDefinition(kind, {
|
|
227
|
+
capabilities: {
|
|
228
|
+
movable: {
|
|
229
|
+
axes: ['x', 'z'],
|
|
230
|
+
gridSnap: true,
|
|
231
|
+
parentFrame: {
|
|
232
|
+
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
|
|
233
|
+
(node.parentId ? nodes[node.parentId] : null) ?? null,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const root = { id: 'root_1', type: groupKind, metadata: {} } as unknown as AnyNode
|
|
240
|
+
const proxyGroup = { id: 'proxy_1', type: groupKind, metadata: {} } as unknown as AnyNode
|
|
241
|
+
const child = {
|
|
242
|
+
id: 'child_proxy_1',
|
|
243
|
+
type: kind,
|
|
244
|
+
parentId: root.id,
|
|
245
|
+
metadata: { nodeSelectionProxyId: proxyGroup.id },
|
|
246
|
+
} as unknown as AnyNode
|
|
247
|
+
|
|
248
|
+
expect(
|
|
249
|
+
resolveCanvasSelectionNode({
|
|
250
|
+
node: child,
|
|
251
|
+
nodes: {
|
|
252
|
+
[root.id]: root,
|
|
253
|
+
[proxyGroup.id]: proxyGroup,
|
|
254
|
+
[child.id]: child,
|
|
255
|
+
},
|
|
256
|
+
selectedIds: [root.id],
|
|
257
|
+
}),
|
|
258
|
+
).toBe(proxyGroup)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('shouldPreserveSelectedRoofHostTarget', () => {
|
|
263
|
+
test('keeps the roof host target while that roof is the sole armed selection', () => {
|
|
264
|
+
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
|
|
265
|
+
|
|
266
|
+
expect(
|
|
267
|
+
shouldPreserveSelectedRoofHostTarget({
|
|
268
|
+
node,
|
|
269
|
+
selectedIds: ['roof_1'],
|
|
270
|
+
armedRoofId: 'roof_1',
|
|
271
|
+
}),
|
|
272
|
+
).toBe(true)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('falls back to segment targeting when the roof host is not armed', () => {
|
|
276
|
+
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
|
|
277
|
+
|
|
278
|
+
expect(
|
|
279
|
+
shouldPreserveSelectedRoofHostTarget({
|
|
280
|
+
node,
|
|
281
|
+
selectedIds: ['roof_1'],
|
|
282
|
+
armedRoofId: null,
|
|
283
|
+
}),
|
|
284
|
+
).toBe(false)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('falls back to segment targeting when the roof is no longer the sole selection', () => {
|
|
288
|
+
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
|
|
289
|
+
|
|
290
|
+
expect(
|
|
291
|
+
shouldPreserveSelectedRoofHostTarget({
|
|
292
|
+
node,
|
|
293
|
+
selectedIds: ['roof_1', 'wall_1'],
|
|
294
|
+
armedRoofId: 'roof_1',
|
|
295
|
+
}),
|
|
296
|
+
).toBe(false)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type ItemNode,
|
|
4
|
+
nodeRegistry,
|
|
5
|
+
resolveSelectionProxyId,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
|
|
8
|
+
export type SelectionModifierKeys = {
|
|
9
|
+
meta: boolean
|
|
10
|
+
ctrl: boolean
|
|
11
|
+
shift: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type NodeSelectionTarget = {
|
|
15
|
+
phase: 'site' | 'structure' | 'furnish'
|
|
16
|
+
structureLayer?: 'zones' | 'elements'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean {
|
|
20
|
+
if (node.id === target.id) return false
|
|
21
|
+
// Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind
|
|
22
|
+
// keeps a proxy for grouped affordances but wants a direct body click to
|
|
23
|
+
// select the clicked node itself.
|
|
24
|
+
return nodeRegistry.get(node.type)?.selectionProxy?.bypassDirectPick?.(node, target) ?? false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveCanvasSelectionNode({
|
|
28
|
+
node,
|
|
29
|
+
nodes,
|
|
30
|
+
selectedIds,
|
|
31
|
+
}: {
|
|
32
|
+
node: AnyNode
|
|
33
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>
|
|
34
|
+
selectedIds: readonly string[]
|
|
35
|
+
}): AnyNode {
|
|
36
|
+
const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node
|
|
37
|
+
let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget
|
|
38
|
+
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
|
|
39
|
+
if (parentFrame) {
|
|
40
|
+
const parent = parentFrame.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
|
|
41
|
+
if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) {
|
|
42
|
+
target = parent
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return target
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean {
|
|
49
|
+
return keys.meta || keys.ctrl || keys.shift
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function selectionModifiersFromEvent(
|
|
53
|
+
event?: {
|
|
54
|
+
metaKey?: boolean
|
|
55
|
+
ctrlKey?: boolean
|
|
56
|
+
shiftKey?: boolean
|
|
57
|
+
nativeEvent?: {
|
|
58
|
+
metaKey?: boolean
|
|
59
|
+
ctrlKey?: boolean
|
|
60
|
+
shiftKey?: boolean
|
|
61
|
+
}
|
|
62
|
+
} | null,
|
|
63
|
+
fallback?: Partial<SelectionModifierKeys>,
|
|
64
|
+
): SelectionModifierKeys {
|
|
65
|
+
const fromEvent = (
|
|
66
|
+
key: keyof SelectionModifierKeys,
|
|
67
|
+
eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey',
|
|
68
|
+
) => {
|
|
69
|
+
if (typeof event?.[eventKey] === 'boolean') return event[eventKey]
|
|
70
|
+
if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey]
|
|
71
|
+
return Boolean(fallback?.[key])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
meta: fromEvent('meta', 'metaKey'),
|
|
76
|
+
ctrl: fromEvent('ctrl', 'ctrlKey'),
|
|
77
|
+
shift: fromEvent('shift', 'shiftKey'),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveSelectedIdsForNodeClick({
|
|
82
|
+
baseSelectedIds,
|
|
83
|
+
currentSelectedIds,
|
|
84
|
+
modifierKeys,
|
|
85
|
+
nodeId,
|
|
86
|
+
}: {
|
|
87
|
+
baseSelectedIds?: readonly string[]
|
|
88
|
+
currentSelectedIds: readonly string[]
|
|
89
|
+
modifierKeys: SelectionModifierKeys
|
|
90
|
+
nodeId: string
|
|
91
|
+
}): string[] {
|
|
92
|
+
if (isSelectionModifierActive(modifierKeys)) {
|
|
93
|
+
const selectedIds = baseSelectedIds ?? currentSelectedIds
|
|
94
|
+
if (selectedIds.includes(nodeId)) {
|
|
95
|
+
return selectedIds.filter((id) => id !== nodeId)
|
|
96
|
+
}
|
|
97
|
+
return [...selectedIds, nodeId]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [nodeId]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function shouldPreserveSelectedRoofHostTarget({
|
|
104
|
+
node,
|
|
105
|
+
selectedIds,
|
|
106
|
+
armedRoofId,
|
|
107
|
+
}: {
|
|
108
|
+
node: AnyNode
|
|
109
|
+
selectedIds: readonly string[]
|
|
110
|
+
armedRoofId: string | null
|
|
111
|
+
}): boolean {
|
|
112
|
+
return (
|
|
113
|
+
node.type === 'roof' &&
|
|
114
|
+
armedRoofId === node.id &&
|
|
115
|
+
selectedIds.length === 1 &&
|
|
116
|
+
selectedIds[0] === node.id
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null {
|
|
121
|
+
if (node.type === 'building') {
|
|
122
|
+
return { phase: 'site' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (node.type === 'zone') {
|
|
126
|
+
return {
|
|
127
|
+
phase: 'structure',
|
|
128
|
+
structureLayer: 'zones',
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (node.type === 'item') {
|
|
133
|
+
const item = node as ItemNode
|
|
134
|
+
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
135
|
+
return {
|
|
136
|
+
phase: 'structure',
|
|
137
|
+
structureLayer: 'elements',
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { phase: 'furnish' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
node.type === 'wall' ||
|
|
145
|
+
node.type === 'fence' ||
|
|
146
|
+
node.type === 'column' ||
|
|
147
|
+
node.type === 'elevator' ||
|
|
148
|
+
node.type === 'slab' ||
|
|
149
|
+
node.type === 'ceiling' ||
|
|
150
|
+
node.type === 'roof' ||
|
|
151
|
+
node.type === 'roof-segment' ||
|
|
152
|
+
node.type === 'stair' ||
|
|
153
|
+
node.type === 'stair-segment' ||
|
|
154
|
+
node.type === 'spawn' ||
|
|
155
|
+
node.type === 'window' ||
|
|
156
|
+
node.type === 'door'
|
|
157
|
+
) {
|
|
158
|
+
return {
|
|
159
|
+
phase: 'structure',
|
|
160
|
+
structureLayer: 'elements',
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const def = nodeRegistry.get(node.type)
|
|
165
|
+
if (!def) return null
|
|
166
|
+
|
|
167
|
+
if (def.category === 'furnish') {
|
|
168
|
+
return { phase: 'furnish' }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
phase: 'structure',
|
|
173
|
+
structureLayer: 'elements',
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/lib/sfx/index.ts
CHANGED