@pascal-app/editor 1.0.0-beta.5 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +284 -237
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +135 -2
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +9 -12
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +22 -5
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +1 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolHint } from '@pascal-app/core'
|
|
2
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
2
3
|
import type { ContinuationContext } from '../../../lib/continuation'
|
|
3
4
|
import type { SnapContext } from '../../../lib/snapping-mode'
|
|
4
5
|
import useEditor from '../../../store/use-editor'
|
|
@@ -27,11 +28,31 @@ export function RegisteredToolHelper({
|
|
|
27
28
|
// Live vertex count of an in-progress polygon draft, so hints gated on a
|
|
28
29
|
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
|
|
29
30
|
const draftVertexCount = useEditor((s) => s.draftVertexCount)
|
|
31
|
+
const visibilityStore = useMemo(
|
|
32
|
+
() => ({
|
|
33
|
+
subscribe: (onChange: () => void) => {
|
|
34
|
+
const unsubscribers = hints.flatMap((hint) =>
|
|
35
|
+
hint.visible ? [hint.visible.subscribe(onChange)] : [],
|
|
36
|
+
)
|
|
37
|
+
return () => {
|
|
38
|
+
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
|
|
42
|
+
}),
|
|
43
|
+
[hints],
|
|
44
|
+
)
|
|
45
|
+
useSyncExternalStore(
|
|
46
|
+
visibilityStore.subscribe,
|
|
47
|
+
visibilityStore.getSnapshot,
|
|
48
|
+
visibilityStore.getSnapshot,
|
|
49
|
+
)
|
|
30
50
|
// Some hints are replaced by live contextual chips, so keep the generic
|
|
31
51
|
// registry renderer from duplicating stale/static versions.
|
|
32
52
|
const visible = hints.filter(
|
|
33
53
|
(hint) =>
|
|
34
54
|
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
|
|
55
|
+
hint.visible?.value() !== false &&
|
|
35
56
|
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
|
36
57
|
)
|
|
37
58
|
if (visible.length === 0 && !snapContext && !continuationContext) return null
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
resolveHomogeneousSelection,
|
|
5
|
+
resolveUniqueSelectionIds,
|
|
6
|
+
} from './homogeneous-selection'
|
|
7
|
+
|
|
8
|
+
function node(
|
|
9
|
+
id: string,
|
|
10
|
+
type: string,
|
|
11
|
+
metadata?: Record<string, unknown>,
|
|
12
|
+
): AnyNode {
|
|
13
|
+
return {
|
|
14
|
+
object: 'node',
|
|
15
|
+
id: id as AnyNodeId,
|
|
16
|
+
type,
|
|
17
|
+
parentId: null,
|
|
18
|
+
visible: true,
|
|
19
|
+
metadata: metadata ?? {},
|
|
20
|
+
children: [],
|
|
21
|
+
} as unknown as AnyNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('resolveHomogeneousSelection', () => {
|
|
25
|
+
test('mixed selection is null', () => {
|
|
26
|
+
const nodes = {
|
|
27
|
+
wall_a: node('wall_a', 'wall'),
|
|
28
|
+
slab_a: node('slab_a', 'slab'),
|
|
29
|
+
}
|
|
30
|
+
expect(resolveHomogeneousSelection(['wall_a', 'slab_a'], nodes)).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('three walls share the wall type', () => {
|
|
34
|
+
const nodes = {
|
|
35
|
+
wall_a: node('wall_a', 'wall'),
|
|
36
|
+
wall_b: node('wall_b', 'wall'),
|
|
37
|
+
wall_c: node('wall_c', 'wall'),
|
|
38
|
+
}
|
|
39
|
+
expect(resolveHomogeneousSelection(['wall_a', 'wall_b', 'wall_c'], nodes)).toBe('wall')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('proxy-promoted children resolve to the parent type', () => {
|
|
43
|
+
const nodes = {
|
|
44
|
+
cabinet_run: node('cabinet_run', 'cabinet'),
|
|
45
|
+
'cabinet-module_a': node('cabinet-module_a', 'cabinet-module', {
|
|
46
|
+
nodeSelectionProxyId: 'cabinet_run',
|
|
47
|
+
}),
|
|
48
|
+
'cabinet-module_b': node('cabinet-module_b', 'cabinet-module', {
|
|
49
|
+
nodeSelectionProxyId: 'cabinet_run',
|
|
50
|
+
}),
|
|
51
|
+
'cabinet-module_c': node('cabinet-module_c', 'cabinet-module', {
|
|
52
|
+
nodeSelectionProxyId: 'cabinet_run',
|
|
53
|
+
}),
|
|
54
|
+
cabinet_run_b: node('cabinet_run_b', 'cabinet'),
|
|
55
|
+
'cabinet-module_d': node('cabinet-module_d', 'cabinet-module', {
|
|
56
|
+
nodeSelectionProxyId: 'cabinet_run_b',
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_d'], nodes)).toBe(
|
|
60
|
+
'cabinet',
|
|
61
|
+
)
|
|
62
|
+
expect(resolveUniqueSelectionIds(['cabinet-module_a', 'cabinet-module_b', 'cabinet-module_c'], nodes)).toEqual(
|
|
63
|
+
['cabinet_run'],
|
|
64
|
+
)
|
|
65
|
+
expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_b'], nodes)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('stale ids are skipped without breaking a homogeneous remainder', () => {
|
|
69
|
+
const nodes = {
|
|
70
|
+
wall_a: node('wall_a', 'wall'),
|
|
71
|
+
wall_b: node('wall_b', 'wall'),
|
|
72
|
+
}
|
|
73
|
+
expect(resolveHomogeneousSelection(['wall_a', 'gone', 'wall_b'], nodes)).toBe('wall')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a single live node after skips is not homogeneous', () => {
|
|
77
|
+
const nodes = { wall_a: node('wall_a', 'wall') }
|
|
78
|
+
expect(resolveHomogeneousSelection(['wall_a', 'gone'], nodes)).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
resolveSelectionProxyId,
|
|
5
|
+
} from '@pascal-app/core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve selection proxies and drop duplicates / missing ids. Session groups
|
|
9
|
+
* are just selections — mixed-type groups stay mixed after this pass, and a
|
|
10
|
+
* pair of children that both proxy to the same parent collapse to one id.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveUniqueSelectionIds(
|
|
13
|
+
ids: readonly string[],
|
|
14
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
15
|
+
): AnyNodeId[] {
|
|
16
|
+
const resolved: AnyNodeId[] = []
|
|
17
|
+
const seen = new Set<string>()
|
|
18
|
+
for (const id of ids) {
|
|
19
|
+
const node = nodes[id]
|
|
20
|
+
if (!node) continue
|
|
21
|
+
const resolvedId = resolveSelectionProxyId(node, nodes)
|
|
22
|
+
if (seen.has(resolvedId)) continue
|
|
23
|
+
seen.add(resolvedId)
|
|
24
|
+
resolved.push(resolvedId)
|
|
25
|
+
}
|
|
26
|
+
return resolved
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shared type when every resolved id is the same kind and at least two nodes
|
|
31
|
+
* remain. Otherwise null — including mixed session groups and proxy-collapsed
|
|
32
|
+
* selections that shrink below two distinct nodes.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveHomogeneousSelection(
|
|
35
|
+
ids: readonly string[],
|
|
36
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
37
|
+
): AnyNode['type'] | null {
|
|
38
|
+
const resolvedIds = resolveUniqueSelectionIds(ids, nodes)
|
|
39
|
+
if (resolvedIds.length < 2) return null
|
|
40
|
+
const first = nodes[resolvedIds[0] ?? '']
|
|
41
|
+
if (!first) return null
|
|
42
|
+
for (let i = 1; i < resolvedIds.length; i++) {
|
|
43
|
+
const node = nodes[resolvedIds[i] ?? '']
|
|
44
|
+
if (!node || node.type !== first.type) return null
|
|
45
|
+
}
|
|
46
|
+
return first.type
|
|
47
|
+
}
|
|
@@ -8,7 +8,9 @@ import { cn } from '../../../lib/utils'
|
|
|
8
8
|
import { getNodeDisplay } from './node-display'
|
|
9
9
|
|
|
10
10
|
interface MobileSelectionBarProps {
|
|
11
|
-
node: AnyNode
|
|
11
|
+
node: AnyNode | null
|
|
12
|
+
label?: string
|
|
13
|
+
icon?: string
|
|
12
14
|
onMove: () => void
|
|
13
15
|
onDuplicate: () => void
|
|
14
16
|
onDelete: () => void
|
|
@@ -20,19 +22,23 @@ const ACTION_BTN =
|
|
|
20
22
|
|
|
21
23
|
export function MobileSelectionBar({
|
|
22
24
|
node,
|
|
25
|
+
label,
|
|
26
|
+
icon,
|
|
23
27
|
onMove,
|
|
24
28
|
onDuplicate,
|
|
25
29
|
onDelete,
|
|
26
30
|
onEdit,
|
|
27
31
|
}: MobileSelectionBarProps) {
|
|
28
|
-
const
|
|
32
|
+
const display = getNodeDisplay(node)
|
|
33
|
+
const resolvedLabel = label ?? display.label
|
|
34
|
+
const resolvedIcon = icon ?? display.icon
|
|
29
35
|
|
|
30
36
|
const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
|
|
31
37
|
|
|
32
38
|
return (
|
|
33
39
|
<div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
|
|
34
40
|
<button
|
|
35
|
-
aria-label={`Edit ${
|
|
41
|
+
aria-label={`Edit ${resolvedLabel}`}
|
|
36
42
|
className={cn(
|
|
37
43
|
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
|
|
38
44
|
)}
|
|
@@ -43,10 +49,10 @@ export function MobileSelectionBar({
|
|
|
43
49
|
alt=""
|
|
44
50
|
className="shrink-0 rounded object-contain"
|
|
45
51
|
height={20}
|
|
46
|
-
src={
|
|
52
|
+
src={resolvedIcon}
|
|
47
53
|
width={20}
|
|
48
54
|
/>
|
|
49
|
-
<span className="truncate font-medium text-foreground text-sm">{
|
|
55
|
+
<span className="truncate font-medium text-foreground text-sm">{resolvedLabel}</span>
|
|
50
56
|
</button>
|
|
51
57
|
|
|
52
58
|
<div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
BuildingNode,
|
|
6
|
+
clearSceneHistory,
|
|
7
|
+
LevelNode,
|
|
8
|
+
useLiveNodeOverrides,
|
|
9
|
+
useScene,
|
|
10
|
+
WallNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import {
|
|
13
|
+
buildMultiNodePatches,
|
|
14
|
+
commitMultiNodeFields,
|
|
15
|
+
fieldVisibleForAll,
|
|
16
|
+
reduceFieldValue,
|
|
17
|
+
reduceHeightBoundMode,
|
|
18
|
+
} from './multi-field-value'
|
|
19
|
+
|
|
20
|
+
type RafFn = (cb: (time: number) => void) => number
|
|
21
|
+
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
|
|
22
|
+
cb(0)
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
|
26
|
+
() => {}
|
|
27
|
+
|
|
28
|
+
const BUILDING_ID = 'building_multi_edit' as AnyNodeId
|
|
29
|
+
const LEVEL_ID = 'level_multi_edit' as AnyNodeId
|
|
30
|
+
const WALL_A = 'wall_multi_a' as AnyNodeId
|
|
31
|
+
const WALL_B = 'wall_multi_b' as AnyNodeId
|
|
32
|
+
const WALL_C = 'wall_multi_c' as AnyNodeId
|
|
33
|
+
|
|
34
|
+
function makeNode(id: string, type: string, fields: Record<string, unknown> = {}): AnyNode {
|
|
35
|
+
return {
|
|
36
|
+
object: 'node',
|
|
37
|
+
id: id as AnyNodeId,
|
|
38
|
+
type,
|
|
39
|
+
parentId: null,
|
|
40
|
+
visible: true,
|
|
41
|
+
metadata: {},
|
|
42
|
+
children: [],
|
|
43
|
+
...fields,
|
|
44
|
+
} as unknown as AnyNode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('reduceFieldValue', () => {
|
|
48
|
+
test('same values collapse to one', () => {
|
|
49
|
+
const nodes = {
|
|
50
|
+
a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
|
|
51
|
+
b: makeNode('b', 'wall', { height: 2.4, thickness: 0.15 }),
|
|
52
|
+
}
|
|
53
|
+
expect(reduceFieldValue(['a', 'b'], 'height', nodes)).toEqual({ kind: 'same', value: 2.4 })
|
|
54
|
+
expect(reduceFieldValue(['a', 'b'], 'thickness', nodes)).toEqual({ kind: 'mixed' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('vec3 compares elementwise', () => {
|
|
58
|
+
const nodes = {
|
|
59
|
+
a: makeNode('a', 'item', { position: [1, 0, 2] }),
|
|
60
|
+
b: makeNode('b', 'item', { position: [1, 0, 2] }),
|
|
61
|
+
c: makeNode('c', 'item', { position: [1, 0, 3] }),
|
|
62
|
+
}
|
|
63
|
+
expect(reduceFieldValue(['a', 'b'], 'position', nodes)).toEqual({
|
|
64
|
+
kind: 'same',
|
|
65
|
+
value: [1, 0, 2],
|
|
66
|
+
})
|
|
67
|
+
expect(reduceFieldValue(['a', 'c'], 'position', nodes)).toEqual({ kind: 'mixed' })
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('reduceHeightBoundMode', () => {
|
|
72
|
+
test('absent height is follows-level, present height is custom', () => {
|
|
73
|
+
const nodes = {
|
|
74
|
+
a: makeNode('a', 'wall'),
|
|
75
|
+
b: makeNode('b', 'wall'),
|
|
76
|
+
c: makeNode('c', 'wall', { height: 3 }),
|
|
77
|
+
}
|
|
78
|
+
expect(reduceHeightBoundMode(['a', 'b'], nodes)).toEqual({ kind: 'same', value: 'storey' })
|
|
79
|
+
expect(reduceHeightBoundMode(['c'], nodes)).toEqual({ kind: 'same', value: 'custom' })
|
|
80
|
+
expect(reduceHeightBoundMode(['a', 'c'], nodes)).toEqual({ kind: 'mixed' })
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('fieldVisibleForAll', () => {
|
|
85
|
+
test('hides when any node would hide the field', () => {
|
|
86
|
+
const nodes = {
|
|
87
|
+
a: makeNode('a', 'slab', { recessed: false }),
|
|
88
|
+
b: makeNode('b', 'slab', { recessed: true }),
|
|
89
|
+
}
|
|
90
|
+
const visibleIf = (n: AnyNode) => !(n as { recessed?: boolean }).recessed
|
|
91
|
+
expect(fieldVisibleForAll(['a'], visibleIf, nodes)).toBe(true)
|
|
92
|
+
expect(fieldVisibleForAll(['a', 'b'], visibleIf, nodes)).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('buildMultiNodePatches', () => {
|
|
97
|
+
test('a mixed field that is never edited produces no patch for it', () => {
|
|
98
|
+
const nodes = {
|
|
99
|
+
a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
|
|
100
|
+
b: makeNode('b', 'wall', { height: 3, thickness: 0.15 }),
|
|
101
|
+
}
|
|
102
|
+
const patches = buildMultiNodePatches(
|
|
103
|
+
['a' as AnyNodeId, 'b' as AnyNodeId],
|
|
104
|
+
() => ({ thickness: 0.3 }),
|
|
105
|
+
nodes,
|
|
106
|
+
)
|
|
107
|
+
expect(patches).toEqual([
|
|
108
|
+
{ id: 'a' as AnyNodeId, data: { thickness: 0.3 } },
|
|
109
|
+
{ id: 'b' as AnyNodeId, data: { thickness: 0.3 } },
|
|
110
|
+
])
|
|
111
|
+
for (const patch of patches) {
|
|
112
|
+
expect(patch.data).not.toHaveProperty('height')
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('derive and reconcile fan out per node into one batch', () => {
|
|
117
|
+
const nodes = {
|
|
118
|
+
a: makeNode('a', 'wall', { height: 2 }),
|
|
119
|
+
b: makeNode('b', 'wall', { height: 2 }),
|
|
120
|
+
}
|
|
121
|
+
const patches = buildMultiNodePatches(
|
|
122
|
+
['a' as AnyNodeId, 'b' as AnyNodeId],
|
|
123
|
+
() => ({ height: 4 }),
|
|
124
|
+
nodes,
|
|
125
|
+
{
|
|
126
|
+
derive: (next) => ({ thickness: (next as { height: number }).height / 10 }),
|
|
127
|
+
reconcile: (prev) => [
|
|
128
|
+
{ id: `${prev.id}_follow` as AnyNodeId, data: { height: 4 } },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
expect(patches).toEqual([
|
|
133
|
+
{ id: 'a' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
|
|
134
|
+
{ id: 'b' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
|
|
135
|
+
{ id: 'a_follow' as AnyNodeId, data: { height: 4 } },
|
|
136
|
+
{ id: 'b_follow' as AnyNodeId, data: { height: 4 } },
|
|
137
|
+
])
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('commitMultiNodeFields', () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
const wallA = WallNode.parse({
|
|
144
|
+
id: WALL_A,
|
|
145
|
+
parentId: LEVEL_ID,
|
|
146
|
+
start: [0, 0],
|
|
147
|
+
end: [4, 0],
|
|
148
|
+
height: 2.4,
|
|
149
|
+
})
|
|
150
|
+
const wallB = WallNode.parse({
|
|
151
|
+
id: WALL_B,
|
|
152
|
+
parentId: LEVEL_ID,
|
|
153
|
+
start: [4, 0],
|
|
154
|
+
end: [4, 3],
|
|
155
|
+
height: 3,
|
|
156
|
+
})
|
|
157
|
+
const wallC = WallNode.parse({
|
|
158
|
+
id: WALL_C,
|
|
159
|
+
parentId: LEVEL_ID,
|
|
160
|
+
start: [4, 3],
|
|
161
|
+
end: [0, 3],
|
|
162
|
+
height: 2.7,
|
|
163
|
+
})
|
|
164
|
+
const level = LevelNode.parse({
|
|
165
|
+
id: LEVEL_ID,
|
|
166
|
+
parentId: BUILDING_ID,
|
|
167
|
+
children: [WALL_A, WALL_B, WALL_C],
|
|
168
|
+
level: 0,
|
|
169
|
+
})
|
|
170
|
+
const building = BuildingNode.parse({
|
|
171
|
+
id: BUILDING_ID,
|
|
172
|
+
parentId: null,
|
|
173
|
+
children: [LEVEL_ID],
|
|
174
|
+
})
|
|
175
|
+
useScene.setState({
|
|
176
|
+
nodes: {
|
|
177
|
+
[BUILDING_ID]: building,
|
|
178
|
+
[LEVEL_ID]: level,
|
|
179
|
+
[WALL_A]: wallA,
|
|
180
|
+
[WALL_B]: wallB,
|
|
181
|
+
[WALL_C]: wallC,
|
|
182
|
+
},
|
|
183
|
+
rootNodeIds: [BUILDING_ID],
|
|
184
|
+
dirtyNodes: new Set<AnyNodeId>(),
|
|
185
|
+
collections: {},
|
|
186
|
+
materials: {},
|
|
187
|
+
readOnly: false,
|
|
188
|
+
} as never)
|
|
189
|
+
clearSceneHistory()
|
|
190
|
+
useLiveNodeOverrides.getState().clearAll()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
useLiveNodeOverrides.getState().clearAll()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('dragging height onto three walls is one undo step', () => {
|
|
198
|
+
commitMultiNodeFields([WALL_A, WALL_B, WALL_C], () => ({ height: 4 }))
|
|
199
|
+
expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(4)
|
|
200
|
+
expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(4)
|
|
201
|
+
expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(4)
|
|
202
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
203
|
+
|
|
204
|
+
useScene.temporal.getState().undo()
|
|
205
|
+
expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(2.4)
|
|
206
|
+
expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(3)
|
|
207
|
+
expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(2.7)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type ParametricDescriptor,
|
|
5
|
+
useLiveNodeOverrides,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
|
|
9
|
+
export type ReducedFieldValue<T = unknown> =
|
|
10
|
+
| { kind: 'same'; value: T }
|
|
11
|
+
| { kind: 'mixed' }
|
|
12
|
+
|
|
13
|
+
const MIXED: { kind: 'mixed' } = { kind: 'mixed' }
|
|
14
|
+
|
|
15
|
+
function fieldValuesEqual(a: unknown, b: unknown): boolean {
|
|
16
|
+
if (Object.is(a, b)) return true
|
|
17
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
|
|
18
|
+
return a.every((value, i) => Object.is(value, b[i]))
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function reduceFieldValue(
|
|
24
|
+
nodeIds: readonly string[],
|
|
25
|
+
key: string,
|
|
26
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
27
|
+
): ReducedFieldValue {
|
|
28
|
+
let seen = false
|
|
29
|
+
let shared: unknown
|
|
30
|
+
for (const id of nodeIds) {
|
|
31
|
+
const node = nodes[id]
|
|
32
|
+
if (!node) continue
|
|
33
|
+
const value = (node as Record<string, unknown>)[key]
|
|
34
|
+
if (!seen) {
|
|
35
|
+
seen = true
|
|
36
|
+
shared = value
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (!fieldValuesEqual(shared, value)) return MIXED
|
|
40
|
+
}
|
|
41
|
+
if (!seen) return MIXED
|
|
42
|
+
return { kind: 'same', value: shared }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type HeightBoundMode = 'storey' | 'custom'
|
|
46
|
+
|
|
47
|
+
export function reduceHeightBoundMode(
|
|
48
|
+
nodeIds: readonly string[],
|
|
49
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
50
|
+
): ReducedFieldValue<HeightBoundMode> {
|
|
51
|
+
let seen = false
|
|
52
|
+
let shared: HeightBoundMode | undefined
|
|
53
|
+
for (const id of nodeIds) {
|
|
54
|
+
const node = nodes[id]
|
|
55
|
+
if (!node) continue
|
|
56
|
+
const mode: HeightBoundMode = (node as { height?: number }).height == null ? 'storey' : 'custom'
|
|
57
|
+
if (!seen) {
|
|
58
|
+
seen = true
|
|
59
|
+
shared = mode
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (mode !== shared) return MIXED
|
|
63
|
+
}
|
|
64
|
+
if (!seen || !shared) return MIXED
|
|
65
|
+
return { kind: 'same', value: shared }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function fieldVisibleForAll(
|
|
69
|
+
nodeIds: readonly string[],
|
|
70
|
+
visibleIf: ((node: AnyNode) => boolean) | undefined,
|
|
71
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
72
|
+
): boolean {
|
|
73
|
+
if (!visibleIf) return true
|
|
74
|
+
for (const id of nodeIds) {
|
|
75
|
+
const node = nodes[id]
|
|
76
|
+
if (!node || !visibleIf(node)) return false
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function firstNumericFieldValue(
|
|
82
|
+
nodeIds: readonly string[],
|
|
83
|
+
key: string,
|
|
84
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
85
|
+
fallback = 0,
|
|
86
|
+
): number {
|
|
87
|
+
for (const id of nodeIds) {
|
|
88
|
+
const node = nodes[id]
|
|
89
|
+
const value = node ? (node as Record<string, unknown>)[key] : undefined
|
|
90
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
91
|
+
}
|
|
92
|
+
return fallback
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function firstVec3FieldValue(
|
|
96
|
+
nodeIds: readonly string[],
|
|
97
|
+
key: string,
|
|
98
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
99
|
+
): [number, number, number] {
|
|
100
|
+
for (const id of nodeIds) {
|
|
101
|
+
const node = nodes[id]
|
|
102
|
+
const value = node ? (node as Record<string, unknown>)[key] : undefined
|
|
103
|
+
if (Array.isArray(value) && value.length >= 3) {
|
|
104
|
+
return [Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [0, 0, 0]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildMultiNodePatches(
|
|
111
|
+
nodeIds: readonly AnyNodeId[],
|
|
112
|
+
patchFor: (node: AnyNode) => Partial<AnyNode>,
|
|
113
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
114
|
+
parametrics?: Pick<ParametricDescriptor<AnyNode>, 'derive' | 'reconcile'>,
|
|
115
|
+
): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
|
|
116
|
+
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
|
117
|
+
const followUps: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
|
118
|
+
for (const id of nodeIds) {
|
|
119
|
+
const node = nodes[id]
|
|
120
|
+
if (!node) continue
|
|
121
|
+
let patch = patchFor(node)
|
|
122
|
+
if (Object.keys(patch).length === 0) continue
|
|
123
|
+
if (parametrics?.derive) {
|
|
124
|
+
const next = { ...node, ...patch } as AnyNode
|
|
125
|
+
patch = { ...patch, ...parametrics.derive(next, patch, node) } as Partial<AnyNode>
|
|
126
|
+
}
|
|
127
|
+
updates.push({ id, data: patch })
|
|
128
|
+
if (parametrics?.reconcile) {
|
|
129
|
+
const next = { ...node, ...patch } as AnyNode
|
|
130
|
+
followUps.push(...parametrics.reconcile(node, next))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return [...updates, ...followUps]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function previewMultiNodeFields(
|
|
137
|
+
entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
|
|
138
|
+
): void {
|
|
139
|
+
if (entries.length === 0) return
|
|
140
|
+
useLiveNodeOverrides.getState().setMany(entries)
|
|
141
|
+
const scene = useScene.getState()
|
|
142
|
+
for (const [id] of entries) scene.markDirty(id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function commitMultiNodeFields(
|
|
146
|
+
nodeIds: readonly AnyNodeId[],
|
|
147
|
+
patchFor: (node: AnyNode) => Partial<AnyNode>,
|
|
148
|
+
parametrics?: Pick<ParametricDescriptor<AnyNode>, 'derive' | 'reconcile'>,
|
|
149
|
+
): void {
|
|
150
|
+
const scene = useScene.getState()
|
|
151
|
+
const patches = buildMultiNodePatches(nodeIds, patchFor, scene.nodes, parametrics)
|
|
152
|
+
const keys = new Set<string>()
|
|
153
|
+
for (const patch of patches) {
|
|
154
|
+
for (const key of Object.keys(patch.data)) keys.add(key)
|
|
155
|
+
}
|
|
156
|
+
const live = useLiveNodeOverrides.getState()
|
|
157
|
+
const keyList = [...keys]
|
|
158
|
+
for (const id of nodeIds) {
|
|
159
|
+
if (keyList.length > 0) live.clearFields(id, keyList)
|
|
160
|
+
scene.markDirty(id)
|
|
161
|
+
}
|
|
162
|
+
if (patches.length > 0) scene.updateNodes(patches)
|
|
163
|
+
}
|