@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
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
95
95
  return Math.max(minimum, Math.round(stair.stepCount ?? 10))
96
96
  }
97
97
 
98
+ function getFloorplanStairBreakStep(stepCount: number) {
99
+ return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68))
100
+ }
101
+
98
102
  function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
99
103
  if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
100
104
  return 0
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
244
248
  const angle = sectorStartAngle + stepSweep * index
245
249
  const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
246
250
  const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
247
- const dashedFromIndex = Math.floor(stepCount * 0.68)
251
+ if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
252
+ return null
253
+ }
248
254
 
249
255
  return (
250
256
  <line
251
257
  key={`${stair.id}:spiral-step:${index}`}
252
258
  pointerEvents="none"
253
259
  stroke={index === stepCount ? curvedAccent : curvedStroke}
254
- strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
255
260
  strokeWidth={index === stepCount ? '1.8' : '1.15'}
256
261
  vectorEffect="non-scaling-stroke"
257
262
  x1={toSvgX(innerPoint.x)}
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
330
335
  const angle = sectorStartAngle + stepSweep * index
331
336
  const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
332
337
  const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
338
+ if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
339
+ return null
340
+ }
333
341
 
334
342
  return (
335
343
  <line
@@ -397,14 +405,16 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
397
405
  strokeWidth={isSelectionActive ? '2' : '1.35'}
398
406
  vectorEffect="non-scaling-stroke"
399
407
  />
400
- {treadBars.map((treadBar, treadIndex) => (
401
- <polygon
402
- fill={straightTread}
403
- key={`${segment.id}:tread:${treadIndex}`}
404
- pointerEvents="none"
405
- points={segment.segmentType === 'landing' ? '' : treadBar.points}
406
- />
407
- ))}
408
+ {treadBars
409
+ .slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
410
+ .map((treadBar, treadIndex) => (
411
+ <polygon
412
+ fill={straightTread}
413
+ key={`${segment.id}:tread:${treadIndex}`}
414
+ pointerEvents="none"
415
+ points={segment.segmentType === 'landing' ? '' : treadBar.points}
416
+ />
417
+ ))}
408
418
  </g>
409
419
  ))}
