@pascal-app/editor 1.0.4 → 1.0.5

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 +39 -37
  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,1323 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, emitter, type GridEvent, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import {
5
+ CursorSphere,
6
+ clearPlacementSurface,
7
+ DimensionPill,
8
+ type DimensionPillPart,
9
+ getGridEventScreenProjection,
10
+ isAngleSnapActive,
11
+ isGridSnapActive,
12
+ isMagneticSnapActive,
13
+ isTypingTarget,
14
+ markToolCancelConsumed,
15
+ publishPlacementSurface,
16
+ triggerSFX,
17
+ useEditor,
18
+ useInteractionScope,
19
+ } from '@pascal-app/editor'
20
+ import { Html } from '@react-three/drei'
21
+ import { useThree } from '@react-three/fiber'
22
+ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
23
+ import { createPortal } from 'react-dom'
24
+ import { type Group, Vector3 } from 'three'
25
+ import type { RunSurfaceBounds, RunSurfaceTarget } from './distribution-run-contract'
26
+ import { clearDrawAlignment } from './draw-alignment'
27
+ import type { RunBodyHit, ScenePort } from './ports'
28
+ import { resolveRunCursorPlane } from './run-cursor'
29
+ import {
30
+ RunDirectionFeedback,
31
+ type RunDirectionMode,
32
+ run3DDirectionCandidates,
33
+ runHorizontalDirectionCandidates,
34
+ } from './run-direction-feedback'
35
+ import { findScreenPort, type PortScreenPoint } from './run-port-snap'
36
+ import { chooseScreenDirection, screenDirectionScore } from './run-screen-direction'
37
+
38
+ export type { RunPoint, RunSurfaceFrame } from './distribution-run-contract'
39
+
40
+ import type { RunPoint, RunSurfaceFrame } from './distribution-run-contract'
41
+
42
+ type RunPointerEvent = GridEvent
43
+
44
+ export type RunConnection = {
45
+ port: ScenePort | null
46
+ body: RunBodyHit | null
47
+ }
48
+
49
+ export type RunCommitResult = {
50
+ nextStart: RunPoint
51
+ nextConnection: RunConnection
52
+ }
53
+
54
+ export type RunCursorRay = {
55
+ origin: RunPoint
56
+ direction: RunPoint
57
+ }
58
+
59
+ export type CameraDirectionProjection = {
60
+ point: RunPoint
61
+ direction: RunPoint
62
+ }
63
+
64
+ type ResolvedRunPoint = RunConnection & {
65
+ point: RunPoint
66
+ frame?: RunSurfaceFrame
67
+ surfaceTarget?: RunSurfaceTarget | null
68
+ snapped: RunPoint | null
69
+ snapScreen?: PortScreenPoint
70
+ directionMode: RunDirectionMode
71
+ }
72
+
73
+ type RunWallSurfaceTarget = Extract<RunSurfaceTarget, { kind: 'wall' }>
74
+
75
+ export function shouldLockRunWallSurface(
76
+ target: RunSurfaceTarget | null | undefined,
77
+ connection: RunConnection,
78
+ ): target is RunWallSurfaceTarget {
79
+ return target?.kind === 'wall' && !connection.port && !connection.body
80
+ }
81
+
82
+ export function isSameRunWallSurface(
83
+ target: RunWallSurfaceTarget,
84
+ hit:
85
+ | {
86
+ kind?: string
87
+ hostId?: string
88
+ face?: string
89
+ side?: string
90
+ }
91
+ | undefined,
92
+ ): boolean {
93
+ return (
94
+ hit?.kind === 'wall' &&
95
+ hit.hostId === target.hostId &&
96
+ hit.face === 'side' &&
97
+ hit.side === target.side
98
+ )
99
+ }
100
+
101
+ const UP: RunPoint = [0, 1, 0]
102
+ const X_AXIS: RunPoint = [1, 0, 0]
103
+ const Z_AXIS: RunPoint = [0, 0, 1]
104
+
105
+ function dotRun(a: readonly number[], b: readonly number[]): number {
106
+ return a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!
107
+ }
108
+
109
+ function crossRun(a: readonly number[], b: readonly number[]): RunPoint {
110
+ return [
111
+ a[1]! * b[2]! - a[2]! * b[1]!,
112
+ a[2]! * b[0]! - a[0]! * b[2]!,
113
+ a[0]! * b[1]! - a[1]! * b[0]!,
114
+ ]
115
+ }
116
+
117
+ function normalizeRun(vector: readonly number[], fallback: RunPoint): RunPoint {
118
+ const length = Math.hypot(vector[0]!, vector[1]!, vector[2]!)
119
+ return length < 1e-9
120
+ ? [...fallback]
121
+ : [vector[0]! / length, vector[1]! / length, vector[2]! / length]
122
+ }
123
+
124
+ /** Build a stable 2D drawing frame for a floor, wall, ceiling, or sloped face. */
125
+ export function createRunSurfaceFrame(
126
+ origin: readonly number[],
127
+ surfaceNormal: readonly number[] = UP,
128
+ ): RunSurfaceFrame {
129
+ const normal = normalizeRun(surfaceNormal, UP)
130
+ // Prefer the building's vertical axis for walls and sloped surfaces. For a
131
+ // horizontal floor/ceiling this deliberately falls back to world X so the
132
+ // frame remains stable and matches the existing floor grid orientation.
133
+ const horizontal = Math.abs(dotRun(normal, UP)) > 0.98
134
+ const tangent = horizontal
135
+ ? ([...X_AXIS] as RunPoint)
136
+ : normalizeRun(crossRun(UP, normal), X_AXIS)
137
+ const bitangent = horizontal
138
+ ? ([...Z_AXIS] as RunPoint)
139
+ : normalizeRun(crossRun(normal, tangent), Z_AXIS)
140
+ return {
141
+ origin: [origin[0]!, origin[1]!, origin[2]!],
142
+ normal,
143
+ tangent,
144
+ bitangent,
145
+ }
146
+ }
147
+
148
+ export function projectRunPointToSurface(
149
+ point: readonly number[],
150
+ frame: RunSurfaceFrame,
151
+ ): RunPoint {
152
+ const offset: RunPoint = [
153
+ point[0]! - frame.origin[0],
154
+ point[1]! - frame.origin[1],
155
+ point[2]! - frame.origin[2],
156
+ ]
157
+ const distance = dotRun(offset, frame.normal)
158
+ return [
159
+ point[0]! - frame.normal[0] * distance,
160
+ point[1]! - frame.normal[1] * distance,
161
+ point[2]! - frame.normal[2] * distance,
162
+ ]
163
+ }
164
+
165
+ export function snapRunPointToSurface(
166
+ point: readonly number[],
167
+ frame: RunSurfaceFrame,
168
+ step: number,
169
+ ): RunPoint {
170
+ const projected = projectRunPointToSurface(point, frame)
171
+ if (step <= 0) return projected
172
+ const offset: RunPoint = [
173
+ projected[0] - frame.origin[0],
174
+ projected[1] - frame.origin[1],
175
+ projected[2] - frame.origin[2],
176
+ ]
177
+ const u = snapRunValue(dotRun(offset, frame.tangent), step)
178
+ const v = snapRunValue(dotRun(offset, frame.bitangent), step)
179
+ return [
180
+ frame.origin[0] + frame.tangent[0] * u + frame.bitangent[0] * v,
181
+ frame.origin[1] + frame.tangent[1] * u + frame.bitangent[1] * v,
182
+ frame.origin[2] + frame.tangent[2] * u + frame.bitangent[2] * v,
183
+ ]
184
+ }
185
+
186
+ export function projectRunToSurfaceAngleLock(
187
+ from: readonly number[],
188
+ raw: readonly number[],
189
+ frame: RunSurfaceFrame,
190
+ sourceDirection: readonly number[] | null = null,
191
+ ): RunPoint {
192
+ const fromOffset: RunPoint = [
193
+ from[0]! - frame.origin[0],
194
+ from[1]! - frame.origin[1],
195
+ from[2]! - frame.origin[2],
196
+ ]
197
+ const rawOffset: RunPoint = [raw[0]! - from[0]!, raw[1]! - from[1]!, raw[2]! - from[2]!]
198
+ const rawU = dotRun(rawOffset, frame.tangent)
199
+ const rawV = dotRun(rawOffset, frame.bitangent)
200
+ const sourceU = sourceDirection ? dotRun(sourceDirection, frame.tangent) : 0
201
+ const sourceV = sourceDirection ? dotRun(sourceDirection, frame.bitangent) : 0
202
+ const sourceAngle =
203
+ sourceDirection && Math.hypot(sourceU, sourceV) > 1e-6
204
+ ? Math.atan2(sourceV, sourceU)
205
+ : Math.atan2(rawV, rawU)
206
+ const angle = Math.round(sourceAngle / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
207
+ const distance = Math.max(0, rawU * Math.cos(angle) + rawV * Math.sin(angle))
208
+ const u = dotRun(fromOffset, frame.tangent) + Math.cos(angle) * distance
209
+ const v = dotRun(fromOffset, frame.bitangent) + Math.sin(angle) * distance
210
+ return [
211
+ frame.origin[0] + frame.tangent[0] * u + frame.bitangent[0] * v,
212
+ frame.origin[1] + frame.tangent[1] * u + frame.bitangent[1] * v,
213
+ frame.origin[2] + frame.tangent[2] * u + frame.bitangent[2] * v,
214
+ ]
215
+ }
216
+
217
+ export type DistributionRunToolConfig = {
218
+ active: boolean
219
+ levelId: AnyNodeId | null
220
+ toolName: string
221
+ initialStart?: RunPoint | null
222
+ initialConnection?: RunConnection | null
223
+ getPorts: () => ScenePort[]
224
+ findBody: (point: RunPoint, surface: RunSurfaceTarget | null) => RunBodyHit | null
225
+ surfaceClearance?: (surface: RunSurfaceTarget | null) => number
226
+ resolveFreeEnd?: (start: RunPoint, end: RunPoint, startConnection: RunConnection) => RunPoint
227
+ /** Minimum drawable centerline length, including fitting clearance. */
228
+ minimumSegmentLength?: number
229
+ inheritFromConnection?: (connection: RunConnection) => void
230
+ commit: (args: {
231
+ start: RunPoint
232
+ end: RunPoint
233
+ startConnection: RunConnection
234
+ endConnection: RunConnection
235
+ surfaceTarget: RunSurfaceTarget | null
236
+ }) => RunCommitResult | null
237
+ onShortcut?: (event: KeyboardEvent, start: RunPoint | null) => void
238
+ }
239
+
240
+ const ANGLE_STEP_RAD = Math.PI / 4
241
+ const ALT_PIXELS_PER_METER = 100
242
+ export const RUN_PREVIEW_OPACITY = 0.55
243
+ export const RUN_SNAP_CURSOR_COLOR = '#22c55e'
244
+
245
+ export function snapRunValue(value: number, step: number): number {
246
+ if (step <= 0) return value
247
+ return Math.round(value / step) * step
248
+ }
249
+
250
+ export function runDistanceSquared(a: readonly number[], b: readonly number[]): number {
251
+ const dx = a[0]! - b[0]!
252
+ const dy = a[1]! - b[1]!
253
+ const dz = a[2]! - b[2]!
254
+ return dx * dx + dy * dy + dz * dz
255
+ }
256
+
257
+ export function projectRunToAngleLock(
258
+ from: RunPoint,
259
+ raw: RunPoint,
260
+ sourceDirection: readonly [number, number, number] | null = null,
261
+ ): RunPoint {
262
+ const dx = raw[0] - from[0]
263
+ const dz = raw[2] - from[2]
264
+ const length = Math.hypot(dx, dz)
265
+ if (length < 1e-4) return [...from]
266
+ if (!sourceDirection) {
267
+ const angle = Math.round(Math.atan2(dz, dx) / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
268
+ const distance = Math.max(0, dx * Math.cos(angle) + dz * Math.sin(angle))
269
+ return [from[0] + Math.cos(angle) * distance, from[1], from[2] + Math.sin(angle) * distance]
270
+ }
271
+ const candidates = runHorizontalDirectionCandidates(sourceDirection)
272
+ let winner = candidates[0]!
273
+ let winningProjection = Number.NEGATIVE_INFINITY
274
+ for (const candidate of candidates) {
275
+ const projection = dx * candidate[0] + dz * candidate[2]
276
+ if (projection > winningProjection) {
277
+ winner = candidate
278
+ winningProjection = projection
279
+ }
280
+ }
281
+ const distance = Math.max(0, winningProjection)
282
+ return [from[0] + winner[0] * distance, from[1], from[2] + winner[2] * distance]
283
+ }
284
+
285
+ function dotRunVector(a: readonly number[], b: readonly number[]): number {
286
+ return a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!
287
+ }
288
+
289
+ function normalizedRunVector(vector: readonly number[]): RunPoint | null {
290
+ const length = Math.hypot(vector[0]!, vector[1]!, vector[2]!)
291
+ return length < 1e-9 ? null : [vector[0]! / length, vector[1]! / length, vector[2]! / length]
292
+ }
293
+
294
+ export function snapRunLength(from: RunPoint, point: RunPoint, step: number): RunPoint {
295
+ const delta: RunPoint = [point[0] - from[0], point[1] - from[1], point[2] - from[2]]
296
+ const length = Math.hypot(...delta)
297
+ if (step <= 0 || length < 1e-9) return point
298
+ const scale = snapRunValue(length, step) / length
299
+ return [from[0] + delta[0] * scale, from[1] + delta[1] * scale, from[2] + delta[2] * scale]
300
+ }
301
+
302
+ export function projectRunToDirection(
303
+ from: RunPoint,
304
+ raw: RunPoint,
305
+ direction: readonly [number, number, number],
306
+ ): RunPoint {
307
+ const distance = Math.max(
308
+ 0,
309
+ dotRun([raw[0] - from[0], raw[1] - from[1], raw[2] - from[2]], direction),
310
+ )
311
+ return [
312
+ from[0] + direction[0] * distance,
313
+ from[1] + direction[1] * distance,
314
+ from[2] + direction[2] * distance,
315
+ ]
316
+ }
317
+
318
+ export function projectRunToCameraDirection(
319
+ from: RunPoint,
320
+ ray: RunCursorRay,
321
+ sourceDirection: readonly [number, number, number],
322
+ minimumDistance: number,
323
+ gridStep: number,
324
+ candidates = run3DDirectionCandidates(sourceDirection),
325
+ surfaceFrame?: RunSurfaceFrame,
326
+ screen?: {
327
+ project: (point: RunPoint) => [number, number] | null
328
+ pointer: [number, number]
329
+ previous: RunPoint | null
330
+ },
331
+ ): CameraDirectionProjection | null {
332
+ const rayDirection = normalizedRunVector(ray.direction)
333
+ if (!rayDirection) return null
334
+ const fromRayOrigin: RunPoint = [
335
+ ray.origin[0] - from[0],
336
+ ray.origin[1] - from[1],
337
+ ray.origin[2] - from[2],
338
+ ]
339
+ let winner: CameraDirectionProjection | null = null
340
+ let winningAim = Number.NEGATIVE_INFINITY
341
+ const projectedCandidates: CameraDirectionProjection[] = []
342
+ const scores: number[] = []
343
+
344
+ for (const direction of candidates) {
345
+ const parallel = dotRunVector(rayDirection, direction)
346
+ const denominator = 1 - parallel * parallel
347
+ const projectedDistance =
348
+ Math.abs(denominator) < 1e-9
349
+ ? minimumDistance
350
+ : (dotRunVector(fromRayOrigin, direction) -
351
+ parallel * dotRunVector(fromRayOrigin, rayDirection)) /
352
+ denominator
353
+ const distance = Math.max(
354
+ minimumDistance,
355
+ snapRunValue(Math.max(projectedDistance, minimumDistance), gridStep),
356
+ )
357
+ const point: RunPoint = [
358
+ from[0] + direction[0] * distance,
359
+ from[1] + direction[1] * distance,
360
+ from[2] + direction[2] * distance,
361
+ ]
362
+ if (
363
+ surfaceFrame &&
364
+ runDistanceSquared(point, projectRunPointToSurface(point, surfaceFrame)) > 1e-8
365
+ ) {
366
+ continue
367
+ }
368
+ const aim = normalizedRunVector([
369
+ point[0] - ray.origin[0],
370
+ point[1] - ray.origin[1],
371
+ point[2] - ray.origin[2],
372
+ ])
373
+ if (!aim) continue
374
+ if (screen) {
375
+ const origin = screen.project(from)
376
+ const tip = screen.project(from.map((value, i) => value + direction[i]!) as RunPoint)
377
+ scores.push(origin && tip ? screenDirectionScore(origin, tip, screen.pointer) : Infinity)
378
+ projectedCandidates.push({ point, direction })
379
+ }
380
+ const aimDot = dotRunVector(rayDirection, aim)
381
+ if (aimDot > winningAim) {
382
+ winningAim = aimDot
383
+ winner = { point, direction }
384
+ }
385
+ }
386
+ if (screen) {
387
+ const previous = projectedCandidates.findIndex(
388
+ (candidate) => screen.previous && dotRunVector(candidate.direction, screen.previous) > 0.9999,
389
+ )
390
+ return projectedCandidates[chooseScreenDirection(scores, previous)] ?? null
391
+ }
392
+ return winner
393
+ }
394
+
395
+ export function stepNominalRunSize(
396
+ sizes: readonly number[],
397
+ current: number,
398
+ direction: 1 | -1,
399
+ ): number {
400
+ let nearest = 0
401
+ for (let index = 1; index < sizes.length; index++) {
402
+ if (Math.abs(sizes[index]! - current) < Math.abs(sizes[nearest]! - current)) nearest = index
403
+ }
404
+ return sizes[Math.min(sizes.length - 1, Math.max(0, nearest + direction))] ?? current
405
+ }
406
+
407
+ function wallSurfaceBounds(hostId: AnyNodeId): RunSurfaceBounds {
408
+ const wall = useScene.getState().nodes[hostId]
409
+ if (wall?.type !== 'wall') {
410
+ return { minU: 0, maxU: 0, minV: 0, maxV: 0 }
411
+ }
412
+ return {
413
+ minU: 0,
414
+ maxU: Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]),
415
+ minV: 0,
416
+ maxV: Math.max(0, wall.height ?? 0),
417
+ }
418
+ }
419
+
420
+ function stableWallFrame(
421
+ frame: RunSurfaceFrame,
422
+ hostId: AnyNodeId,
423
+ activeLevelId: AnyNodeId | null,
424
+ ): RunSurfaceFrame {
425
+ const wall = useScene.getState().nodes[hostId]
426
+ if (wall?.type !== 'wall') return frame
427
+ const origin = new Vector3(wall.start[0], 0, wall.start[1])
428
+ const ownerLevel = wall.parentId ? sceneRegistry.nodes.get(wall.parentId as AnyNodeId) : null
429
+ const activeLevel = activeLevelId ? sceneRegistry.nodes.get(activeLevelId) : null
430
+ if (ownerLevel) ownerLevel.localToWorld(origin)
431
+ if (activeLevel) activeLevel.worldToLocal(origin)
432
+ return {
433
+ ...frame,
434
+ origin: projectRunPointToSurface(origin.toArray(), frame),
435
+ }
436
+ }
437
+
438
+ function publishRunSurface(
439
+ target: RunSurfaceTarget | null,
440
+ point: RunPoint,
441
+ activeLevelId: AnyNodeId | null,
442
+ ): void {
443
+ if (!target) {
444
+ clearPlacementSurface()
445
+ return
446
+ }
447
+ const building = activeLevelId ? sceneRegistry.nodes.get(activeLevelId) : null
448
+ const worldPoint = new Vector3(...point)
449
+ const worldAnchor = new Vector3(...target.frame.origin)
450
+ const worldNormalPoint = new Vector3(
451
+ target.frame.origin[0] + target.frame.normal[0],
452
+ target.frame.origin[1] + target.frame.normal[1],
453
+ target.frame.origin[2] + target.frame.normal[2],
454
+ )
455
+ if (building) {
456
+ building.localToWorld(worldPoint)
457
+ building.localToWorld(worldAnchor)
458
+ building.localToWorld(worldNormalPoint)
459
+ }
460
+ const worldNormal = worldNormalPoint.sub(worldAnchor).normalize()
461
+ publishPlacementSurface(worldPoint, worldNormal, 'fixed-plane', worldAnchor)
462
+ }
463
+
464
+ function surfacePointFromEvent(
465
+ event: RunPointerEvent,
466
+ activeLevelId: AnyNodeId | null,
467
+ ): {
468
+ point: RunPoint
469
+ frame: RunSurfaceFrame
470
+ isHorizontal: boolean
471
+ target: RunSurfaceTarget | null
472
+ } {
473
+ const source = event.surfaceLocalPosition ?? event.localPosition
474
+ const point: RunPoint = [source[0], source[1], source[2]]
475
+ const frame = createRunSurfaceFrame(point, event.surfaceNormal ?? UP)
476
+ const planeDistance = dotRun(point, frame.normal)
477
+ frame.origin = frame.normal.map((value) => value * planeDistance) as RunPoint
478
+ const floorLevelId = event.surfaceHit?.levelId ?? activeLevelId
479
+ const wallNode = event.surfaceHit?.hostId
480
+ ? useScene.getState().nodes[event.surfaceHit.hostId]
481
+ : undefined
482
+ // The selected level can legitimately remain on the ground floor while the
483
+ // cursor is over a wall on another storey. Resolve the wall's owning level
484
+ // from the hit node so the wall target cannot be downgraded to a floor target.
485
+ const wallLevelId = wallNode?.type === 'wall' ? (wallNode.parentId as AnyNodeId) : undefined
486
+ const target =
487
+ event.surfaceHit?.kind === 'wall' && event.surfaceHit.face === 'side' && wallLevelId
488
+ ? {
489
+ kind: 'wall' as const,
490
+ levelId: wallLevelId,
491
+ hostId: event.surfaceHit.hostId,
492
+ side: event.surfaceHit.side ?? 'front',
493
+ frame: stableWallFrame(frame, event.surfaceHit.hostId, activeLevelId),
494
+ bounds: wallSurfaceBounds(event.surfaceHit.hostId),
495
+ }
496
+ : floorLevelId
497
+ ? {
498
+ kind:
499
+ event.surfaceHit?.kind === 'ceiling' || frame.normal[1] < -0.98
500
+ ? ('ceiling' as const)
501
+ : Math.abs(frame.normal[1]) > 0.98
502
+ ? ('floor' as const)
503
+ : ('surface' as const),
504
+ hostId: event.surfaceHit?.hostId,
505
+ levelId: floorLevelId,
506
+ frame,
507
+ }
508
+ : null
509
+ return {
510
+ point,
511
+ frame: target?.frame ?? frame,
512
+ isHorizontal: event.surfaceNormal == null || Math.abs(frame.normal[1]) > 0.98,
513
+ target,
514
+ }
515
+ }
516
+
517
+ export function useDistributionRunTool(config: DistributionRunToolConfig) {
518
+ const { camera, gl } = useThree()
519
+ const initialStartRef = useRef<RunPoint | null>(
520
+ config.initialStart ? [...config.initialStart] : null,
521
+ )
522
+ const initialConnectionRef = useRef<RunConnection>(
523
+ config.initialConnection ?? { port: null, body: null },
524
+ )
525
+ const [start, setStart] = useState<RunPoint | null>(initialStartRef.current)
526
+ const [cursor, setCursor] = useState<RunPoint | null>(initialStartRef.current)
527
+ const [snapTarget, setSnapTarget] = useState<RunPoint | null>(null)
528
+ const [snapScreen, setSnapScreen] = useState<PortScreenPoint | null>(null)
529
+ const [endConnection, setEndConnection] = useState<RunConnection>({
530
+ port: null,
531
+ body: null,
532
+ })
533
+ const [altActive, setAltActive] = useState(false)
534
+ const [directionMode, setDirectionMode] = useState<RunDirectionMode>('free')
535
+ const [lengthInput, setLengthInput] = useState('')
536
+ const [validationMessage, setValidationMessage] = useState<string | null>(null)
537
+
538
+ const configRef = useRef(config)
539
+ configRef.current = config
540
+ const startRef = useRef(start)
541
+ startRef.current = start
542
+ const startConnectionRef = useRef<RunConnection>(initialConnectionRef.current)
543
+ const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
544
+ const lastPointerRef = useRef<GridEvent | null>(null)
545
+ const refreshCursorRef = useRef<() => void>(() => {})
546
+ const lastClientYRef = useRef<number | null>(null)
547
+ const lastResolvedRef = useRef<ResolvedRunPoint | null>(null)
548
+ const lockedWallTargetRef = useRef<RunWallSurfaceTarget | null>(null)
549
+ const forcedDirectionRef = useRef<RunPoint | null>(null)
550
+ const hoveredDirectionRef = useRef<RunPoint | null>(null)
551
+ const lengthInputRef = useRef('')
552
+
553
+ useEffect(() => {
554
+ if (!config.active) return
555
+ const toolName = config.toolName
556
+ useInteractionScope.getState().begin({ kind: 'drafting', tool: toolName })
557
+ return () => {
558
+ useInteractionScope
559
+ .getState()
560
+ .endIf((scope) => scope.kind === 'drafting' && scope.tool === toolName)
561
+ clearPlacementSurface()
562
+ }
563
+ }, [config.active, config.toolName])
564
+
565
+ const refreshCursor = useCallback(() => refreshCursorRef.current(), [])
566
+
567
+ const updateLengthInput = useCallback((value: string) => {
568
+ const normalized = value.replace(',', '.').replace(/[^0-9.]/g, '')
569
+ const firstDot = normalized.indexOf('.')
570
+ const cleaned =
571
+ firstDot < 0
572
+ ? normalized
573
+ : `${normalized.slice(0, firstDot + 1)}${normalized.slice(firstDot + 1).replace(/\./g, '')}`
574
+ lengthInputRef.current = cleaned.slice(0, 12)
575
+ setLengthInput(lengthInputRef.current)
576
+ refreshCursorRef.current()
577
+ }, [])
578
+
579
+ useEffect(() => {
580
+ if (!config.active) return
581
+
582
+ const applyTypedLength = (resolved: ResolvedRunPoint): ResolvedRunPoint => {
583
+ const currentStart = startRef.current
584
+ const typed = Number.parseFloat(lengthInputRef.current)
585
+ if (
586
+ !currentStart ||
587
+ !Number.isFinite(typed) ||
588
+ typed <= 0 ||
589
+ resolved.port ||
590
+ resolved.body
591
+ ) {
592
+ return resolved
593
+ }
594
+ const direction = normalizedRunVector([
595
+ resolved.point[0] - currentStart[0],
596
+ resolved.point[1] - currentStart[1],
597
+ resolved.point[2] - currentStart[2],
598
+ ])
599
+ if (!direction) return resolved
600
+ return {
601
+ ...resolved,
602
+ point: [
603
+ currentStart[0] + direction[0] * typed,
604
+ currentStart[1] + direction[1] * typed,
605
+ currentStart[2] + direction[2] * typed,
606
+ ],
607
+ snapped: null,
608
+ directionMode: resolved.directionMode,
609
+ }
610
+ }
611
+
612
+ const resolvePoint = (event: GridEvent): ResolvedRunPoint => {
613
+ const adapter = configRef.current
614
+ const hit = surfacePointFromEvent(event, adapter.levelId)
615
+ const currentStart = startRef.current
616
+ const previous = lastResolvedRef.current
617
+ const lockedWallTarget = currentStart ? lockedWallTargetRef.current : null
618
+ const lockedWallHit =
619
+ lockedWallTarget && isSameRunWallSurface(lockedWallTarget, event.surfaceHit)
620
+ const working = lockedWallTarget
621
+ ? {
622
+ ...lockedWallTarget.frame,
623
+ origin: lockedWallTarget.frame.origin.map(
624
+ (value, index) =>
625
+ value +
626
+ lockedWallTarget.frame.normal[index]! *
627
+ (adapter.surfaceClearance?.(lockedWallTarget) ?? 0),
628
+ ) as RunPoint,
629
+ }
630
+ : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart
631
+ ? createRunSurfaceFrame(currentStart, UP)
632
+ : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null))
633
+ const hasSurface = !!event.surfaceHit || !working || !event.localRay
634
+ const target = lockedWallTarget ?? (hasSurface ? hit.target : null)
635
+ const hitForCursor = lockedWallTarget
636
+ ? lockedWallHit
637
+ ? { point: hit.point, frame: lockedWallTarget.frame }
638
+ : null
639
+ : hasSurface
640
+ ? { point: hit.point, frame: hit.frame }
641
+ : null
642
+ const resolved = resolveRunCursorPlane({
643
+ hit: hitForCursor,
644
+ working,
645
+ ray: event.localRay,
646
+ fallback: previous?.point ?? currentStart ?? hit.point,
647
+ clearance: adapter.surfaceClearance?.(target) ?? 0,
648
+ })
649
+ const bypass = event.nativeEvent?.altKey === true
650
+ const gridStep = !bypass && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
651
+ const angleLocked = !bypass && (isAngleSnapActive() || isGridSnapActive())
652
+ let point = currentStart
653
+ ? resolved.point
654
+ : snapRunPointToSurface(resolved.point, resolved.frame, gridStep)
655
+ const forcedDirection = forcedDirectionRef.current
656
+ if (currentStart && forcedDirection) {
657
+ point = projectRunToDirection(currentStart, point, forcedDirection)
658
+ }
659
+ if (currentStart && angleLocked && !forcedDirection) {
660
+ const from = projectRunPointToSurface(currentStart, resolved.frame)
661
+ if (runDistanceSquared(from, currentStart) < 1e-6) {
662
+ point = projectRunToSurfaceAngleLock(currentStart, point, resolved.frame)
663
+ }
664
+ }
665
+ if (currentStart && gridStep > 0) {
666
+ point = snapRunLength(currentStart, point, gridStep)
667
+ }
668
+ const sample = { frame: resolved.frame, surfaceTarget: target }
669
+ const native = (event.nativeEvent ?? {}) as {
670
+ clientX?: number
671
+ clientY?: number
672
+ }
673
+ const screenProjection = getGridEventScreenProjection(event)
674
+ const pointer = screenProjection?.pointer ?? [native.clientX ?? NaN, native.clientY ?? NaN]
675
+ const rect = gl.domElement.getBoundingClientRect()
676
+ const level = adapter.levelId ? sceneRegistry.nodes.get(adapter.levelId) : null
677
+ const origin = camera.getWorldPosition(new Vector3())
678
+ const projectConnection = (
679
+ candidate: readonly [number, number, number],
680
+ ): PortScreenPoint | null => {
681
+ if (screenProjection) {
682
+ const [a, b, c, d, e, f] = screenProjection.localToScreen
683
+ return {
684
+ x: a * candidate[0] + c * candidate[2] + e,
685
+ y: b * candidate[0] + d * candidate[2] + f,
686
+ depth: 0,
687
+ }
688
+ }
689
+ const world = new Vector3(...candidate)
690
+ if (level) level.localToWorld(world)
691
+ const projected = world.clone().project(camera)
692
+ if (projected.z < -1 || projected.z > 1) return null
693
+ if (
694
+ event.surfaceHit &&
695
+ world.distanceTo(origin) > new Vector3(...event.position).distanceTo(origin) + 0.03
696
+ )
697
+ return null
698
+ return {
699
+ x: rect.left + ((projected.x + 1) * rect.width) / 2,
700
+ y: rect.top + ((1 - projected.y) * rect.height) / 2,
701
+ depth: world.distanceTo(origin),
702
+ }
703
+ }
704
+ const acceptsConnection = (candidate: RunPoint): boolean => {
705
+ const screen = projectConnection(candidate)
706
+ return !!screen && Math.hypot(screen.x - pointer[0], screen.y - pointer[1]) <= 12
707
+ }
708
+ const resolveConnection = (candidate: RunPoint): ResolvedRunPoint | null => {
709
+ if (bypass || !(isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()))
710
+ return null
711
+ const source = startConnectionRef.current.port
712
+ const hit = findScreenPort(adapter.getPorts(), pointer, projectConnection, source)
713
+ if (hit) {
714
+ return {
715
+ frame: createRunSurfaceFrame([...hit.port.position]),
716
+ surfaceTarget: null,
717
+ point: [...hit.port.position],
718
+ snapped: [...hit.port.position],
719
+ snapScreen: hit.screen,
720
+ directionMode: 'snap',
721
+ port: hit.port,
722
+ body: null,
723
+ }
724
+ }
725
+ const body = adapter.findBody(candidate, target)
726
+ if (body && acceptsConnection(body.point) && body.nodeId !== source?.nodeId) {
727
+ return {
728
+ ...sample,
729
+ point: body.point,
730
+ snapped: body.point,
731
+ directionMode: 'snap',
732
+ port: null,
733
+ body,
734
+ }
735
+ }
736
+ return null
737
+ }
738
+ const connection = resolveConnection(resolved.point)
739
+ if (connection) return connection
740
+ // Rank displayed directions before a surface projection discards height.
741
+ if (currentStart && event.localRay && !bypass && (forcedDirection || angleLocked)) {
742
+ const source = startConnectionRef.current.port?.direction ?? null
743
+ const candidates = forcedDirection
744
+ ? [forcedDirection]
745
+ : source
746
+ ? run3DDirectionCandidates(source)
747
+ : runHorizontalDirectionCandidates(null)
748
+ const directionHit = projectRunToCameraDirection(
749
+ currentStart,
750
+ event.localRay,
751
+ source ?? X_AXIS,
752
+ adapter.minimumSegmentLength ?? 0.05,
753
+ gridStep,
754
+ candidates,
755
+ (target?.kind === 'wall' || target?.kind === 'ceiling') && !forcedDirection
756
+ ? resolved.frame
757
+ : undefined,
758
+ !forcedDirection && event.nativeEvent
759
+ ? {
760
+ project: (candidate) => {
761
+ const level = adapter.levelId ? sceneRegistry.nodes.get(adapter.levelId) : null
762
+ const world = new Vector3(...candidate)
763
+ if (level) level.localToWorld(world)
764
+ world.project(camera)
765
+ if (world.z < -1 || world.z > 1) return null
766
+ const rect = gl.domElement.getBoundingClientRect()
767
+ return [
768
+ rect.left + ((world.x + 1) * rect.width) / 2,
769
+ rect.top + ((1 - world.y) * rect.height) / 2,
770
+ ]
771
+ },
772
+ pointer: [event.nativeEvent.clientX, event.nativeEvent.clientY],
773
+ previous: hoveredDirectionRef.current,
774
+ }
775
+ : undefined,
776
+ )
777
+ if (directionHit) {
778
+ hoveredDirectionRef.current = directionHit.direction
779
+ const directionConnection = resolveConnection(directionHit.point)
780
+ if (directionConnection) return directionConnection
781
+ return {
782
+ point:
783
+ Math.abs(directionHit.direction[1]) < 1e-6 && target?.kind !== 'ceiling'
784
+ ? (adapter.resolveFreeEnd?.(
785
+ currentStart,
786
+ directionHit.point,
787
+ startConnectionRef.current,
788
+ ) ?? directionHit.point)
789
+ : directionHit.point,
790
+ frame:
791
+ event.surfaceHit && !forcedDirection
792
+ ? resolved.frame
793
+ : createRunSurfaceFrame(currentStart),
794
+ surfaceTarget:
795
+ forcedDirection ||
796
+ (Math.abs(directionHit.direction[1]) > 1e-6 && target?.kind === 'floor')
797
+ ? null
798
+ : target,
799
+ snapped: null,
800
+ directionMode: 'angle',
801
+ port: null,
802
+ body: null,
803
+ }
804
+ }
805
+ }
806
+ if (currentStart && target?.kind !== 'ceiling' && Math.abs(resolved.frame.normal[1]) > 0.98) {
807
+ point = adapter.resolveFreeEnd?.(currentStart, point, startConnectionRef.current) ?? point
808
+ }
809
+ return {
810
+ ...sample,
811
+ point,
812
+ snapped: null,
813
+ directionMode: angleLocked ? 'angle' : 'free',
814
+ port: null,
815
+ body: null,
816
+ }
817
+ }
818
+
819
+ const resolveVerticalPoint = (clientY: number): RunPoint | null => {
820
+ const anchor = altAnchorRef.current
821
+ const currentStart = startRef.current
822
+ if (!anchor || !currentStart) return null
823
+ const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
824
+ const delta = snapRunValue((anchor.clientY - clientY) / ALT_PIXELS_PER_METER, step)
825
+ const y = anchor.baseY + delta
826
+ return [currentStart[0], y, currentStart[2]]
827
+ }
828
+
829
+ const updateCursor = (resolved: ResolvedRunPoint) => {
830
+ if (!resolved.frame && lastResolvedRef.current?.frame)
831
+ resolved = {
832
+ ...resolved,
833
+ frame: { ...lastResolvedRef.current.frame, origin: resolved.point },
834
+ surfaceTarget: lockedWallTargetRef.current,
835
+ }
836
+ lastResolvedRef.current = resolved
837
+ const currentStart = startRef.current
838
+ const minimumLength = configRef.current.minimumSegmentLength ?? 0.05
839
+ const length = currentStart
840
+ ? Math.hypot(
841
+ resolved.point[0] - currentStart[0],
842
+ resolved.point[1] - currentStart[1],
843
+ resolved.point[2] - currentStart[2],
844
+ )
845
+ : 0
846
+ const typed = lengthInputRef.current
847
+ setValidationMessage(
848
+ currentStart &&
849
+ typed &&
850
+ (!Number.isFinite(Number.parseFloat(typed)) || Number.parseFloat(typed) <= 0)
851
+ ? 'Enter a positive length'
852
+ : currentStart && length < minimumLength
853
+ ? `Run must be at least ${minimumLength.toFixed(2)} m`
854
+ : null,
855
+ )
856
+ setCursor(resolved.point)
857
+ setSnapTarget(resolved.snapped)
858
+ setSnapScreen(resolved.snapScreen ?? null)
859
+ setEndConnection({
860
+ port: resolved.port,
861
+ body: resolved.port ? null : resolved.body,
862
+ })
863
+ setDirectionMode(resolved.directionMode)
864
+ if (resolved.frame)
865
+ publishRunSurface(
866
+ {
867
+ kind: 'surface',
868
+ levelId: configRef.current.levelId as AnyNodeId,
869
+ frame: resolved.frame,
870
+ },
871
+ resolved.point,
872
+ configRef.current.levelId,
873
+ )
874
+ }
875
+
876
+ const commit = (end: RunPoint, connection: RunConnection) => {
877
+ const currentStart = startRef.current
878
+ if (!currentStart) return
879
+ if (lengthInputRef.current && !(Number(lengthInputRef.current) > 0)) return
880
+ const minimumLength = configRef.current.minimumSegmentLength ?? 0.05
881
+ const length = Math.hypot(
882
+ end[0] - currentStart[0],
883
+ end[1] - currentStart[1],
884
+ end[2] - currentStart[2],
885
+ )
886
+ if (length < minimumLength) {
887
+ return
888
+ }
889
+ const capturedTarget = lastResolvedRef.current?.surfaceTarget ?? null
890
+ const clearance = configRef.current.surfaceClearance?.(capturedTarget) ?? 0
891
+ const surfaceTarget =
892
+ capturedTarget?.kind === 'wall' &&
893
+ [currentStart, end].some((point) => {
894
+ const projected = projectRunPointToSurface(point, capturedTarget.frame)
895
+ return Math.abs(Math.sqrt(runDistanceSquared(point, projected)) - clearance) > 1e-4
896
+ })
897
+ ? null
898
+ : capturedTarget
899
+ const result = configRef.current.commit({
900
+ start: currentStart,
901
+ end,
902
+ startConnection: startConnectionRef.current,
903
+ endConnection: connection,
904
+ surfaceTarget,
905
+ })
906
+ if (!result) {
907
+ setValidationMessage('Fitting clearance is too small for this connection')
908
+ return
909
+ }
910
+ const lockedWallTarget = lockedWallTargetRef.current
911
+ if (lockedWallTarget) {
912
+ const offset = result.nextStart.map(
913
+ (value, index) => value - lockedWallTarget.frame.origin[index]!,
914
+ )
915
+ const clearance = configRef.current.surfaceClearance?.(lockedWallTarget) ?? 0
916
+ if (Math.abs(dotRun(offset, lockedWallTarget.frame.normal) - clearance) > 1e-4) {
917
+ lockedWallTargetRef.current = null
918
+ lastResolvedRef.current = {
919
+ point: result.nextStart,
920
+ frame: createRunSurfaceFrame(result.nextStart),
921
+ surfaceTarget: null,
922
+ snapped: null,
923
+ directionMode: 'free',
924
+ port: null,
925
+ body: null,
926
+ }
927
+ clearPlacementSurface()
928
+ }
929
+ }
930
+ triggerSFX('sfx:item-place')
931
+ startRef.current = result.nextStart
932
+ setStart(result.nextStart)
933
+ setSnapTarget(null)
934
+ setSnapScreen(null)
935
+ setEndConnection({ port: null, body: null })
936
+ lengthInputRef.current = ''
937
+ setLengthInput('')
938
+ setValidationMessage(null)
939
+ startConnectionRef.current = result.nextConnection
940
+ forcedDirectionRef.current = null
941
+ hoveredDirectionRef.current = null
942
+ altAnchorRef.current = null
943
+ setAltActive(false)
944
+ }
945
+
946
+ const onMove = (event: RunPointerEvent) => {
947
+ lastPointerRef.current = event
948
+ const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
949
+ if (typeof clientY === 'number') lastClientYRef.current = clientY
950
+ if (altAnchorRef.current && typeof clientY === 'number') {
951
+ const point = resolveVerticalPoint(clientY)
952
+ if (point) {
953
+ clearDrawAlignment()
954
+ updateCursor({
955
+ point,
956
+ snapped: null,
957
+ port: null,
958
+ body: null,
959
+ directionMode: 'vertical',
960
+ })
961
+ return
962
+ }
963
+ }
964
+ updateCursor(applyTypedLength(resolvePoint(event)))
965
+ }
966
+
967
+ const onClick = (event: GridEvent) => {
968
+ event.nativeEvent?.stopPropagation?.()
969
+ const currentStart = startRef.current
970
+ if (altAnchorRef.current && currentStart) {
971
+ const clientY =
972
+ (event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
973
+ if (typeof clientY === 'number') {
974
+ const point = resolveVerticalPoint(clientY)
975
+ if (point && Math.abs(point[1] - currentStart[1]) >= 1e-4) {
976
+ commit(point, { port: null, body: null })
977
+ }
978
+ }
979
+ return
980
+ }
981
+ const resolved = applyTypedLength(resolvePoint(event))
982
+ updateCursor(resolved)
983
+ if (!currentStart) {
984
+ const connection = {
985
+ port: resolved.port,
986
+ body: resolved.port ? null : resolved.body,
987
+ }
988
+ if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) {
989
+ lockedWallTargetRef.current = resolved.surfaceTarget
990
+ }
991
+ triggerSFX('sfx:grid-snap')
992
+ startConnectionRef.current = connection
993
+ configRef.current.inheritFromConnection?.(connection)
994
+ startRef.current = resolved.point
995
+ setStart(resolved.point)
996
+ setCursor(resolved.point)
997
+ setSnapTarget(resolved.snapped)
998
+ setSnapScreen(resolved.snapScreen ?? null)
999
+ setEndConnection({ port: null, body: null })
1000
+ return
1001
+ }
1002
+ const typedResolved = applyTypedLength(resolved)
1003
+ commit(typedResolved.point, {
1004
+ port: resolved.port,
1005
+ body: resolved.port ? null : resolved.body,
1006
+ })
1007
+ }
1008
+
1009
+ const onKeyDown = (event: KeyboardEvent) => {
1010
+ const target = event.target as HTMLElement | null
1011
+ const isLengthField = target ? target.closest('[data-run-length-input]') !== null : false
1012
+ if ((isTypingTarget(target) || target?.tagName === 'BUTTON') && !isLengthField) return
1013
+ if (event.key === 'Escape' && startRef.current) {
1014
+ event.preventDefault()
1015
+ event.stopImmediatePropagation()
1016
+ onCancel()
1017
+ useEditor.getState().armToolMode({ mode: 'select' })
1018
+ return
1019
+ }
1020
+ if (event.key === 'Alt') {
1021
+ const currentStart = startRef.current
1022
+ if (!currentStart || lastClientYRef.current === null || altAnchorRef.current) return
1023
+ event.preventDefault()
1024
+ altAnchorRef.current = {
1025
+ clientY: lastClientYRef.current,
1026
+ baseY: currentStart[1],
1027
+ }
1028
+ setAltActive(true)
1029
+ return
1030
+ }
1031
+ if (
1032
+ event.key === 'Tab' &&
1033
+ startRef.current &&
1034
+ !event.altKey &&
1035
+ !event.metaKey &&
1036
+ !event.ctrlKey
1037
+ ) {
1038
+ event.preventDefault()
1039
+ event.stopImmediatePropagation()
1040
+ const source = startConnectionRef.current.port?.direction
1041
+ const candidates = source
1042
+ ? run3DDirectionCandidates(source)
1043
+ : runHorizontalDirectionCandidates(null)
1044
+ const current = forcedDirectionRef.current ?? hoveredDirectionRef.current
1045
+ const index = candidates.findIndex(
1046
+ (direction) => current && dotRunVector(direction, current) > 0.9999,
1047
+ )
1048
+ forcedDirectionRef.current =
1049
+ candidates[(index + (event.shiftKey ? candidates.length - 1 : 1)) % candidates.length]!
1050
+ refreshCursorRef.current()
1051
+ return
1052
+ }
1053
+ if (startRef.current && isLengthField && /^[0-9.,]$/.test(event.key)) {
1054
+ event.stopImmediatePropagation()
1055
+ return
1056
+ }
1057
+ if (startRef.current && !isLengthField && /^[0-9.,]$/.test(event.key)) {
1058
+ event.preventDefault()
1059
+ event.stopImmediatePropagation()
1060
+ updateLengthInput(`${lengthInputRef.current}${event.key}`)
1061
+ return
1062
+ }
1063
+ if (startRef.current && event.key === 'Enter') {
1064
+ event.preventDefault()
1065
+ event.stopImmediatePropagation()
1066
+ const resolved = lastResolvedRef.current
1067
+ if (resolved) {
1068
+ const typedResolved = applyTypedLength(resolved)
1069
+ commit(typedResolved.point, {
1070
+ port: typedResolved.port,
1071
+ body: typedResolved.port ? null : typedResolved.body,
1072
+ })
1073
+ }
1074
+ return
1075
+ }
1076
+ configRef.current.onShortcut?.(event, startRef.current)
1077
+ }
1078
+
1079
+ const onKeyUp = (event: KeyboardEvent) => {
1080
+ if (event.key !== 'Alt' || !altAnchorRef.current) return
1081
+ event.preventDefault()
1082
+ altAnchorRef.current = null
1083
+ setAltActive(false)
1084
+ setDirectionMode('free')
1085
+ }
1086
+
1087
+ const onCancel = () => {
1088
+ lockedWallTargetRef.current = null
1089
+ clearDrawAlignment()
1090
+ if (!startRef.current) return
1091
+ markToolCancelConsumed()
1092
+ startRef.current = null
1093
+ setStart(null)
1094
+ setCursor(null)
1095
+ setSnapTarget(null)
1096
+ setSnapScreen(null)
1097
+ setEndConnection({ port: null, body: null })
1098
+ lengthInputRef.current = ''
1099
+ setLengthInput('')
1100
+ setValidationMessage(null)
1101
+ startConnectionRef.current = { port: null, body: null }
1102
+ forcedDirectionRef.current = null
1103
+ hoveredDirectionRef.current = null
1104
+ lastPointerRef.current = null
1105
+ clearPlacementSurface()
1106
+ lastResolvedRef.current = null
1107
+ altAnchorRef.current = null
1108
+ setAltActive(false)
1109
+ }
1110
+
1111
+ refreshCursorRef.current = () => {
1112
+ if (lastPointerRef.current)
1113
+ updateCursor(applyTypedLength(resolvePoint(lastPointerRef.current)))
1114
+ else if (lastResolvedRef.current) updateCursor(applyTypedLength(lastResolvedRef.current))
1115
+ }
1116
+ const unsubscribeSnapping = useEditor.subscribe((state, previous) => {
1117
+ const modeChanged = state.snappingModeByContext !== previous.snappingModeByContext
1118
+ if (!modeChanged && state.gridSnapStep === previous.gridSnapStep) return
1119
+ if (modeChanged) {
1120
+ forcedDirectionRef.current = null
1121
+ hoveredDirectionRef.current = null
1122
+ }
1123
+ refreshCursorRef.current()
1124
+ })
1125
+ emitter.on('grid:click', onClick)
1126
+ emitter.on('grid:move', onMove)
1127
+ emitter.on('tool:cancel', onCancel)
1128
+ window.addEventListener('keydown', onKeyDown, true)
1129
+ window.addEventListener('keyup', onKeyUp)
1130
+ return () => {
1131
+ unsubscribeSnapping()
1132
+ emitter.off('grid:click', onClick)
1133
+ emitter.off('grid:move', onMove)
1134
+ emitter.off('tool:cancel', onCancel)
1135
+ window.removeEventListener('keydown', onKeyDown, true)
1136
+ window.removeEventListener('keyup', onKeyUp)
1137
+ altAnchorRef.current = null
1138
+ lastPointerRef.current = null
1139
+ lockedWallTargetRef.current = null
1140
+ refreshCursorRef.current = () => {}
1141
+ clearPlacementSurface()
1142
+ clearDrawAlignment()
1143
+ }
1144
+ }, [camera, gl, config.active, updateLengthInput])
1145
+
1146
+ return {
1147
+ refreshCursor,
1148
+ start,
1149
+ cursor,
1150
+ snapTarget,
1151
+ snapScreen,
1152
+ altActive,
1153
+ directionMode,
1154
+ lengthInput,
1155
+ validationMessage,
1156
+ onLengthInputChange: updateLengthInput,
1157
+ onDirectionSelect: (direction: RunPoint) => {
1158
+ forcedDirectionRef.current = direction
1159
+ setDirectionMode('angle')
1160
+ refreshCursorRef.current()
1161
+ },
1162
+ startConnection: startConnectionRef.current,
1163
+ endConnection,
1164
+ surfaceTarget: lastResolvedRef.current?.surfaceTarget ?? null,
1165
+ }
1166
+ }
1167
+
1168
+ export function DistributionRunCursor({
1169
+ cursor,
1170
+ start,
1171
+ snapTarget,
1172
+ snapScreen,
1173
+ altActive,
1174
+ unit,
1175
+ extraParts = [],
1176
+ cursorRef,
1177
+ directionMode,
1178
+ startDirection,
1179
+ lengthInput,
1180
+ validationMessage,
1181
+ onLengthInputChange,
1182
+ onDirectionSelect,
1183
+ }: {
1184
+ cursor: RunPoint | null
1185
+ start: RunPoint | null
1186
+ snapTarget: RunPoint | null
1187
+ snapScreen?: PortScreenPoint | null
1188
+ altActive: boolean
1189
+ unit: 'metric' | 'imperial'
1190
+ extraParts?: DimensionPillPart[]
1191
+ cursorRef?: RefObject<Group | null>
1192
+ directionMode: RunDirectionMode
1193
+ startDirection?: readonly [number, number, number] | null
1194
+ lengthInput?: string
1195
+ validationMessage?: string | null
1196
+ minimumSegmentLength?: number
1197
+ onLengthInputChange?: (value: string) => void
1198
+ onDirectionSelect?: (direction: RunPoint) => void
1199
+ }) {
1200
+ const lengthFieldRef = useRef<HTMLInputElement>(null)
1201
+ useEffect(() => {
1202
+ if (start) lengthFieldRef.current?.focus()
1203
+ }, [start])
1204
+ if (!cursor) return null
1205
+ const parts: DimensionPillPart[] = [
1206
+ ...(start
1207
+ ? [
1208
+ {
1209
+ key: 'length',
1210
+ prefix: 'L',
1211
+ value: Math.hypot(cursor[0] - start[0], cursor[1] - start[1], cursor[2] - start[2]),
1212
+ } satisfies DimensionPillPart,
1213
+ ]
1214
+ : []),
1215
+ {
1216
+ key: 'x',
1217
+ prefix: 'X',
1218
+ value: start ? cursor[0] - start[0] : cursor[0],
1219
+ signed: !!start,
1220
+ },
1221
+ {
1222
+ key: 'y',
1223
+ prefix: 'Y',
1224
+ value: start ? cursor[1] - start[1] : cursor[1],
1225
+ signed: !!start,
1226
+ },
1227
+ {
1228
+ key: 'z',
1229
+ prefix: 'Z',
1230
+ value: start ? cursor[2] - start[2] : cursor[2],
1231
+ signed: !!start,
1232
+ },
1233
+ ...extraParts,
1234
+ ]
1235
+ const primary = start ? (altActive ? 'y' : 'length') : undefined
1236
+ const elevated = cursor[1] > 0.001
1237
+ const ground: RunPoint = [cursor[0], 0, cursor[2]]
1238
+
1239
+ return (
1240
+ <>
1241
+ {snapScreen && (
1242
+ <Html style={{ pointerEvents: 'none' }}>
1243
+ {createPortal(
1244
+ <div
1245
+ style={{
1246
+ position: 'fixed',
1247
+ left: snapScreen.x,
1248
+ top: snapScreen.y,
1249
+ pointerEvents: 'none',
1250
+ zIndex: 110,
1251
+ }}
1252
+ >
1253
+ <div className="absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-emerald-500 bg-emerald-500/20 ring-2 ring-background" />
1254
+ </div>,
1255
+ document.body,
1256
+ )}
1257
+ </Html>
1258
+ )}
1259
+ {start && (
1260
+ <RunDirectionFeedback
1261
+ cursor={cursor}
1262
+ mode={directionMode}
1263
+ snapped={!!snapTarget}
1264
+ sourceDirection={startDirection ?? null}
1265
+ start={start}
1266
+ onDirectionSelect={onDirectionSelect}
1267
+ />
1268
+ )}
1269
+ <CursorSphere
1270
+ color={snapTarget ? RUN_SNAP_CURSOR_COLOR : undefined}
1271
+ dotAtTip={elevated || undefined}
1272
+ height={elevated ? cursor[1] : undefined}
1273
+ position={elevated ? ground : cursor}
1274
+ ref={cursorRef}
1275
+ />
1276
+ <group position={cursor}>
1277
+ <Html
1278
+ center
1279
+ position={[0, 1.45, 0]}
1280
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
1281
+ zIndexRange={[100, 0]}
1282
+ >
1283
+ <div className="flex flex-col items-center gap-1">
1284
+ <DimensionPill parts={parts} primary={primary} unit={unit} />
1285
+ {start ? (
1286
+ <label className="rounded-full border border-border/60 bg-background/90 px-3 py-1 text-[11px] tabular-nums text-muted-foreground shadow-sm backdrop-blur">
1287
+ Length:{' '}
1288
+ <input
1289
+ className="w-20 bg-transparent text-center text-foreground outline-none"
1290
+ data-run-length-input
1291
+ inputMode="decimal"
1292
+ onKeyDown={(event) => {
1293
+ if (
1294
+ event.key === 'Backspace' ||
1295
+ event.key === 'Delete' ||
1296
+ event.key === 'ArrowLeft' ||
1297
+ event.key === 'ArrowRight'
1298
+ ) {
1299
+ event.stopPropagation()
1300
+ }
1301
+ }}
1302
+ onChange={(event) => onLengthInputChange?.(event.target.value)}
1303
+ onPointerDown={(event) => event.stopPropagation()}
1304
+ placeholder="type a length"
1305
+ ref={lengthFieldRef}
1306
+ style={{ pointerEvents: 'auto' }}
1307
+ type="text"
1308
+ value={lengthInput ?? ''}
1309
+ />{' '}
1310
+ m<span className="ml-2">Tab: direction</span>
1311
+ </label>
1312
+ ) : null}
1313
+ {validationMessage ? (
1314
+ <div className="rounded-full border border-red-500/50 bg-red-500/10 px-3 py-1 text-[11px] text-red-700 shadow-sm backdrop-blur dark:text-red-300">
1315
+ {validationMessage}
1316
+ </div>
1317
+ ) : null}
1318
+ </div>
1319
+ </Html>
1320
+ </group>
1321
+ </>
1322
+ )
1323
+ }