@pascal-app/editor 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. package/src/lib/window-interaction.ts +0 -142
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  type AnyNodeId,
5
5
  emitter,
6
+ formatFixed,
6
7
  type GridEvent,
7
8
  sceneRegistry,
8
9
  usePascalI18n,
@@ -18,6 +19,7 @@ import {
18
19
  isGridSnapActive,
19
20
  isMagneticSnapActive,
20
21
  isTypingTarget,
22
+ isViewVisible,
21
23
  markToolCancelConsumed,
22
24
  publishPlacementSurface,
23
25
  triggerSFX,
@@ -226,6 +228,11 @@ export type DistributionRunToolConfig = {
226
228
  active: boolean
227
229
  levelId: AnyNodeId | null
228
230
  toolName: string
231
+ routingMode?: 'surface' | 'horizontal' | 'vertical'
232
+ /** Level-local centreline elevation, or the target height for a vertical leg. */
233
+ elevation?: number
234
+ targetElevation?: number
235
+ connectCrossings?: boolean
229
236
  initialStart?: RunPoint | null
230
237
  initialConnection?: RunConnection | null
231
238
  getPorts: () => ScenePort[]
@@ -524,6 +531,7 @@ function surfacePointFromEvent(
524
531
 
525
532
  export function useDistributionRunTool(config: DistributionRunToolConfig) {
526
533
  const { camera, gl } = useThree()
534
+ const planVisible = useEditor((state) => isViewVisible(state, '2d'))
527
535
  const initialStartRef = useRef<RunPoint | null>(
528
536
  config.initialStart ? [...config.initialStart] : null,
529
537
  )
@@ -534,6 +542,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
534
542
  const [cursor, setCursor] = useState<RunPoint | null>(initialStartRef.current)
535
543
  const [snapTarget, setSnapTarget] = useState<RunPoint | null>(null)
536
544
  const [snapScreen, setSnapScreen] = useState<PortScreenPoint | null>(null)
545
+ const [cursorScreen, setCursorScreen] = useState<PortScreenPoint | null>(null)
537
546
  const [endConnection, setEndConnection] = useState<RunConnection>({
538
547
  port: null,
539
548
  body: null,
@@ -592,6 +601,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
592
601
  const typed = Number.parseFloat(lengthInputRef.current)
593
602
  if (
594
603
  !currentStart ||
604
+ configRef.current.routingMode === 'vertical' ||
595
605
  !Number.isFinite(typed) ||
596
606
  typed <= 0 ||
597
607
  resolved.port ||
@@ -617,28 +627,47 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
617
627
  }
618
628
  }
619
629
 
630
+ // A vertical route rises from its start to the target elevation wherever the
631
+ // pointer is, so it resolves without a pointer event.
632
+ const verticalTarget = (currentStart: RunPoint): ResolvedRunPoint => ({
633
+ point: [
634
+ currentStart[0],
635
+ configRef.current.targetElevation ?? currentStart[1],
636
+ currentStart[2],
637
+ ],
638
+ frame: createRunSurfaceFrame(currentStart),
639
+ surfaceTarget: null,
640
+ snapped: null,
641
+ directionMode: 'vertical',
642
+ port: null,
643
+ body: null,
644
+ })
645
+
620
646
  const resolvePoint = (event: GridEvent): ResolvedRunPoint => {
621
647
  const adapter = configRef.current
622
648
  const hit = surfacePointFromEvent(event, adapter.levelId)
623
649
  const currentStart = startRef.current
624
650
  const previous = lastResolvedRef.current
625
- const lockedWallTarget = currentStart ? lockedWallTargetRef.current : null
651
+ const explicitRoute = adapter.routingMode && adapter.routingMode !== 'surface'
652
+ const lockedWallTarget = currentStart && !explicitRoute ? lockedWallTargetRef.current : null
626
653
  const lockedWallHit =
627
654
  lockedWallTarget && isSameRunWallSurface(lockedWallTarget, event.surfaceHit)
628
- const working = lockedWallTarget
629
- ? {
630
- ...lockedWallTarget.frame,
631
- origin: lockedWallTarget.frame.origin.map(
632
- (value, index) =>
633
- value +
634
- lockedWallTarget.frame.normal[index]! *
635
- (adapter.surfaceClearance?.(lockedWallTarget) ?? 0),
636
- ) as RunPoint,
637
- }
638
- : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart
639
- ? createRunSurfaceFrame(currentStart, UP)
640
- : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null))
641
- const hasSurface = !!event.surfaceHit || !working || !event.localRay
655
+ const working = explicitRoute
656
+ ? createRunSurfaceFrame([0, currentStart?.[1] ?? adapter.elevation ?? 0, 0])
657
+ : lockedWallTarget
658
+ ? {
659
+ ...lockedWallTarget.frame,
660
+ origin: lockedWallTarget.frame.origin.map(
661
+ (value, index) =>
662
+ value +
663
+ lockedWallTarget.frame.normal[index]! *
664
+ (adapter.surfaceClearance?.(lockedWallTarget) ?? 0),
665
+ ) as RunPoint,
666
+ }
667
+ : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart
668
+ ? createRunSurfaceFrame(currentStart, UP)
669
+ : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null))
670
+ const hasSurface = !explicitRoute && (!!event.surfaceHit || !working || !event.localRay)
642
671
  const target = lockedWallTarget ?? (hasSurface ? hit.target : null)
643
672
  const hitForCursor = lockedWallTarget
644
673
  ? lockedWallHit
@@ -651,16 +680,25 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
651
680
  hit: hitForCursor,
652
681
  working,
653
682
  ray: event.localRay,
654
- fallback: previous?.point ?? currentStart ?? hit.point,
683
+ fallback: explicitRoute ? [...hit.point] : (previous?.point ?? currentStart ?? hit.point),
655
684
  clearance: adapter.surfaceClearance?.(target) ?? 0,
656
685
  })
