@pascal-app/editor 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import * as core from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
ItemNode,
|
|
5
|
+
nodeRegistry,
|
|
6
|
+
type ShelfEvent,
|
|
7
|
+
ShelfNode,
|
|
8
|
+
sceneRegistry,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { Euler, Group, Quaternion, Vector3 } from 'three'
|
|
12
|
+
import useEditor from '../../../store/use-editor'
|
|
13
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
14
|
+
import { registerHostingTestNode } from '../__fixtures__/hosting'
|
|
15
|
+
import { shelfSurfaceStrategy } from './placement-strategies'
|
|
16
|
+
import type { PlacementContext } from './placement-types'
|
|
17
|
+
|
|
18
|
+
const host = ShelfNode.parse({ id: 'shelf_resolver-host', width: 2, depth: 1, height: 2 })
|
|
19
|
+
const child = ItemNode.parse({
|
|
20
|
+
id: 'item_shelf-resolver-child',
|
|
21
|
+
asset: {
|
|
22
|
+
id: 'book',
|
|
23
|
+
name: 'Book',
|
|
24
|
+
category: 'decor',
|
|
25
|
+
thumbnail: '',
|
|
26
|
+
src: '/book.glb',
|
|
27
|
+
dimensions: [0.5, 0.25, 0.25],
|
|
28
|
+
},
|
|
29
|
+
rotation: [0.1, 0.2, 0.3],
|
|
30
|
+
})
|
|
31
|
+
let rows: number[]
|
|
32
|
+
let restoreRegistry: () => void
|
|
33
|
+
let savedNodes: ReturnType<typeof useScene.getState>['nodes']
|
|
34
|
+
let savedEditor: ReturnType<typeof useEditor.getState>
|
|
35
|
+
let savedScope: ReturnType<typeof useInteractionScope.getState>
|
|
36
|
+
let resolver: ReturnType<typeof spyOn<typeof core, 'resolveSurfacePlacement'>>
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
restoreRegistry = nodeRegistry._snapshot()
|
|
40
|
+
nodeRegistry._reset()
|
|
41
|
+
rows = [0.5, 1.5]
|
|
42
|
+
registerHostingTestNode({
|
|
43
|
+
kind: 'shelf',
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
schema: ShelfNode,
|
|
46
|
+
category: 'furnish',
|
|
47
|
+
defaults: () => ({}),
|
|
48
|
+
capabilities: {
|
|
49
|
+
surfaces: {
|
|
50
|
+
custom: () => rows.map((y) => ({ position: [0, y, 0], normal: [0, 1, 0] })),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
registerHostingTestNode({
|
|
55
|
+
kind: 'item',
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
schema: ItemNode,
|
|
58
|
+
category: 'furnish',
|
|
59
|
+
snapProfile: 'item',
|
|
60
|
+
defaults: () => ({}),
|
|
61
|
+
capabilities: {},
|
|
62
|
+
})
|
|
63
|
+
savedNodes = useScene.getState().nodes
|
|
64
|
+
savedEditor = useEditor.getState()
|
|
65
|
+
savedScope = useInteractionScope.getState()
|
|
66
|
+
useScene.setState({ nodes: { [host.id]: host, [child.id]: child } })
|
|
67
|
+
useInteractionScope.setState({ scope: { kind: 'idle' } })
|
|
68
|
+
useEditor.setState({ mode: 'build', tool: 'item', gridSnapStep: 0.5 })
|
|
69
|
+
useEditor.getState().setSnappingMode('item', 'grid')
|
|
70
|
+
const mesh = new Group()
|
|
71
|
+
mesh.updateMatrixWorld(true)
|
|
72
|
+
sceneRegistry.nodes.set(host.id, mesh)
|
|
73
|
+
resolver = spyOn(core, 'resolveSurfacePlacement')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
resolver.mockRestore()
|
|
78
|
+
restoreRegistry()
|
|
79
|
+
sceneRegistry.nodes.delete(host.id)
|
|
80
|
+
useScene.setState({ nodes: savedNodes })
|
|
81
|
+
useEditor.setState(savedEditor)
|
|
82
|
+
useInteractionScope.setState(savedScope)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function context(draftItem = child): PlacementContext {
|
|
86
|
+
return {
|
|
87
|
+
asset: draftItem.asset,
|
|
88
|
+
draftItem,
|
|
89
|
+
state: {
|
|
90
|
+
surface: 'floor',
|
|
91
|
+
wallId: null,
|
|
92
|
+
roofSegmentId: null,
|
|
93
|
+
ceilingId: null,
|
|
94
|
+
surfaceItemId: null,
|
|
95
|
+
shelfId: null,
|
|
96
|
+
},
|
|
97
|
+
currentCursorRotationY: 0.7,
|
|
98
|
+
gridPosition: new Vector3(),
|
|
99
|
+
levelId: 'level_test',
|
|
100
|
+
} as PlacementContext
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function onShelf(ctx: PlacementContext): PlacementContext {
|
|
104
|
+
return { ...ctx, state: { ...ctx.state, surface: 'shelf-surface', shelfId: host.id } }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hit(y = 1, z = -0.1): ShelfEvent {
|
|
108
|
+
const object = sceneRegistry.nodes.get(host.id)!
|
|
109
|
+
return {
|
|
110
|
+
node: host,
|
|
111
|
+
object,
|
|
112
|
+
normal: [0, 1, 0],
|
|
113
|
+
position: object.localToWorld(new Vector3(0.37, y, z)).toArray(),
|
|
114
|
+
localPosition: [0.37, y, z],
|
|
115
|
+
nativeEvent: {},
|
|
116
|
+
stopPropagation() {},
|
|
117
|
+
} as ShelfEvent
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test.each([
|
|
121
|
+
'grid',
|
|
122
|
+
'off',
|
|
123
|
+
] as const)('midpoint keeps the first declared row and preserves X/Z with grid %s', (mode) => {
|
|
124
|
+
useEditor.getState().setSnappingMode('item', mode)
|
|
125
|
+
for (const declared of [
|
|
126
|
+
[0.5, 1.5],
|
|
127
|
+
[1.5, 0.5],
|
|
128
|
+
]) {
|
|
129
|
+
rows = declared
|
|
130
|
+
const expected: [number, number, number] =
|
|
131
|
+
mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1]
|
|
132
|
+
const ctx = context()
|
|
133
|
+
const entered = shelfSurfaceStrategy.enter(ctx, hit())!
|
|
134
|
+
expect(entered.nodeUpdate).toEqual({
|
|
135
|
+
position: expected,
|
|
136
|
+
parentId: host.id,
|
|
137
|
+
rotation: [0.1, 0.7, 0.3],
|
|
138
|
+
})
|
|
139
|
+
expect(entered.gridPosition).toEqual(expected)
|
|
140
|
+
expect(entered.cursorPosition).toEqual(expected)
|
|
141
|
+
const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())!
|
|
142
|
+
expect(moved.nodeUpdate).toEqual({ position: expected })
|
|
143
|
+
expect(moved.cursorPosition).toEqual(expected)
|
|
144
|
+
const committed = shelfSurfaceStrategy.click(
|
|
145
|
+
{ ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) },
|
|
146
|
+
hit(),
|
|
147
|
+
)!
|
|
148
|
+
expect(committed.nodeUpdate.position).toEqual(expected)
|
|
149
|
+
expect(committed.nodeUpdate.parentId).toBe(host.id)
|
|
150
|
+
}
|
|
151
|
+
expect(resolver).toHaveBeenCalledTimes(6)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test.each([
|
|
155
|
+
[0.999, 0.5],
|
|
156
|
+
[1.001, 1.5],
|
|
157
|
+
])('hit Y %s elects nearest row %s on enter and move', (y, expected) => {
|
|
158
|
+
expect(shelfSurfaceStrategy.enter(context(), hit(y))!.gridPosition[1]).toBe(expected)
|
|
159
|
+
expect(shelfSurfaceStrategy.move(onShelf(context()), hit(y))!.gridPosition[1]).toBe(expected)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test.each([
|
|
163
|
+
[2.01, 0.25],
|
|
164
|
+
[0.5, 1.01],
|
|
165
|
+
])('scaled footprint %s by %s overhang is accepted on enter and move', (width, depth) => {
|
|
166
|
+
const draft = ItemNode.parse({
|
|
167
|
+
...child,
|
|
168
|
+
asset: { ...child.asset, dimensions: [width / 2, 0.25, depth / 2] },
|
|
169
|
+
scale: [2, 1, 2],
|
|
170
|
+
})
|
|
171
|
+
const ctx = context(draft)
|
|
172
|
+
expect(shelfSurfaceStrategy.enter(ctx, hit())).not.toBeNull()
|
|
173
|
+
expect(shelfSurfaceStrategy.move(onShelf(ctx), hit())).not.toBeNull()
|
|
174
|
+
expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('a full-board object is refused when grid snapping puts its centre outside the board', () => {
|
|
178
|
+
const draft = ItemNode.parse({
|
|
179
|
+
...child,
|
|
180
|
+
asset: { ...child.asset, dimensions: [host.width, 0.25, host.depth] },
|
|
181
|
+
})
|
|
182
|
+
expect(shelfSurfaceStrategy.enter(context(draft), hit(1, -0.39))).toBeNull()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test.each([
|
|
186
|
+
'grid',
|
|
187
|
+
'off',
|
|
188
|
+
] as const)('shelf frame conversion retains exact poses with grid %s', (mode) => {
|
|
189
|
+
useEditor.getState().setSnappingMode('item', mode)
|
|
190
|
+
const mesh = sceneRegistry.nodes.get(host.id)!
|
|
191
|
+
mesh.position.set(4, 2, -3)
|
|
192
|
+
mesh.rotation.set(0, Math.PI / 3, 0)
|
|
193
|
+
mesh.updateMatrixWorld(true)
|
|
194
|
+
const event = hit(1.2)
|
|
195
|
+
const local = mesh.worldToLocal(new Vector3(...event.position))
|
|
196
|
+
const expected: [number, number, number] =
|
|
197
|
+
mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z]
|
|
198
|
+
const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
|
|
199
|
+
const entered = shelfSurfaceStrategy.enter(context(), event)!
|
|
200
|
+
expect(entered.nodeUpdate).toEqual({
|
|
201
|
+
position: expected,
|
|
202
|
+
parentId: host.id,
|
|
203
|
+
rotation: [0.1, 0.7 - yaw, 0.3],
|
|
204
|
+
})
|
|
205
|
+
expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray())
|
|
206
|
+
expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('only enter requires an upward normal; moves keep electing rows', () => {
|
|
210
|
+
for (const normal of [[1, 0, 0], [0, -1, 0], undefined] as ShelfEvent['normal'][]) {
|
|
211
|
+
const event = { ...hit(), normal }
|
|
212
|
+
expect(shelfSurfaceStrategy.enter(context(), event)).toBeNull()
|
|
213
|
+
expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition[1]).toBe(0.5)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('missing rows and resolver rejection leave the draft untouched', () => {
|
|
218
|
+
const before = useScene.getState().nodes
|
|
219
|
+
rows = []
|
|
220
|
+
expect(shelfSurfaceStrategy.enter(context(), hit())).toBeNull()
|
|
221
|
+
expect(shelfSurfaceStrategy.move(onShelf(context()), hit())).toBeNull()
|
|
222
|
+
rows = [0.5, 1.5]
|
|
223
|
+
resolver.mockReturnValue(null)
|
|
224
|
+
expect(shelfSurfaceStrategy.enter(context(), hit())).toBeNull()
|
|
225
|
+
expect(shelfSurfaceStrategy.move(onShelf(context()), hit())).toBeNull()
|
|
226
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('a centred unrotated object exactly matching the board is accepted with epsilon tolerance', () => {
|
|
230
|
+
useEditor.getState().setSnappingMode('item', 'off')
|
|
231
|
+
const region = core.shelfSurfaceProvider.surfaces!(host, {
|
|
232
|
+
scene: core.createSceneApi(useScene),
|
|
233
|
+
})[0]!.region
|
|
234
|
+
const draft = ItemNode.parse({
|
|
235
|
+
...child,
|
|
236
|
+
rotation: [0, 0, 0],
|
|
237
|
+
asset: { ...child.asset, dimensions: [region.size![0] * 2, 0.25, region.size![1] * 2] },
|
|
238
|
+
})
|
|
239
|
+
const ctx = { ...context(draft), currentCursorRotationY: 0 }
|
|
240
|
+
for (const x of [0, 1e-7]) {
|
|
241
|
+
const event = { ...hit(), position: [x, 0.5, 0], localPosition: [x, 0.5, 0] } as ShelfEvent
|
|
242
|
+
expect(shelfSurfaceStrategy.enter(ctx, event)).not.toBeNull()
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('shelf validation uses the current shelf after leaving a different surface host', () => {
|
|
247
|
+
const other = ItemNode.parse({
|
|
248
|
+
...child,
|
|
249
|
+
id: 'item_previous-host',
|
|
250
|
+
asset: { ...child.asset, dimensions: [0.01, 0.1, 0.01] },
|
|
251
|
+
})
|
|
252
|
+
useScene.setState({ nodes: { ...useScene.getState().nodes, [other.id]: other } })
|
|
253
|
+
const ctx = onShelf(context(ItemNode.parse({ ...child, rotation: [0, 0, 0] })))
|
|
254
|
+
ctx.state.surfaceItemId = other.id
|
|
255
|
+
ctx.gridPosition.set(0, 0.5, 0)
|
|
256
|
+
expect(shelfSurfaceStrategy.click(ctx, hit())).not.toBeNull()
|
|
257
|
+
})
|
|
@@ -3,16 +3,29 @@ import {
|
|
|
3
3
|
type AssetInput,
|
|
4
4
|
ItemNode,
|
|
5
5
|
resolveSupportSlabPatch,
|
|
6
|
+
type SurfaceRejectReason,
|
|
6
7
|
sceneRegistry,
|
|
7
8
|
useScene,
|
|
8
9
|
} from '@pascal-app/core'
|
|
9
10
|
import { beginPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer'
|
|
10
11
|
import { useCallback, useMemo, useRef } from 'react'
|
|
11
12
|
import type { Vector3 } from 'three'
|
|
13
|
+
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
|
|
14
|
+
import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
|
|
15
|
+
import {
|
|
16
|
+
surfaceAttachmentId,
|
|
17
|
+
surfaceAttachmentUpdates,
|
|
18
|
+
surfaceFramePose,
|
|
19
|
+
updateSurfaceNode,
|
|
20
|
+
} from '../../../lib/surface-attachment'
|
|
21
|
+
import useInteractionScope, {
|
|
22
|
+
isInteractionSubtreeDraft,
|
|
23
|
+
} from '../../../store/use-interaction-scope'
|
|
12
24
|
import usePlacementPreview from '../../../store/use-placement-preview'
|
|
13
25
|
import { stripTransient } from './placement-math'
|
|
14
26
|
|
|
15
27
|
interface OriginalState {
|
|
28
|
+
surfaceId: string | null
|
|
16
29
|
position: [number, number, number]
|
|
17
30
|
rotation: [number, number, number]
|
|
18
31
|
side: ItemNode['side']
|
|
@@ -26,6 +39,7 @@ interface OriginalState {
|
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export interface DraftNodeHandle {
|
|
42
|
+
updateSurface: (data: Partial<ItemNode>, surfaceId: string | null) => void
|
|
29
43
|
/** Current draft item, or null */
|
|
30
44
|
readonly current: ItemNode | null
|
|
31
45
|
/** Whether the current draft was adopted (move mode) vs created (create mode) */
|
|
@@ -50,6 +64,7 @@ export interface DraftNodeHandle {
|
|
|
50
64
|
options?: {
|
|
51
65
|
supportElevationCap?: number | null
|
|
52
66
|
preferredSupportSlabId?: string | null
|
|
67
|
+
onReject?: (reason: SurfaceRejectReason) => void
|
|
53
68
|
pinSupport?: boolean
|
|
54
69
|
},
|
|
55
70
|
) => string | null
|
|
@@ -68,6 +83,7 @@ export interface DraftNodeHandle {
|
|
|
68
83
|
export function useDraftNode(): DraftNodeHandle {
|
|
69
84
|
const draftRef = useRef<ItemNode | null>(null)
|
|
70
85
|
const adoptedRef = useRef(false)
|
|
86
|
+
const ownsSubtreeRef = useRef(false)
|
|
71
87
|
const originalStateRef = useRef<OriginalState | null>(null)
|
|
72
88
|
|
|
73
89
|
const create = useCallback(
|
|
@@ -98,6 +114,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
98
114
|
.set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
|
|
99
115
|
draftRef.current = node
|
|
100
116
|
adoptedRef.current = false
|
|
117
|
+
ownsSubtreeRef.current = false
|
|
101
118
|
originalStateRef.current = null
|
|
102
119
|
return node
|
|
103
120
|
},
|
|
@@ -105,6 +122,8 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
105
122
|
)
|
|
106
123
|
|
|
107
124
|
const adopt = useCallback((node: ItemNode): void => {
|
|
125
|
+
ownsSubtreeRef.current =
|
|
126
|
+
useInteractionScope.getState().adoptSubtree(node.id) || isInteractionSubtreeDraft(node.id)
|
|
108
127
|
// Save original state so destroy() can restore it
|
|
109
128
|
const meta =
|
|
110
129
|
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
|
@@ -112,6 +131,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
112
131
|
: {}
|
|
113
132
|
|
|
114
133
|
originalStateRef.current = {
|
|
134
|
+
surfaceId: surfaceAttachmentId(node),
|
|
115
135
|
position: [...node.position] as [number, number, number],
|
|
116
136
|
rotation: [...node.rotation] as [number, number, number],
|
|
117
137
|
side: node.side,
|
|
@@ -122,7 +142,10 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
122
142
|
metadata: node.metadata,
|
|
123
143
|
}
|
|
124
144
|
|
|
125
|
-
draftRef.current =
|
|
145
|
+
draftRef.current = {
|
|
146
|
+
...node,
|
|
147
|
+
...surfaceFramePose(node.parentId, surfaceAttachmentId(node), node, false),
|
|
148
|
+
}
|
|
126
149
|
adoptedRef.current = true
|
|
127
150
|
|
|
128
151
|
// Mark as transient so it renders as a draft
|
|
@@ -143,12 +166,44 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
143
166
|
options?: {
|
|
144
167
|
supportElevationCap?: number | null
|
|
145
168
|
preferredSupportSlabId?: string | null
|
|
169
|
+
onReject?: (reason: SurfaceRejectReason) => void
|
|
146
170
|
pinSupport?: boolean
|
|
147
171
|
},
|
|
148
172
|
): string | null => {
|
|
149
173
|
const draft = draftRef.current
|
|
150
174
|
if (!draft) return null
|
|
151
175
|
|
|
176
|
+
const surfaceId = surfaceAttachmentId(useScene.getState().nodes[draft.id] ?? draft)
|
|
177
|
+
const stored = surfaceFramePose(
|
|
178
|
+
finalUpdate.parentId ?? draft.parentId,
|
|
179
|
+
surfaceId,
|
|
180
|
+
{ ...draft, ...finalUpdate },
|
|
181
|
+
true,
|
|
182
|
+
)
|
|
183
|
+
finalUpdate = { ...finalUpdate, ...stored }
|
|
184
|
+
if (isFreshPlacementMetadata(originalStateRef.current?.metadata)) {
|
|
185
|
+
const effectiveNode = ItemNode.parse({ ...draft, ...finalUpdate })
|
|
186
|
+
const id = commitFreshPlacementSubtree(
|
|
187
|
+
draft.id,
|
|
188
|
+
{
|
|
189
|
+
...finalUpdate,
|
|
190
|
+
...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
|
|
191
|
+
maxElevation: options?.supportElevationCap,
|
|
192
|
+
preferredSlabId: options?.preferredSupportSlabId,
|
|
193
|
+
pinSupport: options?.pinSupport,
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
options?.onReject,
|
|
197
|
+
)
|
|
198
|
+
if (!id) return null
|
|
199
|
+
if (usePlacementPreview.getState().node?.id === draft.id) {
|
|
200
|
+
usePlacementPreview.getState().clear()
|
|
201
|
+
}
|
|
202
|
+
draftRef.current = null
|
|
203
|
+
adoptedRef.current = false
|
|
204
|
+
originalStateRef.current = null
|
|
205
|
+
return id
|
|
206
|
+
}
|
|
152
207
|
if (adoptedRef.current) {
|
|
153
208
|
// Move mode: update in place (single undoable action)
|
|
154
209
|
const { parentId: newParentId, ...updateProps } = finalUpdate
|
|
@@ -159,16 +214,20 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
159
214
|
const original = originalStateRef.current!
|
|
160
215
|
|
|
161
216
|
// Restore original state while paused — so the undo baseline is clean
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
217
|
+
updateSurfaceNode(
|
|
218
|
+
draft.id,
|
|
219
|
+
{
|
|
220
|
+
position: original.position,
|
|
221
|
+
rotation: original.rotation,
|
|
222
|
+
side: original.side,
|
|
223
|
+
parentId: original.parentId,
|
|
224
|
+
roofSegmentId: original.roofSegmentId,
|
|
225
|
+
roofFace: original.roofFace,
|
|
226
|
+
blockFaceId: original.blockFaceId,
|
|
227
|
+
metadata: original.metadata,
|
|
228
|
+
},
|
|
229
|
+
original.surfaceId,
|
|
230
|
+
)
|
|
172
231
|
|
|
173
232
|
// Resume → tracked update (undo reverts to original)
|
|
174
233
|
useScene.temporal.getState().resume()
|
|
@@ -180,28 +239,32 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
180
239
|
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
181
240
|
})
|
|
182
241
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
updateSurfaceNode(
|
|
243
|
+
draft.id,
|
|
244
|
+
{
|
|
245
|
+
position: updateProps.position ?? draft.position,
|
|
246
|
+
rotation: updateProps.rotation ?? draft.rotation,
|
|
247
|
+
side: updateProps.side ?? draft.side,
|
|
248
|
+
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
249
|
+
parentId: parentId as string,
|
|
250
|
+
// Forward the roof host explicitly: strategies set it on every
|
|
251
|
+
// commit (segment id on a roof face, undefined elsewhere), and
|
|
252
|
+
// dropping it here strands the item in the roof frame without
|
|
253
|
+
// the segment transform.
|
|
254
|
+
roofSegmentId: updateProps.roofSegmentId,
|
|
255
|
+
roofFace: updateProps.roofFace,
|
|
256
|
+
blockFaceId: updateProps.blockFaceId,
|
|
257
|
+
// Only when the strategy decided about wallId (roof commits clear
|
|
258
|
+
// it) — floor/ceiling commits never managed the field.
|
|
259
|
+
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
|
|
260
|
+
...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
|
|
261
|
+
maxElevation: options?.supportElevationCap,
|
|
262
|
+
preferredSlabId: options?.preferredSupportSlabId,
|
|
263
|
+
pinSupport: options?.pinSupport,
|
|
264
|
+
}),
|
|
265
|
+
},
|
|
266
|
+
surfaceId,
|
|
267
|
+
)
|
|
205
268
|
|
|
206
269
|
useScene.temporal.getState().pause()
|
|
207
270
|
|
|
@@ -222,6 +285,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
222
285
|
|
|
223
286
|
beginPerfAction('place:item', draft.id)
|
|
224
287
|
// Delete draft while paused (invisible to undo)
|
|
288
|
+
updateSurfaceNode(draft.id, {}, null)
|
|
225
289
|
useScene.getState().deleteNode(draft.id)
|
|
226
290
|
draftRef.current = null
|
|
227
291
|
|
|
@@ -259,7 +323,10 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
259
323
|
},
|
|
260
324
|
),
|
|
261
325
|
})
|
|
262
|
-
useScene.getState().
|
|
326
|
+
useScene.getState().applyNodeChanges({
|
|
327
|
+
create: [{ node: committedNode, parentId }],
|
|
328
|
+
update: surfaceAttachmentUpdates(committedNode.id, parentId, surfaceId),
|
|
329
|
+
})
|
|
263
330
|
if (usePlacementPreview.getState().node?.id === draft.id) {
|
|
264
331
|
usePlacementPreview.getState().clear()
|
|
265
332
|
}
|
|
@@ -279,8 +346,12 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
279
346
|
if (!draftRef.current) return
|
|
280
347
|
|
|
281
348
|
const draftId = draftRef.current.id
|
|
282
|
-
|
|
283
|
-
|
|
349
|
+
if (ownsSubtreeRef.current) {
|
|
350
|
+
draftRef.current = null
|
|
351
|
+
adoptedRef.current = false
|
|
352
|
+
originalStateRef.current = null
|
|
353
|
+
return
|
|
354
|
+
} else if (adoptedRef.current && originalStateRef.current) {
|
|
284
355
|
// Move mode: restore original state instead of deleting — but only
|
|
285
356
|
// if no other system has already committed a new position for this
|
|
286
357
|
// node. The 2D `FloorplanRegistryMoveOverlay` commits via
|
|
@@ -293,6 +364,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
293
364
|
const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
|
|
294
365
|
const livePosition = live?.position
|
|
295
366
|
const externallyMoved =
|
|
367
|
+
!live?.metadata?.isTransient &&
|
|
296
368
|
!!livePosition &&
|
|
297
369
|
(livePosition[0] !== original.position[0] ||
|
|
298
370
|
livePosition[1] !== original.position[1] ||
|
|
@@ -307,16 +379,20 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
307
379
|
return
|
|
308
380
|
}
|
|
309
381
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
382
|
+
updateSurfaceNode(
|
|
383
|
+
id,
|
|
384
|
+
{
|
|
385
|
+
position: original.position,
|
|
386
|
+
rotation: original.rotation,
|
|
387
|
+
side: original.side,
|
|
388
|
+
parentId: original.parentId,
|
|
389
|
+
roofSegmentId: original.roofSegmentId,
|
|
390
|
+
roofFace: original.roofFace,
|
|
391
|
+
blockFaceId: original.blockFaceId,
|
|
392
|
+
metadata: original.metadata,
|
|
393
|
+
},
|
|
394
|
+
original.surfaceId,
|
|
395
|
+
)
|
|
320
396
|
|
|
321
397
|
// Also reset the Three.js mesh directly — the store update triggers a React
|
|
322
398
|
// re-render but the mesh position was mutated by useFrame and may not reset
|
|
@@ -329,6 +405,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
329
405
|
}
|
|
330
406
|
} else {
|
|
331
407
|
// Create mode: delete the transient node
|
|
408
|
+
updateSurfaceNode(draftRef.current.id, {}, null)
|
|
332
409
|
useScene.getState().deleteNode(draftRef.current.id)
|
|
333
410
|
}
|
|
334
411
|
|
|
@@ -340,6 +417,17 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
340
417
|
}
|
|
341
418
|
}, [])
|
|
342
419
|
|
|
420
|
+
const updateSurface = useCallback((data: Partial<ItemNode>, surfaceId: string | null) => {
|
|
421
|
+
const draft = draftRef.current
|
|
422
|
+
if (!draft) return
|
|
423
|
+
const pose = { ...draft, ...data }
|
|
424
|
+
updateSurfaceNode(
|
|
425
|
+
draft.id,
|
|
426
|
+
{ ...data, ...surfaceFramePose(pose.parentId, surfaceId, pose, true) },
|
|
427
|
+
surfaceId,
|
|
428
|
+
)
|
|
429
|
+
}, [])
|
|
430
|
+
|
|
343
431
|
return useMemo(
|
|
344
432
|
() => ({
|
|
345
433
|
get current() {
|
|
@@ -348,11 +436,12 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
348
436
|
get isAdopted() {
|
|
349
437
|
return adoptedRef.current
|
|
350
438
|
},
|
|
439
|
+
updateSurface,
|
|
351
440
|
create,
|
|
352
441
|
adopt,
|
|
353
442
|
commit,
|
|
354
443
|
destroy,
|
|
355
444
|
}),
|
|
356
|
-
[create, adopt, commit, destroy],
|
|
445
|
+
[create, adopt, commit, destroy, updateSurface],
|
|
357
446
|
)
|
|
358
447
|
}
|