@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.
Files changed (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -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 = {
@@ -7,6 +7,7 @@ import {
7
7
  sceneRegistry,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
+ import { ProceduralItemNode, shelfRecipe } from '@pascal-app/core/procedural-items'
10
11
  import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
11
12
  import { _roots, act, createRoot } from '@react-three/fiber'
12
13
  import { createElement } from 'react'
@@ -18,11 +19,16 @@ import {
18
19
  type WebGLRenderer,
19
20
  } from 'three'
20
21
  import useEditor from '../store/use-editor'
22
+ import useInteractionScope from '../store/use-interaction-scope'
21
23
  import { useCeilingEvents } from './use-ceiling-events'
22
24
 
23
- test('ceiling-item placement keeps move and commit hits while an unhovered ceiling is batched', async () => {
25
+ test.each([
26
+ 'item',
27
+ 'procedural-item',
28
+ ])('%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', async (kind) => {
24
29
  const previousViewer = useViewer.getState()
25
30
  const previousEditor = useEditor.getState()
31
+ const previousInteraction = useInteractionScope.getState()
26
32
  const previousScene = useScene.getState()
27
33
  const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
28
34
  const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
@@ -73,13 +79,29 @@ test('ceiling-item placement keeps move and commit hits while an unhovered ceili
73
79
  cameraDragging: false,
74
80
  selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
75
81
  })
76
- useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
82
+ if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
83
+ else {
84
+ useEditor.setState({ selectedItem: null })
85
+ useEditor.getState().setMovingNode(
86
+ ProceduralItemNode.parse({
87
+ recipe: {
88
+ ...shelfRecipe,
89
+ mounting: { attachTo: 'ceiling', reference: 'top' },
90
+ surfaces: [
91
+ { id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] },
92
+ ],
93
+ },
94
+ parentId: ceiling.id,
95
+ }) as never,
96
+ )
97
+ }
77
98
  await root.configure({
78
99
  gl: {
79
100
  domElement: canvas,
80
101
  render() {},
81
102
  setSize() {},
82
103
  setPixelRatio() {},
104
+ xr: { isPresenting: false },
83
105
  } as unknown as WebGLRenderer,
84
106
  camera,
85
107
  frameloop: 'never',
@@ -116,6 +138,7 @@ test('ceiling-item placement keeps move and commit hits while an unhovered ceili
116
138
  surface.material.dispose()
117
139
  useScene.setState(previousScene)
118
140
  useEditor.setState(previousEditor)
141
+ useInteractionScope.setState(previousInteraction)
119
142
  useViewer.setState(previousViewer)
120
143
  actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
121
144
  }
@@ -7,6 +7,7 @@ import {
7
7
  sceneRegistry,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
+ import { isProceduralItem } from '@pascal-app/core/procedural-items'
10
11
  import { useViewer } from '@pascal-app/viewer'
11
12
  import { useThree } from '@react-three/fiber'
12
13
  import { useEffect, useRef } from 'react'
@@ -55,13 +56,18 @@ export function useCeilingEvents() {
55
56
  const hoveredRef = useRef<string | null>(null)
56
57
 
57
58
  useEffect(() => {
59
+ // XR placement is driven by the controller-ray bridge, never screen coordinates.
60
+ if (gl.xr.isPresenting) return
58
61
  const canvas = gl.domElement
59
62
 
60
63
  const isActive = (): boolean => {
61
64
  const ed = useEditor.getState()
62
65
  if (ed.selectedItem?.attachTo === 'ceiling') return true
63
66
  const moving = getMovingNode()
64
- return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
67
+ return (
68
+ (moving?.type === 'item' && moving.asset?.attachTo === 'ceiling') ||
69
+ (isProceduralItem(moving) && moving.recipe.mounting?.attachTo === 'ceiling')
70
+ )
65
71
  }
66
72
 
67
73
  type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
@@ -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({
@@ -9,7 +9,6 @@ import {
9
9
  } from '@pascal-app/core'
10
10
  import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
11
11
  import { useEffect } from 'react'
12
- import { Vector3 } from 'three'
13
12
  import {
14
13
  cutSelectionToEditorClipboard,
15
14
  deleteSelection,
@@ -18,9 +17,9 @@ import {
18
17
  import {
19
18
  classifyParticipant,
20
19
  collectParticipants,
21
- computeGroupBox,
22
- expandToComponent,
20
+ groupPlanBounds,
23
21
  levelFrame,
22
+ planBoundsCenter,
24
23
  rotateGroupPatches,
25
24
  } from '../components/editor/group-transform-shared'
26
25
  import { steppedRotation } from '../components/tools/item/placement-math'
@@ -32,10 +31,14 @@ import { isActive } from '../lib/interaction/scope'
32
31
  import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
33
32
  import { sfxEmitter } from '../lib/sfx-bus'
34
33
  import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
34
+ import { leaveUnitFocus } from '../lib/units'
35
35
  import { toggleWindowOpenState } from '../lib/window-interaction'
36
36
  import useDeleteConfirmation from '../store/use-delete-confirmation'
37
37
  import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
38
- import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
38
+ import useInteractionScope, {
39
+ getMovingNode,
40
+ isInteractionSubtreeDraft,
41
+ } from '../store/use-interaction-scope'
39
42
  import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups'
40
43
 
41
44
  // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
@@ -65,27 +68,20 @@ function rotateGroupSelection(direction: 1 | -1): boolean {
65
68
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
66
69
  )
67
70
  if (participantIds.length === 0) return false
68
- const fullIds = expandToComponent(participantIds, nodes, levelId)
69
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
71
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
70
72
  if (starts.length === 0) return false
71
73
 
72
- // Same pivot as the 3D gizmo: the selection's world bbox center, converted
73
- // into the level frame before orbiting placements (a rotated building would
74
- // otherwise displace the centre).
75
- const box = computeGroupBox(fullIds)
76
- if (!box) return false
77
- const worldCenter = new Vector3(
78
- (box.min.x + box.max.x) / 2,
79
- box.min.y,
80
- (box.min.z + box.max.z) / 2,
81
- )
82
- const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse)
74
+ // Same pivot as the dashed boxes and the rotate gizmo: the selection's box
75
+ // centre in the level frame.
76
+ const bounds = groupPlanBounds(starts, levelFrame(levelId).inverse)
77
+ if (!bounds) return false
78
+ const [pivotX, pivotZ] = planBoundsCenter(bounds)
83
79
 
84
80
  // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta
85
81
  // (see rotateGroupPatches), so keyboard direction matches the single-node
86
82
  // steppedRotation sense.
87
83
  const delta = -direction * (Math.PI / 4)
88
- const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta)
84
+ const patches = rotateGroupPatches(starts, links, { x: pivotX, z: pivotZ }, delta)
89
85
  // Space detection stays out: a rigid rotation of existing walls must not
90
86
  // re-create the room's auto floors/ceilings at the new bearing.
91
87
  pauseSpaceDetection()
@@ -115,7 +111,13 @@ const exitToSelectAfterUnconsumedCancel = () => {
115
111
  const currentPhase = useEditor.getState().phase
116
112
  const currentStructureLayer = useEditor.getState().structureLayer
117
113
 
118
- useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
114
+ useInteractionScope
115
+ .getState()
116
+ .endIf(
117
+ (sc) =>
118
+ (sc.kind === 'placing' && isInteractionSubtreeDraft()) ||
119
+ (sc.kind === 'reshaping' && sc.reshape === 'hole'),
120
+ )
119
121
 
120
122
  // From zone mode, return to structure select
121
123
  if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
@@ -133,6 +135,20 @@ const exitToSelectAfterUnconsumedCancel = () => {
133
135
  useEditor.getState().setSelectedReferenceId(null)
134
136
  }
135
137
 
138
+ // Cancel the active editor action with the same consume-or-exit semantics as
139
+ // Escape. Spatial inputs use this instead of unconditionally selecting the
140
+ // Select tool, so multi-step tools can keep their tool active after clearing
141
+ // the current draft.
142
+ export const cancelActiveTool = () => {
143
+ _toolCancelConsumed = false
144
+ emitter.emit('tool:cancel')
145
+ if (!_toolCancelConsumed) {
146
+ if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' })
147
+ else exitToSelectAfterUnconsumedCancel()
148
+ }
149
+ return _toolCancelConsumed
150
+ }
151
+
136
152
  // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
137
153
  // reads as "abort this action", not history undo — behave exactly like Escape
138
154
  // and report whether anything was in flight so the undo/redo arms know to
@@ -175,7 +191,8 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
175
191
  return true
176
192
  }
177
193
 
178
- export const isToolOwnedRotation = () => {
194
+ /** Whether an armed tool owns the rotation key (`R` or `T`) instead of the selection. */
195
+ export const isToolOwnedRotation = (key: 'r' | 't' = 'r') => {
179
196
  const editor = useEditor.getState()
180
197
  const moving = getMovingNode()
181
198
  if (
@@ -195,7 +212,10 @@ export const isToolOwnedRotation = () => {
195
212
  // exist. Without this check, selecting an existing item in the 2D plan
196
213
  // while the item tool is armed silently drops the global rotate key.
197
214
  (editor.tool === 'item' && editor.selectedItem !== null) ||
198
- editor.tool === 'lean-to-extension')
215
+ editor.tool === 'lean-to-extension' ||
216
+ // R toggles the wall tool between line and rectangle drawing; T stays
217
+ // the selection's.
218
+ (editor.tool === 'wall' && key === 'r'))
199
219
  )
200
220
  }
201
221
 
@@ -384,14 +404,9 @@ export const useKeyboard = ({
384
404
  return
385
405
  }
386
406
 
387
- _toolCancelConsumed = false
388
- emitter.emit('tool:cancel')
389
-
390
407
  // Only switch to select mode if no tool had an active mid-action to cancel.
391
408
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
392
- if (!_toolCancelConsumed) {
393
- exitToSelectAfterUnconsumedCancel()
394
- }
409
+ cancelActiveTool()
395
410
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
396
411
  e.preventDefault()
397
412
  useEditor.getState().setPhase('site')
@@ -617,7 +632,7 @@ export const useKeyboard = ({
617
632
  } else if (
618
633
  (e.key === 't' || e.key === 'T') &&
619
634
  !isVersionPreviewMode &&
620
- !isToolOwnedRotation() &&
635
+ !isToolOwnedRotation('t') &&
621
636
  canRunGlobalRotationShortcut()
622
637
  ) {
623
638
  // Rotate selected node counter-clockwise