@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,240 @@
1
+ import { expect, spyOn, test } from 'bun:test'
2
+ import {
3
+ CeilingNode,
4
+ emitter,
5
+ type GridEvent,
6
+ nodeRegistry,
7
+ registerNode,
8
+ SlabNode,
9
+ useRegistry,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
13
+ import { _roots, act, createRoot } from '@react-three/fiber'
14
+ import { createElement } from 'react'
15
+ import {
16
+ DoubleSide,
17
+ Mesh,
18
+ MeshBasicMaterial,
19
+ PerspectiveCamera,
20
+ PlaneGeometry,
21
+ type WebGLRenderer,
22
+ } from 'three'
23
+ import { DRAFTING_SURFACE_EXTENSION_KEY } from '../lib/interaction/registered-drafting'
24
+ import useInteractionScope from '../store/use-interaction-scope'
25
+ import { useGridEvents } from './use-grid-events'
26
+
27
+ test('grid moves throttle camera drags at 100 ms and resume immediately during tool drags', async () => {
28
+ const previousViewer = useViewer.getState()
29
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
30
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
31
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
32
+ let now = 0
33
+ const clock = spyOn(performance, 'now').mockImplementation(() => now)
34
+ let rectReads = 0
35
+ const canvas = Object.assign(new EventTarget(), {
36
+ getBoundingClientRect() {
37
+ rectReads++
38
+ return { left: 0, top: 0, width: 100, height: 100 }
39
+ },
40
+ }) as unknown as HTMLCanvasElement
41
+ const root = createRoot(canvas)
42
+ const camera = new PerspectiveCamera(60, 1, 0.1, 100)
43
+ camera.position.set(0, 10, 10)
44
+ camera.lookAt(0, 0, 0)
45
+ camera.updateMatrixWorld()
46
+ const delivered: GridEvent[] = []
47
+ const onMove = (event: GridEvent) => delivered.push(event)
48
+ emitter.on('grid:move', onMove)
49
+
50
+ function Grid() {
51
+ useGridEvents(5)
52
+ return null
53
+ }
54
+
55
+ const send = (clientX = 50) => {
56
+ canvas.dispatchEvent(
57
+ Object.assign(new Event('pointermove'), { clientX, clientY: 50, button: 0 }),
58
+ )
59
+ }
60
+
61
+ try {
62
+ useViewer.setState({
63
+ cameraDragging: false,
64
+ inputDragging: false,
65
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
66
+ })
67
+ await root.configure({
68
+ gl: {
69
+ domElement: canvas,
70
+ render() {},
71
+ setSize() {},
72
+ setPixelRatio() {},
73
+ } as unknown as WebGLRenderer,
74
+ camera,
75
+ frameloop: 'never',
76
+ dpr: 1,
77
+ size: { width: 100, height: 100, top: 0, left: 0 },
78
+ })
79
+ await act(async () => {
80
+ root.render(createElement(Grid))
81
+ })
82
+ send()
83
+ expect(rectReads).toBe(1)
84
+ expect(delivered).toHaveLength(1)
85
+ const initial = delivered[0]!
86
+ expect(initial.position[1]).toBeCloseTo(5)
87
+ expect(initial.localPosition).toEqual(initial.position)
88
+
89
+ for (const inputDragging of [false, true]) {
90
+ const before = delivered.length
91
+ useViewer.setState({ cameraDragging: true, inputDragging })
92
+ send(55)
93
+ expect(delivered).toHaveLength(before + 1)
94
+ for (now = 1; now < 100; now++) send(60)
95
+ expect(rectReads).toBe(before + 1)
96
+ expect(delivered).toHaveLength(before + 1)
97
+
98
+ now = 100
99
+ send(60)
100
+ expect(delivered).toHaveLength(before + 2)
101
+ expect(delivered.at(-1)!.position).not.toEqual(delivered.at(-2)!.position)
102
+ now = 101
103
+ useViewer.setState({ cameraDragging: false })
104
+ send()
105
+ expect(delivered).toHaveLength(before + 3)
106
+ expect(rectReads).toBe(delivered.length)
107
+ expect(delivered.at(-1)!.position).toEqual(initial.position)
108
+ expect(delivered.at(-1)!.localPosition).toEqual(initial.localPosition)
109
+ now = 0
110
+ }
111
+
112
+ await act(async () => {
113
+ root.render(null)
114
+ })
115
+ const before = delivered.length
116
+ send()
117
+ expect(delivered).toHaveLength(before)
118
+ expect(rectReads).toBe(before)
119
+ } finally {
120
+ await act(async () => {
121
+ root.render(null)
122
+ })
123
+ clock.mockRestore()
124
+ emitter.off('grid:move', onMove)
125
+ _roots.delete(canvas)
126
+ useViewer.setState(previousViewer)
127
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
128
+ }
129
+ })
130
+
131
+ test.each([
132
+ 'slab',
133
+ 'ceiling',
134
+ ] as const)('grid moves keep the same %s hit when its source joins or leaves a batch', async (kind) => {
135
+ const previousViewer = useViewer.getState()
136
+ const previousScope = useInteractionScope.getState().scope
137
+ const restoreRegistry = nodeRegistry._snapshot()
138
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
139
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
140
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
141
+ const canvas = Object.assign(new EventTarget(), {
142
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
143
+ }) as unknown as HTMLCanvasElement
144
+ const root = createRoot(canvas)
145
+ const camera = new PerspectiveCamera(60, 1, 0.1, 100)
146
+ camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10)
147
+ camera.lookAt(0, 5, 0)
148
+ camera.updateMatrixWorld()
149
+ const surface = new Mesh(
150
+ new PlaneGeometry(20, 20).rotateX(-Math.PI / 2),
151
+ new MeshBasicMaterial({ side: DoubleSide }),
152
+ )
153
+ surface.position.y = 5
154
+ surface.updateMatrixWorld(true)
155
+ const surfaceId = `${kind}_grid_batch_test` as const
156
+ const wall = WallNode.parse({ start: [0, 0], end: [0, 2] })
157
+ const delivered: GridEvent[] = []
158
+ const onMove = (event: GridEvent) => delivered.push(event)
159
+ emitter.on('grid:move', onMove)
160
+
161
+ function Grid() {
162
+ useRegistry(surfaceId, kind, { current: surface })
163
+ useGridEvents(0)
164
+ return null
165
+ }
166
+ const send = () => {
167
+ const before = delivered.length
168
+ canvas.dispatchEvent(
169
+ Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }),
170
+ )
171
+ expect(delivered).toHaveLength(before + 1)
172
+ return delivered.at(-1)!
173
+ }
174
+
175
+ try {
176
+ nodeRegistry._reset()
177
+ registerNode({
178
+ kind,
179
+ schemaVersion: 1,
180
+ category: 'structure',
181
+ capabilities: {},
182
+ schema: kind === 'ceiling' ? CeilingNode : SlabNode,
183
+ defaults: () => ({}),
184
+ drafting: { surfaceQuery: true },
185
+ extensions: {
186
+ [DRAFTING_SURFACE_EXTENSION_KEY]: {
187
+ kind,
188
+ ...(kind === 'ceiling' ? { raycast: 'underside' } : {}),
189
+ },
190
+ },
191
+ })
192
+ useViewer.setState({
193
+ cameraDragging: false,
194
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
195
+ })
196
+ await root.configure({
197
+ gl: {
198
+ domElement: canvas,
199
+ render() {},
200
+ setSize() {},
201
+ setPixelRatio() {},
202
+ } as unknown as WebGLRenderer,
203
+ camera,
204
+ frameloop: 'never',
205
+ dpr: 1,
206
+ size: { width: 100, height: 100, top: 0, left: 0 },
207
+ })
208
+ for (const scope of [
209
+ { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' },
210
+ { kind: 'drafting', tool: kind },
211
+ ] as const) {
212
+ await act(async () => {
213
+ useInteractionScope.setState({ scope })
214
+ root.render(createElement(Grid))
215
+ })
216
+ const before = send()
217
+ expect(before.surfaceHit?.hostId).toBe(surfaceId)
218
+ expect(before.position[1]).toBeCloseTo(5)
219
+ hideFromScene(surface, 'batched')
220
+ const batched = send()
221
+ expect(batched.surfaceHit).toEqual(before.surfaceHit)
222
+ expect(batched.position).toEqual(before.position)
223
+ expect(batched.localPosition).toEqual(before.localPosition)
224
+ showInScene(surface, 'batched')
225
+ expect(send().position).toEqual(before.position)
226
+ }
227
+ } finally {
228
+ await act(async () => {
229
+ root.render(null)
230
+ })
231
+ emitter.off('grid:move', onMove)
232
+ _roots.delete(canvas)
233
+ surface.geometry.dispose()
234
+ surface.material.dispose()
235
+ restoreRegistry()
236
+ useInteractionScope.setState({ scope: previousScope })
237
+ useViewer.setState(previousViewer)
238
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
239
+ }
240
+ })
@@ -3,14 +3,26 @@ import {
3
3
  type EventSuffix,
4
4
  emitter,
5
5
  type GridEvent,
6
+ nodeRegistry,
6
7
  sceneRegistry,
8
+ useScene,
7
9
  } from '@pascal-app/core'
