@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.
Files changed (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. 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 Shortcuts
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 Shortcuts</DialogTitle>
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 (!obj.visible) obj.visible = true
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 = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
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 previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
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 (isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)) {
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 = {
@@ -101,6 +101,7 @@ test.each([
101
101
  render() {},
102
102
  setSize() {},
103
103
  setPixelRatio() {},
104
+ xr: { isPresenting: false },
104
105
  } as unknown as WebGLRenderer,
105
106
  camera,
106
107
  frameloop: 'never',
@@ -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 = semanticSurfaceQueryRef.current ? getSurfaceIntersection() : null
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
- semanticSurfaceQueryRef.current && point.surface
249
- ? {
250
- kind: point.surface.descriptor.kind,
251
- hostId: point.surface.hostId,
252
- face: classifiedFace?.face ?? 'unknown',
253
- levelId: useViewer.getState().selection.levelId ?? undefined,
254
- side: classifiedFace?.side,
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({