@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
@@ -9,12 +9,14 @@ import {
9
9
  movingAlignmentAnchors,
10
10
  type NodeEvent,
11
11
  resolveAlignment,
12
+ resolveSupportSlabPatch,
12
13
  StairNode,
13
14
  StairSegmentNode,
14
15
  syncAutoStairOpenings,
15
16
  useScene,
16
17
  } from '@pascal-app/core'
17
18
  import { useViewer } from '@pascal-app/viewer'
19
+ import { useThree } from '@react-three/fiber'
18
20
  import { useEffect, useMemo, useRef } from 'react'
19
21
  import * as THREE from 'three'
20
22
  import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -31,8 +33,11 @@ import useEditor, {
31
33
  } from '../../../store/use-editor'
32
34
 
33
35
  import useFacingPose from '../../../store/use-facing-pose'
36
+ import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
34
37
  import { CursorSphere } from '../shared/cursor-sphere'
35
38
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
39
+ import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
40
+ import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
36
41
  import {
37
42
  DEFAULT_CURVED_STAIR_INNER_RADIUS,
38
43
  DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
@@ -57,6 +62,7 @@ const GRID_OFFSET = 0.02
57
62
  /** Figma-style alignment-snap threshold (meters), matching the move tools. */
58
63
  const ALIGNMENT_THRESHOLD_M = 0.08
59
64
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
65
+ type MoveTriggerEvent = GridEvent | NodeEvent<AnyNode>
60
66
 
61
67
  const CLICK_TRIGGER_KINDS = [
62
68
  'shelf',
@@ -149,7 +155,6 @@ function createDefaultStairNode({
149
155
  slabOpeningMode: 'destination',
150
156
  openingOffset: DEFAULT_STAIR_OPENING_OFFSET,
151
157
  width: DEFAULT_STAIR_WIDTH,
152
- totalRise: DEFAULT_STAIR_HEIGHT,
153
158
  stepCount: DEFAULT_STAIR_STEP_COUNT,
154
159
  thickness: DEFAULT_STAIR_THICKNESS,
155
160
  fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
@@ -172,6 +177,7 @@ function commitStairPlacement(
172
177
  levelId: LevelNode['id'],
173
178
  position: [number, number, number],
174
179
  rotation: number,
180
+ supportElevationCap: number | null,
175
181
  ): void {
176
182
  const { createNodes, nodes } = useScene.getState()
177
183
  const placementLevelId = resolveStairPlacementLevelId(
@@ -192,13 +198,27 @@ function commitStairPlacement(
192
198
  })
193
199
  const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
194
200
 
195
- const stair = createDefaultStairNode({
196
- name,
197
- levelId: placementLevelId,
198
- nextLevelId,
199
- position,
200
- rotation,
201
- segmentId: segment.id,
201
+ const stair = StairNode.parse({
202
+ ...createDefaultStairNode({
203
+ name,
204
+ levelId: placementLevelId,
205
+ nextLevelId,
206
+ position,
207
+ rotation,
208
+ segmentId: segment.id,
209
+ }),
210
+ parentId: placementLevelId,
211
+ })
212
+ const prospectiveNodes = {
213
+ ...nodes,
214
+ [stair.id]: stair,
215
+ [segment.id]: { ...segment, parentId: stair.id },
216
+ } as Record<string, AnyNode>
217
+ const committedStair = StairNode.parse({
218
+ ...stair,
219
+ ...resolveSupportSlabPatch(stair, prospectiveNodes, {
220
+ maxElevation: supportElevationCap,
221
+ }),
202
222
  })
203
223
 
204
224
  const createdLevel = destinationPlan?.createdLevel
@@ -209,17 +229,21 @@ function commitStairPlacement(
209
229
 
210
230
  createNodes([
211
231
  ...levelCreateOps,
212
- { node: stair, parentId: placementLevelId },
213
- { node: segment, parentId: stair.id },
232
+ { node: committedStair, parentId: placementLevelId },
233
+ { node: segment, parentId: committedStair.id },
214
234
  ])
215
235
 
216
236
  sfxEmitter.emit('sfx:structure-build')
217
237
  }
218
238
 
219
239
  export const StairTool: React.FC = () => {
240
+ const camera = useThree((state) => state.camera)
241
+ const cameraRef = useRef(camera)
242
+ cameraRef.current = camera
220
243
  const cursorRef = useRef<THREE.Group>(null)
221
244
  const previewRef = useRef<THREE.Group>(null)
222
245
  const rotationRef = useRef(0)
246
+ const supportCapRef = useRef<number | null>(null)
223
247
  const previousGridPosRef = useRef<[number, number] | null>(null)
224
248
  const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
225
249
  const currentLevelId = useViewer((state) => state.selection.levelId)
@@ -230,11 +254,16 @@ export const StairTool: React.FC = () => {
230
254
  if (!currentLevelId) return
231
255
 
232
256
  const openingPreview = createSurfaceOpeningPreviewController()
257
+ // Refuses the duplicate commit triggers a single physical click produces
258
+ // — see `stair-click-guard.ts`. Fresh per armed session.
259
+ const commitGate = createStairCommitGate()
233
260
 
234
261
  // Reset rotation when tool activates
235
262
  rotationRef.current = 0
263
+ useStairBuildPreview.getState().reset()
236
264
  if (previewRef.current) previewRef.current.rotation.y = 0
237
265
  lastCanonicalPositionRef.current = null
266
+ supportCapRef.current = null
238
267
 
239
268
  const buildPreviewScene = (position: [number, number, number], rotation: number) => {
240
269
  const nodes = useScene.getState().nodes
@@ -281,10 +310,15 @@ export const StairTool: React.FC = () => {
281
310
  // cheap because it has no opening sync).
282
311
  let lastPreviewKey: string | null = null
283
312
 
284
- const applyDraftPreview = (position: [number, number, number], rotation: number) => {
285
- const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}`
313
+ const applyDraftPreview = (
314
+ position: [number, number, number],
315
+ rotation: number,
316
+ supportElevationCap: number | null,
317
+ ) => {
318
+ const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportElevationCap?.toFixed(3) ?? 'none'}`
286
319
  if (key === lastPreviewKey) return
287
320
  lastPreviewKey = key
321
+ useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation)
288
322
  const preview = buildPreviewScene(position, rotation)
289
323
  const visualPosition = preview
290
324
  ? getFloorStackPreviewPosition({
@@ -293,6 +327,7 @@ export const StairTool: React.FC = () => {
293
327
  rotation,
294
328
  levelId: preview.placementLevelId,
295
329
  nodes: preview.previewNodes,
330
+ maxElevation: supportElevationCap,
296
331
  })
297
332
  : position
298
333
  if (cursorRef.current) {
@@ -388,25 +423,34 @@ export const StairTool: React.FC = () => {
388
423
  return [x, z]
389
424
  }
390
425
 
391
- const onGridMove = (event: GridEvent) => {
426
+ const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => {
427
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
428
+ supportCapRef.current = pointed?.elevation ?? null
429
+ const fallbackPosition =
430
+ 'node' in event ? lastCanonicalPositionRef.current : event.localPosition
431
+ if (!pointed?.localPoint && !fallbackPosition) return null
432
+ const rawX = pointed?.localPoint?.[0] ?? fallbackPosition![0]
433
+ const rawZ = pointed?.localPoint?.[2] ?? fallbackPosition![2]
392
434
  // Grid snap follows the global mode (live step so the HUD chip is
393
435
  // honest); Off keeps the raw cursor. Shift cycles the mode centrally.
394
436
  const step = useEditor.getState().gridSnapStep
395
437
  const [gridX, gridZ] = alignPoint(
396
- isGridSnapActive()
397
- ? Math.round(event.localPosition[0] / step) * step
398
- : event.localPosition[0],
399
- isGridSnapActive()
400
- ? Math.round(event.localPosition[2] / step) * step
401
- : event.localPosition[2],
402
- event.localPosition[0],
403
- event.localPosition[2],
438
+ isGridSnapActive() ? Math.round(rawX / step) * step : rawX,
439
+ isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ,
440
+ rawX,
441
+ rawZ,
404
442
  !isAlignmentGuideActive(),
405
443
  isMagneticSnapActive(),
406
444
  )
407
- const position: [number, number, number] = [gridX, 0, gridZ]
445
+ return [gridX, 0, gridZ]
446
+ }
447
+
448
+ const onPointerMove = (event: MoveTriggerEvent) => {
449
+ const position = resolveStairPosition(event)
450
+ if (!position) return
451
+ const [gridX, , gridZ] = position
408
452
  lastCanonicalPositionRef.current = position
409
- applyDraftPreview(position, rotationRef.current)
453
+ applyDraftPreview(position, rotationRef.current, supportCapRef.current)
410
454
 
411
455
  if (
412
456
  (isGridSnapActive() || isMagneticSnapActive()) &&
@@ -419,37 +463,28 @@ export const StairTool: React.FC = () => {
419
463
  previousGridPosRef.current = [gridX, gridZ]
420
464
  }
421
465
 
422
- const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
423
- const step = useEditor.getState().gridSnapStep
424
- const [gridX, gridZ] = alignPoint(
425
- isGridSnapActive()
426
- ? Math.round(event.localPosition[0] / step) * step
427
- : event.localPosition[0],
428
- isGridSnapActive()
429
- ? Math.round(event.localPosition[2] / step) * step
430
- : event.localPosition[2],
431
- event.localPosition[0],
432
- event.localPosition[2],
433
- !isAlignmentGuideActive(),
434
- isMagneticSnapActive(),
435
- )
436
- return [gridX, 0, gridZ]
437
- }
438
-
439
466
  const commitAtCursor = (event: ClickTriggerEvent) => {
440
467
  if (!currentLevelId) return
468
+ // One physical click can reach here twice (node click synthesized on
469
+ // pointerup + the native browser click driving `grid:click`) — see
470
+ // `stair-click-guard.ts`. The gate refuses anything after a single-
471
+ // continuation commit; the swallow below eats the same gesture's
472
+ // follow-up click while the tool stays armed (repeat continuation).
473
+ if (!commitGate.shouldCommit()) return
441
474
  const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
442
475
  if (nodeEvent) {
443
476
  nodeEvent.stopPropagation()
444
477
  nodeEvent.nativeEvent.stopPropagation()
478
+ // The canvas-level `grid:click` listener is out of stopPropagation's
479
+ // reach — without this, the browser click that follows this
480
+ // pointerup-synthesized node click commits a second stair.
481
+ swallowFollowUpBrowserClick()
445
482
  }
446
483
 
447
- const position = nodeEvent
448
- ? lastCanonicalPositionRef.current
449
- : getAlignedGridPosition(event as GridEvent)
484
+ const position = resolveStairPosition(event)
450
485
  if (!position) return
451
486
 
452
- commitStairPlacement(currentLevelId, position, rotationRef.current)
487
+ commitStairPlacement(currentLevelId, position, rotationRef.current, supportCapRef.current)
453
488
  openingPreview.clear()
454
489
  // Commit cleared the opening preview, so force the next hover (even on the
455
490
  // same cell) to rebuild rather than dedupe against the just-placed key.
@@ -462,8 +497,14 @@ export const StairTool: React.FC = () => {
462
497
  if (useEditor.getState().getContinuation('point') === 'repeat') {
463
498
  alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
464
499
  } else {
500
+ commitGate.markExited()
465
501
  useFacingPose.getState().clear()
466
502
  useEditor.getState().setTool(null)
503
+ // Return to select mode explicitly (matches the spawn tool's exit).
504
+ // The selection managers route node clicks only while
505
+ // `mode === 'select'`; exiting with `mode: 'build'` + a null tool
506
+ // left every click dead until the user pressed Escape.
507
+ useEditor.getState().setMode('select')
467
508
  }
468
509
  }
469
510
 
@@ -482,34 +523,44 @@ export const StairTool: React.FC = () => {
482
523
  sfxEmitter.emit('sfx:item-rotate')
483
524
  rotationRef.current += rotationDelta
484
525
  if (lastCanonicalPositionRef.current) {
485
- applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current)
526
+ applyDraftPreview(
527
+ lastCanonicalPositionRef.current,
528
+ rotationRef.current,
529
+ supportCapRef.current,
530
+ )
486
531
  } else if (previewRef.current) {
487
532
  previewRef.current.rotation.y = rotationRef.current
488
533
  }
489
534
  }
490
535
  }
491
536
 
492
- emitter.on('grid:move', onGridMove)
537
+ emitter.on('grid:move', onPointerMove)
493
538
  emitter.on('grid:click', commitAtCursor)
494
539
  type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
495
540
  type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
541
+ type MoveKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
496
542
  for (const kind of CLICK_TRIGGER_KINDS) {
497
543
  const key = `${kind}:click` as ClickKey
498
544
  emitter.on(key, commitAtCursor as never)
545
+ const moveKey = `${kind}:move` as MoveKey
546
+ emitter.on(moveKey, onPointerMove as never)
499
547
  }
500
548
  window.addEventListener('keydown', onKeyDown)
501
549
 
502
550
  return () => {
503
- emitter.off('grid:move', onGridMove)
551
+ emitter.off('grid:move', onPointerMove)
504
552
  emitter.off('grid:click', commitAtCursor)
505
553
  for (const kind of CLICK_TRIGGER_KINDS) {
506
554
  const key = `${kind}:click` as ClickKey
507
555
  emitter.off(key, commitAtCursor as never)
556
+ const moveKey = `${kind}:move` as MoveKey
557
+ emitter.off(moveKey, onPointerMove as never)
508
558
  }
509
559
  window.removeEventListener('keydown', onKeyDown)
510
560
  useAlignmentGuides.getState().clear()
511
561
  openingPreview.clear()
512
562
  useFacingPose.getState().clear()
563
+ useStairBuildPreview.getState().reset()
513
564
  }
514
565
  }, [currentLevelId])
515
566
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AnyNodeDefinition,
2
3
  type AnyNodeId,
3
4
  type BuildingNode,
4
5
  type CeilingNode,
@@ -10,17 +11,21 @@ import {
10
11
  } from '@pascal-app/core'
11
12
  import { useViewer } from '@pascal-app/viewer'
12
13
  import { type ComponentType, lazy, Suspense, useMemo } from 'react'
14
+ import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
13
15
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
14
16
  import {
15
17
  useControlPointReshape,
16
18
  useEditingHole,
17
19
  useEndpointReshape,
18
20
  useIsCurveReshape,
21
+ useIsFloorplanDrivenReshape,
22
+ useIsToolDrivenReshape,
19
23
  useMovingNode,
20
24
  useReshapingNode,
21
25
  useTangentReshape,
22
26
  } from '../../store/use-interaction-scope'
23
27
  import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
28
+ import { Elevation3DGuideLayer } from '../editor/elevation-3d-guide-layer'
24
29
  import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
25
30
  import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
26
31
  import { ElevatorTool } from './elevator/elevator-tool'
@@ -29,6 +34,7 @@ import { RoofTool } from './roof/roof-tool'
29
34
  import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
30
35
  import { FacingPoseIndicator } from './shared/facing-pose-indicator'
31
36
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
37
+ import { TerrainSculptTool } from './site/terrain-sculpt-tool'
32
38
  import { StairTool } from './stair/stair-tool'
33
39
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
34
40
  import { ZoneTool } from './zone/zone-tool'
@@ -36,6 +42,29 @@ import { ZoneTool } from './zone/zone-tool'
36
42
  // Cache lazy tool components keyed by their loader so React.lazy isn't
37
43
  // re-invoked across renders.
38
44
  const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
45
+ const registryToolPreloadCache = new WeakMap<AnyNodeDefinition, Promise<void>>()
46
+
47
+ export function preloadRegistryToolModules(tool: string | null): Promise<void> {
48
+ if (!tool) return Promise.resolve()
49
+ const def = nodeRegistry.get(tool)
50
+ if (!def) return Promise.resolve()
51
+ const cached = registryToolPreloadCache.get(def)
52
+ if (cached) return cached
53
+
54
+ const loaders: Array<() => Promise<unknown>> = []
55
+ if (def.tool) loaders.push(def.tool)
56
+ if (def.preview) loaders.push(def.preview)
57
+ if (def.renderer?.kind === 'parametric') loaders.push(def.renderer.module)
58
+ if (def.system) loaders.push(def.system.module)
59
+ if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel)
60
+ if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection)
61
+ const moveTool = def.affordanceTools?.move
62
+ if (moveTool) loaders.push(moveTool)
63
+
64
+ const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined)
65
+ registryToolPreloadCache.set(def, preload)
66
+ return preload
67
+ }
39
68
 
40
69
  function getRegistryTool(tool: Tool | null): ComponentType | null {
41
70
  if (!tool) return null
@@ -43,7 +72,13 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
43
72
  if (!def?.tool) return null
44
73
  const cached = lazyToolCache.get(def.tool)
45
74
  if (cached) return cached
46
- const Comp = lazy(def.tool as () => Promise<{ default: ComponentType }>)
75
+ const Comp = lazy(async () => {
76
+ // Placement can only begin once the node's preview, committed renderer,
77
+ // inspector, and move contribution are warm. This keeps the click itself
78
+ // synchronous even under Next.js dev-time on-demand compilation.
79
+ await preloadRegistryToolModules(tool)
80
+ return def.tool!() as Promise<{ default: ComponentType }>
81
+ })
47
82
  lazyToolCache.set(def.tool, Comp)
48
83
  return Comp
49
84
  }
@@ -73,6 +108,8 @@ export const ToolManager: React.FC = () => {
73
108
  const controlPointReshape = useControlPointReshape()
74
109
  const tangentReshape = useTangentReshape()
75
110
  const isCurveReshape = useIsCurveReshape()
111
+ const isToolDrivenReshape = useIsToolDrivenReshape()
112
+ const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
76
113
  const reshapingNode = useReshapingNode()
77
114
  // The endpoint affordance tool's `target` is kind-specific
78
115
  // (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
@@ -128,9 +165,14 @@ export const ToolManager: React.FC = () => {
128
165
  | CeilingNode['id']
129
166
  | undefined
130
167
 
131
- // Keep the site vertex flags available in select mode; the editor component
132
- // switches to full polygon editing only after a flag activates site mode.
133
- const showSiteBoundaryEditor = phase === 'site' || mode === 'select'
168
+ // Site boundary handles normally share one 2D/3D rule. Sculpt is the deliberate
169
+ // 3D exception: the brush only owns this canvas, where PolygonEditor can hand
170
+ // off its pointer before a boundary drag starts.
171
+ const sculpting = mode === 'terrain-sculpt'
172
+ // Sculpt keeps the 3D property controls visible. PolygonEditor marks its
173
+ // pointer before the canvas-level brush listener runs, and activating one
174
+ // exits sculpt mode before starting the boundary drag.
175
+ const showSiteBoundaryEditor = sculpting || siteBoundaryHandlesEnabled({ mode, phase })
134
176
 
135
177
  // A multi-selection is manipulated as one rigid group (drag / R / T), so
136
178
  // per-node reshape chrome — the slab / ceiling boundary editors' vertex and
@@ -143,6 +185,7 @@ export const ToolManager: React.FC = () => {
143
185
  mode === 'select' &&
144
186
  isSoleSelection &&
145
187
  selectedSlabId !== undefined &&
188
+ !isFloorplanDrivenReshape &&
146
189
  !editingSlabHoleIsManual
147
190
 
148
191
  // Show slab hole editor when editing a hole on the selected slab
@@ -150,6 +193,7 @@ export const ToolManager: React.FC = () => {
150
193
  selectedSlabId !== undefined &&
151
194
  editingHole !== null &&
152
195
  editingHole.nodeId === selectedSlabId &&
196
+ !isFloorplanDrivenReshape &&
153
197
  editingSlabHoleIsManual
154
198
 
155
199
  // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
@@ -158,13 +202,15 @@ export const ToolManager: React.FC = () => {
158
202
  mode === 'select' &&
159
203
  isSoleSelection &&
160
204
  selectedCeilingId !== undefined &&
205
+ !isFloorplanDrivenReshape &&
161
206
  (!editingHole || editingHole.nodeId !== selectedCeilingId)
162
207
 
163
208
  // Show ceiling hole editor when editing a hole on the selected ceiling
164
209
  const showCeilingHoleEditor =
165
210
  selectedCeilingId !== undefined &&
166
211
  editingHole !== null &&
167
- editingHole.nodeId === selectedCeilingId
212
+ editingHole.nodeId === selectedCeilingId &&
213
+ !isFloorplanDrivenReshape
168
214
 
169
215
  // Show zone boundary editor when in structure/select mode with a zone selected
170
216
  // Hide when editing a slab or ceiling to avoid overlapping handles
@@ -172,6 +218,7 @@ export const ToolManager: React.FC = () => {
172
218
  phase === 'structure' &&
173
219
  mode === 'select' &&
174
220
  selectedZoneId !== null &&
221
+ !isFloorplanDrivenReshape &&
175
222
  !showSlabBoundaryEditor &&
176
223
  !showCeilingBoundaryEditor
177
224
 
@@ -217,6 +264,10 @@ export const ToolManager: React.FC = () => {
217
264
  <>
218
265
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
219
266
  {showSiteBoundaryEditor && <SiteBoundaryEditor />}
267
+ {/* Terrain sculpting is a mode rather than a `tools[phase][tool]` entry —
268
+ it places no node — so it gets its own gate here. World-space, because
269
+ the ground is not building-local. */}
270
+ {sculpting && <TerrainSculptTool />}
220
271
  {showMover && movingNode?.type === 'building' && (
221
272
  <MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
222
273
  )}
@@ -271,7 +322,8 @@ export const ToolManager: React.FC = () => {
271
322
  </Suspense>
272
323
  ) : null
273
324
  })()}
274
- {endpointTarget &&
325
+ {isToolDrivenReshape &&
326
+ endpointTarget &&
275
327
  reshapingNode &&
276
328
  (() => {
277
329
  const RegistryAffordance = getRegistryAffordanceTool(
@@ -284,7 +336,8 @@ export const ToolManager: React.FC = () => {
284
336
  </Suspense>
285
337
  ) : null
286
338
  })()}
287
- {isCurveReshape &&
339
+ {isToolDrivenReshape &&
340
+ isCurveReshape &&
288
341
  reshapingNode &&
289
342
  (() => {
290
343
  const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
@@ -294,7 +347,8 @@ export const ToolManager: React.FC = () => {
294
347
  </Suspense>
295
348
  ) : null
296
349
  })()}
297
- {controlPointTarget &&
350
+ {isToolDrivenReshape &&
351
+ controlPointTarget &&
298
352
  (() => {
299
353
  const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
300
354
  return RegistryAffordance ? (
@@ -303,7 +357,8 @@ export const ToolManager: React.FC = () => {
303
357
  </Suspense>
304
358
  ) : null
305
359
  })()}
306
- {tangentTarget &&
360
+ {isToolDrivenReshape &&
361
+ tangentTarget &&
307
362
  (() => {
308
363
  const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
309
364
  return RegistryAffordance ? (
@@ -344,6 +399,9 @@ export const ToolManager: React.FC = () => {
344
399
  {/* Wall-plane proximity / sill / equal-spacing guides for openings,
345
400
  published by the door/window move tools in the same world frame. */}
346
401
  <OpeningGuides3DLayer />
402
+ {/* Structural Y-datum feedback for slab, ceiling, wall, and fence
403
+ elevation handles. Ephemeral editor chrome; never scene data. */}
404
+ <Elevation3DGuideLayer />
347
405
  {/* "Magnetic" beacon at the active wall-draft snap point. */}
348
406
  <WallSnapBeaconLayer />
349
407
  </group>
@@ -2,8 +2,14 @@ import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeId,
5
+ applyHeightPatch,
6
+ createTerrainField,
5
7
  DoorNode as DoorSchema,
8
+ encodeTerrainField,
9
+ flattenPatch,
10
+ GROUND_SUPPORT_ID,
6
11
  runAsSingleSceneHistoryStep,
12
+ spatialGridManager,
7
13
  useScene,
8
14
  type WallNode,
9
15
  WallNode as WallSchema,
@@ -14,6 +20,7 @@ import useInteractionScope from '../../../store/use-interaction-scope'
14
20
  import {
15
21
  createWallOnCurrentLevel,
16
22
  resolveEndpointWallSplit,
23
+ resolveTerrainWallConstructionOptions,
17
24
  snapWallDraftPointDetailed,
18
25
  } from './wall-drafting'
19
26
  import type { WallPlanPoint } from './wall-snap-geometry'
@@ -86,7 +93,7 @@ describe('createWallOnCurrentLevel', () => {
86
93
  useEditor.getState().setSnappingMode('wall', 'lines')
87
94
  useInteractionScope
88
95
  .getState()
89
- .begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' })
96
+ .begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint', driver: 'tool' })
90
97
  seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
91
98
  useScene.temporal.getState().clear()
92
99
  useScene.temporal.getState().resume()
@@ -102,6 +109,83 @@ describe('createWallOnCurrentLevel', () => {
102
109
  expect(levelWalls()).toHaveLength(2)
103
110
  })
104
111
 
112
+ test('committed wall preserves the ghost construction elevation on ground', () => {
113
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
114
+ supportCap: 1.75,
115
+ preferredSupportSlabId: GROUND_SUPPORT_ID,
116
+ constructionElevation: 1.75,
117
+ constructionHeight: 2.5,
118
+ })
119
+
120
+ expect(created?.supportSlabId).toBe(GROUND_SUPPORT_ID)
121
+ expect(created?.supportOffset).toBe(1.75)
122
+ expect(created?.height).toBe(2.5)
123
+ const support = spatialGridManager.getSlabSupportForWall(
124
+ LEVEL_ID,
125
+ created?.start ?? [0, 0],
126
+ created?.end ?? [0, 0],
127
+ created?.curveOffset,
128
+ created?.thickness,
129
+ created?.supportSlabId,
130
+ undefined,
131
+ created?.supportOffset,
132
+ )
133
+ expect(support.elevation).toBe(1.75)
134
+ })
135
+
136
+ test('2D terrain construction options freeze the first-point elevation and wall height', () => {
137
+ const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] })
138
+ const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5)
139
+ if (!patch) throw new Error('Expected terrain patch')
140
+ const terrain = applyHeightPatch(field, patch)
141
+ const site = {
142
+ id: 'site_test',
143
+ type: 'site',
144
+ object: 'node',
145
+ parentId: null,
146
+ visible: true,
147
+ metadata: {},
148
+ children: ['building_test'],
149
+ terrain: encodeTerrainField(terrain),
150
+ } as unknown as AnyNode
151
+ const building = {
152
+ id: 'building_test',
153
+ type: 'building',
154
+ object: 'node',
155
+ parentId: site.id,
156
+ visible: true,
157
+ metadata: {},
158
+ children: [LEVEL_ID],
159
+ position: [0, 0, 0],
160
+ rotation: [0, 0, 0],
161
+ } as AnyNode
162
+ const level = {
163
+ id: LEVEL_ID,
164
+ type: 'level',
165
+ object: 'node',
166
+ parentId: building.id,
167
+ visible: true,
168
+ metadata: {},
169
+ children: [],
170
+ level: 0,
171
+ height: 3,
172
+ } as AnyNode
173
+ const nodes = Object.fromEntries([site, building, level].map((node) => [node.id, node]))
174
+
175
+ expect(resolveTerrainWallConstructionOptions(nodes, LEVEL_ID, [0, 0])).toEqual({
176
+ constructionElevation: 1.5,
177
+ constructionHeight: 3,
178
+ supportCap: 1.5,
179
+ })
180
+ expect(
181
+ resolveTerrainWallConstructionOptions(nodes, LEVEL_ID, [0, 0], { height: 2.25 }),
182
+ ).toEqual({
183
+ constructionElevation: 1.5,
184
+ constructionHeight: 2.25,
185
+ supportCap: 1.5,
186
+ })
187
+ })
188
+
105
189
  test('endpoint near the host start corner snaps there without splitting', () => {
106
190
  const created = createWallOnCurrentLevel([2, 2], [0.015, 0])
107
191
 
@@ -326,5 +410,38 @@ describe('snapWallDraftPointDetailed', () => {
326
410
 
327
411
  expect(result.point).toEqual([3.99, 0.03])
328
412
  expect(result.snap).toBeNull()
413
+ expect(result.targetWallIds).toEqual([])
414
+ })
415
+
416
+ // Endpoint-move regression: walls attached to the moving corner keep their
417
+ // pre-drag coordinates in the scene during the drag, so their stale corner
418
+ // recreates the old junction inside the connect radius. The move tools must
419
+ // pass those walls in `ignoreWallIds` (attached mode) or a sub-5cm corner
420
+ // correction — e.g. squaring a scan-imported 91° junction — can never land.
421
+ test('a stale linked-wall corner swallows a sub-connect-radius correction unless ignored', () => {
422
+ // `wall_d` shares the dragged corner of `wall_c` at [2, 0.03]; the user
423
+ // drops 3cm away at [2, 0] to square the junction.
424
+ const linked = makeWall([2, 0.03], [2, 2], 'wall_d')
425
+
426
+ const captured = snapWallDraftPointDetailed({
427
+ point: [2, 0],
428
+ walls: [linked],
429
+ ignoreWallIds: ['wall_c'],
430
+ magnetic: false,
431
+ step: 0,
432
+ })
433
+ expect(captured.point).toEqual([2, 0.03])
434
+ expect(captured.snap).toBe('endpoint')
435
+ expect(captured.targetWallIds).toEqual(['wall_d'])
436
+
437
+ const freed = snapWallDraftPointDetailed({
438
+ point: [2, 0],
439
+ walls: [linked],
440
+ ignoreWallIds: ['wall_c', 'wall_d'],
441
+ magnetic: false,
442
+ step: 0,
443
+ })
444
+ expect(freed.point).toEqual([2, 0])
445
+ expect(freed.snap).toBeNull()
329
446
  })
330
447
  })