8
- import { useViewer } from '@pascal-app/viewer'
10
+ import { setSurfaceRaycastLayers, timeSpan, useViewer } from '@pascal-app/viewer'
9
11
  import { useThree } from '@react-three/fiber'
10
12
  import { useEffect, useRef } from 'react'
11
- import { Plane, Raycaster, Vector2, Vector3 } from 'three'
13
+ import { Matrix3, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
12
14
  import { getPlacementSurface } from '../lib/active-placement-surface'
15
+ import { raycastCeilingUnderside } from '../lib/ceiling-surface-raycast'
13
16
  import { resolveTerrainGroundHit } from '../lib/ground-surface'
17
+ import {
18
+ type DraftingSurfaceExtension,
19
+ registeredDraftingConfig,
20
+ registeredDraftingSurface,
21
+ } from '../lib/interaction/registered-drafting'
22
+ import useInteractionScope from '../store/use-interaction-scope'
23
+
24
+ // Keep tool previews tracking camera navigation at 10 Hz without querying every move.
25
+ const CAMERA_DRAG_MOVE_INTERVAL_MS = 100
14
26
 
15
27
  /**
16
28
  * Custom grid events hook that uses manual raycasting instead of mesh events.
@@ -18,11 +30,28 @@ import { resolveTerrainGroundHit } from '../lib/ground-surface'
18
30
  */
19
31
  export function useGridEvents(gridY: number) {
20
32
  const { camera, gl } = useThree()
33
+ const interactionScope = useInteractionScope((state) => state.scope)
34
+ const semanticSurfaceQueryRef = useRef(false)
35
+ semanticSurfaceQueryRef.current =
36
+ interactionScope.kind === 'placing' ||
37
+ interactionScope.kind === 'moving' ||
38
+ registeredDraftingConfig(interactionScope)?.surfaceQuery === true
21
39
  const raycaster = useRef(new Raycaster())
22
40
  const pointer = useRef(new Vector2())
23
41
  const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
24
42
  const intersectionPoint = useRef(new Vector3())
25
43
 
44
+ type GridIntersection = {
45
+ point: Vector3
46
+ surface?: {
47
+ point: Vector3
48
+ object: Object3D
49
+ hostId: AnyNodeId
50
+ descriptor: DraftingSurfaceExtension
51
+ worldNormal?: Vector3
52
+ }
53
+ }
54
+
26
55
  // Update ground plane when grid Y changes
27
56
  useEffect(() => {
28
57
  groundPlane.current.constant = -gridY
@@ -30,8 +59,64 @@ export function useGridEvents(gridY: number) {
30
59
 
31
60
  useEffect(() => {
32
61
  const canvas = gl.domElement
62
+ setSurfaceRaycastLayers(raycaster.current.layers)
63
+
64
+ const getSurfaceIntersection = (): GridIntersection | null => {
65
+ let closest: GridIntersection | null = null
66
+ let closestDistance = Number.POSITIVE_INFINITY
67
+
68
+ for (const [type, definition] of nodeRegistry.entries()) {
69
+ const descriptor = registeredDraftingSurface(definition)
70
+ if (!descriptor) continue
71
+ for (const id of sceneRegistry.byType[type] ?? []) {
72
+ const root = sceneRegistry.nodes.get(id)
73
+ if (!root) continue
74
+ const scope = useInteractionScope.getState().scope
75
+ const surfaceDraft = registeredDraftingConfig(scope)?.surfaceQuery === true
76
+ if (surfaceDraft && useScene.getState().nodes[id as AnyNodeId]?.visible === false)
77
+ continue
78
+ if (surfaceDraft && !root.visible) continue
79
+ const intersections =
80
+ surfaceDraft && descriptor.raycast === 'underside'
81
+ ? raycastCeilingUnderside(raycaster.current, root)
82
+ : raycaster.current.intersectObject(root, true)
83
+ const hit = intersections.find((candidate) => {
84
+ if (!surfaceDraft) return true
85
+ if (useScene.getState().nodes[id as AnyNodeId]?.visible === false) return false
86
+ let object: Object3D | null = candidate.object
87
+ while (object) {
88
+ if (!object.visible || object.userData.wallHidden === true) return false
89
+ object = object.parent
90
+ }
91
+ return true
92
+ })
93
+ if (!hit || hit.distance >= closestDistance) continue
94
+ closestDistance = hit.distance
95
+ const worldNormal = hit.face
96
+ ? hit.face.normal
97
+ .clone()
98
+ .applyNormalMatrix(new Matrix3().getNormalMatrix(hit.object.matrixWorld))
99
+ .normalize()
100
+ : undefined
101
+ if (surfaceDraft && worldNormal && worldNormal.dot(raycaster.current.ray.direction) > 0)
102
+ worldNormal.negate()
103
+ closest = {
104
+ point: hit.point.clone(),
105
+ surface: {
106
+ point: hit.point.clone(),
107
+ object: hit.object,
108
+ hostId: id as AnyNodeId,
109
+ descriptor,
110
+ worldNormal,
111
+ },
112
+ }
113
+ }
114
+ }
115
+
116
+ return closest
117
+ }
33
118
 
34
- const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => {
119
+ const getIntersection = (nativeEvent: MouseEvent | PointerEvent): GridIntersection | null => {
35
120
  // Convert mouse position to normalized device coordinates (-1 to +1)
36
121
  const rect = canvas.getBoundingClientRect()
37
122
  pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
@@ -40,6 +125,33 @@ export function useGridEvents(gridY: number) {
40
125
  // Update raycaster
41
126
  raycaster.current.setFromCamera(pointer.current, camera)
42
127
 
128
+ // R3F node events can be stopped by another mesh, so this canvas-level
129
+ // raycast is the reliable architectural-surface source for placement and
130
+ // drawing tools. Keep it separate from the ordinary grid point so tools
131
+ // that intentionally place on the floor retain their existing behavior.
132
+ // Architectural meshes are expensive to raycast and are meaningful only
133
+ // to an active placement/drafting interaction. Floor tools retain the
134
+ // ordinary terrain/grid intersection without scanning every wall.
135
+ const surfaceHit = semanticSurfaceQueryRef.current ? getSurfaceIntersection() : null
136
+
137
+ // A semantic architectural hit is the authoritative cursor position.
138
+ // Do not replace it with the terrain/grid intersection below: that would
139
+ // make a wall hit carry wall metadata while still placing at the ground
140
+ // floor, especially in perspective views.
141
+ if (surfaceHit) return surfaceHit
142
+
143
+ const scope = useInteractionScope.getState().scope
144
+ const surfaceDraft = registeredDraftingConfig(scope)?.surfaceQuery === true
145
+ const workingSurface = getPlacementSurface()
146
+ if (surfaceDraft && workingSurface) {
147
+ const plane = new Plane().setFromNormalAndCoplanarPoint(
148
+ workingSurface.normal,
149
+ workingSurface.point,
150
+ )
151
+ const projected = raycaster.current.ray.intersectPlane(plane, intersectionPoint.current)
152
+ return { point: (projected ?? workingSurface.point).clone() }
153
+ }
154
+
43
155
  // Sculpted ground wins over the plane, but only while the plane IS the
44
156
  // ground (see `isSiteGroundPlane`): a plane riding a storey base or a slab
45
157
  // top is a real flat surface and must stay planar. The march is what removes
@@ -60,14 +172,18 @@ export function useGridEvents(gridY: number) {
60
172
  [direction.x, direction.y, direction.z],
61
173
  -groundPlane.current.constant,
62
174
  )
63
- if (hit) return intersectionPoint.current.set(hit.x, hit.y, hit.z).clone()
175
+ if (hit) {
176
+ return {
177
+ point: intersectionPoint.current.set(hit.x, hit.y, hit.z).clone(),
178
+ }
179
+ }
64
180
 
65
181
  // Intersect with ground plane
66
182
  if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) {
67
- return intersectionPoint.current.clone()
183
+ return { point: intersectionPoint.current.clone() }
68
184
  }
69
185
 
70
- return null
186
+ return surfaceHit
71
187
  }
72
188
 
73
189
  const emit = (suffix: EventSuffix, nativeEvent: MouseEvent | PointerEvent) => {
@@ -75,14 +191,69 @@ export function useGridEvents(gridY: number) {
75
191
  if (!point) return
76
192
 
77
193
  // Convert world-space point to building-local for tools that live inside a building.
78
- const buildingId = useViewer.getState().selection.buildingId
194
+ const scope = useInteractionScope.getState().scope
195
+ const surfaceDraft = registeredDraftingConfig(scope)?.surfaceQuery === true
196
+ const buildingId = surfaceDraft
197
+ ? useViewer.getState().selection.levelId
198
+ : useViewer.getState().selection.buildingId
79
199
  const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
80
- const localPoint = buildingMesh ? buildingMesh.worldToLocal(point.clone()) : point
200
+ const localPoint = buildingMesh ? buildingMesh.worldToLocal(point.point.clone()) : point.point
201
+ const surfaceLocalPoint = point.surface
202
+ ? buildingMesh
203
+ ? buildingMesh.worldToLocal(point.surface.point.clone())
204
+ : point.surface.point.clone()
205
+ : undefined
206
+ const localNormal = point.surface?.worldNormal
207
+ ? buildingMesh
208
+ ? buildingMesh
209
+ .worldToLocal(point.surface.point.clone().add(point.surface.worldNormal))
210
+ .sub(surfaceLocalPoint ?? localPoint)
211
+ .normalize()
212
+ : point.surface.worldNormal
213
+ : undefined
214
+ const surfaceNode = point.surface
215
+ ? useScene.getState().nodes[point.surface.hostId]
216
+ : undefined
217
+ const classifiedFace =
218
+ point.surface && localNormal
219
+ ? point.surface.descriptor.classifyFace?.(surfaceNode, [
220
+ localNormal.x,
221
+ localNormal.y,
222
+ localNormal.z,
223
+ ])
224
+ : null
225
+ const { origin, direction } = raycaster.current.ray
226
+ const localRayOrigin = buildingMesh
227
+ ? buildingMesh.worldToLocal(origin.clone())
228
+ : origin.clone()
229
+ const localRayDirection = buildingMesh
230
+ ? buildingMesh.worldToLocal(origin.clone().add(direction)).sub(localRayOrigin).normalize()
231
+ : direction.clone()
81
232
 
82
233
  const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
83
234
  const payload: GridEvent = {
84
- position: [point.x, point.y, point.z],
235
+ localFrameId: buildingId ?? undefined,
236
+ position: [point.point.x, point.point.y, point.point.z],
85
237
  localPosition: [localPoint.x, localPoint.y, localPoint.z],
238
+ localRay: {
239
+ origin: [localRayOrigin.x, localRayOrigin.y, localRayOrigin.z],
240
+ direction: [localRayDirection.x, localRayDirection.y, localRayDirection.z],
241
+ },
242
+ surfaceLocalPosition: surfaceLocalPoint
243
+ ? [surfaceLocalPoint.x, surfaceLocalPoint.y, surfaceLocalPoint.z]
244
+ : undefined,
245
+ surfaceNormal: localNormal ? [localNormal.x, localNormal.y, localNormal.z] : undefined,
246
+ 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,
86
257
  nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
87
258
  }
88
259
 
@@ -107,9 +278,17 @@ export function useGridEvents(gridY: number) {
107
278
  emit('click', e)
108
279
  }
109
280
 
281
+ let lastCameraDragMove = Number.NEGATIVE_INFINITY
110
282
  const handlePointerMove = (e: PointerEvent) => {
111
- // Emit move even if camera is dragging, so tools like PolygonEditor still work
112
- emit('move', e)
283
+ // Moves keep tool cursor snapshots current, including wheel zoom during a tool gesture.
284
+ if (useViewer.getState().cameraDragging) {
285
+ const now = performance.now()
286
+ if (now - lastCameraDragMove < CAMERA_DRAG_MOVE_INTERVAL_MS) return
287
+ lastCameraDragMove = now
288
+ } else {
289
+ lastCameraDragMove = Number.NEGATIVE_INFINITY
290
+ }
291
+ timeSpan('pointer', () => emit('move', e))
113
292
  }
114
293
 
115
294
  const handleDoubleClick = (e: MouseEvent) => {