@pascal-app/editor 0.9.2 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
BuildingNode,
|
|
6
|
+
clearSceneHistory,
|
|
7
|
+
LevelNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { installHistoryCommandDelegate, runRedo, runUndo } from './history'
|
|
11
|
+
|
|
12
|
+
type RafFn = (cb: (time: number) => void) => number
|
|
13
|
+
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
|
|
14
|
+
cb(0)
|
|
15
|
+
return 0
|
|
16
|
+
}
|
|
17
|
+
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
|
18
|
+
() => {}
|
|
19
|
+
|
|
20
|
+
const BUILDING_ID = 'building_history_controller' as AnyNodeId
|
|
21
|
+
const LEVEL_ID = 'level_history_controller' as AnyNodeId
|
|
22
|
+
let disposeController = () => {}
|
|
23
|
+
|
|
24
|
+
function levelNumber(): number {
|
|
25
|
+
return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('editor history controller', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
disposeController()
|
|
31
|
+
disposeController = () => {}
|
|
32
|
+
const level = LevelNode.parse({
|
|
33
|
+
id: LEVEL_ID,
|
|
34
|
+
parentId: BUILDING_ID,
|
|
35
|
+
children: [],
|
|
36
|
+
level: 0,
|
|
37
|
+
})
|
|
38
|
+
const building = BuildingNode.parse({
|
|
39
|
+
id: BUILDING_ID,
|
|
40
|
+
parentId: null,
|
|
41
|
+
children: [LEVEL_ID],
|
|
42
|
+
})
|
|
43
|
+
useScene.setState({
|
|
44
|
+
nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level },
|
|
45
|
+
rootNodeIds: [BUILDING_ID],
|
|
46
|
+
dirtyNodes: new Set<AnyNodeId>(),
|
|
47
|
+
collections: {},
|
|
48
|
+
materials: {},
|
|
49
|
+
readOnly: false,
|
|
50
|
+
} as never)
|
|
51
|
+
clearSceneHistory()
|
|
52
|
+
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
disposeController()
|
|
57
|
+
disposeController = () => {}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('delegates undo and redo while a host delegate is installed', () => {
|
|
61
|
+
const undo = mock(() => {})
|
|
62
|
+
const redo = mock(() => {})
|
|
63
|
+
disposeController = installHistoryCommandDelegate({ undo, redo })
|
|
64
|
+
|
|
65
|
+
runUndo()
|
|
66
|
+
runRedo()
|
|
67
|
+
|
|
68
|
+
expect(undo).toHaveBeenCalledTimes(1)
|
|
69
|
+
expect(redo).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(levelNumber()).toBe(1)
|
|
71
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('falls back to standalone Zundo undo and redo when no controller is installed', () => {
|
|
75
|
+
runUndo()
|
|
76
|
+
expect(levelNumber()).toBe(0)
|
|
77
|
+
expect(useScene.temporal.getState().futureStates).toHaveLength(1)
|
|
78
|
+
|
|
79
|
+
runRedo()
|
|
80
|
+
expect(levelNumber()).toBe(1)
|
|
81
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('an older cleanup cannot uninstall a newer controller', () => {
|
|
85
|
+
const firstUndo = mock(() => {})
|
|
86
|
+
const stopFirst = installHistoryCommandDelegate({ undo: firstUndo, redo: () => {} })
|
|
87
|
+
const secondUndo = mock(() => {})
|
|
88
|
+
disposeController = installHistoryCommandDelegate({ undo: secondUndo, redo: () => {} })
|
|
89
|
+
|
|
90
|
+
stopFirst()
|
|
91
|
+
runUndo()
|
|
92
|
+
|
|
93
|
+
expect(firstUndo).toHaveBeenCalledTimes(0)
|
|
94
|
+
expect(secondUndo).toHaveBeenCalledTimes(1)
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/lib/history.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
|
|
2
2
|
|
|
3
|
+
export type HistoryCommandDelegate = {
|
|
4
|
+
undo: () => void
|
|
5
|
+
redo: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let historyCommandDelegate: HistoryCommandDelegate | null = null
|
|
9
|
+
|
|
10
|
+
export function installHistoryCommandDelegate(delegate: HistoryCommandDelegate): () => void {
|
|
11
|
+
historyCommandDelegate = delegate
|
|
12
|
+
return () => {
|
|
13
|
+
if (historyCommandDelegate === delegate) historyCommandDelegate = null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
function refreshSceneAfterHistoryJump() {
|
|
4
18
|
useLiveNodeOverrides.getState().clearAll()
|
|
5
19
|
useLiveTransforms.getState().clearAll()
|
|
@@ -11,11 +25,19 @@ function refreshSceneAfterHistoryJump() {
|
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
export function runUndo() {
|
|
28
|
+
if (historyCommandDelegate) {
|
|
29
|
+
historyCommandDelegate.undo()
|
|
30
|
+
return
|
|
31
|
+
}
|
|
14
32
|
useScene.temporal.getState().undo()
|
|
15
33
|
refreshSceneAfterHistoryJump()
|
|
16
34
|
}
|
|
17
35
|
|
|
18
36
|
export function runRedo() {
|
|
37
|
+
if (historyCommandDelegate) {
|
|
38
|
+
historyCommandDelegate.redo()
|
|
39
|
+
return
|
|
40
|
+
}
|
|
19
41
|
useScene.temporal.getState().redo()
|
|
20
42
|
refreshSceneAfterHistoryJump()
|
|
21
43
|
}
|
|
@@ -17,9 +17,10 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
|
|
|
17
17
|
{ kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
|
|
18
18
|
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
|
19
19
|
{ kind: 'drafting', tool: 'wall' },
|
|
20
|
-
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
|
|
20
|
+
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', driver: 'tool', holeIndex: 0 },
|
|
21
21
|
{ kind: 'box-select' },
|
|
22
22
|
{ kind: 'painting' },
|
|
23
|
+
{ kind: 'sculpting' },
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
describe('resolveOverlayPolicy', () => {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { AnyNode } from '@pascal-app/core'
|
|
14
14
|
|
|
15
15
|
export type InteractionView = '2d' | '3d'
|
|
16
|
+
export type ReshapeDriver = 'tool' | 'floorplan'
|
|
16
17
|
|
|
17
18
|
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
|
|
18
19
|
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
|
@@ -47,6 +48,8 @@ export type InteractionScope =
|
|
|
47
48
|
kind: 'reshaping'
|
|
48
49
|
nodeId: string
|
|
49
50
|
reshape: ReshapeKind
|
|
51
|
+
// Floorplan affordances own their commit and must not mount a second 3D reshape tool.
|
|
52
|
+
driver: ReshapeDriver
|
|
50
53
|
holeIndex?: number
|
|
51
54
|
endpoint?: 'start' | 'end'
|
|
52
55
|
index?: number
|
|
@@ -56,6 +59,13 @@ export type InteractionScope =
|
|
|
56
59
|
| { kind: 'box-select' }
|
|
57
60
|
// Material paint application.
|
|
58
61
|
| { kind: 'painting' }
|
|
62
|
+
// Terrain sculpting. Held for the whole time the sculpt tool is armed, not
|
|
63
|
+
// just while the pointer is down — the same lifetime as `painting`, and for
|
|
64
|
+
// the same reason: an *active* scope is what makes every other object's
|
|
65
|
+
// handles, the floating action menu, and the zone labels step back
|
|
66
|
+
// (`resolveOverlayPolicy`). A brush that only claimed the scope between
|
|
67
|
+
// pointer-down and -up would flicker all of that back on between dabs.
|
|
68
|
+
| { kind: 'sculpting' }
|
|
59
69
|
|
|
60
70
|
export type InteractionKind = InteractionScope['kind']
|
|
61
71
|
|
|
@@ -126,12 +136,14 @@ export function editingHoleInfo(
|
|
|
126
136
|
export function holeEditScope(target: {
|
|
127
137
|
nodeId: string
|
|
128
138
|
holeIndex: number
|
|
139
|
+
driver?: ReshapeDriver
|
|
129
140
|
}): ActiveInteractionScope {
|
|
130
141
|
return {
|
|
131
142
|
kind: 'reshaping',
|
|
132
143
|
nodeId: target.nodeId,
|
|
133
144
|
reshape: 'hole',
|
|
134
145
|
holeIndex: target.holeIndex,
|
|
146
|
+
driver: target.driver ?? 'tool',
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
|
|
@@ -142,6 +154,14 @@ export function isCurveReshape(scope: InteractionScope): boolean {
|
|
|
142
154
|
return scope.kind === 'reshaping' && scope.reshape === 'curve'
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
export function isToolDrivenReshape(scope: InteractionScope): boolean {
|
|
158
|
+
return scope.kind === 'reshaping' && scope.driver === 'tool'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isFloorplanDrivenReshape(scope: InteractionScope): boolean {
|
|
162
|
+
return scope.kind === 'reshaping' && scope.driver === 'floorplan'
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
|
|
146
166
|
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
|
|
147
167
|
// the duration of the drag).
|
|
@@ -181,31 +201,43 @@ export function reshapingNodeId(scope: InteractionScope): string | null {
|
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
// Builders so producers don't re-spell the discriminator at every call site.
|
|
184
|
-
export function curveReshapeScope(
|
|
185
|
-
|
|
204
|
+
export function curveReshapeScope(
|
|
205
|
+
nodeId: string,
|
|
206
|
+
driver: ReshapeDriver = 'tool',
|
|
207
|
+
): ActiveInteractionScope {
|
|
208
|
+
return { kind: 'reshaping', nodeId, reshape: 'curve', driver }
|
|
186
209
|
}
|
|
187
210
|
|
|
188
211
|
export function endpointReshapeScope(
|
|
189
212
|
nodeId: string,
|
|
190
213
|
endpoint: 'start' | 'end',
|
|
214
|
+
driver: ReshapeDriver = 'tool',
|
|
191
215
|
): ActiveInteractionScope {
|
|
192
|
-
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
|
|
216
|
+
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint, driver }
|
|
193
217
|
}
|
|
194
218
|
|
|
195
|
-
export function controlPointReshapeScope(
|
|
196
|
-
|
|
219
|
+
export function controlPointReshapeScope(
|
|
220
|
+
nodeId: string,
|
|
221
|
+
index: number,
|
|
222
|
+
driver: ReshapeDriver = 'tool',
|
|
223
|
+
): ActiveInteractionScope {
|
|
224
|
+
return { kind: 'reshaping', nodeId, reshape: 'control-point', index, driver }
|
|
197
225
|
}
|
|
198
226
|
|
|
199
227
|
export function tangentReshapeScope(
|
|
200
228
|
nodeId: string,
|
|
201
229
|
index: number,
|
|
202
230
|
side: 'in' | 'out',
|
|
231
|
+
driver: ReshapeDriver = 'tool',
|
|
203
232
|
): ActiveInteractionScope {
|
|
204
|
-
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
|
|
233
|
+
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side, driver }
|
|
205
234
|
}
|
|
206
235
|
|
|
207
236
|
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
|
|
208
237
|
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
|
|
209
|
-
export function boundaryReshapeScope(
|
|
210
|
-
|
|
238
|
+
export function boundaryReshapeScope(
|
|
239
|
+
nodeId: string,
|
|
240
|
+
driver: ReshapeDriver = 'tool',
|
|
241
|
+
): ActiveInteractionScope {
|
|
242
|
+
return { kind: 'reshaping', nodeId, reshape: 'boundary', driver }
|
|
211
243
|
}
|
|
@@ -11,7 +11,7 @@ import { buildLevelDuplicateCreateOps } from './level-duplication'
|
|
|
11
11
|
|
|
12
12
|
describe('buildLevelDuplicateCreateOps', () => {
|
|
13
13
|
test('parents a duplicated bootstrap level back to its building', () => {
|
|
14
|
-
const level = LevelNode.parse({ level: 0, children: [] })
|
|
14
|
+
const level = LevelNode.parse({ level: 0, height: 3.25, children: [] })
|
|
15
15
|
const building = BuildingNode.parse({ children: [level.id] })
|
|
16
16
|
const wall = WallNode.parse({
|
|
17
17
|
parentId: level.id,
|
|
@@ -36,6 +36,7 @@ describe('buildLevelDuplicateCreateOps', () => {
|
|
|
36
36
|
|
|
37
37
|
expect(sourceLevel.parentId).toBeNull()
|
|
38
38
|
expect(levelCreateOp?.parentId).toBe(building.id)
|
|
39
|
+
expect(levelCreateOp?.node.type === 'level' ? levelCreateOp.node.height : undefined).toBe(3.25)
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
test('does not copy spawn points from the source level', () => {
|
|
@@ -72,6 +72,11 @@ describe('linear measurements', () => {
|
|
|
72
72
|
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
|
|
73
73
|
})
|
|
74
74
|
|
|
75
|
+
test('formats metric measurements in whole millimeters', () => {
|
|
76
|
+
expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm')
|
|
77
|
+
expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm')
|
|
78
|
+
})
|
|
79
|
+
|
|
75
80
|
test('formats imperial measurements as feet and inches', () => {
|
|
76
81
|
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
|
|
77
82
|
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
|
package/src/lib/measurements.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MeasurementPoint } from '@pascal-app/core'
|
|
2
2
|
|
|
3
3
|
export type LinearUnit = 'metric' | 'imperial'
|
|
4
|
+
export type MetricNotation = 'meters' | 'millimeters'
|
|
4
5
|
|
|
5
6
|
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
|
|
6
7
|
export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
|
|
@@ -173,7 +174,11 @@ export function formatVolumeLabel(
|
|
|
173
174
|
return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
export function formatLinearMeasurement(
|
|
177
|
+
export function formatLinearMeasurement(
|
|
178
|
+
meters: number,
|
|
179
|
+
unit: LinearUnit,
|
|
180
|
+
metricNotation: MetricNotation = 'meters',
|
|
181
|
+
): string {
|
|
177
182
|
if (!Number.isFinite(meters)) return '--'
|
|
178
183
|
|
|
179
184
|
const absoluteMeters = Math.abs(meters)
|
|
@@ -192,6 +197,12 @@ export function formatLinearMeasurement(meters: number, unit: LinearUnit): strin
|
|
|
192
197
|
return `${sign}${wholeFeet}'${inches}"`
|
|
193
198
|
}
|
|
194
199
|
|
|
200
|
+
if (metricNotation === 'millimeters') {
|
|
201
|
+
const roundedMillimeters = Math.round(absoluteMeters * 1000)
|
|
202
|
+
const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
|
|
203
|
+
return `${sign}${roundedMillimeters}mm`
|
|
204
|
+
}
|
|
205
|
+
|
|
195
206
|
const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
|
|
196
207
|
const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
|
|
197
208
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const activeAnimations = new WeakMap<HTMLElement, Animation>()
|
|
2
|
+
|
|
3
|
+
export function playBlockedQuickActionFeedback(button: HTMLButtonElement, reducedMotion: boolean) {
|
|
4
|
+
const content = button.querySelector<HTMLElement>('[data-quick-action-feedback]')
|
|
5
|
+
if (!content) return
|
|
6
|
+
|
|
7
|
+
activeAnimations.get(content)?.cancel()
|
|
8
|
+
content.style.color = 'var(--destructive)'
|
|
9
|
+
|
|
10
|
+
const keyframes: Keyframe[] = reducedMotion
|
|
11
|
+
? [{ opacity: 1 }, { opacity: 1 }]
|
|
12
|
+
: [
|
|
13
|
+
{ transform: 'translateX(0)' },
|
|
14
|
+
{ transform: 'translateX(-2.5px)', offset: 0.18 },
|
|
15
|
+
{ transform: 'translateX(2px)', offset: 0.38 },
|
|
16
|
+
{ transform: 'translateX(-1.5px)', offset: 0.58 },
|
|
17
|
+
{ transform: 'translateX(1px)', offset: 0.76 },
|
|
18
|
+
{ transform: 'translateX(0)' },
|
|
19
|
+
]
|
|
20
|
+
const animation = content.animate(keyframes, {
|
|
21
|
+
duration: reducedMotion ? 240 : 320,
|
|
22
|
+
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
activeAnimations.set(content, animation)
|
|
26
|
+
void animation.finished
|
|
27
|
+
.catch(() => undefined)
|
|
28
|
+
.finally(() => {
|
|
29
|
+
if (activeAnimations.get(content) !== animation) return
|
|
30
|
+
activeAnimations.delete(content)
|
|
31
|
+
content.style.removeProperty('color')
|
|
32
|
+
})
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
3
|
+
import { collectQuickActionNodeScope } from './quick-action-nodes'
|
|
4
|
+
|
|
5
|
+
function fixtureNode({
|
|
6
|
+
id,
|
|
7
|
+
parentId,
|
|
8
|
+
children = [],
|
|
9
|
+
type = 'item',
|
|
10
|
+
}: {
|
|
11
|
+
id: string
|
|
12
|
+
parentId?: string
|
|
13
|
+
children?: string[]
|
|
14
|
+
type?: string
|
|
15
|
+
}) {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
type,
|
|
19
|
+
parentId,
|
|
20
|
+
children,
|
|
21
|
+
} as unknown as AnyNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('collectQuickActionNodeScope', () => {
|
|
25
|
+
test('includes nested children of a selected node sibling', () => {
|
|
26
|
+
const run = fixtureNode({
|
|
27
|
+
id: 'run',
|
|
28
|
+
children: ['left-base', 'selected-base'],
|
|
29
|
+
})
|
|
30
|
+
const leftBase = fixtureNode({
|
|
31
|
+
id: 'left-base',
|
|
32
|
+
parentId: run.id,
|
|
33
|
+
children: ['expanded-wall'],
|
|
34
|
+
})
|
|
35
|
+
const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id })
|
|
36
|
+
const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: leftBase.id })
|
|
37
|
+
const nodes = Object.fromEntries(
|
|
38
|
+
[run, leftBase, selectedBase, expandedWall].map((node) => [node.id, node]),
|
|
39
|
+
) as Record<AnyNodeId, AnyNode>
|
|
40
|
+
|
|
41
|
+
const collected = collectQuickActionNodeScope(nodes, selectedBase.id)
|
|
42
|
+
|
|
43
|
+
expect(collected?.[expandedWall.id as AnyNodeId]).toBe(expandedWall)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('includes other run subtrees when the provider declares level scope', () => {
|
|
47
|
+
const level = fixtureNode({
|
|
48
|
+
id: 'level',
|
|
49
|
+
type: 'level',
|
|
50
|
+
children: ['selected-run', 'other-run'],
|
|
51
|
+
})
|
|
52
|
+
const selectedRun = fixtureNode({
|
|
53
|
+
id: 'selected-run',
|
|
54
|
+
parentId: level.id,
|
|
55
|
+
children: ['selected-base'],
|
|
56
|
+
})
|
|
57
|
+
const selectedBase = fixtureNode({ id: 'selected-base', parentId: selectedRun.id })
|
|
58
|
+
const otherRun = fixtureNode({
|
|
59
|
+
id: 'other-run',
|
|
60
|
+
parentId: level.id,
|
|
61
|
+
children: ['other-base'],
|
|
62
|
+
})
|
|
63
|
+
const otherBase = fixtureNode({
|
|
64
|
+
id: 'other-base',
|
|
65
|
+
parentId: otherRun.id,
|
|
66
|
+
children: ['expanded-wall'],
|
|
67
|
+
})
|
|
68
|
+
const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: otherBase.id })
|
|
69
|
+
const nodes = Object.fromEntries(
|
|
70
|
+
[level, selectedRun, selectedBase, otherRun, otherBase, expandedWall].map((node) => [
|
|
71
|
+
node.id,
|
|
72
|
+
node,
|
|
73
|
+
]),
|
|
74
|
+
) as Record<AnyNodeId, AnyNode>
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
collectQuickActionNodeScope(nodes, selectedBase.id)?.[expandedWall.id as AnyNodeId],
|
|
78
|
+
).toBeUndefined()
|
|
79
|
+
expect(
|
|
80
|
+
collectQuickActionNodeScope(nodes, selectedBase.id, 'level')?.[expandedWall.id as AnyNodeId],
|
|
81
|
+
).toBe(expandedWall)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('fails closed when a level-scoped provider has no level ancestor', () => {
|
|
85
|
+
const run = fixtureNode({ id: 'run', children: ['selected-base'] })
|
|
86
|
+
const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id })
|
|
87
|
+
const nodes = Object.fromEntries([run, selectedBase].map((node) => [node.id, node])) as Record<
|
|
88
|
+
AnyNodeId,
|
|
89
|
+
AnyNode
|
|
90
|
+
>
|
|
91
|
+
|
|
92
|
+
expect(collectQuickActionNodeScope(nodes, selectedBase.id, 'level')).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId, NodeQuickActionNodeScope } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function collectQuickActionNodeScope(
|
|
4
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
5
|
+
selectedId: string,
|
|
6
|
+
scope: NodeQuickActionNodeScope = 'family',
|
|
7
|
+
): Record<AnyNodeId, AnyNode> | null {
|
|
8
|
+
const selected = nodes[selectedId as AnyNodeId]
|
|
9
|
+
if (!selected) return null
|
|
10
|
+
|
|
11
|
+
const collected: Record<AnyNodeId, AnyNode> = {}
|
|
12
|
+
const addSubtree = (rootId: string | null | undefined) => {
|
|
13
|
+
if (!rootId) return
|
|
14
|
+
const pending = [rootId]
|
|
15
|
+
|
|
16
|
+
while (pending.length > 0) {
|
|
17
|
+
const id = pending.pop()
|
|
18
|
+
if (!id || collected[id as AnyNodeId]) continue
|
|
19
|
+
const node = nodes[id as AnyNodeId]
|
|
20
|
+
if (!node) continue
|
|
21
|
+
|
|
22
|
+
collected[node.id as AnyNodeId] = node
|
|
23
|
+
for (const childId of (node as { children?: readonly string[] }).children ?? []) {
|
|
24
|
+
pending.push(childId)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (scope === 'level') {
|
|
30
|
+
const visited = new Set<AnyNodeId>()
|
|
31
|
+
let current: AnyNode | undefined = selected
|
|
32
|
+
while (current && !visited.has(current.id as AnyNodeId)) {
|
|
33
|
+
visited.add(current.id as AnyNodeId)
|
|
34
|
+
if (current.type === 'level') {
|
|
35
|
+
addSubtree(current.id)
|
|
36
|
+
return collected
|
|
37
|
+
}
|
|
38
|
+
current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
addSubtree(selected.id)
|
|
44
|
+
addSubtree(selected.parentId)
|
|
45
|
+
|
|
46
|
+
return collected
|
|
47
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeId,
|
|
@@ -8,14 +8,18 @@ import {
|
|
|
8
8
|
type CabinetNode as CabinetNodeType,
|
|
9
9
|
type LevelNode,
|
|
10
10
|
MeasurementNode,
|
|
11
|
+
SceneMaterial,
|
|
12
|
+
type SceneMaterialId,
|
|
11
13
|
useScene,
|
|
12
14
|
WallNode,
|
|
15
|
+
WindowNode,
|
|
13
16
|
} from '@pascal-app/core'
|
|
14
17
|
import { useViewer } from '@pascal-app/viewer'
|
|
15
18
|
import {
|
|
16
19
|
copySelectedNodesToEditorClipboard,
|
|
17
20
|
getEditorClipboardSnapshot,
|
|
18
21
|
pasteEditorClipboardToLevel,
|
|
22
|
+
pasteSystemEditorClipboardToLevel,
|
|
19
23
|
} from './scene-clipboard'
|
|
20
24
|
|
|
21
25
|
const sourceLevelId = 'level_clipboard-source' as LevelNode['id']
|
|
@@ -77,6 +81,7 @@ function seedCabinetRun() {
|
|
|
77
81
|
[leftModule.id]: leftModule as AnyNode,
|
|
78
82
|
[rightModule.id]: rightModule as AnyNode,
|
|
79
83
|
},
|
|
84
|
+
materials: {},
|
|
80
85
|
rootNodeIds: [sourceLevel.id, targetLevel.id],
|
|
81
86
|
} as never)
|
|
82
87
|
useViewer.getState().setSelection({
|
|
@@ -193,4 +198,128 @@ describe('scene clipboard', () => {
|
|
|
193
198
|
expect(Array.isArray(anchor)).toBe(false)
|
|
194
199
|
if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id)
|
|
195
200
|
})
|
|
201
|
+
|
|
202
|
+
test('detaches a standalone copied opening so its move tool can rehost it', () => {
|
|
203
|
+
const wall = WallNode.parse({
|
|
204
|
+
id: 'wall_clipboard-window-host',
|
|
205
|
+
parentId: sourceLevelId,
|
|
206
|
+
start: [0, 0],
|
|
207
|
+
end: [3, 0],
|
|
208
|
+
})
|
|
209
|
+
const window = WindowNode.parse({
|
|
210
|
+
id: 'window_clipboard-standalone',
|
|
211
|
+
parentId: wall.id,
|
|
212
|
+
wallId: wall.id,
|
|
213
|
+
position: [1.5, 1.2, 0],
|
|
214
|
+
})
|
|
215
|
+
useScene.setState((state) => ({
|
|
216
|
+
nodes: {
|
|
217
|
+
...state.nodes,
|
|
218
|
+
[sourceLevelId]: makeLevel(sourceLevelId, [wall.id]),
|
|
219
|
+
[wall.id]: { ...wall, children: [window.id] },
|
|
220
|
+
[window.id]: window,
|
|
221
|
+
},
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
expect(copySelectedNodesToEditorClipboard([window.id])).toBe(true)
|
|
225
|
+
const result = pasteEditorClipboardToLevel(targetLevelId)
|
|
226
|
+
expect(result?.pastedIds).toHaveLength(1)
|
|
227
|
+
|
|
228
|
+
const pastedWindow = result?.pastedIds[0]
|
|
229
|
+
? useScene.getState().nodes[result.pastedIds[0]]
|
|
230
|
+
: undefined
|
|
231
|
+
expect(pastedWindow?.type).toBe('window')
|
|
232
|
+
if (pastedWindow?.type === 'window') {
|
|
233
|
+
expect(pastedWindow.parentId).toBe(targetLevelId)
|
|
234
|
+
expect(pastedWindow.wallId).toBeUndefined()
|
|
235
|
+
expect(pastedWindow.roofSegmentId).toBeUndefined()
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('round-trips nodes and custom scene materials through the browser clipboard', async () => {
|
|
240
|
+
let systemClipboardText = ''
|
|
241
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
242
|
+
configurable: true,
|
|
243
|
+
value: {
|
|
244
|
+
readText: async () => systemClipboardText,
|
|
245
|
+
writeText: async (text: string) => {
|
|
246
|
+
systemClipboardText = text
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const materialId = 'mat_clipboard-blue' as SceneMaterialId
|
|
252
|
+
const material = SceneMaterial.parse({
|
|
253
|
+
id: materialId,
|
|
254
|
+
name: 'Clipboard blue',
|
|
255
|
+
material: { properties: { color: '#2266dd' } },
|
|
256
|
+
})
|
|
257
|
+
const wall = WallNode.parse({
|
|
258
|
+
id: 'wall_clipboard-material',
|
|
259
|
+
parentId: sourceLevelId,
|
|
260
|
+
start: [0, 0],
|
|
261
|
+
end: [3, 0],
|
|
262
|
+
slots: { exterior: `scene:${materialId}` },
|
|
263
|
+
})
|
|
264
|
+
useScene.setState((state) => ({
|
|
265
|
+
materials: { [materialId]: material },
|
|
266
|
+
nodes: {
|
|
267
|
+
...state.nodes,
|
|
268
|
+
[sourceLevelId]: makeLevel(sourceLevelId, [wall.id]),
|
|
269
|
+
[wall.id]: wall,
|
|
270
|
+
},
|
|
271
|
+
}))
|
|
272
|
+
|
|
273
|
+
expect(copySelectedNodesToEditorClipboard([wall.id])).toBe(true)
|
|
274
|
+
expect(systemClipboardText).toContain('pascal.scene-nodes')
|
|
275
|
+
|
|
276
|
+
useScene.setState({
|
|
277
|
+
materials: {},
|
|
278
|
+
nodes: {
|
|
279
|
+
[targetLevelId]: makeLevel(targetLevelId),
|
|
280
|
+
},
|
|
281
|
+
rootNodeIds: [targetLevelId],
|
|
282
|
+
} as never)
|
|
283
|
+
useViewer.getState().setSelection({ levelId: targetLevelId, selectedIds: [] })
|
|
284
|
+
|
|
285
|
+
const result = await pasteSystemEditorClipboardToLevel()
|
|
286
|
+
expect(result?.pastedIds).toHaveLength(1)
|
|
287
|
+
expect(result?.createdMaterialIds).toEqual([materialId])
|
|
288
|
+
expect(useScene.getState().materials[materialId]).toEqual(material)
|
|
289
|
+
|
|
290
|
+
const pastedWall = Object.values(useScene.getState().nodes).find((node) => node.type === 'wall')
|
|
291
|
+
expect(pastedWall?.type).toBe('wall')
|
|
292
|
+
if (pastedWall?.type === 'wall') {
|
|
293
|
+
expect(pastedWall.slots?.exterior).toBe(`scene:${materialId}`)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('waits for an in-flight copy before reading the browser clipboard', async () => {
|
|
298
|
+
let systemClipboardText = 'older clipboard contents'
|
|
299
|
+
let finishWrite!: () => void
|
|
300
|
+
const readText = mock(async () => systemClipboardText)
|
|
301
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
302
|
+
configurable: true,
|
|
303
|
+
value: {
|
|
304
|
+
readText,
|
|
305
|
+
writeText: (text: string) =>
|
|
306
|
+
new Promise<void>((resolve) => {
|
|
307
|
+
finishWrite = () => {
|
|
308
|
+
systemClipboardText = text
|
|
309
|
+
resolve()
|
|
310
|
+
}
|
|
311
|
+
}),
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
expect(copySelectedNodesToEditorClipboard([runId])).toBe(true)
|
|
316
|
+
const paste = pasteSystemEditorClipboardToLevel(targetLevelId)
|
|
317
|
+
await Promise.resolve()
|
|
318
|
+
expect(readText).not.toHaveBeenCalled()
|
|
319
|
+
|
|
320
|
+
finishWrite()
|
|
321
|
+
const result = await paste
|
|
322
|
+
expect(readText).toHaveBeenCalledTimes(1)
|
|
323
|
+
expect(result?.pastedIds).toHaveLength(1)
|
|
324
|
+
})
|
|
196
325
|
})
|