@pascal-app/editor 0.9.2 → 1.0.0-beta.2

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -23,6 +23,7 @@ import {
23
23
  resolveAlignment,
24
24
  resolveConnectivityUpdates,
25
25
  resolveFacingIndicator,
26
+ resolveSupportSlabPatch,
26
27
  sceneRegistry,
27
28
  spatialGridManager,
28
29
  useLiveNodeOverrides,
@@ -30,6 +31,7 @@ import {
30
31
  useScene,
31
32
  } from '@pascal-app/core'
32
33
  import { useViewer } from '@pascal-app/viewer'
34
+ import { useThree } from '@react-three/fiber'
33
35
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
34
36
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
35
37
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
@@ -54,6 +56,7 @@ import { DragBoundingBox } from '../shared/drag-bounding-box'
54
56
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
55
57
  import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
56
58
  import { PlacementBox } from '../shared/placement-box'
59
+ import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
57
60
 
58
61
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
59
62
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
@@ -231,6 +234,15 @@ const CLICK_TRIGGER_KINDS = [
231
234
  ] as const
232
235
 
233
236
  export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
237
+ // Live camera ref — the pointer-surface cap reconstructs the cursor world
238
+ // ray (camera → grid hit) to find which walking surface is aimed at.
239
+ const camera = useThree((s) => s.camera)
240
+ const cameraRef = useRef(camera)
241
+ cameraRef.current = camera
242
+ // Level-local elevation of the surface the pointer ray points at,
243
+ // refreshed per grid move. Caps the floor-support election so a deck
244
+ // hanging above the aimed-at floor never lifts the dragged node.
245
+ const supportCapRef = useRef<number | null>(null)
234
246
  // Kinds whose `position` lives in a host parent's local frame declare
235
247
  // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
236
248
  // plan-frame cursor through the capability's hooks and previews via
@@ -356,6 +368,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
356
368
  ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0]
357
369
  : rotationY
358
370
  })(),
371
+ maxElevation: supportCapRef.current,
359
372
  })
360
373
  },
361
374
  [parentFrame, frameParent, node],
@@ -404,6 +417,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
404
417
  rotationRef.current = originalRotationY
405
418
  altRef.current = false
406
419
  validRef.current = true
420
+ // No pointer surface known yet — uncapped election (the node keeps its
421
+ // persisted host / committed elevation until the first grid move).
422
+ supportCapRef.current = null
407
423
  // Re-sync the box transform to the (possibly new) node. `node` changes
408
424
  // without this component remounting whenever a positioned preset re-arms a
409
425
  // fresh clone after a drop, or the user picks a different catalog tile —
@@ -591,8 +607,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
591
607
  }
592
608
 
593
609
  const onGridMove = (event: GridEvent) => {
594
- const rawX = event.localPosition[0]
595
- const rawZ = event.localPosition[2]
610
+ // The pointer decides the target surface AND the cursor plan point,
611
+ // both resolved from the true camera ray in one place. The event's
612
+ // own hit can't be used directly: its plane rides at the ghost's
613
+ // last height, so whenever that plane sits on a different storey
614
+ // than the aimed-at surface the hit XZ is perspective-skewed along
615
+ // the ray — electing at that skewed point is what made a drag over
616
+ // a deck-above-a-floor hop between the two surfaces (each hop moved
617
+ // the plane, which re-skewed the next hit, which flipped the
618
+ // election back). Cap and XZ from the same ray ∩ pointed-surface
619
+ // test are plane-height independent, so the elected surface is a
620
+ // single fixed point per pointer ray.
621
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
622
+ supportCapRef.current = pointed?.elevation ?? null
623
+ const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0]
624
+ const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
596
625
  revealFreshPlacement()
597
626
 
