@pascal-app/editor 1.0.1 → 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 +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- 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/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- 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-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 +67 -21
- 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 +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- 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 +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- 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/slider-control.tsx +76 -31
- 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/parametric-inspector.tsx +3 -15
- 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 +585 -369
- 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.tsx +1 -1
- 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-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 +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- 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 +42 -29
- package/src/index.tsx +63 -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/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -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/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/terrain-panel-model.ts +117 -0
- 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-interaction-scope.ts +155 -6
|
@@ -227,12 +227,12 @@ export function KeyboardShortcutsDialog() {
|
|
|
227
227
|
<DialogTrigger asChild>
|
|
228
228
|
<Button className="w-full justify-start gap-2" variant="outline">
|
|
229
229
|
<Keyboard className="size-4" />
|
|
230
|
-
Keyboard
|
|
230
|
+
Keyboard shortcuts
|
|
231
231
|
</Button>
|
|
232
232
|
</DialogTrigger>
|
|
233
233
|
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl">
|
|
234
234
|
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
|
235
|
-
<DialogTitle>Keyboard
|
|
235
|
+
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
|
236
236
|
<DialogDescription>
|
|
237
237
|
Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
|
|
238
238
|
during an active gesture to build freely.
|
|
@@ -118,7 +118,7 @@ export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) {
|
|
|
118
118
|
Export 3D print STL
|
|
119
119
|
</Button>
|
|
120
120
|
{error && (
|
|
121
|
-
<div className="flex gap-2 text-destructive text-xs">
|
|
121
|
+
<div className="col-span-full flex gap-2 text-destructive text-xs">
|
|
122
122
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
|
123
123
|
<span>{error}</span>
|
|
124
124
|
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
3
|
+
import { isVisibleInFloorplan } from './floorplan-preview-visibility'
|
|
4
|
+
|
|
5
|
+
const node = (id: string, type: string, parentId: string | null, visible = true) =>
|
|
6
|
+
({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode
|
|
7
|
+
|
|
8
|
+
describe('isVisibleInFloorplan', () => {
|
|
9
|
+
test('a hidden Site keeps the buildings on it in the plan', () => {
|
|
10
|
+
const nodes: Record<string, AnyNode> = {
|
|
11
|
+
site_a: node('site_a', 'site', null, false),
|
|
12
|
+
building_a: node('building_a', 'building', 'site_a'),
|
|
13
|
+
level_a: node('level_a', 'level', 'building_a'),
|
|
14
|
+
wall_a: node('wall_a', 'wall', 'level_a'),
|
|
15
|
+
wall_b: node('wall_b', 'wall', 'level_a', false),
|
|
16
|
+
}
|
|
17
|
+
expect(isVisibleInFloorplan(nodes.wall_a!, nodes)).toBe(true)
|
|
18
|
+
expect(isVisibleInFloorplan(nodes.wall_b!, nodes)).toBe(false)
|
|
19
|
+
expect(isVisibleInFloorplan(nodes.site_a!, nodes)).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('a hidden building or level still hides its subtree', () => {
|
|
23
|
+
const nodes: Record<string, AnyNode> = {
|
|
24
|
+
site_a: node('site_a', 'site', null),
|
|
25
|
+
building_a: node('building_a', 'building', 'site_a', false),
|
|
26
|
+
level_a: node('level_a', 'level', 'building_a'),
|
|
27
|
+
wall_a: node('wall_a', 'wall', 'level_a'),
|
|
28
|
+
building_b: node('building_b', 'building', 'site_a'),
|
|
29
|
+
level_b: node('level_b', 'level', 'building_b', false),
|
|
30
|
+
wall_b: node('wall_b', 'wall', 'level_b'),
|
|
31
|
+
}
|
|
32
|
+
expect(isVisibleInFloorplan(nodes.wall_a!, nodes)).toBe(false)
|
|
33
|
+
expect(isVisibleInFloorplan(nodes.wall_b!, nodes)).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AnyNode, hidesDescendants } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A node draws in the plan unless it, or an ancestor whose flag reaches its
|
|
5
|
+
* descendants, is hidden. A hidden Site keeps its buildings on the plan; see
|
|
6
|
+
* `hidesDescendants`.
|
|
7
|
+
*/
|
|
8
|
+
export function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
|
|
9
|
+
if (node.visible === false) return false
|
|
10
|
+
const seen = new Set<string>([node.id])
|
|
11
|
+
let current: AnyNode | undefined = node.parentId ? nodes[node.parentId] : undefined
|
|
12
|
+
while (current) {
|
|
13
|
+
if (seen.has(current.id)) return true
|
|
14
|
+
seen.add(current.id)
|
|
15
|
+
if (current.visible === false && hidesDescendants(current)) return false
|
|
16
|
+
current = current.parentId ? nodes[current.parentId] : undefined
|
|
17
|
+
}
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
rotateFloorplanPoint,
|
|
63
63
|
visibleFloorplanViewWidth,
|
|
64
64
|
} from './floorplan-preview-navigation'
|
|
65
|
+
import { isVisibleInFloorplan } from './floorplan-preview-visibility'
|
|
65
66
|
|
|
66
67
|
const READ_ONLY_PALETTE: FloorplanPalette = {
|
|
67
68
|
selectedStroke: '#4f46e5',
|
|
@@ -193,18 +194,6 @@ export function normalizeFloorplanPreviewNodes(
|
|
|
193
194
|
return normalized
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
|
|
197
|
-
const seen = new Set<string>()
|
|
198
|
-
let current: AnyNode | undefined = node
|
|
199
|
-
while (current) {
|
|
200
|
-
if (seen.has(current.id)) return true
|
|
201
|
-
seen.add(current.id)
|
|
202
|
-
if (current.visible === false) return false
|
|
203
|
-
current = current.parentId ? nodes[current.parentId] : undefined
|
|
204
|
-
}
|
|
205
|
-
return true
|
|
206
|
-
}
|
|
207
|
-
|
|
208
197
|
function buildFloorplanGeometries(
|
|
209
198
|
nodes: Record<string, AnyNode>,
|
|
210
199
|
installedPlugins: readonly string[] | undefined,
|
|
@@ -33,10 +33,14 @@ export const ViewerZoneSystem = () => {
|
|
|
33
33
|
// Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
|
|
34
34
|
// The editor ZoneSystem handles the selected zone's opacity animation.
|
|
35
35
|
const isSelected = id === zoneId
|
|
36
|
+
// A zone the author hid (sidebar eye) takes the group with it — this
|
|
37
|
+
// per-frame write would otherwise undo the renderer's `visible` prop.
|
|
38
|
+
const nodeVisible = zone.visible !== false
|
|
36
39
|
const shouldShowGeometry =
|
|
40
|
+
nodeVisible &&
|
|
37
41
|
!isCaptureMode &&
|
|
38
42
|
((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
|
|
39
|
-
if (
|
|
43
|
+
if (obj.visible !== nodeVisible) obj.visible = nodeVisible
|
|
40
44
|
obj.traverse((child) => {
|
|
41
45
|
if ((child as Mesh).isMesh) {
|
|
42
46
|
child.visible = shouldShowGeometry
|
|
@@ -44,7 +48,8 @@ export const ViewerZoneSystem = () => {
|
|
|
44
48
|
})
|
|
45
49
|
|
|
46
50
|
// Labels: always visible on the current level (regardless of mode or zone selection)
|
|
47
|
-
const showLabel =
|
|
51
|
+
const showLabel =
|
|
52
|
+
nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
|
|
48
53
|
const targetOpacity = showLabel ? '1' : '0'
|
|
49
54
|
const labelEl = document.getElementById(`${id}-label`)
|
|
50
55
|
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BuildingNode,
|
|
4
|
+
ColumnNode,
|
|
5
|
+
clearSceneHistory,
|
|
6
|
+
LevelNode,
|
|
7
|
+
SiteNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { create } from '@react-three/test-renderer'
|
|
11
|
+
import { type SaveStatus, useAutoSave } from './use-auto-save'
|
|
12
|
+
|
|
13
|
+
const originalWindow = globalThis.window
|
|
14
|
+
const previousScene = useScene.getState()
|
|
15
|
+
let renderer: Awaited<ReturnType<typeof create>> | undefined
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await renderer?.unmount()
|
|
19
|
+
renderer = undefined
|
|
20
|
+
globalThis.window = originalWindow
|
|
21
|
+
useScene.setState(previousScene, true)
|
|
22
|
+
clearSceneHistory()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function graph(count: number) {
|
|
26
|
+
if (count === 0) return { nodes: {}, rootNodeIds: [] }
|
|
27
|
+
const columns = Array.from({ length: count - 3 }, (_, index) =>
|
|
28
|
+
ColumnNode.parse({ id: `column_autosave_${index}`, parentId: 'level_autosave' }),
|
|
29
|
+
)
|
|
30
|
+
const level = LevelNode.parse({
|
|
31
|
+
id: 'level_autosave',
|
|
32
|
+
parentId: 'building_autosave',
|
|
33
|
+
children: columns.map((node) => node.id),
|
|
34
|
+
})
|
|
35
|
+
const building = BuildingNode.parse({
|
|
36
|
+
id: 'building_autosave',
|
|
37
|
+
parentId: 'site_autosave',
|
|
38
|
+
children: [level.id],
|
|
39
|
+
})
|
|
40
|
+
const site = SiteNode.parse({ id: 'site_autosave', children: [building.id] })
|
|
41
|
+
const nodes = [site, building, level, ...columns]
|
|
42
|
+
return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [site.id] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function mount(
|
|
46
|
+
loadedCount: number,
|
|
47
|
+
guardAgainstSceneWipe?: boolean,
|
|
48
|
+
save?: () => Promise<void>,
|
|
49
|
+
) {
|
|
50
|
+
globalThis.window = new EventTarget() as unknown as Window & typeof globalThis
|
|
51
|
+
useScene.setState({ ...graph(0), readOnly: false })
|
|
52
|
+
clearSceneHistory()
|
|
53
|
+
const writes: number[] = []
|
|
54
|
+
const statuses: SaveStatus[] = []
|
|
55
|
+
let controls: ReturnType<typeof useAutoSave> | undefined
|
|
56
|
+
function Host(props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) {
|
|
57
|
+
controls = useAutoSave({
|
|
58
|
+
...props,
|
|
59
|
+
onSave: async (scene) => {
|
|
60
|
+
writes.push(Object.keys(scene.nodes).length)
|
|
61
|
+
await save?.()
|
|
62
|
+
},
|
|
63
|
+
onSaveStatusChange: (status) => statuses.push(status),
|
|
64
|
+
})
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
renderer = await create(<Host guardAgainstSceneWipe={guardAgainstSceneWipe} />)
|
|
68
|
+
useScene.setState(graph(loadedCount))
|
|
69
|
+
clearSceneHistory()
|
|
70
|
+
controls!.isLoadingSceneRef.current = false
|
|
71
|
+
statuses.length = 0
|
|
72
|
+
return {
|
|
73
|
+
writes,
|
|
74
|
+
statuses,
|
|
75
|
+
controls: controls!,
|
|
76
|
+
update: (props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) =>
|
|
77
|
+
renderer!.update(<Host {...props} />),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test.each([
|
|
82
|
+
'undo',
|
|
83
|
+
'redo',
|
|
84
|
+
'delete',
|
|
85
|
+
] as const)('autosave permits a 5 to 4 single-node %s', async (action) => {
|
|
86
|
+
const { writes, statuses } = await mount(5)
|
|
87
|
+
if (action === 'undo') {
|
|
88
|
+
useScene.setState(graph(4))
|
|
89
|
+
clearSceneHistory()
|
|
90
|
+
useScene.setState(graph(5))
|
|
91
|
+
useScene.temporal.getState().undo()
|
|
92
|
+
} else if (action === 'redo') {
|
|
93
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
94
|
+
useScene.temporal.getState().undo()
|
|
95
|
+
useScene.temporal.getState().redo()
|
|
96
|
+
} else {
|
|
97
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
98
|
+
}
|
|
99
|
+
expect(Object.keys(useScene.getState().nodes)).toHaveLength(4)
|
|
100
|
+
await Bun.sleep(1100)
|
|
101
|
+
expect(statuses).not.toContain('error')
|
|
102
|
+
expect(writes).toEqual([4])
|
|
103
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('autosave permits a single-node deletion on exit', async () => {
|
|
107
|
+
const { writes, statuses } = await mount(5)
|
|
108
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
109
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
110
|
+
expect(statuses).not.toContain('error')
|
|
111
|
+
expect(writes).toEqual([4])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('autosave blocks a full wipe and resumes after an allowed edit', async () => {
|
|
115
|
+
const { writes, statuses, controls } = await mount(78)
|
|
116
|
+
useScene.setState(graph(4))
|
|
117
|
+
controls.saveNow()
|
|
118
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
119
|
+
expect(statuses).toContain('error')
|
|
120
|
+
expect(writes).toEqual([])
|
|
121
|
+
useScene.setState(graph(5))
|
|
122
|
+
controls.saveNow()
|
|
123
|
+
await Bun.sleep(0)
|
|
124
|
+
expect(writes).toEqual([5])
|
|
125
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('collaboration scaffold deletion waits for persistence and reports saved', async () => {
|
|
129
|
+
const idle = Promise.withResolvers<void>()
|
|
130
|
+
const { writes, statuses } = await mount(78, false, () => idle.promise)
|
|
131
|
+
useScene.setState(graph(4))
|
|
132
|
+
await Bun.sleep(1100)
|
|
133
|
+
expect(statuses).not.toContain('error')
|
|
134
|
+
expect(writes).toEqual([4])
|
|
135
|
+
expect(statuses).toEqual(['pending', 'saving'])
|
|
136
|
+
idle.resolve()
|
|
137
|
+
await Bun.sleep(0)
|
|
138
|
+
expect(statuses).toEqual(['pending', 'saving', 'saved'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test.each([
|
|
142
|
+
'pagehide',
|
|
143
|
+
'unmount',
|
|
144
|
+
])('collaboration scaffold deletion still flushes on %s', async (event) => {
|
|
145
|
+
const { writes, statuses } = await mount(78, false)
|
|
146
|
+
useScene.setState(graph(4))
|
|
147
|
+
if (event === 'pagehide') window.dispatchEvent(new Event(event))
|
|
148
|
+
else {
|
|
149
|
+
await renderer!.unmount()
|
|
150
|
+
renderer = undefined
|
|
151
|
+
}
|
|
152
|
+
expect(statuses).not.toContain('error')
|
|
153
|
+
expect(writes).toEqual([4])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('collaboration guard exemption preserves preview pause and resume', async () => {
|
|
157
|
+
const { writes, statuses, update } = await mount(78, false)
|
|
158
|
+
useScene.setState(graph(4))
|
|
159
|
+
await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: true })
|
|
160
|
+
expect(statuses.at(-1)).toBe('paused')
|
|
161
|
+
await Bun.sleep(1100)
|
|
162
|
+
expect(writes).toEqual([])
|
|
163
|
+
await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: false })
|
|
164
|
+
await Bun.sleep(1100)
|
|
165
|
+
expect(writes).toEqual([4])
|
|
166
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
167
|
+
expect(statuses).not.toContain('error')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('changing guard ownership recovers an error and keeps the new stored baseline', async () => {
|
|
171
|
+
const { writes, statuses, controls, update } = await mount(78)
|
|
172
|
+
useScene.setState(graph(4))
|
|
173
|
+
controls.saveNow()
|
|
174
|
+
expect(statuses.at(-1)).toBe('error')
|
|
175
|
+
await update({ guardAgainstSceneWipe: false })
|
|
176
|
+
controls.saveNow()
|
|
177
|
+
await Bun.sleep(0)
|
|
178
|
+
expect(writes).toEqual([4])
|
|
179
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
180
|
+
await update({ guardAgainstSceneWipe: true })
|
|
181
|
+
useScene.setState(graph(3))
|
|
182
|
+
controls.saveNow()
|
|
183
|
+
await Bun.sleep(0)
|
|
184
|
+
expect(writes).toEqual([4, 3])
|
|
185
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('collaboration callback failures still report error', async () => {
|
|
189
|
+
const { writes, statuses, controls } = await mount(78, false, async () => {
|
|
190
|
+
throw new Error('Collaboration failed')
|
|
191
|
+
})
|
|
192
|
+
useScene.setState(graph(4))
|
|
193
|
+
controls.saveNow()
|
|
194
|
+
await Bun.sleep(0)
|
|
195
|
+
expect(writes).toEqual([4])
|
|
196
|
+
expect(statuses).toEqual(['pending', 'saving', 'error'])
|
|
197
|
+
})
|
|
@@ -8,7 +8,11 @@ const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
|
8
8
|
const STRUCTURAL_NODE_COUNT = 4
|
|
9
9
|
|
|
10
10
|
export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
|
|
11
|
-
return
|
|
11
|
+
return (
|
|
12
|
+
previousNodeCount > STRUCTURAL_NODE_COUNT &&
|
|
13
|
+
currentNodeCount <= STRUCTURAL_NODE_COUNT &&
|
|
14
|
+
previousNodeCount - currentNodeCount > 1
|
|
15
|
+
)
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/**
|
|
@@ -37,8 +41,8 @@ export function createStoredNodeCountTracker(initialNodeCount: number) {
|
|
|
37
41
|
* which is an accidental full deletion far more often than an intent. The
|
|
38
42
|
* caller reports the block; on `true` the write becomes the new baseline.
|
|
39
43
|
*/
|
|
40
|
-
allowWrite(nodeCount: number) {
|
|
41
|
-
if (isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
44
|
+
allowWrite(nodeCount: number, guardAgainstSceneWipe = true) {
|
|
45
|
+
if (guardAgainstSceneWipe && isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
42
46
|
count = nodeCount
|
|
43
47
|
return true
|
|
44
48
|
},
|
|
@@ -65,10 +69,14 @@ export function decideExitFlush(opts: {
|
|
|
65
69
|
hasDirtyChanges: boolean
|
|
66
70
|
storedNodeCount: number
|
|
67
71
|
currentNodeCount: number
|
|
72
|
+
guardAgainstSceneWipe?: boolean
|
|
68
73
|
}): ExitFlushDecision {
|
|
69
74
|
if (!opts.hasDirtyChanges) return 'skip-clean'
|
|
70
75
|
if (opts.isLoadingScene) return 'skip-loading'
|
|
71
|
-
if (
|
|
76
|
+
if (
|
|
77
|
+
opts.guardAgainstSceneWipe !== false &&
|
|
78
|
+
isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)
|
|
79
|
+
) {
|
|
72
80
|
return 'blocked-suspicious'
|
|
73
81
|
}
|
|
74
82
|
return 'flush'
|
|
@@ -77,6 +85,7 @@ export function decideExitFlush(opts: {
|
|
|
77
85
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
78
86
|
|
|
79
87
|
interface UseAutoSaveOptions {
|
|
88
|
+
guardAgainstSceneWipe?: boolean
|
|
80
89
|
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
|
|
81
90
|
onDirty?: () => void
|
|
82
91
|
onSaveStatusChange?: (status: SaveStatus) => void
|
|
@@ -90,6 +99,7 @@ interface UseAutoSaveOptions {
|
|
|
90
99
|
* ⚠️ Mount in exactly ONE component (the Editor).
|
|
91
100
|
*/
|
|
92
101
|
export function useAutoSave({
|
|
102
|
+
guardAgainstSceneWipe = true,
|
|
93
103
|
onSave,
|
|
94
104
|
onDirty,
|
|
95
105
|
onSaveStatusChange,
|
|
@@ -116,6 +126,7 @@ export function useAutoSave({
|
|
|
116
126
|
const onDirtyRef = useRef(onDirty)
|
|
117
127
|
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
|
|
118
128
|
const isVersionPreviewModeRef = useRef(isVersionPreviewMode)
|
|
129
|
+
const guardAgainstSceneWipeRef = useRef(guardAgainstSceneWipe)
|
|
119
130
|
|
|
120
131
|
useEffect(() => {
|
|
121
132
|
onSaveRef.current = onSave
|
|
@@ -129,6 +140,9 @@ export function useAutoSave({
|
|
|
129
140
|
useEffect(() => {
|
|
130
141
|
isVersionPreviewModeRef.current = isVersionPreviewMode
|
|
131
142
|
}, [isVersionPreviewMode])
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
guardAgainstSceneWipeRef.current = guardAgainstSceneWipe
|
|
145
|
+
}, [guardAgainstSceneWipe])
|
|
132
146
|
|
|
133
147
|
const setSaveStatus = useCallback((status: SaveStatus) => {
|
|
134
148
|
onSaveStatusChangeRef.current?.(status)
|
|
@@ -166,7 +180,7 @@ export function useAutoSave({
|
|
|
166
180
|
|
|
167
181
|
const currentNodeCount = Object.keys(nodes).length
|
|
168
182
|
const previousNodeCount = storedNodeCount.count
|
|
169
|
-
if (!storedNodeCount.allowWrite(currentNodeCount)) {
|
|
183
|
+
if (!storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)) {
|
|
170
184
|
console.warn(
|
|
171
185
|
`[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
172
186
|
)
|
|
@@ -266,6 +280,7 @@ export function useAutoSave({
|
|
|
266
280
|
hasDirtyChanges: hasDirtyChangesRef.current,
|
|
267
281
|
storedNodeCount: previousNodeCount,
|
|
268
282
|
currentNodeCount,
|
|
283
|
+
guardAgainstSceneWipe: guardAgainstSceneWipeRef.current,
|
|
269
284
|
})
|
|
270
285
|
if (decision === 'skip-clean') return
|
|
271
286
|
if (decision === 'skip-loading') {
|
|
@@ -282,7 +297,7 @@ export function useAutoSave({
|
|
|
282
297
|
return
|
|
283
298
|
}
|
|
284
299
|
// 'flush' — adopt the write as the new stored baseline.
|
|
285
|
-
storedNodeCount.allowWrite(currentNodeCount)
|
|
300
|
+
storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)
|
|
286
301
|
|
|
287
302
|
hasDirtyChangesRef.current = false
|
|
288
303
|
const sceneGraph = {
|
|
@@ -56,6 +56,8 @@ export function useCeilingEvents() {
|
|
|
56
56
|
const hoveredRef = useRef<string | null>(null)
|
|
57
57
|
|
|
58
58
|
useEffect(() => {
|
|
59
|
+
// XR placement is driven by the controller-ray bridge, never screen coordinates.
|
|
60
|
+
if (gl.xr.isPresenting) return
|
|
59
61
|
const canvas = gl.domElement
|
|
60
62
|
|
|
61
63
|
const isActive = (): boolean => {
|
|
@@ -70,6 +70,7 @@ test('grid moves throttle camera drags at 100 ms and resume immediately during t
|
|
|
70
70
|
render() {},
|
|
71
71
|
setSize() {},
|
|
72
72
|
setPixelRatio() {},
|
|
73
|
+
xr: { isPresenting: false },
|
|
73
74
|
} as unknown as WebGLRenderer,
|
|
74
75
|
camera,
|
|
75
76
|
frameloop: 'never',
|
|
@@ -199,6 +200,7 @@ test.each([
|
|
|
199
200
|
render() {},
|
|
200
201
|
setSize() {},
|
|
201
202
|
setPixelRatio() {},
|
|
203
|
+
xr: { isPresenting: false },
|
|
202
204
|
} as unknown as WebGLRenderer,
|
|
203
205
|
camera,
|
|
204
206
|
frameloop: 'never',
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type EventSuffix,
|
|
4
4
|
emitter,
|
|
5
5
|
type GridEvent,
|
|
6
|
+
hiddenWallPointerEventsHeld,
|
|
6
7
|
nodeRegistry,
|
|
7
8
|
sceneRegistry,
|
|
8
9
|
useScene,
|
|
@@ -58,6 +59,8 @@ export function useGridEvents(gridY: number) {
|
|
|
58
59
|
}, [gridY])
|
|
59
60
|
|
|
60
61
|
useEffect(() => {
|
|
62
|
+
// XR placement is driven by the controller-ray bridge, never screen coordinates.
|
|
63
|
+
if (gl.xr.isPresenting) return
|
|
61
64
|
const canvas = gl.domElement
|
|
62
65
|
setSurfaceRaycastLayers(raycaster.current.layers)
|
|
63
66
|
|
|
@@ -132,7 +135,10 @@ export function useGridEvents(gridY: number) {
|
|
|
132
135
|
// Architectural meshes are expensive to raycast and are meaningful only
|
|
133
136
|
// to an active placement/drafting interaction. Floor tools retain the
|
|
134
137
|
// ordinary terrain/grid intersection without scanning every wall.
|
|
135
|
-
const surfaceHit =
|
|
138
|
+
const surfaceHit =
|
|
139
|
+
semanticSurfaceQueryRef.current || hiddenWallPointerEventsHeld()
|
|
140
|
+
? getSurfaceIntersection()
|
|
141
|
+
: null
|
|
136
142
|
|
|
137
143
|
// A semantic architectural hit is the authoritative cursor position.
|
|
138
144
|
// Do not replace it with the terrain/grid intersection below: that would
|
|
@@ -244,16 +250,15 @@ export function useGridEvents(gridY: number) {
|
|
|
244
250
|
: undefined,
|
|
245
251
|
surfaceNormal: localNormal ? [localNormal.x, localNormal.y, localNormal.z] : undefined,
|
|
246
252
|
surfaceObject: point.surface?.object,
|
|
247
|
-
surfaceHit:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
: undefined,
|
|
253
|
+
surfaceHit: point.surface
|
|
254
|
+
? {
|
|
255
|
+
kind: point.surface.descriptor.kind,
|
|
256
|
+
hostId: point.surface.hostId,
|
|
257
|
+
face: classifiedFace?.face ?? 'unknown',
|
|
258
|
+
levelId: useViewer.getState().selection.levelId ?? undefined,
|
|
259
|
+
side: classifiedFace?.side,
|
|
260
|
+
}
|
|
261
|
+
: undefined,
|
|
257
262
|
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
|
|
258
263
|
}
|
|
259
264
|
|
|
@@ -106,6 +106,13 @@ describe('rotation shortcut ownership', () => {
|
|
|
106
106
|
expect(isToolOwnedRotation()).toBe(true)
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
test('leaves R to the wall tool for its line / rectangle toggle, but not T', () => {
|
|
110
|
+
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
|
|
111
|
+
|
|
112
|
+
expect(isToolOwnedRotation('r')).toBe(true)
|
|
113
|
+
expect(isToolOwnedRotation('t')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
109
116
|
test('leaves R and T to a moving lean-to extension', () => {
|
|
110
117
|
const leanTo = { id: 'lean_to_moving', type: 'lean-to-extension' } as unknown as AnyNode
|
|
111
118
|
useInteractionScope.getState().begin({
|