686
+ const routeElevation = currentStart?.[1] ?? adapter.elevation ?? 0
687
+ if (explicitRoute) {
688
+ resolved.point[1] = routeElevation
689
+ if (currentStart && adapter.routingMode === 'vertical') return verticalTarget(currentStart)
690
+ }
657
691
  const bypass = event.nativeEvent?.altKey === true
658
692
  const gridStep = !bypass && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
659
693
  const angleLocked = !bypass && (isAngleSnapActive() || isGridSnapActive())
660
694
  let point = currentStart
661
695
  ? resolved.point
662
696
  : snapRunPointToSurface(resolved.point, resolved.frame, gridStep)
663
- const forcedDirection = forcedDirectionRef.current
697
+ const selectedDirection = forcedDirectionRef.current
698
+ const forcedDirection =
699
+ explicitRoute && selectedDirection && Math.abs(selectedDirection[1]) > 1e-6
700
+ ? null
701
+ : selectedDirection
664
702
  if (currentStart && forcedDirection) {
665
703
  point = projectRunToDirection(currentStart, point, forcedDirection)
666
704
  }
@@ -673,7 +711,11 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
673
711
  if (currentStart && gridStep > 0) {
674
712
  point = snapRunLength(currentStart, point, gridStep)
675
713
  }
676
- const sample = { frame: resolved.frame, surfaceTarget: target }
714
+ const drawingTarget: RunSurfaceTarget | null =
715
+ explicitRoute && adapter.levelId
716
+ ? { kind: 'surface', levelId: adapter.levelId, frame: resolved.frame }
717
+ : target
718
+ const sample = { frame: resolved.frame, surfaceTarget: drawingTarget }
677
719
  const native = (event.nativeEvent ?? {}) as {
678
720
  clientX?: number
679
721
  clientY?: number
@@ -713,11 +755,22 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
713
755
  const screen = projectConnection(candidate)
714
756
  return !!screen && Math.hypot(screen.x - pointer[0], screen.y - pointer[1]) <= 12
715
757
  }
