@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
|
2
|
+
import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
3
|
+
import { Euler, Quaternion, Vector3 } from 'three'
|
|
4
|
+
|
|
5
|
+
type Pose = { position: [number, number, number]; rotation: [number, number, number] }
|
|
6
|
+
|
|
7
|
+
export function surfaceAttachmentId(node: Pick<AnyNode, 'id' | 'parentId'>): string | null {
|
|
8
|
+
const parent = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : undefined
|
|
9
|
+
return isProceduralItem(parent) ? (parent.attachments[node.id] ?? null) : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function surfaceAttachmentUpdates(
|
|
13
|
+
id: AnyNodeId,
|
|
14
|
+
parentId: string | null | undefined,
|
|
15
|
+
surfaceId: string | null,
|
|
16
|
+
) {
|
|
17
|
+
const nodes = useScene.getState().nodes
|
|
18
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
19
|
+
for (const hostId of new Set([nodes[id]?.parentId, parentId])) {
|
|
20
|
+
const host = hostId ? nodes[hostId as AnyNodeId] : undefined
|
|
21
|
+
if (!isProceduralItem(host)) continue
|
|
22
|
+
const nextId = host.id === parentId ? surfaceId : null
|
|
23
|
+
if ((host.attachments[id] ?? null) === nextId) continue
|
|
24
|
+
const attachments = { ...host.attachments }
|
|
25
|
+
if (nextId === null) delete attachments[id]
|
|
26
|
+
else attachments[id] = nextId
|
|
27
|
+
updates.push({ id: host.id, data: { attachments } })
|
|
28
|
+
}
|
|
29
|
+
return updates
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateSurfaceNode(
|
|
33
|
+
id: AnyNodeId,
|
|
34
|
+
data: Partial<AnyNode>,
|
|
35
|
+
surfaceId?: string | null,
|
|
36
|
+
) {
|
|
37
|
+
const node = useScene.getState().nodes[id]
|
|
38
|
+
const parentId = data.parentId === undefined ? node?.parentId : data.parentId
|
|
39
|
+
const selected =
|
|
40
|
+
surfaceId === undefined && node && node.parentId === parentId
|
|
41
|
+
? surfaceAttachmentId(node)
|
|
42
|
+
: (surfaceId ?? null)
|
|
43
|
+
useScene
|
|
44
|
+
.getState()
|
|
45
|
+
.updateNodes([{ id, data }, ...surfaceAttachmentUpdates(id, parentId, selected)])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function surfaceFramePose(
|
|
49
|
+
parentId: string | null | undefined,
|
|
50
|
+
surfaceId: string | null,
|
|
51
|
+
pose: Pose,
|
|
52
|
+
toStorage: boolean,
|
|
53
|
+
): Pose {
|
|
54
|
+
const host = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
|
|
55
|
+
if (!isProceduralItem(host) || surfaceId === null)
|
|
56
|
+
return { position: pose.position, rotation: pose.rotation }
|
|
57
|
+
const surface = evaluateRecipe(host.recipe, host.parameters).surfaces.find(
|
|
58
|
+
(s) => s.id === surfaceId,
|
|
59
|
+
)
|
|
60
|
+
if (!surface) throw new Error('Missing attachment surface')
|
|
61
|
+
const q = new Quaternion().setFromEuler(new Euler(...surface.rotation))
|
|
62
|
+
const position = new Vector3(...pose.position)
|
|
63
|
+
if (toStorage) position.sub(new Vector3(...surface.position)).applyQuaternion(q.clone().invert())
|
|
64
|
+
else position.applyQuaternion(q).add(new Vector3(...surface.position))
|
|
65
|
+
if (!surface.rotation[0] && !surface.rotation[2] && !pose.rotation[0] && !pose.rotation[2]) {
|
|
66
|
+
return {
|
|
67
|
+
position: position.toArray(),
|
|
68
|
+
rotation: [0, pose.rotation[1] + (toStorage ? -1 : 1) * surface.rotation[1], 0],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const rotation = new Euler().setFromQuaternion(
|
|
72
|
+
new Quaternion()
|
|
73
|
+
.setFromEuler(new Euler(...pose.rotation))
|
|
74
|
+
.premultiply(toStorage ? q.invert() : q),
|
|
75
|
+
)
|
|
76
|
+
return { position: position.toArray(), rotation: [rotation.x, rotation.y, rotation.z] }
|
|
77
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type SiteNode, type TerrainVerb, useScene } from '@pascal-app/core'
|
|
4
|
+
import useEditor from '../store/use-editor'
|
|
5
|
+
import type { PanelRow } from './panel-rows'
|
|
6
|
+
import { brushRadiusRange, flattenSite, resetSiteTerrain } from './terrain-sculpt'
|
|
7
|
+
|
|
8
|
+
const TERRAIN_VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth']
|
|
9
|
+
|
|
10
|
+
export function useTerrainPanelRows(): PanelRow[] {
|
|
11
|
+
const verb = useEditor((state) => state.terrainVerb)
|
|
12
|
+
const setVerb = useEditor((state) => state.setTerrainVerb)
|
|
13
|
+
const brush = useEditor((state) => state.terrainBrush)
|
|
14
|
+
const setBrush = useEditor((state) => state.setTerrainBrush)
|
|
15
|
+
const flattenTarget = useEditor((state) => state.terrainFlattenTarget)
|
|
16
|
+
const setFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget)
|
|
17
|
+
const sampling = useEditor((state) => state.terrainSampling)
|
|
18
|
+
const setSampling = useEditor((state) => state.setTerrainSampling)
|
|
19
|
+
const site = useScene((state) => {
|
|
20
|
+
const root = state.rootNodeIds[0]
|
|
21
|
+
const node = root ? state.nodes[root] : undefined
|
|
22
|
+
return node?.type === 'site' ? (node as SiteNode) : null
|
|
23
|
+
})
|
|
24
|
+
const [minRadius, maxRadius] = brushRadiusRange(site)
|
|
25
|
+
const verbIndex = TERRAIN_VERBS.indexOf(verb)
|
|
26
|
+
const cycleVerb = (direction: -1 | 1) => {
|
|
27
|
+
const next =
|
|
28
|
+
TERRAIN_VERBS[(verbIndex + direction + TERRAIN_VERBS.length) % TERRAIN_VERBS.length]
|
|
29
|
+
if (next) setVerb(next)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rows: PanelRow[] = [
|
|
33
|
+
{
|
|
34
|
+
id: 'terrain-verb',
|
|
35
|
+
kind: 'cycle',
|
|
36
|
+
label: 'Brush',
|
|
37
|
+
next: () => cycleVerb(1),
|
|
38
|
+
previous: () => cycleVerb(-1),
|
|
39
|
+
value: verb.charAt(0).toUpperCase() + verb.slice(1),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'terrain-radius',
|
|
43
|
+
kind: 'stepper',
|
|
44
|
+
label: 'Size',
|
|
45
|
+
max: maxRadius,
|
|
46
|
+
min: minRadius,
|
|
47
|
+
onChange: (radius) => setBrush({ radius }),
|
|
48
|
+
step: 0.5,
|
|
49
|
+
unit: 'm',
|
|
50
|
+
value: Math.max(minRadius, Math.min(maxRadius, brush.radius)),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'terrain-strength',
|
|
54
|
+
kind: 'stepper',
|
|
55
|
+
label: 'Strength',
|
|
56
|
+
max: 1,
|
|
57
|
+
min: 0.05,
|
|
58
|
+
onChange: (strength) => setBrush({ strength }),
|
|
59
|
+
step: 0.05,
|
|
60
|
+
value: brush.strength,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'terrain-softness',
|
|
64
|
+
kind: 'stepper',
|
|
65
|
+
label: 'Softness',
|
|
66
|
+
max: 1,
|
|
67
|
+
min: 0,
|
|
68
|
+
onChange: (falloff) => setBrush({ falloff }),
|
|
69
|
+
step: 0.05,
|
|
70
|
+
value: brush.falloff,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'terrain-shape',
|
|
74
|
+
kind: 'choice',
|
|
75
|
+
label: 'Shape',
|
|
76
|
+
onSelect: () => setBrush({ shape: brush.shape === 'round' ? 'square' : 'round' }),
|
|
77
|
+
value: brush.shape === 'round' ? 'Round' : 'Square',
|
|
78
|
+
},
|
|
79
|
+
...(verb === 'flatten'
|
|
80
|
+
? ([
|
|
81
|
+
{
|
|
82
|
+
id: 'terrain-target',
|
|
83
|
+
kind: 'stepper',
|
|
84
|
+
label: 'Target',
|
|
85
|
+
max: 50,
|
|
86
|
+
min: -50,
|
|
87
|
+
onChange: setFlattenTarget,
|
|
88
|
+
step: 0.1,
|
|
89
|
+
unit: 'm',
|
|
90
|
+
value: flattenTarget ?? 0,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'terrain-sampling',
|
|
94
|
+
kind: 'choice',
|
|
95
|
+
label: 'Pick height',
|
|
96
|
+
onSelect: () => setSampling(!sampling),
|
|
97
|
+
value: sampling ? 'Armed' : 'Off',
|
|
98
|
+
},
|
|
99
|
+
] satisfies PanelRow[])
|
|
100
|
+
: []),
|
|
101
|
+
{
|
|
102
|
+
id: 'terrain-level-lot',
|
|
103
|
+
kind: 'choice',
|
|
104
|
+
label: 'Whole lot',
|
|
105
|
+
onSelect: site ? () => flattenSite(site, flattenTarget ?? 0) : undefined,
|
|
106
|
+
value: 'Level',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'terrain-clear',
|
|
110
|
+
kind: 'choice',
|
|
111
|
+
label: 'Terrain',
|
|
112
|
+
onSelect: site?.terrain ? () => resetSiteTerrain(site) : undefined,
|
|
113
|
+
value: site?.terrain ? 'Clear' : 'Empty',
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
return rows
|
|
117
|
+
}
|
package/src/lib/units.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
type LevelNode,
|
|
6
|
+
pointInPolygon2D,
|
|
7
|
+
resolveAutoZonePolygon,
|
|
8
|
+
snapWorldXZToBuildingLocal,
|
|
9
|
+
UnitNode,
|
|
10
|
+
useScene,
|
|
11
|
+
type ZoneNode,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { useEffect } from 'react'
|
|
15
|
+
import useEditor, { type StructureLayer } from '../store/use-editor'
|
|
16
|
+
import { getActiveBuildingPose } from './world-grid-snap'
|
|
17
|
+
|
|
18
|
+
type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
|
|
19
|
+
|
|
20
|
+
// Unit focus is a session gesture: the layer the user was on when focus began
|
|
21
|
+
// comes back when focus ends, unless they switched layers by hand meanwhile.
|
|
22
|
+
let restoreLayer: StructureLayer | null = null
|
|
23
|
+
let entering = false
|
|
24
|
+
let leaving = false
|
|
25
|
+
|
|
26
|
+
export function focusedUnitNode(): UnitNode | null {
|
|
27
|
+
const id = useViewer.getState().focusedUnitId
|
|
28
|
+
const node = id ? useScene.getState().nodes[id] : undefined
|
|
29
|
+
return node?.type === 'unit' ? node : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Levels carrying member zones, lowest ordinal first. */
|
|
33
|
+
export function unitMemberLevels(unit: UnitNode, nodes: Nodes): LevelNode[] {
|
|
34
|
+
const levels = new Map<LevelNode['id'], LevelNode>()
|
|
35
|
+
for (const zoneId of unit.members) {
|
|
36
|
+
const zone = nodes[zoneId]
|
|
37
|
+
const level =
|
|
38
|
+
zone?.type === 'zone' && zone.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
|
|
39
|
+
if (level?.type === 'level') levels.set(level.id, level)
|
|
40
|
+
}
|
|
41
|
+
return [...levels.values()].sort((a, b) => a.level - b.level)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function enterUnitFocus(unitId: UnitNode['id']): void {
|
|
45
|
+
if (entering) return
|
|
46
|
+
entering = true
|
|
47
|
+
try {
|
|
48
|
+
const nodes = useScene.getState().nodes
|
|
49
|
+
const unit = nodes[unitId]
|
|
50
|
+
if (unit?.type !== 'unit') return
|
|
51
|
+
const editor = useEditor.getState()
|
|
52
|
+
if (restoreLayer === null) restoreLayer = editor.structureLayer
|
|
53
|
+
if (editor.phase !== 'structure') editor.setPhase('structure')
|
|
54
|
+
if (useEditor.getState().structureLayer !== 'zones') {
|
|
55
|
+
useEditor.getState().setStructureLayer('zones')
|
|
56
|
+
}
|
|
57
|
+
const viewer = useViewer.getState()
|
|
58
|
+
if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
|
|
59
|
+
|
|
60
|
+
const { selection, setSelection } = useViewer.getState()
|
|
61
|
+
const levels = unitMemberLevels(unit, nodes)
|
|
62
|
+
const levelId = levels.some((level) => level.id === selection.levelId)
|
|
63
|
+
? selection.levelId
|
|
64
|
+
: (levels[0]?.id ?? selection.levelId)
|
|
65
|
+
const buildingId = (unit.parentId as BuildingNode['id'] | null) ?? selection.buildingId
|
|
66
|
+
const unitSelected = selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId
|
|
67
|
+
if (selection.buildingId !== buildingId || selection.levelId !== levelId || !unitSelected) {
|
|
68
|
+
setSelection({ buildingId, levelId, selectedIds: [unitId] })
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
entering = false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
|
|
76
|
+
const layer = restoreLayer
|
|
77
|
+
restoreLayer = null
|
|
78
|
+
const editor = useEditor.getState()
|
|
79
|
+
if (
|
|
80
|
+
!keepLayer &&
|
|
81
|
+
layer &&
|
|
82
|
+
layer !== 'zones' &&
|
|
83
|
+
editor.phase === 'structure' &&
|
|
84
|
+
editor.structureLayer === 'zones'
|
|
85
|
+
) {
|
|
86
|
+
editor.setStructureLayer(layer)
|
|
87
|
+
}
|
|
88
|
+
const { selection, setSelection } = useViewer.getState()
|
|
89
|
+
if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
|
|
90
|
+
setSelection({ selectedIds: [] })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Ends unit focus. Returns false when no unit was focused. */
|
|
95
|
+
export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
|
|
96
|
+
const viewer = useViewer.getState()
|
|
97
|
+
const unitId = viewer.focusedUnitId
|
|
98
|
+
if (!unitId) return false
|
|
99
|
+
leaving = true
|
|
100
|
+
try {
|
|
101
|
+
viewer.setFocusedUnit(null)
|
|
102
|
+
} finally {
|
|
103
|
+
leaving = false
|
|
104
|
+
}
|
|
105
|
+
finishLeave(unitId, options?.keepLayer ?? false)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Puts a zone in exactly one unit (or none): it leaves every other unit in the
|
|
111
|
+
* same store update, so the move is one undo step.
|
|
112
|
+
*/
|
|
113
|
+
export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id'] | null): void {
|
|
114
|
+
const { nodes, updateNodes } = useScene.getState()
|
|
115
|
+
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
|
116
|
+
for (const node of Object.values(nodes)) {
|
|
117
|
+
if (node.type !== 'unit' || node.id === unitId || !node.members.includes(zoneId)) continue
|
|
118
|
+
updates.push({ id: node.id, data: { members: node.members.filter((id) => id !== zoneId) } })
|
|
119
|
+
}
|
|
120
|
+
const target = unitId ? nodes[unitId] : undefined
|
|
121
|
+
if (target?.type === 'unit' && !target.members.includes(zoneId)) {
|
|
122
|
+
updates.push({ id: target.id, data: { members: [...target.members, zoneId] } })
|
|
123
|
+
}
|
|
124
|
+
if (updates.length > 0) updateNodes(updates)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
|
|
128
|
+
const unit = useScene.getState().nodes[unitId]
|
|
129
|
+
if (unit?.type !== 'unit') return
|
|
130
|
+
assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The zone under a point on the current level, in building-local XZ. A 3D
|
|
135
|
+
* click lands on whatever surface is closest (a floor slab sits above the
|
|
136
|
+
* zone fill), so painting resolves the zone from the hit point instead of the
|
|
137
|
+
* hit mesh. Nested zones resolve to the smallest one.
|
|
138
|
+
*/
|
|
139
|
+
export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
|
|
140
|
+
const levelId = useViewer.getState().selection.levelId
|
|
141
|
+
if (!levelId) return null
|
|
142
|
+
const nodes = useScene.getState().nodes
|
|
143
|
+
const resolve = (id: AnyNodeId) => nodes[id]
|
|
144
|
+
let best: ZoneNode | null = null
|
|
145
|
+
let bestArea = Number.POSITIVE_INFINITY
|
|
146
|
+
for (const node of Object.values(nodes)) {
|
|
147
|
+
if (node.type !== 'zone' || node.parentId !== levelId) continue
|
|
148
|
+
const polygon = resolveAutoZonePolygon(node, resolve)
|
|
149
|
+
if (!pointInPolygon2D([x, z], polygon)) continue
|
|
150
|
+
let area = 0
|
|
151
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
152
|
+
const a = polygon[i]!
|
|
153
|
+
const b = polygon[(i + 1) % polygon.length]!
|
|
154
|
+
area += a[0] * b[1] - b[0] * a[1]
|
|
155
|
+
}
|
|
156
|
+
area = Math.abs(area) / 2
|
|
157
|
+
if (area < bestArea) {
|
|
158
|
+
best = node
|
|
159
|
+
bestArea = area
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
|
|
166
|
+
const pose = getActiveBuildingPose()
|
|
167
|
+
const [x, z] = pose
|
|
168
|
+
? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
|
|
169
|
+
: [worldX, worldZ]
|
|
170
|
+
return zoneAtLevelPoint(x, z)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const ZONE_PAINT_DELAY_MS = 250
|
|
174
|
+
const pendingPaints = new Map<ZoneNode['id'], ReturnType<typeof setTimeout>>()
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The canvas paint gesture. The toggle waits out the double-click window so
|
|
178
|
+
* the second click of a double-click cancels it instead of toggling twice;
|
|
179
|
+
* the double-click itself then selects the zone.
|
|
180
|
+
*/
|
|
181
|
+
export function paintZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
|
|
182
|
+
if (cancelPendingZonePaint(zoneId)) return
|
|
183
|
+
pendingPaints.set(
|
|
184
|
+
zoneId,
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
pendingPaints.delete(zoneId)
|
|
187
|
+
toggleZoneMembership(unitId, zoneId)
|
|
188
|
+
}, ZONE_PAINT_DELAY_MS),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function cancelPendingZonePaint(zoneId: ZoneNode['id']): boolean {
|
|
193
|
+
const pending = pendingPaints.get(zoneId)
|
|
194
|
+
if (!pending) return false
|
|
195
|
+
clearTimeout(pending)
|
|
196
|
+
pendingPaints.delete(zoneId)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['id'] | null {
|
|
201
|
+
const scene = useScene.getState()
|
|
202
|
+
const building = scene.nodes[buildingId]
|
|
203
|
+
if (building?.type !== 'building') return null
|
|
204
|
+
const unitCount = building.children.filter((id) => scene.nodes[id]?.type === 'unit').length
|
|
205
|
+
const unit = UnitNode.parse({ name: `Unit ${unitCount + 1}` })
|
|
206
|
+
scene.createNode(unit, buildingId)
|
|
207
|
+
enterUnitFocus(unit.id)
|
|
208
|
+
return unit.id
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Keeps unit focus coherent with the rest of the editor: a focus set from
|
|
213
|
+
* anywhere (the unit inspector calls the viewer store directly) gets the full
|
|
214
|
+
* enter treatment, and focus ends when the user switches layer or phase by
|
|
215
|
+
* hand, selects anything other than the focused unit, or the unit is deleted.
|
|
216
|
+
*/
|
|
217
|
+
export function useUnitFocusRules(): void {
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const unsubscribeViewer = useViewer.subscribe((state, prev) => {
|
|
220
|
+
if (state.focusedUnitId !== prev.focusedUnitId) {
|
|
221
|
+
if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
|
|
222
|
+
else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const unitId = state.focusedUnitId
|
|
226
|
+
if (!unitId || state.selection === prev.selection) return
|
|
227
|
+
// Selecting a zone keeps focus: renaming or recolouring a zone row is
|
|
228
|
+
// part of arranging the unit, and zones live on the layer focus uses.
|
|
229
|
+
const { selectedIds } = state.selection
|
|
230
|
+
const selectsOther =
|
|
231
|
+
selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
|
|
232
|
+
if (selectsOther) leaveUnitFocus()
|
|
233
|
+
})
|
|
234
|
+
const unsubscribeEditor = useEditor.subscribe((state, prev) => {
|
|
235
|
+
if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
|
|
236
|
+
if (!useViewer.getState().focusedUnitId) return
|
|
237
|
+
if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
|
|
238
|
+
leaveUnitFocus({ keepLayer: true })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
const unsubscribeScene = useScene.subscribe((state, prev) => {
|
|
242
|
+
if (state.nodes === prev.nodes) return
|
|
243
|
+
const unitId = useViewer.getState().focusedUnitId
|
|
244
|
+
if (unitId && state.nodes[unitId]?.type !== 'unit') leaveUnitFocus()
|
|
245
|
+
})
|
|
246
|
+
return () => {
|
|
247
|
+
unsubscribeViewer()
|
|
248
|
+
unsubscribeEditor()
|
|
249
|
+
unsubscribeScene()
|
|
250
|
+
for (const pending of pendingPaints.values()) clearTimeout(pending)
|
|
251
|
+
pendingPaints.clear()
|
|
252
|
+
}
|
|
253
|
+
}, [])
|
|
254
|
+
}
|
package/src/lib/usdz-export.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { AnyNode } from '@pascal-app/core'
|
|
2
2
|
import type { Object3D } from 'three'
|
|
3
3
|
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import { createExportTextureUtils } from './export-texture-utils'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
7
|
+
type GlbExport,
|
|
8
|
+
type GlbExportOptions,
|
|
9
|
+
preparePortableSceneFromViewer,
|
|
10
|
+
withExportDeadline,
|
|
11
|
+
} from './glb-export'
|
|
6
12
|
import { createUsdzScene, disposeExportResources } from './portable-export'
|
|
7
13
|
|
|
8
14
|
export type UsdzExportOptions = Pick<
|
|
9
15
|
GlbExportOptions,
|
|
10
|
-
'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning'
|
|
16
|
+
'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning' | 'timeoutMs'
|
|
11
17
|
>
|
|
12
18
|
|
|
13
19
|
/** Export a native, self-contained USDZ with no glTF conversion fallback. */
|
|
@@ -16,23 +22,44 @@ export async function exportSceneToUsdz(
|
|
|
16
22
|
nodes: Record<string, AnyNode>,
|
|
17
23
|
options: UsdzExportOptions = {},
|
|
18
24
|
): Promise<Uint8Array<ArrayBuffer>> {
|
|
19
|
-
const
|
|
25
|
+
const textureUtils = createExportTextureUtils()
|
|
26
|
+
const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
20
27
|
...options,
|
|
21
28
|
textures: 'embed',
|
|
29
|
+
animations: 'none',
|
|
30
|
+
textureUtils,
|
|
22
31
|
})
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
// Assigned inside the raced closure, so plain `let`s narrow to `never` in
|
|
33
|
+
// the `finally`.
|
|
34
|
+
const run: { prepared: GlbExport | null; scene: Object3D | null; abandoned: boolean } = {
|
|
35
|
+
prepared: null,
|
|
36
|
+
scene: null,
|
|
37
|
+
abandoned: false,
|
|
38
|
+
}
|
|
26
39
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
return await withExportDeadline(
|
|
41
|
+
(async () => {
|
|
42
|
+
run.prepared = await preparation
|
|
43
|
+
if (run.abandoned) throw new Error('USDZ export abandoned')
|
|
44
|
+
for (const warning of run.prepared.warnings) options.onWarning?.(warning)
|
|
45
|
+
run.scene = createUsdzScene(run.prepared.scene)
|
|
46
|
+
const exporter = new USDZExporter()
|
|
47
|
+
exporter.textureUtils = textureUtils as unknown as USDZExporter['textureUtils']
|
|
48
|
+
return exporter.parseAsync(run.scene, {
|
|
49
|
+
onlyVisible: options.onlyVisible ?? true,
|
|
50
|
+
quickLookCompatible: true,
|
|
51
|
+
})
|
|
52
|
+
})(),
|
|
53
|
+
options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
54
|
+
'USDZ',
|
|
55
|
+
() => {
|
|
56
|
+
run.abandoned = true
|
|
57
|
+
},
|
|
58
|
+
)
|
|
34
59
|
} finally {
|
|
35
|
-
if (scene) disposeExportResources(scene, { textures: false })
|
|
36
|
-
prepared.dispose()
|
|
60
|
+
if (run.scene) disposeExportResources(run.scene, { textures: false })
|
|
61
|
+
if (run.prepared) run.prepared.dispose()
|
|
62
|
+
else preparation.then((late) => late.dispose()).catch(() => {})
|
|
63
|
+
await textureUtils.dispose()
|
|
37
64
|
}
|
|
38
65
|
}
|
|
@@ -1,40 +1,16 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { useMemo } from 'react'
|
|
5
|
+
import { getLinearDisplay } from './linear-display'
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
* every length input honors the metric/imperial toggle identically.
|
|
10
|
-
*
|
|
11
|
-
* Values are always STORED in the field's own unit (meters for `unit === 'm'`).
|
|
12
|
-
* When the viewer preference is imperial AND the field is a meter length, the
|
|
13
|
-
* value is DISPLAYED (and edited) in feet; otherwise the conversions are the
|
|
14
|
-
* identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
|
|
15
|
-
* …) behave exactly as before.
|
|
16
|
-
*
|
|
17
|
-
* Used by both `SliderControl` and `MetricControl` — keep the two in sync via
|
|
18
|
-
* this single source of truth.
|
|
19
|
-
*/
|
|
20
|
-
export function useLinearDisplay(unit: string, precision: number) {
|
|
7
|
+
/** Shared display/input conversion; values and bounds stay in the stored unit. */
|
|
8
|
+
export function useLinearDisplay(unit: string, precision: number, step = 1) {
|
|
21
9
|
const viewerUnit = useViewer((state) => state.unit)
|
|
22
|
-
const
|
|
23
|
-
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
|
10
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
24
11
|
|
|
25
|
-
|
|
26
|
-
(
|
|
27
|
-
[
|
|
12
|
+
return useMemo(
|
|
13
|
+
() => getLinearDisplay(unit, viewerUnit, metricNotation, precision, step),
|
|
14
|
+
[unit, viewerUnit, metricNotation, precision, step],
|
|
28
15
|
)
|
|
29
|
-
const toStored = useCallback(
|
|
30
|
-
(display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
|
|
31
|
-
[isImperial],
|
|
32
|
-
)
|
|
33
|
-
// Round a stored value so it lands on a clean number of DISPLAY-unit digits.
|
|
34
|
-
const roundStored = useCallback(
|
|
35
|
-
(stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
|
|
36
|
-
[toDisplay, toStored, precision],
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
return { isImperial, displayUnit, toDisplay, toStored, roundStored }
|
|
40
16
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { getActiveBuildingId } from './world-grid-snap'
|
|
5
|
+
|
|
6
|
+
const originalNodes = useScene.getState().nodes
|
|
7
|
+
const originalSelection = useViewer.getState().selection
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
useScene.setState({ nodes: originalNodes } as never)
|
|
11
|
+
useViewer.setState({ selection: originalSelection } as never)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('getActiveBuildingId', () => {
|
|
15
|
+
test('prefers the active level owner over a stale selected building', () => {
|
|
16
|
+
const staleBuilding = BuildingNode.parse({})
|
|
17
|
+
const activeBuilding = BuildingNode.parse({})
|
|
18
|
+
const level = LevelNode.parse({ parentId: activeBuilding.id })
|
|
19
|
+
useScene.setState({
|
|
20
|
+
nodes: {
|
|
21
|
+
[staleBuilding.id]: staleBuilding,
|
|
22
|
+
[activeBuilding.id]: activeBuilding,
|
|
23
|
+
[level.id]: level,
|
|
24
|
+
},
|
|
25
|
+
} as never)
|
|
26
|
+
useViewer.setState({
|
|
27
|
+
selection: {
|
|
28
|
+
buildingId: staleBuilding.id,
|
|
29
|
+
levelId: level.id,
|
|
30
|
+
zoneId: null,
|
|
31
|
+
selectedIds: [],
|
|
32
|
+
},
|
|
33
|
+
} as never)
|
|
34
|
+
|
|
35
|
+
expect(getActiveBuildingId()).toBe(activeBuilding.id)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('resolves legacy level ownership from the building children list', () => {
|
|
39
|
+
const level = LevelNode.parse({})
|
|
40
|
+
const building = BuildingNode.parse({ children: [level.id] })
|
|
41
|
+
useScene.setState({ nodes: { [building.id]: building, [level.id]: level } } as never)
|
|
42
|
+
useViewer.setState({
|
|
43
|
+
selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
|
|
44
|
+
} as never)
|
|
45
|
+
|
|
46
|
+
expect(getActiveBuildingId()).toBe(building.id)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type BuildingPose,
|
|
16
16
|
type ResolveAlignmentInBuildingResult,
|
|
17
17
|
resolveAlignmentInBuildingWorld,
|
|
18
|
+
resolveBuildingForLevel,
|
|
18
19
|
snapWorldXZToBuildingLocal,
|
|
19
20
|
useLiveTransforms,
|
|
20
21
|
useScene,
|
|
@@ -39,7 +40,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
39
40
|
* guides drift off the visible grid mid-drag (and through any post-drag
|
|
40
41
|
* frame where the live override is still set).
|
|
41
42
|
*/
|
|
42
|
-
export function
|
|
43
|
+
export function getActiveBuildingId(): AnyNodeId | null {
|
|
43
44
|
const sel = useViewer.getState().selection
|
|
44
45
|
const nodes = useScene.getState().nodes
|
|
45
46
|
// Match `use-floorplan-scene-data.ts`: prefer the active level's
|
|
@@ -48,12 +49,15 @@ export function getActiveBuildingPose(): BuildingPose | null {
|
|
|
48
49
|
// different level, the level path is the authoritative one.
|
|
49
50
|
let buildingId: AnyNodeId | null = null
|
|
50
51
|
if (sel.levelId) {
|
|
51
|
-
|
|
52
|
-
if (level && level.type === 'level' && level.parentId) {
|
|
53
|
-
buildingId = level.parentId as AnyNodeId
|
|
54
|
-
}
|
|
52
|
+
buildingId = resolveBuildingForLevel(sel.levelId as AnyNodeId, nodes)
|
|
55
53
|
}
|
|
56
54
|
if (!buildingId) buildingId = sel.buildingId ?? null
|
|
55
|
+
return buildingId
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getActiveBuildingPose(): BuildingPose | null {
|
|
59
|
+
const buildingId = getActiveBuildingId()
|
|
60
|
+
const nodes = useScene.getState().nodes
|
|
57
61
|
const building = buildingId ? nodes[buildingId] : null
|
|
58
62
|
if (building?.type !== 'building') return null
|
|
59
63
|
const live = useLiveTransforms.getState().transforms.get(buildingId as string)
|