@pascal-app/editor 1.0.4 → 1.0.6

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ import { alignFloorplanDraftPoint, useAlignmentGuides } from '@pascal-app/editor'
4
+
5
+ type Vec3 = [number, number, number]
6
+
7
+ /**
8
+ * Layer Figma-style alignment guides onto a draw-tool cursor point so HVAC /
9
+ * DWV runs and equipment line up with each other (and every other node) while
10
+ * being drawn — the same feedback walls get.
11
+ *
12
+ * Treats the point as a single corner anchor, gathers candidates from the live
13
+ * scene (every kind contributes via `nodeAlignmentAnchors`), publishes the
14
+ * guides to `useAlignmentGuides` (rendered in BOTH the 2D floor plan and the
15
+ * 3D view), and returns the point with the snap applied. Y is preserved — only
16
+ * XZ is aligned.
17
+ *
18
+ * - `applySnap: false` publishes the guide passively without pulling the point
19
+ * off a constrained ray (e.g. an angle-locked run continuation).
20
+ * - `bypass: true` clears guides and returns the point untouched (Alt, or when
21
+ * a stronger port / run-body snap already won).
22
+ */
23
+ export function alignDrawPoint(point: Vec3, opts: { applySnap: boolean; bypass?: boolean }): Vec3 {
24
+ if (opts.bypass) {
25
+ useAlignmentGuides.getState().clear()
26
+ return point
27
+ }
28
+ const [x, z] = alignFloorplanDraftPoint([point[0], point[2]], { applySnap: opts.applySnap })
29
+ return [x, point[1], z]
30
+ }
31
+
32
+ /** Drop any alignment guides this tool published (cancel / commit / unmount). */
33
+ export function clearDrawAlignment(): void {
34
+ useAlignmentGuides.getState().clear()
35
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { RunSurfaceTarget } from './distribution-run-contract'
3
+ import { findNearestPort3D, findNearestPortXZ, type ScenePort } from './ports'
4
+
5
+ const ports: ScenePort[] = [
6
+ {
7
+ nodeId: 'duct-segment_a',
8
+ id: 'floor',
9
+ position: [0, 0, 0],
10
+ diameter: 0.1,
11
+ direction: [1, 0, 0],
12
+ },
13
+ {
14
+ nodeId: 'duct-segment_b',
15
+ id: 'wall',
16
+ position: [0.4, 2, 0],
17
+ diameter: 0.1,
18
+ direction: [1, 0, 0],
19
+ },
20
+ ]
21
+
22
+ describe('distribution port distance metrics', () => {
23
+ test('wall drafting uses true 3D distance', () => {
24
+ expect(findNearestPort3D([0.4, 1.8, 0], ports, 0.5)?.id).toBe('wall')
25
+ expect(findNearestPort3D([0, 1.8, 0], ports, 0.1)).toBeNull()
26
+ })
27
+
28
+ test('floor drafting retains the legacy XZ metric', () => {
29
+ expect(findNearestPortXZ([0, 1.8, 0], ports, 0.1)?.id).toBe('floor')
30
+ })
31
+
32
+ test('wall drafting rejects a nearby port on the wrong plane', () => {
33
+ const wall: RunSurfaceTarget = {
34
+ kind: 'wall',
35
+ levelId: 'level_1',
36
+ hostId: 'wall_1',
37
+ side: 'front',
38
+ frame: {
39
+ origin: [0, 0, 0],
40
+ normal: [0, 0, 1],
41
+ tangent: [1, 0, 0],
42
+ bitangent: [0, 1, 0],
43
+ },
44
+ bounds: { minU: 0, maxU: 10, minV: 0, maxV: 3 },
45
+ }
46
+ const offPlane: ScenePort = {
47
+ nodeId: 'duct-segment_c',
48
+ id: 'off-plane',
49
+ position: [0, 1.8, 0.25],
50
+ diameter: 0.1,
51
+ direction: [1, 0, 0],
52
+ }
53
+ expect(findNearestPort3D([0, 1.8, 0], [offPlane], 0.1, wall)).toBeNull()
54
+ expect(findNearestPort3D([0.4, 2, 0], ports, 0.1, wall)?.id).toBe('wall')
55
+ })
56
+ })
@@ -0,0 +1,378 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ findLevelAncestorId,
5
+ type NodePort,
6
+ nodeRegistry,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import type { RunSurfaceTarget } from './distribution-run-contract'
10
+
11
+ /** A port plus the scene node that owns it. */
12
+ export type ScenePort = NodePort & { nodeId: AnyNodeId }
13
+
14
+ /**
15
+ * Filter narrowing which ports a tool will snap to.
16
+ * - `excludeNodeId` skips the node currently being drawn/placed so a
17
+ * tool doesn't snap to its own preview.
18
+ * - `systems` keeps only ports on the listed distribution loops — duct
19
+ * tools pass the air loops so they ignore refrigerant service ports;
20
+ * the lineset tool passes `'refrigerant'` so it ignores duct collars.
21
+ * A port with no `system` matches any filter.
22
+ */
23
+ export type PortFilter = {
24
+ levelId?: AnyNodeId
25
+ excludeNodeId?: AnyNodeId
26
+ systems?: readonly string[]
27
+ }
28
+
29
+ /**
30
+ * Gather every typed port in the scene by asking each node's registered
31
+ * `def.ports`. Positions are level-local meters (the kind applies its own
32
+ * transform inside `def.ports`).
33
+ */
34
+ export function collectScenePorts(
35
+ filter: PortFilter = {},
36
+ sceneNodes?: Readonly<Record<AnyNodeId, AnyNode>>,
37
+ ): ScenePort[] {
38
+ const { excludeNodeId, systems, levelId } = filter
39
+ const nodes = sceneNodes ?? useScene.getState().nodes
40
+ const result: ScenePort[] = []
41
+ for (const node of Object.values(nodes)) {
42
+ if (
43
+ !node ||
44
+ node.visible === false ||
45
+ node.id === excludeNodeId ||
46
+ (levelId && findLevelAncestorId(node.id, nodes) !== levelId)
47
+ )
48
+ continue
49
+ const ports = nodeRegistry.get(node.type)?.ports?.(node)
50
+ if (!ports) continue
51
+ for (const port of ports) {
52
+ if (systems && port.system !== undefined && !systems.includes(port.system)) continue
53
+ result.push({ ...port, nodeId: node.id })
54
+ }
55
+ }
56
+ return result
57
+ }
58
+
59
+ /**
60
+ * Nearest port within `radius` of `point` on the XZ plane. Y is ignored —
61
+ * grid events ride the floor plane while ports usually hang at duct
62
+ * height, so a vertical-distance check would make elevated ports
63
+ * unreachable. The snap adopts the port's full 3D position.
64
+ */
65
+ export function findNearestPortXZ(
66
+ point: readonly [number, number, number],
67
+ ports: ScenePort[],
68
+ radius: number,
69
+ ): ScenePort | null {
70
+ let best: ScenePort | null = null
71
+ let bestDistSq = radius * radius
72
+ for (const port of ports) {
73
+ const dx = port.position[0] - point[0]
74
+ const dz = port.position[2] - point[2]
75
+ const distSq = dx * dx + dz * dz
76
+ if (distSq <= bestDistSq) {
77
+ bestDistSq = distSq
78
+ best = port
79
+ }
80
+ }
81
+ return best
82
+ }
83
+
84
+ /** Nearest port using true 3D distance. Use this while drafting on a wall. */
85
+ export function findNearestPort3D(
86
+ point: readonly [number, number, number],
87
+ ports: ScenePort[],
88
+ radius: number,
89
+ surface?: RunSurfaceTarget | null,
90
+ ): ScenePort | null {
91
+ let best: ScenePort | null = null
92
+ let bestDistSq = radius * radius
93
+ for (const port of ports) {
94
+ if (surface?.kind === 'wall') {
95
+ const planeDistance =
96
+ (port.position[0] - surface.frame.origin[0]) * surface.frame.normal[0] +
97
+ (port.position[1] - surface.frame.origin[1]) * surface.frame.normal[1] +
98
+ (port.position[2] - surface.frame.origin[2]) * surface.frame.normal[2]
99
+ if (Math.abs(planeDistance) > radius) continue
100
+ }
101
+ const dx = port.position[0] - point[0]
102
+ const dy = port.position[1] - point[1]
103
+ const dz = port.position[2] - point[2]
104
+ const distSq = dx * dx + dy * dy + dz * dz
105
+ if (distSq <= bestDistSq) {
106
+ bestDistSq = distSq
107
+ best = port
108
+ }
109
+ }
110
+ return best
111
+ }
112
+
113
+ // ─── Run-body hits ───────────────────────────────────────────────────
114
+
115
+ /** Closest-point hit on a run's centerline (not its end ports). */
116
+ export type RunBodyHit = {
117
+ nodeId: AnyNodeId
118
+ /** Polyline segment hit — between `path[segmentIndex]` and `path[segmentIndex + 1]`. */
119
+ segmentIndex: number
120
+ /** Closest point on the centerline, level-local meters (Y interpolated). */
121
+ point: [number, number, number]
122
+ }
123
+
124
+ /**
125
+ * Nearest point on any run CENTERLINE of the given kinds within `radius` of `point`
126
+ * on the XZ plane — how a branch taps the side of a trunk. Same XZ-only
127
+ * distance convention as `findNearestPortXZ` (grid events ride the floor,
128
+ * runs hang above it); the hit adopts the centerline's full 3D
129
+ * position. Vertical risers project to a point in XZ and are skipped —
130
+ * tapping those isn't meaningful.
131
+ */
132
+ export function findNearestRunBodyXZ(
133
+ point: readonly [number, number, number],
134
+ radius: number,
135
+ filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[]; levelId?: AnyNodeId },
136
+ sceneNodes?: Readonly<Record<AnyNodeId, AnyNode>>,
137
+ ): RunBodyHit | null {
138
+ const { kinds } = filter
139
+ const nodes = sceneNodes ?? useScene.getState().nodes
140
+ let best: RunBodyHit | null = null
141
+ let bestDistSq = radius * radius
142
+ for (const node of Object.values(nodes)) {
143
+ if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
144
+ if (filter.levelId && findLevelAncestorId(node.id, nodes) !== filter.levelId) continue
145
+ const path = (node as { path?: Array<readonly [number, number, number]> }).path
146
+ if (!path) continue
147
+ for (let i = 0; i < path.length - 1; i++) {
148
+ const a = path[i]!
149
+ const b = path[i + 1]!
150
+ const abx = b[0] - a[0]
151
+ const abz = b[2] - a[2]
152
+ const lenSq = abx * abx + abz * abz
153
+ if (lenSq < 1e-8) continue // vertical riser — no XZ extent
154
+ const t = Math.min(
155
+ 1,
156
+ Math.max(0, ((point[0] - a[0]) * abx + (point[2] - a[2]) * abz) / lenSq),
157
+ )
158
+ const cx = a[0] + abx * t
159
+ const cz = a[2] + abz * t
160
+ const dx = point[0] - cx
161
+ const dz = point[2] - cz
162
+ const distSq = dx * dx + dz * dz
163
+ if (distSq <= bestDistSq) {
164
+ bestDistSq = distSq
165
+ best = {
166
+ nodeId: node.id,
167
+ segmentIndex: i,
168
+ point: [cx, a[1] + (b[1] - a[1]) * t, cz],
169
+ }
170
+ }
171
+ }
172
+ }
173
+ return best
174
+ }
175
+
176
+ /** Nearest run centerline point using true 3D distance, including risers. */
177
+ export function findNearestRunBody3D(
178
+ point: readonly [number, number, number],
179
+ radius: number,
180
+ filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[]; levelId?: AnyNodeId },
181
+ surface?: RunSurfaceTarget | null,
182
+ sceneNodes?: Readonly<Record<AnyNodeId, AnyNode>>,
183
+ ): RunBodyHit | null {
184
+ const { kinds } = filter
185
+ const nodes = sceneNodes ?? useScene.getState().nodes
186
+ let best: RunBodyHit | null = null
187
+ let bestDistSq = radius * radius
188
+ for (const node of Object.values(nodes)) {
189
+ if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
190
+ if (filter.levelId && findLevelAncestorId(node.id, nodes) !== filter.levelId) continue
191
+ if (node.visible === false) continue
192
+ const path = (node as { path?: Array<readonly [number, number, number]> }).path
193
+ if (!path) continue
194
+ for (let i = 0; i < path.length - 1; i++) {
195
+ const a = path[i]!
196
+ const b = path[i + 1]!
197
+ if (surface?.kind === 'wall') {
198
+ const aPlane =
199
+ (a[0] - surface.frame.origin[0]) * surface.frame.normal[0] +
200
+ (a[1] - surface.frame.origin[1]) * surface.frame.normal[1] +
201
+ (a[2] - surface.frame.origin[2]) * surface.frame.normal[2]
202
+ const bPlane =
203
+ (b[0] - surface.frame.origin[0]) * surface.frame.normal[0] +
204
+ (b[1] - surface.frame.origin[1]) * surface.frame.normal[1] +
205
+ (b[2] - surface.frame.origin[2]) * surface.frame.normal[2]
206
+ if (Math.abs(aPlane) > radius && Math.abs(bPlane) > radius) continue
207
+ }
208
+ const abx = b[0] - a[0]
209
+ const aby = b[1] - a[1]
210
+ const abz = b[2] - a[2]
211
+ const lenSq = abx * abx + aby * aby + abz * abz
212
+ if (lenSq < 1e-8) continue
213
+ const t = Math.min(
214
+ 1,
215
+ Math.max(
216
+ 0,
217
+ ((point[0] - a[0]) * abx + (point[1] - a[1]) * aby + (point[2] - a[2]) * abz) / lenSq,
218
+ ),
219
+ )
220
+ const hit: [number, number, number] = [a[0] + abx * t, a[1] + aby * t, a[2] + abz * t]
221
+ const dx = point[0] - hit[0]
222
+ const dy = point[1] - hit[1]
223
+ const dz = point[2] - hit[2]
224
+ const distSq = dx * dx + dy * dy + dz * dz
225
+ if (distSq <= bestDistSq) {
226
+ bestDistSq = distSq
227
+ best = { nodeId: node.id, segmentIndex: i, point: hit }
228
+ }
229
+ }
230
+ }
231
+ return best
232
+ }
233
+
234
+ /**
235
+ * Where a drawn segment `start`→`end` crosses straight THROUGH an
236
+ * existing run's centerline in XZ — the four-way (cross) case, as
237
+ * opposed to ending ON a run (the tee case). The crossing must be
238
+ * INTERIOR to both: strictly between the drawn segment's ends (so the
239
+ * run truly passes through, not just touches at a tip — those are tee
240
+ * taps) and strictly inside the hit trunk segment, clear of its joints
241
+ * by `endMargin` meters so the run legs have room. The hit's `point`
242
+ * adopts the trunk centerline's interpolated 3D position (the drawn run
243
+ * snaps onto the trunk's height). Returns the nearest such crossing, or
244
+ * null. Vertical risers (no XZ extent) are skipped, same as the body
245
+ * query.
246
+ */
247
+ export function findRunBodyCrossingXZ(
248
+ start: readonly [number, number, number],
249
+ end: readonly [number, number, number],
250
+ endMargin: number,
251
+ filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[] },
252
+ ): RunBodyHit | null {
253
+ const { kinds } = filter
254
+ const { nodes } = useScene.getState()
255
+ const dx = end[0] - start[0]
256
+ const dz = end[2] - start[2]
257
+ const drawnLenSq = dx * dx + dz * dz
258
+ if (drawnLenSq < 1e-8) return null
259
+ const drawnLen = Math.sqrt(drawnLenSq)
260
+ // Interior margins as a fraction of each segment's length.
261
+ const drawnPad = Math.min(0.45, endMargin / drawnLen)
262
+ let best: RunBodyHit | null = null
263
+ let bestScore = Number.POSITIVE_INFINITY
264
+ for (const node of Object.values(nodes)) {
265
+ if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
266
+ const path = (node as { path?: Array<readonly [number, number, number]> }).path
267
+ if (!path) continue
268
+ for (let i = 0; i < path.length - 1; i++) {
269
+ const a = path[i]!
270
+ const b = path[i + 1]!
271
+ const ex = b[0] - a[0]
272
+ const ez = b[2] - a[2]
273
+ const runLenSq = ex * ex + ez * ez
274
+ if (runLenSq < 1e-8) continue // vertical riser — no XZ extent
275
+ // Solve start + s·d = a + t·e in XZ. denom is the 2D cross of the
276
+ // two directions; ~0 means parallel (no single crossing).
277
+ const denom = dx * ez - dz * ex
278
+ if (Math.abs(denom) < 1e-9) continue
279
+ const wx = a[0] - start[0]
280
+ const wz = a[2] - start[2]
281
+ const s = (wx * ez - wz * ex) / denom
282
+ const t = (wx * dz - wz * dx) / denom
283
+ const runLen = Math.sqrt(runLenSq)
284
+ const runPad = Math.min(0.45, endMargin / runLen)
285
+ // Strictly interior to both segments, clear of the trunk's joints.
286
+ if (s <= drawnPad || s >= 1 - drawnPad) continue
287
+ if (t <= runPad || t >= 1 - runPad) continue
288
+ // Prefer the crossing nearest the drawn start (first run hit).
289
+ if (s < bestScore) {
290
+ bestScore = s
291
+ best = {
292
+ nodeId: node.id,
293
+ segmentIndex: i,
294
+ point: [a[0] + ex * t, a[1] + (b[1] - a[1]) * t, a[2] + ez * t],
295
+ }
296
+ }
297
+ }
298
+ }
299
+ return best
300
+ }
301
+
302
+ /** Surface-local crossing for wall drafting. Intersects projected U/V lines
303
+ * and ignores runs that are not coplanar with the selected wall. */
304
+ export function findRunBodyCrossingSurface(
305
+ start: readonly [number, number, number],
306
+ end: readonly [number, number, number],
307
+ endMargin: number,
308
+ surface: RunSurfaceTarget,
309
+ filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[] },
310
+ ): RunBodyHit | null {
311
+ const project = (p: readonly [number, number, number]): [number, number, number] => {
312
+ const d: [number, number, number] = [
313
+ p[0] - surface.frame.origin[0],
314
+ p[1] - surface.frame.origin[1],
315
+ p[2] - surface.frame.origin[2],
316
+ ]
317
+ return [
318
+ d[0] * surface.frame.tangent[0] +
319
+ d[1] * surface.frame.tangent[1] +
320
+ d[2] * surface.frame.tangent[2],
321
+ d[0] * surface.frame.bitangent[0] +
322
+ d[1] * surface.frame.bitangent[1] +
323
+ d[2] * surface.frame.bitangent[2],
324
+ d[0] * surface.frame.normal[0] +
325
+ d[1] * surface.frame.normal[1] +
326
+ d[2] * surface.frame.normal[2],
327
+ ]
328
+ }
329
+ const s0 = project(start),
330
+ s1 = project(end)
331
+ const dx = s1[0] - s0[0],
332
+ dy = s1[1] - s0[1]
333
+ const drawnLen = Math.hypot(dx, dy)
334
+ if (drawnLen < 1e-8) return null
335
+ const drawnPad = Math.min(0.45, endMargin / drawnLen)
336
+ const { kinds } = filter
337
+ const { nodes } = useScene.getState()
338
+ let best: RunBodyHit | null = null
339
+ let bestScore = Number.POSITIVE_INFINITY
340
+ for (const node of Object.values(nodes)) {
341
+ if (
342
+ !node ||
343
+ node.visible === false ||
344
+ node.parentId !== surface.levelId ||
345
+ !kinds.includes(node.type) ||
346
+ node.id === filter.excludeNodeId
347
+ )
348
+ continue
349
+ const path = (node as { path?: Array<readonly [number, number, number]> }).path
350
+ if (!path) continue
351
+ for (let i = 0; i < path.length - 1; i++) {
352
+ const a = path[i]!,
353
+ b = path[i + 1]!,
354
+ pa = project(a),
355
+ pb = project(b)
356
+ const ex = pb[0] - pa[0],
357
+ ey = pb[1] - pa[1],
358
+ runLen = Math.hypot(ex, ey)
359
+ const denom = dx * ey - dy * ex
360
+ if (runLen < 1e-8 || Math.abs(denom) < 1e-9) continue
361
+ const wx = pa[0] - s0[0],
362
+ wy = pa[1] - s0[1]
363
+ const s = (wx * ey - wy * ex) / denom,
364
+ t = (wx * dy - wy * dx) / denom
365
+ if (Math.abs(pa[2] + t * (pb[2] - pa[2]) - (s0[2] + s * (s1[2] - s0[2]))) > 0.01) continue
366
+ const runPad = Math.min(0.45, endMargin / runLen)
367
+ if (s <= drawnPad || s >= 1 - drawnPad || t <= runPad || t >= 1 - runPad || s >= bestScore)
368
+ continue
369
+ bestScore = s
370
+ best = {
371
+ nodeId: node.id,
372
+ segmentIndex: i,
373
+ point: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t],
374
+ }
375
+ }
376
+ }
377
+ return best
378
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNodeId } from '@pascal-app/core'
3
+ import {
4
+ createRunSurfaceFrame,
5
+ isSameRunWallSurface,
6
+ shouldLockRunWallSurface,
7
+ } from './distribution-run-tool'
8
+ import { intersectRunPlane, resolveRunCursorPlane } from './run-cursor'
9
+
10
+ describe('surface-first run cursor', () => {
11
+ test('matches only the wall host and face that started the draft', () => {
12
+ const target = {
13
+ kind: 'wall' as const,
14
+ levelId: 'level-1' as AnyNodeId,
15
+ hostId: 'wall-1' as AnyNodeId,
16
+ side: 'front' as const,
17
+ frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]),
18
+ bounds: { minU: -1, maxU: 1, minV: -1, maxV: 1 },
19
+ }
20
+
21
+ expect(
22
+ isSameRunWallSurface(target, {
23
+ kind: 'wall',
24
+ hostId: target.hostId,
25
+ face: 'side',
26
+ side: 'front',
27
+ }),
28
+ ).toBe(true)
29
+ expect(
30
+ isSameRunWallSurface(target, {
31
+ kind: 'wall',
32
+ hostId: target.hostId,
33
+ face: 'side',
34
+ side: 'back',
35
+ }),
36
+ ).toBe(false)
37
+ expect(
38
+ isSameRunWallSurface(target, {
39
+ kind: 'wall',
40
+ hostId: 'wall-2',
41
+ face: 'side',
42
+ side: 'front',
43
+ }),
44
+ ).toBe(false)
45
+ })
46
+
47
+ test('locks a wall only when the start is not snapped to a body or port', () => {
48
+ const target = {
49
+ kind: 'wall' as const,
50
+ levelId: 'level-1' as AnyNodeId,
51
+ hostId: 'wall-1' as AnyNodeId,
52
+ side: 'front' as const,
53
+ frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]),
54
+ bounds: { minU: 0, maxU: 1, minV: 0, maxV: 1 },
55
+ }
56
+
57
+ expect(shouldLockRunWallSurface(target, { port: null, body: null })).toBe(true)
58
+ expect(shouldLockRunWallSurface(target, { port: {} as never, body: null })).toBe(false)
59
+ expect(shouldLockRunWallSurface(target, { port: null, body: {} as never })).toBe(false)
60
+ })
61
+
62
+ test('reacquires either ceiling face from free space with duct or pipe clearance', () => {
63
+ for (const side of [-1, 1]) {
64
+ for (const clearance of [0.0254, 0.1016]) {
65
+ const result = resolveRunCursorPlane({
66
+ hit: {
67
+ point: [2, 3, 4],
68
+ frame: createRunSurfaceFrame([0, 3, 0], [0, side, 0]),
69
+ },
70
+ working: createRunSurfaceFrame([0, 1, 0]),
71
+ fallback: [2, 1, 4],
72
+ clearance,
73
+ })
74
+ expect(result.point[1]).toBeCloseTo(3 + side * clearance)
75
+ expect(result.frame.origin[1]).toBeCloseTo(result.point[1])
76
+ expect(result.frame.normal).toEqual([0, side, 0])
77
+ }
78
+ }
79
+ })
80
+
81
+ test('wall hit wins over a ground fallback and applies clearance once', () => {
82
+ const frame = createRunSurfaceFrame([0, 0, 2], [0, 0, 1])
83
+ const result = resolveRunCursorPlane({
84
+ hit: { point: [1, 1.5, 2], frame },
85
+ working: null,
86
+ fallback: [9, 0, 9],
87
+ clearance: 0.0508,
88
+ })
89
+ expect(result.point).toEqual([1, 1.5, 2.0508])
90
+ })
91
+ test('empty space continues on the wall plane along the actual mouse ray', () => {
92
+ const frame = createRunSurfaceFrame([0, 0, 2], [0, 0, 1])
93
+ const result = resolveRunCursorPlane({
94
+ hit: null,
95
+ working: frame,
96
+ ray: { origin: [0, 4, 6], direction: [1, -0.5, -1] },
97
+ fallback: [0, 0, 0],
98
+ clearance: 0.1,
99
+ })
100
+ expect(result.point).toEqual([4, 2, 2])
101
+ })
102
+ test('ceiling target replaces wall plane and offsets downward', () => {
103
+ const result = resolveRunCursorPlane({
104
+ hit: { point: [2, 3, 4], frame: createRunSurfaceFrame([0, 3, 0], [0, -1, 0]) },
105
+ working: createRunSurfaceFrame([0, 0, 4], [0, 0, 1]),
106
+ fallback: [0, 0, 0],
107
+ clearance: 0.1,
108
+ })
109
+ expect(result.point).toEqual([2, 2.9, 4])
110
+ })
111
+ test('parallel and behind-camera intersections preserve the last point', () => {
112
+ const working = createRunSurfaceFrame([0, 0, 2], [0, 0, 1])
113
+ expect(intersectRunPlane({ origin: [0, 0, 0], direction: [0, 0, -1] }, working)).toBeNull()
114
+ expect(
115
+ resolveRunCursorPlane({
116
+ hit: null,
117
+ working,
118
+ ray: { origin: [0, 0, 0], direction: [1, 0, 0] },
119
+ fallback: [1, 2, 2],
120
+ clearance: 0,
121
+ }).point,
122
+ ).toEqual([1, 2, 2])
123
+ })
124
+ test('floor and sloped planes use their own normal for clearance', () => {
125
+ const frame = createRunSurfaceFrame([0, 2, 0], [0, Math.SQRT1_2, Math.SQRT1_2])
126
+ const result = resolveRunCursorPlane({
127
+ hit: { point: [0, 2, 0], frame },
128
+ working: null,
129
+ fallback: [0, 0, 0],
130
+ clearance: 0.1,
131
+ })
132
+ expect(result.point[1]).toBeCloseTo(2 + Math.SQRT1_2 * 0.1)
133
+ expect(result.point[2]).toBeCloseTo(Math.SQRT1_2 * 0.1)
134
+ })
135
+ })
@@ -0,0 +1,36 @@
1
+ import type { RunPoint, RunSurfaceFrame } from './distribution-run-contract'
2
+
3
+ export function intersectRunPlane(
4
+ ray: { origin: RunPoint; direction: RunPoint },
5
+ frame: RunSurfaceFrame,
6
+ ): RunPoint | null {
7
+ const denominator = ray.direction.reduce((sum, value, i) => sum + value * frame.normal[i]!, 0)
8
+ if (Math.abs(denominator) < 1e-5) return null
9
+ const distance =
10
+ frame.origin.reduce((sum, value, i) => sum + (value - ray.origin[i]!) * frame.normal[i]!, 0) /
11
+ denominator
12
+ if (distance < 0 || !Number.isFinite(distance)) return null
13
+ return ray.origin.map((value, i) => value + distance * ray.direction[i]!) as RunPoint
14
+ }
15
+
16
+ export function resolveRunCursorPlane({
17
+ hit,
18
+ working,
19
+ ray,
20
+ fallback,
21
+ clearance,
22
+ }: {
23
+ hit: { point: RunPoint; frame: RunSurfaceFrame } | null
24
+ working: RunSurfaceFrame | null
25
+ ray?: { origin: RunPoint; direction: RunPoint }
26
+ fallback: RunPoint
27
+ clearance: number
28
+ }): { point: RunPoint; frame: RunSurfaceFrame } {
29
+ if (hit) {
30
+ const offset = (point: RunPoint): RunPoint =>
31
+ point.map((value, i) => value + hit.frame.normal[i]! * clearance) as RunPoint
32
+ return { point: offset(hit.point), frame: { ...hit.frame, origin: offset(hit.frame.origin) } }
33
+ }
34
+ if (!working) throw new Error('A cursor requires a surface or a working plane')
35
+ return { point: ray ? (intersectRunPlane(ray, working) ?? fallback) : fallback, frame: working }
36
+ }