598
627
  const resolved = resolvePlanarCursorPosition({
@@ -721,9 +750,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
721
750
  // the absolute world plan position here. Polygon-based kinds
722
751
  // (slab / ceiling / fence) follow a different delta contract —
723
752
  // their floor-plan move-targets handle the override themselves.
753
+ // The pointer surface cap rides along so FloorElevationSystem's
754
+ // per-frame Y agrees with this tool's preview.
724
755
  useLiveTransforms.getState().set(node.id, {
725
756
  position,
726
757
  rotation: rotationRef.current,
758
+ supportElevationCap: supportCapRef.current ?? undefined,
727
759
  })
728
760
  syncParentFramePreview(position)
729
761
  markMovedNodeDirty()
@@ -781,9 +813,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
781
813
  let committedId = node.id as AnyNodeId
782
814
 
783
815
  if (useScene.getState().nodes[node.id]) {
816
+ const effectiveNode = {
817
+ ...(node as Record<string, unknown>),
818
+ position,
819
+ rotation,
820
+ } as AnyNode
784
821
  const data = {
785
822
  position,
786
823
  rotation,
824
+ // The pointer cap makes the persisted host reproduce the capped
825
+ // election — a drop under a deck stores the aimed-at lower slab
826
+ // (or the ground), not the deck hanging above.
827
+ ...resolveSupportSlabPatch(
828
+ effectiveNode,
829
+ {
830
+ ...useScene.getState().nodes,
831
+ [node.id]: effectiveNode,
832
+ },
833
+ { maxElevation: supportCapRef.current },
834
+ ),
787
835
  ...(isNew
788
836
  ? {
789
837
  metadata: stripPlacementMetadataFlags(node.metadata),
@@ -819,6 +867,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
819
867
  const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId]
820
868
  if (liveNode && liveParent) {
821
869
  parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene))
870
+ const committedNodes = useScene.getState().nodes
871
+ const committedParent = committedNodes[frameParent.id as AnyNodeId]
872
+ if (committedParent) {
873
+ useScene
874
+ .getState()
875
+ .updateNode(
876
+ committedParent.id,
877
+ resolveSupportSlabPatch(committedParent, committedNodes),
878
+ )
879
+ }
822
880
  }
823
881
  }
824
882
  useScene.temporal.getState().pause()
@@ -834,9 +892,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
834
892
  metadata: {},
835
893
  position,
836
894
  rotation,
837
- })
895
+ }) as AnyNode
896
+ const committedNode = def.schema.parse({
897
+ ...reparsed,
898
+ parentId: node.parentId,
899
+ ...resolveSupportSlabPatch(
900
+ { ...reparsed, parentId: node.parentId } as AnyNode,
901
+ {
902
+ ...useScene.getState().nodes,
903
+ [reparsed.id]: reparsed,
904
+ },
905
+ { maxElevation: supportCapRef.current },
906
+ ),
907
+ }) as AnyNode
838
908
  useScene.temporal.getState().resume()
839
- useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
909
+ useScene.getState().createNode(committedNode, node.parentId as AnyNodeId)
840
910
  useScene.temporal.getState().pause()
841
911
  committed = true
842
912
  }
@@ -901,6 +971,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
901
971
  useLiveTransforms.getState().set(node.id, {
902
972
  position,
903
973
  rotation: rotationRef.current,
974
+ supportElevationCap: supportCapRef.current ?? undefined,
904
975
  })
905
976
  syncParentFramePreview(position)
906
977
  markMovedNodeDirty()
@@ -31,6 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
31
31
  import { sfxEmitter } from '../../../lib/sfx-bus'
32
32
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
33
33
  import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
34
+ import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview'
34
35
  import { CursorSphere } from '../shared/cursor-sphere'
35
36
 
36
37
  const DEFAULT_WALL_HEIGHT = 0.5
@@ -476,6 +477,9 @@ export const RoofTool: React.FC = () => {
476
477
  })
477
478
 
478
479
  if (corner1Ref.current) {
480
+ const draftPreview = useFloorplanDraftPreview.getState()
481
+ draftPreview.setRoofDraftStart([corner1Ref.current[0], corner1Ref.current[2]])
482
+ draftPreview.setRoofDraftEnd([gridX, gridZ])
479
483
  updateOutline(corner1Ref.current, cursorPosition)
480
484
  }
481
485
  }