410
420
  {arrow?.polyline && arrow.polyline.length >= 2 ? (
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  type CeilingNode,
5
5
  emitter,
6
+ resolveCeilingHeight,
6
7
  resolveLevelId,
7
8
  sceneRegistry,
8
9
  snapPointToGrid,
@@ -151,6 +152,9 @@ const CeilingSelectionAffordance = ({
151
152
  () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
152
153
  [ceiling, liveOverride],
153
154
  )
155
+ // Explicit height when stored, else the live level-top bound the ceiling
156
+ // follows (primitive selector — re-render-safe).
157
+ const resolvedHeight = useScene((s) => resolveCeilingHeight(effectiveCeiling, s.nodes))
154
158
  const [levelObject, setLevelObject] = useState<Object3D | null>(
155
159
  () => sceneRegistry.nodes.get(levelId) ?? null,
156
160
  )
@@ -221,7 +225,7 @@ const CeilingSelectionAffordance = ({
221
225
  )
222
226
  raycasterRef.current.setFromCamera(ndcRef.current, camera)
223
227
 
224
- planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0)
228
+ planePointRef.current.set(0, resolvedHeight + BRACKET_Y_OFFSET, 0)
225
229
  levelObject.localToWorld(planePointRef.current)
226
230
 
227
231
  planeOriginRef.current.set(0, 0, 0)
@@ -238,7 +242,7 @@ const CeilingSelectionAffordance = ({
238
242
  levelObject.worldToLocal(localIntersectionRef.current)
239
243
  return [localIntersectionRef.current.x, localIntersectionRef.current.z]
240
244
  },
241
- [camera, effectiveCeiling.height, gl.domElement, levelObject],
245
+ [camera, resolvedHeight, gl.domElement, levelObject],
242
246
  )
243
247
 
244
248
  const handleCornerPointerDown = useCallback(
@@ -438,10 +442,7 @@ const CeilingSelectionAffordance = ({
438
442
  if (!levelObject || corners.length === 0) return null
439
443
 
440
444
  return createPortal(
441
- <group
442
- position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}
443
- ref={bracketsRootRef}
444
- >
445
+ <group position={[0, resolvedHeight + BRACKET_Y_OFFSET, 0]} ref={bracketsRootRef}>
445
446
  {corners.map((corner, index) => (
446
447
  <CornerBracket
447
448
  ceiling={effectiveCeiling}
@@ -507,7 +508,11 @@ const CornerBracket = ({
507
508
  node: ceiling,
508
509
  nativeEvent: e.nativeEvent,
509
510
  localPosition: [0, 0, 0],
510
- position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
511
+ position: [
512
+ corner.corner[0],
513
+ resolveCeilingHeight(ceiling, useScene.getState().nodes),
514
+ corner.corner[1],
515
+ ],
511
516
  stopPropagation: () => e.stopPropagation(),
512
517
  viaHandle: true,
513
518
  })
@@ -5,6 +5,7 @@ import {
5
5
  getWallCurveFrameAt,
6
6
  getWallCurveLength,
7
7
  isCurvedWall,
8
+ resolveFenceSupportSlabPatch,
8
9
  snapPointAlongAngleRay,
9
10
  useScene,
10
11
  type WallNode,
@@ -187,9 +188,22 @@ export function snapFenceDraftPoint(args: {
187
188
  return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
188
189
  }
189
190
 
191
+ export type FenceCommitOptions = {
192
+ /**
193
+ * Pointer-decided support cap (level-local Y) from
194
+ * `resolvePointerSupportSurface` — the 3D tool passes the elevation of
195
+ * the surface the commit click actually aimed at, so a fence drawn on a
196
+ * deck top persists the deck as its lift host while one drawn at the
197
+ * floor underneath stays grounded. Omitted by 2D floor-plan commits (no
198
+ * camera ray): those keep the uncapped max election.
199
+ */
200
+ supportCap?: number | null
201
+ }
202
+
190
203
  export function createFenceOnCurrentLevel(
191
204
  start: FencePlanPoint,
192
205
  end: FencePlanPoint,
206
+ options?: FenceCommitOptions,
193
207
  ): FenceNode | null {
194
208
  const currentLevelId = useViewer.getState().selection.levelId
195
209
  const { createNode, nodes } = useScene.getState()
@@ -209,6 +223,13 @@ export function createFenceOnCurrentLevel(
209
223
  start,
210
224
  end,
211
225
  })
226
+ // Fences run no per-frame support election — the persisted host IS the
227
+ // lift (absent = level floor), so elect it at commit, pointer-capped.
228
+ fence.supportSlabId = resolveFenceSupportSlabPatch(
229
+ { ...fence, parentId: currentLevelId },
230
+ nodes,
231
+ { maxElevation: options?.supportCap ?? null },
232
+ ).supportSlabId
212
233
 
213
234
  createNode(fence, currentLevelId)
214
235
  sfxEmitter.emit('sfx:structure-build')
@@ -225,6 +246,7 @@ export function createFenceOnCurrentLevel(
225
246
  export function createSplineFenceOnCurrentLevel(
226
247
  path: FencePlanPoint[],
227
248
  tangents = getTwoPointFenceCurveTangents(path),
249
+ options?: FenceCommitOptions,
228
250
  ): FenceNode | null {
229
251
  const currentLevelId = useViewer.getState().selection.levelId
230
252
  const { createNode, nodes } = useScene.getState()
@@ -250,6 +272,11 @@ export function createSplineFenceOnCurrentLevel(
250
272
  path,
251
273
  tangents,
252
274
  })
275
+ fence.supportSlabId = resolveFenceSupportSlabPatch(
276
+ { ...fence, parentId: currentLevelId },
277
+ nodes,
278
+ { maxElevation: options?.supportCap ?? null },
279
+ ).supportSlabId
253
280
 
254
281
  createNode(fence, currentLevelId)
255
282
  sfxEmitter.emit('sfx:structure-build')
@@ -2,12 +2,14 @@ import {
2
2
  type AnyNodeId,
3
3
  type AssetInput,
4
4
  ItemNode,
5
+ resolveSupportSlabPatch,
5
6
  sceneRegistry,
6
7
  useScene,
7
8
  } from '@pascal-app/core'
8
9
  import { useViewer } from '@pascal-app/viewer'
9
10
  import { useCallback, useMemo, useRef } from 'react'
10
11
  import type { Vector3 } from 'three'
12
+ import usePlacementPreview from '../../../store/use-placement-preview'
11
13
  import { stripTransient } from './placement-math'
12
14
 
13
15
  interface OriginalState {
@@ -38,8 +40,14 @@ export interface DraftNodeHandle {
38
40
  ) => ItemNode | null
39
41
  /** Take ownership of an existing scene node as the draft (for move mode). */
40
42
  adopt: (node: ItemNode) => void
41
- /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
42
- commit: (finalUpdate: Partial<ItemNode>) => string | null
43
+ /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place.
44
+ * `supportElevationCap` (floor commits) is the pointer-decided surface
45
+ * elevation — it caps the persisted `supportSlabId` election so the
46
+ * commit lands on the surface the cursor pointed at. */
47
+ commit: (
48
+ finalUpdate: Partial<ItemNode>,
49
+ options?: { supportElevationCap?: number | null },
50
+ ) => string | null
43
51
  /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
44
52
  destroy: () => void
45
53
  }
@@ -80,6 +88,9 @@ export function useDraftNode(): DraftNodeHandle {
80
88
  })
81
89
 
82
90
  useScene.getState().createNode(node, currentLevelId)
91
+ usePlacementPreview
92
+ .getState()
93
+ .set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
83
94
  draftRef.current = node
84
95
  adoptedRef.current = false
85
96
  originalStateRef.current = null
@@ -112,100 +123,142 @@ export function useDraftNode(): DraftNodeHandle {
112
123
  useScene.getState().updateNode(node.id, {
113
124
  metadata: { ...meta, isTransient: true },
114
125
  })
126
+ usePlacementPreview
127
+ .getState()
128
+ .set(
129
+ node,
130
+ node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] ?? null) : null,
131
+ )
115
132
  }, [])
116
133
 
117
- const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
118
- const draft = draftRef.current
119
- if (!draft) return null
134
+ const commit = useCallback(
135
+ (
136
+ finalUpdate: Partial<ItemNode>,
137
+ options?: { supportElevationCap?: number | null },
138
+ ): string | null => {
139
+ const draft = draftRef.current
140
+ if (!draft) return null
141
+
142
+ if (adoptedRef.current) {
143
+ // Move mode: update in place (single undoable action)
144
+ const { parentId: newParentId, ...updateProps } = finalUpdate
145
+ const parentId =
146
+ newParentId ??
147
+ originalStateRef.current?.parentId ??
148
+ useViewer.getState().selection.levelId
149
+ const original = originalStateRef.current!
150
+
151
+ // Restore original state while paused — so the undo baseline is clean
152
+ useScene.getState().updateNode(draft.id, {
153
+ position: original.position,
154
+ rotation: original.rotation,
155
+ side: original.side,
156
+ parentId: original.parentId,
157
+ roofSegmentId: original.roofSegmentId,
158
+ roofFace: original.roofFace,
159
+ metadata: original.metadata,
160
+ })
161
+
162
+ // Resume → tracked update (undo reverts to original)
163
+ useScene.temporal.getState().resume()
164
+
165
+ const effectiveNode = ItemNode.parse({
166
+ ...draft,
167
+ ...updateProps,
168
+ parentId,
169
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
170
+ })
171
+
172
+ useScene.getState().updateNode(draft.id, {
173
+ position: updateProps.position ?? draft.position,
174
+ rotation: updateProps.rotation ?? draft.rotation,
175
+ side: updateProps.side ?? draft.side,
176
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
177
+ parentId: parentId as string,
178
+ // Forward the roof host explicitly: strategies set it on every
179
+ // commit (segment id on a roof face, undefined elsewhere), and
180
+ // dropping it here strands the item in the roof frame without
181
+ // the segment transform.
182
+ roofSegmentId: updateProps.roofSegmentId,
183
+ roofFace: updateProps.roofFace,
184
+ // Only when the strategy decided about wallId (roof commits clear
185
+ // it) — floor/ceiling commits never managed the field.
186
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
187
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
188
+ maxElevation: options?.supportElevationCap,
189
+ }),
190
+ })
191
+
192
+ useScene.temporal.getState().pause()
193
+
194
+ const id = draft.id
195
+ if (usePlacementPreview.getState().node?.id === id) {
196
+ usePlacementPreview.getState().clear()
197
+ }
198
+ draftRef.current = null
199
+ adoptedRef.current = false
200
+ originalStateRef.current = null
201
+ return id
202
+ }
120
203
 
121
- if (adoptedRef.current) {
122
- // Move mode: update in place (single undoable action)
204
+ // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
123
205
  const { parentId: newParentId, ...updateProps } = finalUpdate
124
- const parentId =
125
- newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
126
- const original = originalStateRef.current!
206
+ const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
207
+ if (!parentId) return null
127
208
 
128
- // Restore original state while paused so the undo baseline is clean
129
- useScene.getState().updateNode(draft.id, {
130
- position: original.position,
131
- rotation: original.rotation,
132
- side: original.side,
133
- parentId: original.parentId,
134
- roofSegmentId: original.roofSegmentId,
135
- roofFace: original.roofFace,
136
- metadata: original.metadata,
137
- })
209
+ // Delete draft while paused (invisible to undo)
210
+ useScene.getState().deleteNode(draft.id)
211
+ draftRef.current = null
138
212
 
139
- // Resumetracked update (undo reverts to original)
213
+ // Briefly resume create fresh node (the single undoable action)
140
214
  useScene.temporal.getState().resume()
141
215
 
142
- useScene.getState().updateNode(draft.id, {
216
+ const finalNode = ItemNode.parse({
217
+ name: draft.name,
218
+ asset: draft.asset,
143
219
  position: updateProps.position ?? draft.position,
144
220
  rotation: updateProps.rotation ?? draft.rotation,
221
+ scale: updateProps.scale ?? draft.scale,
145
222
  side: updateProps.side ?? draft.side,
146
- metadata: updateProps.metadata ?? stripTransient(draft.metadata),
147
- parentId: parentId as string,
148
- // Forward the roof host explicitly: strategies set it on every
149
- // commit (segment id on a roof face, undefined elsewhere), and
150
- // dropping it here strands the item in the roof frame without
151
- // the segment transform.
223
+ // Carry painted slot overrides so a duplicated item keeps its materials.
224
+ ...(draft.slots ? { slots: draft.slots } : {}),
225
+ // Roof host — see the move-mode commit above for why this must be
226
+ // forwarded explicitly.
152
227
  roofSegmentId: updateProps.roofSegmentId,
153
228
  roofFace: updateProps.roofFace,
154
- // Only when the strategy decided about wallId (roof commits clear
155
- // it) — floor/ceiling commits never managed the field.
156
229
  ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
230
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
231
+ parentId,
232
+ })
233
+ const nodes = useScene.getState().nodes
234
+ const committedNode = ItemNode.parse({
235
+ ...finalNode,
236
+ ...resolveSupportSlabPatch(
237
+ finalNode,
238
+ { ...nodes, [finalNode.id]: finalNode },
239
+ { maxElevation: options?.supportElevationCap },
240
+ ),
157
241
  })
242
+ useScene.getState().createNode(committedNode, parentId)
243
+ if (usePlacementPreview.getState().node?.id === draft.id) {
244
+ usePlacementPreview.getState().clear()
245
+ }
158
246
 
247
+ // Re-pause for next draft cycle
159
248
  useScene.temporal.getState().pause()
160
249
 
161
- const id = draft.id
162
- draftRef.current = null
163
250
  adoptedRef.current = false
164
251
  originalStateRef.current = null
165
- return id
166
- }
167
-
168
- // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
169
- const { parentId: newParentId, ...updateProps } = finalUpdate
170
- const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
171
- if (!parentId) return null
172
-
173
- // Delete draft while paused (invisible to undo)
174
- useScene.getState().deleteNode(draft.id)
175
- draftRef.current = null
176
-
177
- // Briefly resume → create fresh node (the single undoable action)
178
- useScene.temporal.getState().resume()
179
-
180
- const finalNode = ItemNode.parse({
181
- name: draft.name,
182
- asset: draft.asset,
183
- position: updateProps.position ?? draft.position,
184
- rotation: updateProps.rotation ?? draft.rotation,
185
- scale: updateProps.scale ?? draft.scale,
186
- side: updateProps.side ?? draft.side,
187
- // Carry painted slot overrides so a duplicated item keeps its materials.
188
- ...(draft.slots ? { slots: draft.slots } : {}),
189
- // Roof host — see the move-mode commit above for why this must be
190
- // forwarded explicitly.
191
- roofSegmentId: updateProps.roofSegmentId,
192
- roofFace: updateProps.roofFace,
193
- ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
194
- metadata: updateProps.metadata ?? stripTransient(draft.metadata),
195
- })
196
- useScene.getState().createNode(finalNode, parentId)
197
-
198
- // Re-pause for next draft cycle
199
- useScene.temporal.getState().pause()
200
-
201
- adoptedRef.current = false
202
- originalStateRef.current = null
203
- return finalNode.id
204
- }, [])
252
+ return committedNode.id
253
+ },
254
+ [],
255
+ )
205
256
 
206
257
  const destroy = useCallback(() => {
207
258
  if (!draftRef.current) return
208
259
 
260
+ const draftId = draftRef.current.id
261
+
209
262
  if (adoptedRef.current && originalStateRef.current) {
210
263
  // Move mode: restore original state instead of deleting — but only
211
264
  // if no other system has already committed a new position for this
@@ -227,6 +280,9 @@ export function useDraftNode(): DraftNodeHandle {
227
280
  draftRef.current = null
228
281
  adoptedRef.current = false
229
282
  originalStateRef.current = null
283
+ if (usePlacementPreview.getState().node?.id === draftId) {
284
+ usePlacementPreview.getState().clear()
285
+ }
230
286
  return
231
287
  }
232
288
 
@@ -257,6 +313,9 @@ export function useDraftNode(): DraftNodeHandle {
257
313
  draftRef.current = null
258
314
  adoptedRef.current = false
259
315
  originalStateRef.current = null
316
+ if (usePlacementPreview.getState().node?.id === draftId) {
317
+ usePlacementPreview.getState().clear()
318
+ }
260
319
  }, [])
261
320
 
262
321
  return useMemo(
@@ -62,6 +62,10 @@ import {
62
62
  type PreviewBounds,
63
63
  updateLineGeometry,
64
64
  } from '../shared/placement-box-geometry'
65
+ import {
66
+ resolvePointerSupportElevation,
67
+ resolvePointerSupportSurface,
68
+ } from '../shared/pointer-support-cap'
65
69
  import {
66
70
  getDetachedAttachmentPreviewLift,
67
71
  getGridAlignedDimensions,
@@ -269,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
269
273
  const raycastDisabledMeshRef = useRef<Object3D | null>(null)
270
274
  const restoreRaycastsRef = useRef<Array<() => void>>([])
271
275
  const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
276
+ // Level-local elevation of the surface the pointer ray actually points
277
+ // at (deck top, floor slab, or ground), refreshed on every grid move.
278
+ // Threaded into the floor-support election as its cap so the POINTER
279
+ // decides the target surface — without it the election lifts the ghost
280
+ // by the MAX overlapping slab and a deck above the aimed-at floor
281
+ // captures the item (and the grid-plane feedback makes it blink).
282
+ const pointerSupportCapRef = useRef<number | null>(null)
272
283
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
273
284
 
274
285
  // Live camera ref — the shelf-stickiness test reconstructs the cursor world
@@ -420,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
420
431
  node: previewNode,
421
432
  position,
422
433
  rotation: previewNode.rotation,
434
+ maxElevation: pointerSupportCapRef.current,
423
435
  })
424
436
  },
425
437
  [asset?.attachTo, draftNode],
@@ -449,6 +461,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
449
461
  surfaceItemId: null,
450
462
  shelfId: null,
451
463
  }
464
+ // No pointer surface known yet — fall back to the uncapped election
465
+ // (an adopted move draft keeps its persisted host until the first move).
466
+ pointerSupportCapRef.current = null
452
467
  if (!asset.attachTo && placementState.current.surface === 'floor') {
453
468
  gridPosition.current.y = 0
454
469
  if (cursorGroupRef.current) {
@@ -848,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
848
863
 
849
864
  has3DPointerDrivenMoveRef.current = true
850
865
 
866
+ // The pointer decides the target surface AND the floor point: cap
867
+ // the floor-support election at the elevation of the surface the
868
+ // camera ray actually hits, and re-aim the event at the ray's
869
+ // crossing of that surface's plane. The grid event's own hit can't
870
+ // be used directly — its plane rides at the ghost's last height, so
871
+ // its Y is a feedback loop (the under-deck blink) and its XZ is
872
+ // perspective-skewed along the ray whenever the plane sits on a
873
+ // different storey than the pointed surface (the skew is what made
874
+ // a drag over a deck-above-a-floor hop between the two surfaces).
875
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
876
+ pointerSupportCapRef.current = pointed?.elevation ?? null
877
+ const surfaceEvent: GridEvent =
878
+ pointed?.worldPoint && pointed.localPoint
879
+ ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
880
+ : event
881
+
851
882
  // Shelf stickiness: while hosting on a shelf, ignore floor events while
852
883
  // the cursor ray still points at the shelf volume (the ray merely slipped
853
884
  // off a board / through a gap and hit the floor behind). Detach to the
@@ -855,10 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
855
886
  // item oscillates between the shelf row and the floor on every micro-move.
856
887
  if (placementState.current.surface === 'shelf-surface') {
857
888
  if (cursorRayIntersectsActiveShelf(event.position)) return
858
- detachItemSurfaceToFloor(event as unknown as ItemEvent)
889
+ // Land at the pointed surface's plan point — the raw grid hit is
890
+ // still skewed by the plane riding at the shelf-surface height.
891
+ detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
859
892
  }
860
893
 
861
- const floorEvent = applyFloorGrabOffset(event)
894
+ const floorEvent = applyFloorGrabOffset(surfaceEvent)
862
895
 
863
896
  lastRawPos.current.set(
864
897
  floorEvent.localPosition[0],
@@ -941,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
941
974
 
942
975
  if (draft) draft.position = gridPos
943
976
 
944
- // Publish live transform for 2D floorplan
977
+ // Publish live transform for 2D floorplan (and the pointer surface
978
+ // cap, so FloorElevationSystem's per-frame Y agrees with the ghost).
945
979
  if (draft) {
946
980
  useLiveTransforms.getState().set(draft.id, {
947
981
  position: gridPos,
948
982
  rotation: cursorGroupRef.current.rotation.y,
983
+ supportElevationCap: pointerSupportCapRef.current ?? undefined,
949
984
  })
950
985
  }
951
986
 
@@ -973,7 +1008,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
973
1008
 
974
1009
  const committedId = draftNode.current?.id ?? null
975
1010
  const wasAdopted = draftNode.isAdopted
976
- const finalId = draftNode.commit(result.nodeUpdate)
1011
+ // Carry the pointer surface cap into the commit so the persisted
1012
+ // supportSlabId reproduces the capped election (elects the aimed-at
1013
+ // lower slab — or the ground — instead of a deck hanging above).
1014
+ const finalId = draftNode.commit(result.nodeUpdate, {
1015
+ supportElevationCap: pointerSupportCapRef.current,
1016
+ })
977
1017
  finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
978
1018
  draftNode.create(
979
1019
  gridPosition.current,
@@ -1392,6 +1432,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1392
1432
 
1393
1433
  const detachItemSurfaceToFloor = (event: ItemEvent) => {
1394
1434
  hostSurfaceDragAnchor = null
1435
+ // Landing back on the floor: refresh the pointer surface cap from
1436
+ // this event's world hit so the first floor position already targets
1437
+ // the aimed-at surface (not a deck above it).
1438
+ pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [
1439
+ event.position[0],
1440
+ event.position[1],
1441
+ event.position[2],
1442
+ ])
1395
1443
  // Coming back from a host: forget the floor grab too, so the item
1396
1444
  // centers under the cursor instead of restoring the pre-drag offset —
1397
1445
  // and landing on the floor is "anchoring elsewhere", so a later return