@pascal-app/editor 1.0.0-beta.4 → 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 +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- 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 +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -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 +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- 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 +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- 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 +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- 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 +204 -114
- 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 +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- 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 +447 -180
- 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 +63 -8
- 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-brush-cursor.tsx +2 -1
- 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 +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- 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 +12 -13
- 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 +23 -6
- 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 +78 -26
- 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/panel-wrapper.tsx +228 -12
- 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/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -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 +128 -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 +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- 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 +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- 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 +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -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/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -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.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -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 +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -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/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- 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 +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -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/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- 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/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
10
|
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
11
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
12
12
|
import { useShallow } from 'zustand/react/shallow'
|
|
13
13
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
14
14
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveRotateHandleHelpHints,
|
|
20
20
|
resolveSelectModeHelpHints,
|
|
21
21
|
} from '../../../lib/contextual-help'
|
|
22
|
+
import { getContextualHelpNodeExtension } from '../../../lib/contextual-help-extension'
|
|
22
23
|
import { continuationContextOf } from '../../../lib/continuation'
|
|
23
24
|
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
|
24
25
|
import type { ReshapeKind } from '../../../lib/interaction/scope'
|
|
@@ -33,7 +34,6 @@ import { BuildingHelper } from './building-helper'
|
|
|
33
34
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
|
34
35
|
import { ItemHelper } from './item-helper'
|
|
35
36
|
import { RegisteredToolHelper } from './registered-tool-helper'
|
|
36
|
-
import { RoofHelper } from './roof-helper'
|
|
37
37
|
|
|
38
38
|
// Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
|
|
39
39
|
// snapping chip is the main control; these just name the gesture + Esc.
|
|
@@ -88,6 +88,9 @@ type ActiveModifierKeys = {
|
|
|
88
88
|
shift: boolean
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const EMPTY_CONTEXTUAL_HINTS: ContextualShortcutHint[] = []
|
|
92
|
+
const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
|
|
93
|
+
|
|
91
94
|
function useActiveModifierKeys(): ActiveModifierKeys {
|
|
92
95
|
const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
|
|
93
96
|
command: false,
|
|
@@ -143,6 +146,21 @@ export function HelperManager() {
|
|
|
143
146
|
.filter((node): node is AnyNode => node !== undefined),
|
|
144
147
|
),
|
|
145
148
|
)
|
|
149
|
+
const contextualHelpNode =
|
|
150
|
+
scope.kind === 'mesh-editing'
|
|
151
|
+
? selectedNodes.find((node) => node.id === scope.nodeId) ?? null
|
|
152
|
+
: null
|
|
153
|
+
const contextualHelpExtension = contextualHelpNode
|
|
154
|
+
? getContextualHelpNodeExtension(nodeRegistry.get(contextualHelpNode.type))
|
|
155
|
+
: undefined
|
|
156
|
+
const contextualEditHints = useSyncExternalStore(
|
|
157
|
+
contextualHelpExtension?.subscribe ?? NO_CONTEXTUAL_HELP_SUBSCRIPTION,
|
|
158
|
+
() =>
|
|
159
|
+
contextualHelpNode
|
|
160
|
+
? (contextualHelpExtension?.getHints(contextualHelpNode.id) ?? EMPTY_CONTEXTUAL_HINTS)
|
|
161
|
+
: EMPTY_CONTEXTUAL_HINTS,
|
|
162
|
+
() => EMPTY_CONTEXTUAL_HINTS,
|
|
163
|
+
)
|
|
146
164
|
// The snapping context for whatever's active (wall / item / polygon) — drives
|
|
147
165
|
// which snapping chips the HUD shows, derived once and shared by every branch.
|
|
148
166
|
const snapContext = useMemo(
|
|
@@ -152,6 +170,10 @@ export function HelperManager() {
|
|
|
152
170
|
mode,
|
|
153
171
|
tool,
|
|
154
172
|
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
|
|
173
|
+
profileOfNode: (nodeId) => {
|
|
174
|
+
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
175
|
+
return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
|
|
176
|
+
},
|
|
155
177
|
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
|
|
156
178
|
}),
|
|
157
179
|
[scope, mode, tool],
|
|
@@ -212,6 +234,22 @@ export function HelperManager() {
|
|
|
212
234
|
)
|
|
213
235
|
}
|
|
214
236
|
|
|
237
|
+
// A single-node resize arrow is still an active snapping interaction. Roof
|
|
238
|
+
// width/depth handles opt into grid snapping, so keep the mode and grid-step
|
|
239
|
+
// controls visible for the whole drag instead of falling through to idle
|
|
240
|
+
// selection hints.
|
|
241
|
+
if (activeHandleDrag) {
|
|
242
|
+
return (
|
|
243
|
+
<ContextualHelperPanel
|
|
244
|
+
hints={[
|
|
245
|
+
{ keys: ['Drag'], label: 'Resize' },
|
|
246
|
+
{ keys: ['Esc'], label: 'Cancel' },
|
|
247
|
+
]}
|
|
248
|
+
snapContext={snapContext}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
215
253
|
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
|
|
216
254
|
// before the select branch so the idle "drag selected / add objects" hints
|
|
217
255
|
// never leak over an in-progress reshape — and it gets its own snapping chip.
|
|
@@ -227,13 +265,13 @@ export function HelperManager() {
|
|
|
227
265
|
const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata)
|
|
228
266
|
? continuationContextOf(movingNode.type)
|
|
229
267
|
: null
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
const collisionValidatesDrop =
|
|
269
|
+
nodeRegistry.get(movingNode.type)?.capabilities.floorPlaced?.collides === true
|
|
232
270
|
return (
|
|
233
271
|
<ItemHelper
|
|
234
272
|
continuationContext={movingContinuationContext}
|
|
235
273
|
showEsc
|
|
236
|
-
showForce={
|
|
274
|
+
showForce={collisionValidatesDrop}
|
|
237
275
|
snapContext={snapContext}
|
|
238
276
|
/>
|
|
239
277
|
)
|
|
@@ -255,6 +293,10 @@ export function HelperManager() {
|
|
|
255
293
|
return <ContextualHelperPanel hints={terrainSculptHints(terrainVerb, terrainSampling)} />
|
|
256
294
|
}
|
|
257
295
|
|
|
296
|
+
if (scope.kind === 'mesh-editing') {
|
|
297
|
+
return <ContextualHelperPanel hints={contextualEditHints} snapContext={snapContext} />
|
|
298
|
+
}
|
|
299
|
+
|
|
258
300
|
// Idle select only — an active scope (handle-drag, box-select, …) must not show
|
|
259
301
|
// the idle selection hints.
|
|
260
302
|
if (mode === 'select' && scope.kind === 'idle') {
|
|
@@ -273,12 +315,6 @@ export function HelperManager() {
|
|
|
273
315
|
)
|
|
274
316
|
}
|
|
275
317
|
|
|
276
|
-
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
|
277
|
-
// `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
|
|
278
|
-
// generic tool branch so the snap-context fallback below doesn't capture it
|
|
279
|
-
// and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes.
|
|
280
|
-
if (tool === 'roof') return <RoofHelper snapContext={snapContext} />
|
|
281
|
-
|
|
282
318
|
// Registry-first: a kind renders the generic `RegisteredToolHelper` when it
|
|
283
319
|
// declares `def.toolHints`, OR whenever its draft resolves to a snap /
|
|
284
320
|
// continuation context — so a snappable tool with NO hand-written hints (e.g.
|
|
@@ -6,7 +6,7 @@ interface ItemHelperProps {
|
|
|
6
6
|
showEsc?: boolean
|
|
7
7
|
snapContext?: SnapContext | null
|
|
8
8
|
// Whether to advertise Alt = force-place. Only meaningful for kinds that
|
|
9
|
-
// collision-validate their drop
|
|
9
|
+
// collision-validate their drop.
|
|
10
10
|
showForce?: boolean
|
|
11
11
|
// Set for a fresh point-kind placement (e.g. a positioned preset) so the
|
|
12
12
|
// once/repeat continuation chip shows; null for an existing-node move.
|
|
@@ -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
|
+
})
|