758
+ // An explicit route may only connect at the height its free end reaches above that
759
+ // plan point, which includes the adapter's fall.
760
+ const routeHeightAt = (candidate: readonly number[]): number => {
761
+ const level: RunPoint = [candidate[0]!, routeElevation, candidate[2]!]
762
+ return currentStart
763
+ ? (adapter.resolveFreeEnd?.(currentStart, level, startConnectionRef.current) ?? level)[1]
764
+ : routeElevation
765
+ }
766
+ const onRoute = (candidate: readonly number[]) =>
767
+ !explicitRoute || Math.abs(candidate[1]! - routeHeightAt(candidate)) < 1e-4
716
768
  const resolveConnection = (candidate: RunPoint): ResolvedRunPoint | null => {
717
769
  if (bypass || !(isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()))
718
770
  return null
719
771
  const source = startConnectionRef.current.port
720
- const hit = findScreenPort(adapter.getPorts(), pointer, projectConnection, source)
772
+ const ports = adapter.getPorts().filter((port) => onRoute(port.position))
773
+ const hit = findScreenPort(ports, pointer, projectConnection, source)
721
774
  if (hit) {
722
775
  return {
723
776
  frame: createRunSurfaceFrame([...hit.port.position]),
@@ -730,8 +783,19 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
730
783
  body: null,
731
784
  }
732
785
  }
733
- const body = adapter.findBody(candidate, target)
734
- if (body && acceptsConnection(body.point) && body.nodeId !== source?.nodeId) {
786
+ const body =
787
+ adapter.connectCrossings === false
788
+ ? null
789
+ : adapter.findBody(
790
+ explicitRoute ? [candidate[0], routeHeightAt(candidate), candidate[2]] : candidate,
791
+ drawingTarget,
792
+ )
793
+ if (
794
+ body &&
795
+ onRoute(body.point) &&
796
+ acceptsConnection(body.point) &&
797
+ body.nodeId !== source?.nodeId
798
+ ) {
735
799
  return {
736
800
  ...sample,
737
801
  point: body.point,
@@ -747,7 +811,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
747
811
  if (connection) return connection
748
812
  // Rank displayed directions before a surface projection discards height.
749
813
  if (currentStart && event.localRay && !bypass && (forcedDirection || angleLocked)) {
750
- const source = startConnectionRef.current.port?.direction ?? null
814
+ const source = explicitRoute ? null : (startConnectionRef.current.port?.direction ?? null)
751
815
  const candidates = forcedDirection
752
816
  ? [forcedDirection]
753
817
  : source
@@ -803,7 +867,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
803
867
  forcedDirection ||
804
868
  (Math.abs(directionHit.direction[1]) > 1e-6 && target?.kind === 'floor')
805
869
  ? null
806
- : target,
870
+ : drawingTarget,
807
871
  snapped: null,
808
872
  directionMode: 'angle',
809
873
  port: null,
@@ -951,7 +1015,12 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
951
1015
  setAltActive(false)
952
1016
  }
953
1017
 
1018
+ const updateCursorScreen = (event: GridEvent) => {
1019
+ const pointer = getGridEventScreenProjection(event)?.pointer
1020
+ setCursorScreen(pointer ? { x: pointer[0], y: pointer[1], depth: 0 } : null)
1021
+ }
954
1022
  const onMove = (event: RunPointerEvent) => {
1023
+ updateCursorScreen(event)
955
1024
  lastPointerRef.current = event
956
1025
  const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
957
1026
  if (typeof clientY === 'number') lastClientYRef.current = clientY
@@ -973,6 +1042,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
973
1042
  }
974
1043
 
975
1044
  const onClick = (event: GridEvent) => {
1045
+ updateCursorScreen(event)
976
1046
  event.nativeEvent?.stopPropagation?.()
977
1047
  const currentStart = startRef.current
978
1048
  if (altAnchorRef.current && currentStart) {
@@ -993,8 +1063,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
993
1063
  port: resolved.port,
994
1064
  body: resolved.port ? null : resolved.body,
995
1065
  }
996
- if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) {
997
- lockedWallTargetRef.current = resolved.surfaceTarget
1066
+ if (!configRef.current.routingMode || configRef.current.routingMode === 'surface') {
1067
+ if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) {
1068
+ lockedWallTargetRef.current = resolved.surfaceTarget
1069
+ }
998
1070
  }
999
1071
  triggerSFX('sfx:grid-snap')
1000
1072
  startConnectionRef.current = connection
@@ -1005,6 +1077,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1005
1077
  setSnapTarget(resolved.snapped)
1006
1078
  setSnapScreen(resolved.snapScreen ?? null)
1007
1079
  setEndConnection({ port: null, body: null })
1080
+ // A vertical route ends at the target, not under the pointer: show that
1081
+ // riser now so the next click or Enter places what the preview shows.
1082
+ if (configRef.current.routingMode === 'vertical')
1083
+ updateCursor(verticalTarget(resolved.point))
1008
1084
  return
1009
1085
  }
1010
1086
  const typedResolved = applyTypedLength(resolved)
@@ -1036,6 +1112,11 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1036
1112
  setAltActive(true)
1037
1113
  return
1038
1114
  }
1115
+ if (
1116
+ configRef.current.routingMode === 'vertical' &&
1117
+ (event.key === 'Tab' || /^[0-9.,]$/.test(event.key))
1118
+ )
1119
+ return
1039
1120
  if (
1040
1121
  event.key === 'Tab' &&
1041
1122
  startRef.current &&
@@ -1045,7 +1126,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1045
1126
  ) {
1046
1127
  event.preventDefault()
1047
1128
  event.stopImmediatePropagation()
1048
- const source = startConnectionRef.current.port?.direction
1129
+ const source =
1130
+ configRef.current.routingMode === 'horizontal'
1131
+ ? null
1132
+ : startConnectionRef.current.port?.direction
1049
1133
  const candidates = source
1050
1134
  ? run3DDirectionCandidates(source)
1051
1135
  : runHorizontalDirectionCandidates(null)
@@ -1117,7 +1201,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1117
1201
  }
1118
1202
 
1119
1203
  refreshCursorRef.current = () => {
1120
- if (lastPointerRef.current)
1204
+ const currentStart = startRef.current
1205
+ if (currentStart && configRef.current.routingMode === 'vertical')
1206
+ updateCursor(verticalTarget(currentStart))
1207
+ else if (lastPointerRef.current)
1121
1208
  updateCursor(applyTypedLength(resolvePoint(lastPointerRef.current)))
1122
1209
  else if (lastResolvedRef.current) updateCursor(applyTypedLength(lastResolvedRef.current))
1123
1210
  }
@@ -1151,18 +1238,39 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1151
1238
  }
1152
1239
  }, [camera, gl, config.active, updateLengthInput])
