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

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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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) => {
@@ -0,0 +1,244 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BlockNode,
6
+ clearSceneHistory,
7
+ DuctFittingNode,
8
+ DuctSegmentNode,
9
+ emitter,
10
+ nodeRegistry,
11
+ PipeFittingNode,
12
+ PipeSegmentNode,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { runRedo, runUndo } from '../lib/history'
16
+ import { meshEditScope } from '../lib/interaction/scope'
17
+ import useEditor from '../store/use-editor'
18
+ import useInteractionScope from '../store/use-interaction-scope'
19
+ import {
20
+ blocksSnappingShortcut,
21
+ canCycleSnappingModeShortcut,
22
+ canRunGlobalRotationShortcut,
23
+ isToolOwnedCanopyForm,
24
+ isToolOwnedRotation,
25
+ markToolCancelConsumed,
26
+ runHistoryShortcut,
27
+ } from './use-keyboard'
28
+
29
+ type RafFn = (callback: (time: number) => void) => number
30
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
31
+ callback,
32
+ ) => {
33
+ callback(0)
34
+ return 0
35
+ }
36
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
37
+ () => {}
38
+
39
+ const NODE_ID = 'block_history' as AnyNodeId
40
+
41
+ describe('snapping shortcuts while entering a run length', () => {
42
+ test('allows snap mode and spacing shortcuts in the duct and pipe length field', () => {
43
+ expect(
44
+ blocksSnappingShortcut({
45
+ tagName: 'INPUT',
46
+ isContentEditable: false,
47
+ hasAttribute: (name) => name === 'data-run-length-input',
48
+ }),
49
+ ).toBe(false)
50
+ })
51
+
52
+ test('continues to protect ordinary text fields and editable content', () => {
53
+ for (const tagName of ['INPUT', 'TEXTAREA', 'DIV']) {
54
+ expect(
55
+ blocksSnappingShortcut({
56
+ tagName,
57
+ isContentEditable: tagName === 'DIV',
58
+ hasAttribute: () => false,
59
+ }),
60
+ ).toBe(true)
61
+ }
62
+ expect(blocksSnappingShortcut(null)).toBe(false)
63
+ })
64
+ })
65
+
66
+ beforeEach(() => {
67
+ const node = BlockNode.parse({ id: NODE_ID, position: [0, 0, 0] })
68
+ useScene.setState({
69
+ nodes: { [NODE_ID]: node },
70
+ rootNodeIds: [NODE_ID],
71
+ dirtyNodes: new Set<AnyNodeId>(),
72
+ collections: {},
73
+ materials: {},
74
+ readOnly: false,
75
+ } as never)
76
+ clearSceneHistory()
77
+ useScene.getState().updateNode(NODE_ID, { position: [1, 2, 3] } as Partial<AnyNode>)
78
+ })
79
+
80
+ afterEach(() => {
81
+ useInteractionScope.getState().end()
82
+ useEditor.getState().armToolMode({ mode: 'select' })
83
+ useEditor.setState({ selectedItem: null })
84
+ clearSceneHistory()
85
+ })
86
+
87
+ describe('rotation shortcut ownership', () => {
88
+ test('does not reserve R and T for an armed item tool without a placement item', () => {
89
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
90
+
91
+ expect(isToolOwnedRotation()).toBe(false)
92
+ })
93
+
94
+ test('leaves R and T to the active item placement tool', () => {
95
+ useEditor.getState().setSelectedItem({
96
+ asset: { id: 'asset:test' },
97
+ } as never)
98
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
99
+
100
+ expect(isToolOwnedRotation()).toBe(true)
101
+ })
102
+
103
+ test('leaves R and T to the active lean-to placement tool', () => {
104
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'lean-to-extension' })
105
+
106
+ expect(isToolOwnedRotation()).toBe(true)
107
+ })
108
+
109
+ test('leaves R and T to a moving lean-to extension', () => {
110
+ const leanTo = { id: 'lean_to_moving', type: 'lean-to-extension' } as unknown as AnyNode
111
+ useInteractionScope.getState().begin({
112
+ kind: 'moving',
113
+ node: leanTo,
114
+ nodeId: leanTo.id,
115
+ nodeType: leanTo.type,
116
+ view: '3d',
117
+ })
118
+
119
+ expect(isToolOwnedRotation()).toBe(true)
120
+ })
121
+
122
+ test('leaves F to the active lean-to placement tool', () => {
123
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'lean-to-extension' })
124
+
125
+ expect(isToolOwnedCanopyForm()).toBe(true)
126
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
127
+ expect(isToolOwnedCanopyForm()).toBe(false)
128
+ })
129
+ })
130
+
131
+ describe('history shortcuts during block editing', () => {
132
+ test('reserves global rotation shortcuts for the active mesh editor', () => {
133
+ expect(canRunGlobalRotationShortcut()).toBe(true)
134
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
135
+ expect(canRunGlobalRotationShortcut()).toBe(false)
136
+ })
137
+
138
+ test('keeps Shift available to cycle snapping while a mesh operation is active', () => {
139
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
140
+ expect(canCycleSnappingModeShortcut(true)).toBe(true)
141
+
142
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID, 'operating', 'translate'))
143
+ expect(canCycleSnappingModeShortcut(true)).toBe(true)
144
+ })
145
+
146
+ test('undoes and redoes mesh changes without leaving component selection mode', () => {
147
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
148
+
149
+ expect(runHistoryShortcut('undo')).toBe(true)
150
+ expect((useScene.getState().nodes[NODE_ID] as BlockNode).position).toEqual([0, 0, 0])
151
+ expect(useInteractionScope.getState().scope).toEqual({
152
+ kind: 'mesh-editing',
153
+ nodeId: NODE_ID,
154
+ phase: 'selecting',
155
+ })
156
+
157
+ expect(runHistoryShortcut('redo')).toBe(true)
158
+ expect((useScene.getState().nodes[NODE_ID] as BlockNode).position).toEqual([1, 2, 3])
159
+ expect(useInteractionScope.getState().scope).toEqual({
160
+ kind: 'mesh-editing',
161
+ nodeId: NODE_ID,
162
+ phase: 'selecting',
163
+ })
164
+ })
165
+ })
166
+
167
+ describe('history while drawing distribution runs', () => {
168
+ let restoreRegistry = () => {}
169
+
170
+ beforeEach(() => {
171
+ restoreRegistry = nodeRegistry._snapshot()
172
+ nodeRegistry._reset()
173
+ for (const kind of ['duct-segment', 'pipe-segment']) {
174
+ nodeRegistry._register({
175
+ kind,
176
+ schemaVersion: 1,
177
+ drafting: { cancelOnHistoryJump: true },
178
+ } as never)
179
+ }
180
+ })
181
+
182
+ afterEach(() => {
183
+ restoreRegistry()
184
+ restoreRegistry = () => {}
185
+ })
186
+
187
+ for (const [kind, schema, fittingSchema] of [
188
+ ['duct', DuctSegmentNode, DuctFittingNode],
189
+ ['pipe', PipeSegmentNode, PipeFittingNode],
190
+ ] as const) {
191
+ test(`undoes and redoes individual ${kind} commits while drafting`, () => {
192
+ clearSceneHistory()
193
+ const first = schema.parse({
194
+ path: [
195
+ [0, 1, 0],
196
+ [1, 1, 0],
197
+ ],
198
+ })
199
+ const second = schema.parse({
200
+ path: [
201
+ [1, 1, 0],
202
+ [2, 1, 0],
203
+ ],
204
+ })
205
+ useScene.getState().applyNodeChanges({ create: [{ node: first }] })
206
+ const fitting = fittingSchema.parse({ position: [1, 1, 0] })
207
+ const trimmedPath: [number, number, number][] = [
208
+ [0, 1, 0],
209
+ [0.8, 1, 0],
210
+ ]
211
+ useScene.getState().applyNodeChanges({
212
+ create: [{ node: second }, { node: fitting }],
213
+ update: [{ id: first.id, data: { path: trimmedPath } }],
214
+ })
215
+ expect(useScene.temporal.getState().pastStates).toHaveLength(2)
216
+ useInteractionScope.getState().begin({ kind: 'drafting', tool: first.type })
217
+ let cancellations = 0
218
+ const cancel = () => {
219
+ cancellations += 1
220
+ markToolCancelConsumed()
221
+ }
222
+ emitter.on('tool:cancel', cancel)
223
+ try {
224
+ expect(runHistoryShortcut('undo')).toBe(true)
225
+ expect(useScene.getState().nodes[second.id]).toBeUndefined()
226
+ expect(useScene.getState().nodes[fitting.id]).toBeUndefined()
227
+ expect(useScene.getState().nodes[first.id]).toMatchObject({ path: first.path })
228
+ expect(runUndo().kind).toBe('applied')
229
+ expect(useScene.getState().nodes[first.id]).toBeUndefined()
230
+ expect(runRedo().kind).toBe('applied')
231
+ expect(useScene.getState().nodes[first.id]).toBeDefined()
232
+ expect(useScene.getState().nodes[second.id]).toBeUndefined()
233
+ expect(runHistoryShortcut('redo')).toBe(true)
234
+ expect(useScene.getState().nodes[second.id]).toBeDefined()
235
+ expect(useScene.getState().nodes[fitting.id]).toBeDefined()
236
+ expect(useScene.getState().nodes[first.id]).toMatchObject({ path: trimmedPath })
237
+ expect(cancellations).toBe(4)
238
+ expect(useInteractionScope.getState().scope).toEqual({ kind: 'drafting', tool: first.type })
239
+ } finally {
240
+ emitter.off('tool:cancel', cancel)
241
+ }
242
+ })
243
+ }
244
+ })