@@ -497,11 +501,17 @@ export const RoofTool: React.FC = () => {
497
501
  setSelection({ selectedIds: [roofId as AnyNode['id']] })
498
502
 
499
503
  corner1Ref.current = null
504
+ const draftPreview = useFloorplanDraftPreview.getState()
505
+ draftPreview.setRoofDraftStart(null)
506
+ draftPreview.setRoofDraftEnd(null)
500
507
  outlineRef.current.visible = false
501
508
  alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
502
509
  clearSurfacePlanSnapFeedback()
503
510
  } else {
504
511
  corner1Ref.current = [gridX, y, gridZ]
512
+ const draftPreview = useFloorplanDraftPreview.getState()
513
+ draftPreview.setRoofDraftStart([gridX, gridZ])
514
+ draftPreview.setRoofDraftEnd([gridX, gridZ])
505
515
  sfxEmitter.emit('sfx:structure-build-start')
506
516
  setPreview((prev) => ({
507
517
  ...prev,
@@ -514,6 +524,9 @@ export const RoofTool: React.FC = () => {
514
524
  if (corner1Ref.current) {
515
525
  markToolCancelConsumed()
516
526
  corner1Ref.current = null
527
+ const draftPreview = useFloorplanDraftPreview.getState()
528
+ draftPreview.setRoofDraftStart(null)
529
+ draftPreview.setRoofDraftEnd(null)
517
530
  outlineRef.current.visible = false
518
531
  setPreview((prev) => ({ ...prev, corner1: null }))
519
532
  }
@@ -531,6 +544,9 @@ export const RoofTool: React.FC = () => {
531
544
  clearSurfacePlanSnapFeedback()
532
545
 
533
546
  corner1Ref.current = null
547
+ const draftPreview = useFloorplanDraftPreview.getState()
548
+ draftPreview.setRoofDraftStart(null)
549
+ draftPreview.setRoofDraftEnd(null)
534
550
  }
535
551
  }, [currentLevelId, setSelection])
536
552
 
@@ -6,6 +6,8 @@ type FloorStackPreviewArgs = {
6
6
  rotation?: unknown
7
7
  levelId?: string | null
8
8
  nodes?: Record<AnyNodeId, AnyNode>
9
+ /** Pointer-decided support cap — see `FloorPlacedElevationArgs.maxElevation`. */
10
+ maxElevation?: number | null
9
11
  }
10
12
 
11
13
  export function getFloorStackPreviewPosition({
@@ -14,6 +16,7 @@ export function getFloorStackPreviewPosition({
14
16
  rotation,
15
17
  levelId,
16
18
  nodes,
19
+ maxElevation,
17
20
  }: FloorStackPreviewArgs): [number, number, number] {
18
21
  return getFloorStackedPosition({
19
22
  node,
@@ -21,5 +24,6 @@ export function getFloorStackPreviewPosition({
21
24
  position,
22
25
  rotation,
23
26
  levelId,
27
+ maxElevation,
24
28
  })
25
29
  }
@@ -0,0 +1,123 @@
1
+ import {
2
+ type AnyNodeId,
3
+ GROUND_SUPPORT_ID,
4
+ type GridEvent,
5
+ sceneRegistry,
6
+ terrainSupportLift,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+ import { Vector3 } from 'three'
11
+ import { publishPlacementSurface } from '../../../lib/active-placement-surface'
12
+ import type { PointerSupportSurface } from './pointer-support-cap'
13
+
14
+ const SURFACE_UP = new Vector3(0, 1, 0)
15
+ const worldScratch = new Vector3()
16
+ const localScratch = new Vector3()
17
+ const surfacePointScratch = new Vector3()
18
+
19
+ export type HorizontalConstructionPlane = {
20
+ /** Building-local Y used by the cursor and draft preview. */
21
+ localY: number
22
+ /** World Y used by the shared placement grid. */
23
+ worldY: number
24
+ /** Level-local base elevation used when the node is committed. */
25
+ elevation: number | null
26
+ /** Support source selected at the first click. */
27
+ supportSlabId: string | null
28
+ /** Optional scene node from which this frozen plane was derived. */
29
+ sourceNodeId: AnyNodeId | null
30
+ }
31
+
32
+ export function resolveEventConstructionPlane(
33
+ event: GridEvent,
34
+ pointed: PointerSupportSurface | null,
35
+ ): HorizontalConstructionPlane {
36
+ if (pointed) {
37
+ return {
38
+ localY: pointed.localPoint?.[1] ?? event.localPosition[1],
39
+ worldY: pointed.worldY,
40
+ elevation: pointed.elevation,
41
+ supportSlabId: pointed.supportSlabId,
42
+ sourceNodeId: pointed.sourceNodeId,
43
+ }
44
+ }
45
+
46
+ let elevation: number | null = null
47
+ const levelId = useViewer.getState().selection.levelId
48
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : undefined
49
+ if (levelMesh) {
50
+ localScratch.set(event.position[0], event.position[1], event.position[2])
51
+ levelMesh.worldToLocal(localScratch)
52
+ elevation = localScratch.y
53
+ }
54
+
55
+ return {
56
+ localY: event.localPosition[1],
57
+ worldY: event.position[1],
58
+ elevation,
59
+ supportSlabId: null,
60
+ sourceNodeId: null,
61
+ }
62
+ }
63
+
64
+ export function resolveLevelConstructionPlane(): HorizontalConstructionPlane | null {
65
+ const { buildingId, levelId } = useViewer.getState().selection
66
+ if (!levelId) return null
67
+
68
+ const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId)
69
+ worldScratch.set(0, 0, 0)
70
+ if (levelMesh) levelMesh.localToWorld(worldScratch)
71
+
72
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
73
+ localScratch.copy(worldScratch)
74
+ if (buildingMesh) buildingMesh.worldToLocal(localScratch)
75
+
76
+ return {
77
+ localY: localScratch.y,
78
+ worldY: worldScratch.y,
79
+ elevation: 0,
80
+ supportSlabId: null,
81
+ sourceNodeId: null,
82
+ }
83
+ }
84
+
85
+ export function resampleTerrainConstructionPlane(
86
+ plane: HorizontalConstructionPlane,
87
+ point: readonly [number, number],
88
+ ): HorizontalConstructionPlane {
89
+ if (plane.supportSlabId !== GROUND_SUPPORT_ID || plane.sourceNodeId) return plane
90
+
91
+ const levelId = useViewer.getState().selection.levelId
92
+ if (!levelId) return plane
93
+ const elevation = terrainSupportLift(useScene.getState().nodes, levelId, point[0], point[1])
94
+ if (elevation == null) return plane
95
+
96
+ const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId)
97
+ worldScratch.set(point[0], elevation, point[1])
98
+ if (levelMesh) levelMesh.localToWorld(worldScratch)
99
+
100
+ const buildingId = useViewer.getState().selection.buildingId
101
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
102
+ localScratch.copy(worldScratch)
103
+ if (buildingMesh) buildingMesh.worldToLocal(localScratch)
104
+
105
+ return {
106
+ localY: localScratch.y,
107
+ worldY: worldScratch.y,
108
+ elevation,
109
+ supportSlabId: GROUND_SUPPORT_ID,
110
+ sourceNodeId: null,
111
+ }
112
+ }
113
+
114
+ export function publishHorizontalConstructionPlane(
115
+ event: GridEvent,
116
+ plane: HorizontalConstructionPlane,
117
+ ): void {
118
+ publishPlacementSurface(
119
+ surfacePointScratch.set(event.position[0], plane.worldY, event.position[2]),
120
+ SURFACE_UP,
121
+ 'fixed-plane',
122
+ )
123
+ }
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ sceneRegistry,
6
+ spatialGridManager,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+ import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three'
11
+ import { resolvePointerSupportSurface } from './pointer-support-cap'
12
+
13
+ const LEVEL_ID = 'level_test' as AnyNodeId
14
+ const WALL_ID = 'wall_test' as AnyNodeId
15
+
16
+ describe('resolvePointerSupportSurface node tops', () => {
17
+ beforeEach(() => {
18
+ spatialGridManager.clear()
19
+ sceneRegistry.clear()
20
+ useViewer.setState({
21
+ selection: {
22
+ buildingId: null,
23
+ levelId: LEVEL_ID,
24
+ zoneId: null,
25
+ selectedIds: [],
26
+ },
27
+ } as never)
28
+ useScene.setState({
29
+ nodes: {
30
+ [LEVEL_ID]: {
31
+ id: LEVEL_ID,
32
+ type: 'level',
33
+ object: 'node',
34
+ parentId: null,
35
+ visible: true,
36
+ metadata: {},
37
+ children: [WALL_ID],
38
+ level: 0,
39
+ } as AnyNode,
40
+ [WALL_ID]: {
41
+ id: WALL_ID,
42
+ type: 'wall',
43
+ object: 'node',
44
+ parentId: LEVEL_ID,
45
+ visible: true,
46
+ metadata: {},
47
+ children: [],
48
+ assemblyLayers: [],
49
+ start: [-1, 0],
50
+ end: [1, 0],
51
+ frontSide: 'unknown',
52
+ backSide: 'unknown',
53
+ } as AnyNode,
54
+ },
55
+ })
56
+ })
57
+
58
+ afterEach(() => {
59
+ sceneRegistry.clear()
60
+ })
61
+
62
+ test('prefers the nearest upward-facing registered node surface over the ground', () => {
63
+ const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial())
64
+ wallMesh.position.y = 1
65
+ wallMesh.updateMatrixWorld(true)
66
+ sceneRegistry.nodes.set(WALL_ID, wallMesh)
67
+ sceneRegistry.byType.wall!.add(WALL_ID)
68
+
69
+ const camera = new PerspectiveCamera()
70
+ camera.position.set(0, 5, 0)
71
+ camera.updateMatrixWorld(true)
72
+
73
+ const support = resolvePointerSupportSurface(camera, [0, 0, 0], {
74
+ includeNodeTopSurfaces: true,
75
+ })
76
+
77
+ expect(support?.sourceNodeId).toBe(WALL_ID)
78
+ expect(support?.elevation).toBeCloseTo(2)
79
+ expect(support?.worldPoint).toEqual([0, 2, 0])
80
+ })
81
+
82
+ test('keeps the ground result when node-top surfaces are not requested', () => {
83
+ const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial())
84
+ wallMesh.position.y = 1
85
+ wallMesh.updateMatrixWorld(true)
86
+ sceneRegistry.nodes.set(WALL_ID, wallMesh)
87
+ sceneRegistry.byType.wall!.add(WALL_ID)
88
+
89
+ const camera = new PerspectiveCamera()
90
+ camera.position.set(0, 5, 0)
91
+ camera.updateMatrixWorld(true)
92
+
93
+ const support = resolvePointerSupportSurface(camera, [0, 0, 0])
94
+
95
+ expect(support?.sourceNodeId).toBeNull()
96
+ expect(support?.elevation).toBe(0)
97
+ })
98
+ })