1153
1240
 
1241
+ // The resolver reads config through a ref; changing these controls must refresh a stationary cursor.
1242
+ // biome-ignore lint/correctness/useExhaustiveDependencies: configuration changes invalidate the cursor
1243
+ useEffect(() => {
1244
+ forcedDirectionRef.current = null
1245
+ if (config.routingMode !== 'surface') lockedWallTargetRef.current = null
1246
+ refreshCursor()
1247
+ }, [
1248
+ config.routingMode,
1249
+ config.elevation,
1250
+ config.targetElevation,
1251
+ config.connectCrossings,
1252
+ refreshCursor,
1253
+ ])
1254
+
1154
1255
  return {
1155
1256
  refreshCursor,
1257
+ routingMode: config.routingMode,
1156
1258
  start,
1157
1259
  cursor,
1158
1260
  snapTarget,
1159
1261
  snapScreen,
1262
+ cursorScreen: planVisible ? cursorScreen : null,
1160
1263
  altActive,
1161
1264
  directionMode,
1162
1265
  lengthInput,
1163
1266
  validationMessage,
1164
1267
  onLengthInputChange: updateLengthInput,
1165
1268
  onDirectionSelect: (direction: RunPoint) => {
1269
+ if (
1270
+ configRef.current.routingMode === 'vertical' ||
1271
+ (configRef.current.routingMode === 'horizontal' && Math.abs(direction[1]) > 1e-6)
1272
+ )
1273
+ return
1166
1274
  forcedDirectionRef.current = direction
1167
1275
  setDirectionMode('angle')
1168
1276
  refreshCursorRef.current()
@@ -1183,6 +1291,7 @@ export function DistributionRunCursor({
1183
1291
  extraParts = [],
1184
1292
  cursorRef,
1185
1293
  directionMode,
1294
+ routingMode,
1186
1295
  startDirection,
1187
1296
  lengthInput,
1188
1297
  validationMessage,
@@ -1198,6 +1307,7 @@ export function DistributionRunCursor({
1198
1307
  extraParts?: DimensionPillPart[]
1199
1308
  cursorRef?: RefObject<Group | null>
1200
1309
  directionMode: RunDirectionMode
1310
+ routingMode?: 'surface' | 'horizontal' | 'vertical'
1201
1311
  startDirection?: readonly [number, number, number] | null
1202
1312
  lengthInput?: string
1203
1313
  validationMessage?: string | null
@@ -1208,8 +1318,8 @@ export function DistributionRunCursor({
1208
1318
  const i18n = usePascalI18n()
1209
1319
  const lengthFieldRef = useRef<HTMLInputElement>(null)
1210
1320
  useEffect(() => {
1211
- if (start) lengthFieldRef.current?.focus()
1212
- }, [start])
1321
+ if (start && routingMode !== 'vertical') lengthFieldRef.current?.focus()
1322
+ }, [start, routingMode])
1213
1323
  if (!cursor) return null
1214
1324
  const parts: DimensionPillPart[] = [
1215
1325
  ...(start
@@ -1270,9 +1380,9 @@ export function DistributionRunCursor({
1270
1380
  cursor={cursor}
1271
1381
  mode={directionMode}
1272
1382
  snapped={!!snapTarget}
1273
- sourceDirection={startDirection ?? null}
1383
+ sourceDirection={routingMode === 'horizontal' ? null : (startDirection ?? null)}
1274
1384
  start={start}
1275
- onDirectionSelect={onDirectionSelect}
1385
+ onDirectionSelect={routingMode === 'vertical' ? undefined : onDirectionSelect}
1276
1386
  />
1277
1387
  )}
1278
1388
  <CursorSphere
@@ -1291,7 +1401,15 @@ export function DistributionRunCursor({
1291
1401
  >
1292
1402
  <div className="flex flex-col items-center gap-1">
1293
1403
  <DimensionPill parts={parts} primary={primary} unit={unit} />
1294
- {start ? (
1404
+ {start && routingMode === 'vertical' ? (
1405
+ <div className="rounded-full border border-border/60 bg-background/90 px-3 py-1 text-xs shadow-sm">
1406
+ {i18n._({
1407
+ id: 'editor.tools.distribution.distributionRunTool.targetElevation',
1408
+ message: 'Target elevation: {elevation} m · click or Enter to place',
1409
+ values: { elevation: formatFixed(cursor[1], 2, i18n.getLocale()) },
1410
+ })}
1411
+ </div>
1412
+ ) : start ? (
1295
1413
  <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">
1296
1414
  {messageWithSlot(
1297
1415
  i18n,
@@ -1,8 +1,7 @@
1
- import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
1
+ import type { AnyNodeId, SpawnNode } from '@pascal-app/core'
2
2
  import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
3
3
  import { Suspense, useMemo } from 'react'
4
4
  import { useMovingNode } from '../../../store/use-interaction-scope'
5
- import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
5
  import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
7
6
  import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
8
7
 
@@ -20,8 +19,6 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
20
19
  * 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only
21
20
  * declare `capabilities.movable` (shelf, spawn, duct-terminal,
22
21
  * hvac-equipment, …).
23
- * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
24
- * mover hasn't been ported to a kind-owned affordance yet.
25
22
  */
26
23
  export const MoveTool: React.FC<{
27
24
  onNodeMoved?: (nodeId: AnyNodeId) => void
@@ -47,7 +44,5 @@ export const MoveTool: React.FC<{
47
44
  return <MoveRegistryNodeTool node={movingNode} />
48
45
  }
49
46
 
50
- if (movingNode.type === 'elevator')
51
- return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
52
47
  return null
53
48
  }
@@ -9,6 +9,7 @@ import {
9
9
  collectAlignmentAnchors,
10
10
  emitter,
11
11
  findLevelAncestorId,
12
+ formatLinearMeasurement,
12
13
  type GridEvent,
13
14
  getScaledDimensions,
14
15
  getWallBodyCenterOffset,
@@ -49,7 +50,6 @@ import {
49
50
  publishPlacementSurface,
50
51
  } from '../../../lib/active-placement-surface'
51
52
  import { EDITOR_LAYER } from '../../../lib/constants'
52
- import { formatLinearMeasurement } from '../../../lib/measurements'
53
53
  import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
54
54
  import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
55
55
  import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  emitter,
7
+ formatLinearMeasurement,
7
8
  sceneRegistry,
8
9
  useScene,
9
10
  type WallFace,
@@ -27,7 +28,6 @@ import {
27
28
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
28
29
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
29
30
  import { EDITOR_LAYER } from '../../../lib/constants'
30
- import { formatLinearMeasurement } from '../../../lib/measurements'
31
31
  import { paintRegionTargets, usePaintRegionMode } from '../../../lib/paint-region-mode'
32
32
  import { isTypingTarget } from '../../../lib/typing-target'
33
33
  import {
@@ -107,6 +107,7 @@ test('focus completion: mounted registry mover owns only outside R T and Alt', a
107
107
  cancelAnimationFrame: { configurable: true, value: (key: number) => frames.delete(key) },
108
108
  IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
109
109
  })
110
+ let supportY = 0
110
111
  core.registerNode({
111
112
  kind: node.type,
112
113
  schemaVersion: 1,
@@ -114,7 +115,14 @@ test('focus completion: mounted registry mover owns only outside R T and Alt', a
114
115
  schema: z.object({ type: z.literal(node.type) }).passthrough(),
115
116
  defaults: () => ({}),
116
117
  capabilities: {
117
- movable: { isValidPosition: () => false },
118
+ movable: {
119
+ isValidPosition: () => false,
120
+ resolvePosition: ({ position }: { position: [number, number, number] }) => [
121
+ position[0],
122
+ supportY,
123
+ position[2],
124
+ ],
125
+ },
118
126
  floorPlaced: {
119
127
  collides: true,
120
128
  footprint: (candidate: AnyNode) => ({
@@ -251,6 +259,39 @@ test('focus completion: mounted registry mover owns only outside R T and Alt', a
251
259
  expect((await key('t', false)).defaultPrevented).toBe(true)
252
260
  expect(mesh.rotation.y).toBeCloseTo(0.25)
253
261
  expect(core.useLiveTransforms.getState().transforms.get(id)?.rotation).toBeCloseTo(0.25)
262
+ supportY = 0.75
263
+ await act(async () => {
264
+ core.emitter.emit('grid:move', {
265
+ nativeEvent: {
266
+ target: { closest: () => null },
267
+ } as unknown as import('@pascal-app/core').GridEvent['nativeEvent'],
268
+ position: [7, 0, 3],
269
+ localPosition: [7, 0, 3],
270
+ stopPropagation() {},
271
+ } as import('@pascal-app/core').GridEvent)
272
+ flushFrames()
273
+ })
274
+ expect(mesh.position.y).toBeCloseTo(0.75)
275
+ expect(core.useLiveTransforms.getState().transforms.get(id)?.position?.[1]).toBeCloseTo(0.75)
276
+ expect(
277
+ (core.useScene.getState().nodes[id] as unknown as { position: number[] }).position[1],
278
+ ).toBe(0)
279
+ supportY = 1.25
280
+ await key('Alt', false)
281
+ await act(async () => {
282
+ core.emitter.emit('grid:click', {
283
+ nativeEvent: {
284
+ target: { closest: () => null },
285
+ } as unknown as import('@pascal-app/core').GridEvent['nativeEvent'],
286
+ position: [7, 0, 3],
287
+ localPosition: [7, 0, 3],
288
+ stopPropagation() {},
289
+ } as import('@pascal-app/core').GridEvent)
290
+ flushFrames()
291
+ })
292
+ expect(
293
+ (core.useScene.getState().nodes[id] as unknown as { position: number[] }).position[1],
294
+ ).toBeCloseTo(1.25)
254
295
  } finally {
255
296
  if (root)
256
297
  await act(async () => {
@@ -484,6 +484,18 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
484
484
  return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y
485
485
  }
486
486
 
487
+ const resolvePosition = (position: [number, number, number]) => {
488
+ const nodes = useScene.getState().nodes
489
+ const current = nodes[node.id] ?? node
490
+ return (
491
+ nodeRegistry.get(current.type)?.capabilities?.movable?.resolvePosition?.({
492
+ node: current,
493
+ position,
494
+ nodes,
495
+ }) ?? position
496
+ )
497
+ }
498
+
487
499
  const keepStartingElevation = (position: [number, number, number], yaw: number) => {
488
500
  if (!preserveLevelAndElevation) return position
489
501
  const rotation = toCommitRotation(yaw)
@@ -534,14 +546,19 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
534
546
  // them through `useLiveNodeOverrides` (transient — no history churn;
535
547
  // GeometrySystem merges overrides via getEffectiveNode). Each connected
536
548
  // node is re-dirtied so its geometry rebuilds against the new override.
537
- const previewConnectivity = (position: [number, number, number], rotationY: number) => {
549
+ // False, previewing nothing, when the connected joints cannot follow.
550
+ const previewConnectivity = (
551
+ position: [number, number, number],
552
+ rotationY: number,
553
+ ): boolean => {
538
554
  const connectivity = connectivityRef.current
539
- if (!connectivity) return
555
+ if (!connectivity) return true
540
556
  const updates = resolveConnectivityUpdates(
541
557
  connectivity,
542
558
  buildPreviewNode(position, rotationY),
543
559
  )
544
- if (updates.length === 0) return
560
+ if (!updates) return false
561
+ if (updates.length === 0) return true
545
562
  useLiveNodeOverrides
546
563
  .getState()
547
564
  .setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
@@ -549,6 +566,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
549
566
  for (const u of updates) {
550
567
  if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
551
568
  }
569
+ return true
552
570
  }
553
571
 
554
572
  const clearConnectivityOverrides = () => {
@@ -1026,7 +1044,11 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1026
1044
  },
1027
1045
  ).position
1028
1046
  }
1029
- position = keepStartingElevation(position, rotationRef.current)
1047
+ position = resolvePosition(keepStartingElevation(position, rotationRef.current))
1048
+ // Carry connected ductwork along (preview only — committed on drop). A
1049
+ // position the connected joints cannot follow holds the last frame,
1050
+ // which is what the drop commits.
1051
+ if (!previewConnectivity(position, rotationRef.current)) return
1030
1052
  const visualPosition = getVisualPosition(position)
1031
1053
  hasMovedRef.current = true
1032
1054
  setCursorPosition(visualPosition)
@@ -1054,8 +1076,6 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1054
1076
  })
1055
1077
  syncParentFramePreview(position)
1056
1078
  markMovedNodeDirty()
1057
- // Carry connected ductwork along (preview only — committed on drop).
1058
- previewConnectivity(position, rotationRef.current)
1059
1079
 
1060
1080
  const soundStep = {
1061
1081
  coords: [x, z],
@@ -1116,7 +1136,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1116
1136
  // tool — unless Alt (free place) is held to force placement. Other kinds
1117
1137
  // carry no validity box (`validRef` stays true), so they're never blocked.
1118
1138
  if (!validRef.current && !altRef.current) return
1119
- const position: [number, number, number] = [...lastCursorRef.current]
1139
+ const position = resolvePosition([...lastCursorRef.current])
1120
1140
 
1121
1141
  const rotation = toCommitRotation(rotationRef.current)
1122
1142
  let committedId = node.id as AnyNodeId
@@ -1171,8 +1191,9 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1171
1191
  ? resolveConnectivityUpdates(
1172
1192
  connectivityRef.current,
1173
1193
  buildPreviewNode(position, rotationRef.current),
1174
- ).filter((u) => useScene.getState().nodes[u.id])
1194
+ )?.filter((u) => useScene.getState().nodes[u.id])
1175
1195
  : []
1196
+ if (!connectivityUpdates) return
1176
1197
  const surfaceId = surfaceAttachmentId(effectiveNode)
1177
1198
  itemSurfaceMove?.restore()
1178
1199
  useScene.temporal.getState().resume()
@@ -1298,7 +1319,6 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1298
1319
  attachmentRotationRef.current,
1299
1320
  )
1300
1321
  if (nextFreeRotation === null) return
1301
- sfxEmitter.emit('sfx:item-rotate')
1302
1322
  const hostedPose = itemSurfaceMove?.rotate(nextFreeRotation)
1303
1323
  let position = hostedPose?.position ?? lastCursorRef.current
1304
1324
  if (
@@ -1319,10 +1339,16 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1319
1339
  )
1320
1340
  position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1321
1341
  }
1322
- position = keepStartingElevation(position, hostedPose?.rotationY ?? nextFreeRotation)
1342
+ const rotationY = hostedPose?.rotationY ?? nextFreeRotation
1343
+ position = keepStartingElevation(position, rotationY)
1344
+ // Rotating the fitting swings its collars — connected ducts follow. A
1345
+ // swing they cannot follow holds the last frame; a surface-hosted node
1346
+ // has already turned with its host, so its drop refuses instead.
1347
+ if (!previewConnectivity(position, rotationY) && !hostedPose) return
1348
+ sfxEmitter.emit('sfx:item-rotate')
1323
1349
  lastCursorRef.current = position
1324
- freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
1325
- rotationRef.current = freeRotationRef.current
1350
+ freeRotationRef.current = rotationY
1351
+ rotationRef.current = rotationY
1326
1352
  setCursorRotationY(previewRotationY(rotationRef.current))
1327
1353
  const visualPosition = getVisualPosition(position)
1328
1354
  setCursorPosition(visualPosition)
@@ -1335,8 +1361,6 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1335
1361
  })
1336
1362
  syncParentFramePreview(position)
1337
1363
  markMovedNodeDirty()
1338
- // Rotating the fitting swings its collars — connected ducts follow.
1339
- previewConnectivity(position, rotationRef.current)
1340
1364
  // Rotation changes the footprint's collision span — re-check validity.
1341
1365
  if (!itemSurfaceMove?.hosted && itemSurfaceMove?.rejection && lastSurfaceEvent)
1342
1366
  onItemMove(lastSurfaceEvent)
@@ -2,16 +2,12 @@
2
2
 
3
3
  import '../../../three-types'
4
4
 
5
+ import { formatLinearMeasurement, type LinearUnit, type MetricNotation } from '@pascal-app/core'
5
6
  import { useEffect, useMemo } from 'react'
6
7
  import { PlaneGeometry } from 'three'
7
8
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
8
9
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
9
10
  import { EDITOR_LAYER } from '../../../lib/constants'
10
- import {
11
- formatLinearMeasurement,
12
- type LinearUnit,
13
- type MetricNotation,
14
- } from '../../../lib/measurements'
15
11
  import { Html } from '../../editor/localized-html'
16
12
  import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry'
17
13