@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
@@ -2,11 +2,16 @@ import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
4
  DEFAULT_ANGLE_STEP,
5
+ DEFAULT_LEVEL_HEIGHT,
5
6
  type DoorNode,
7
+ GROUND_SUPPORT_ID,
6
8
  getScaledDimensions,
7
9
  type ItemNode,
10
+ resolveWallSupportSlabPatch,
8
11
  runAsSingleSceneHistoryStep,
9
12
  snapPointAlongAngleRay,
13
+ spatialGridManager,
14
+ terrainSupportLift,
10
15
  useScene,
11
16
  type WallNode,
12
17
  WallNode as WallSchema,
@@ -26,6 +31,7 @@ import {
26
31
  type WallDraftSnapResult,
27
32
  type WallPlanPoint,
28
33
  type WallSnapRadii,
34
+ wallIdsAtSnapPoint,
29
35
  } from './wall-snap-geometry'
30
36
 
31
37
  // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
@@ -72,7 +78,11 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
72
78
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
73
79
  }
74
80
 
75
- function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
81
+ function splitWallAtPoint(
82
+ wall: WallNode,
83
+ splitPoint: WallPlanPoint,
84
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
85
+ ): [WallNode, WallNode] {
76
86
  const { id: _id, parentId: _parentId, children, ...rest } = wall
77
87
 
78
88
  const first = WallSchema.parse({
@@ -88,7 +98,24 @@ function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode,
88
98
  children: [],
89
99
  })
90
100
 
91
- return [first, second]
101
+ if (wall.supportSlabId !== GROUND_SUPPORT_ID || !wall.parentId) {
102
+ return [first, second]
103
+ }
104
+
105
+ const levelId = wall.parentId
106
+ const originalElevation =
107
+ (terrainSupportLift(nodes, levelId, wall.start[0], wall.start[1]) ?? 0) +
108
+ (wall.supportOffset ?? 0)
109
+ const rebase = (segment: WallNode): WallNode => {
110
+ const terrainElevation =
111
+ terrainSupportLift(nodes, levelId, segment.start[0], segment.start[1]) ?? 0
112
+ const supportOffset = originalElevation - terrainElevation
113
+ return {
114
+ ...segment,
115
+ supportOffset: Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
116
+ }
117
+ }
118
+ return [rebase(first), rebase(second)]
92
119
  }
93
120
 
94
121
  function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
@@ -287,7 +314,7 @@ function splitWallIfNeeded(
287
314
  return { walls, point: intersection.point }
288
315
  }
289
316
 
290
- const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
317
+ const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, nodes)
291
318
  const attachmentUpdates = buildAttachmentMigrationPlan(
292
319
  wallToSplit,
293
320
  intersection.point,
@@ -393,7 +420,7 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
393
420
  snapRadii,
394
421
  } = args
395
422
 
396
- if (bypassSnap) return { point, snap: null }
423
+ if (bypassSnap) return { point, snap: null, targetWallIds: [] }
397
424
 
398
425
  // Discrete special points (corner / midpoint / crossing) are taken from the
399
426
  // raw cursor so an interim grid snap can't mask them. A corner always wins,
@@ -419,8 +446,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
419
446
  ignoreWallIds,
420
447
  radius: snapRadii?.wall,
421
448
  })
422
- if (wallSnap) return { point: wallSnap, snap: 'wall' }
423
- return { point: basePoint, snap: null }
449
+ if (wallSnap) {
450
+ return {
451
+ point: wallSnap,
452
+ snap: 'wall',
453
+ targetWallIds: wallIdsAtSnapPoint(wallSnap, walls, ignoreWallIds),
454
+ }
455
+ }
456
+ return { point: basePoint, snap: null, targetWallIds: [] }
424
457
  }
425
458
 
426
459
  // Non-magnetic modes (grid / off / angles): connectivity still sticks so a
@@ -440,9 +473,15 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
440
473
  ignoreWallIds,
441
474
  radius: WALL_CONNECT_SNAP_RADIUS,
442
475
  })
443
- if (connectWall) return { point: connectWall, snap: 'wall' }
476
+ if (connectWall) {
477
+ return {
478
+ point: connectWall,
479
+ snap: 'wall',
480
+ targetWallIds: wallIdsAtSnapPoint(connectWall, walls, ignoreWallIds),
481
+ }
482
+ }
444
483
 
445
- return { point: basePoint, snap: null }
484
+ return { point: basePoint, snap: null, targetWallIds: [] }
446
485
  }
447
486
 
448
487
  export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint {
@@ -453,9 +492,45 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b
453
492
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
454
493
  }
455
494
 
495
+ export type WallConstructionOptions = {
496
+ /** Pointer-decided maximum support elevation in level-local metres. */
497
+ supportCap?: number | null
498
+ /** Support source selected by the first click or inherited from a snapped wall. */
499
+ preferredSupportSlabId?: string | null
500
+ /** Frozen level-local Y shown by the draft ghost. */
501
+ constructionElevation?: number | null
502
+ /** Height shown by the draft ghost. */
503
+ constructionHeight?: number | null
504
+ }
505
+
506
+ export function resolveTerrainWallConstructionOptions(
507
+ nodes: Record<string, AnyNode>,
508
+ levelId: string,
509
+ point: WallPlanPoint,
510
+ defaults?: Record<string, unknown>,
511
+ ): WallConstructionOptions | undefined {
512
+ const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1])
513
+ if (constructionElevation == null) return undefined
514
+
515
+ const level = nodes[levelId]
516
+ const constructionHeight =
517
+ typeof defaults?.height === 'number'
518
+ ? defaults.height
519
+ : level?.type === 'level'
520
+ ? (level.height ?? DEFAULT_LEVEL_HEIGHT)
521
+ : DEFAULT_LEVEL_HEIGHT
522
+
523
+ return {
524
+ constructionElevation,
525
+ constructionHeight,
526
+ supportCap: constructionElevation,
527
+ }
528
+ }
529
+
456
530
  export function createWallOnCurrentLevel(
457
531
  start: WallPlanPoint,
458
532
  end: WallPlanPoint,
533
+ options?: WallConstructionOptions,
459
534
  ): WallNode | null {
460
535
  const currentLevelId = useViewer.getState().selection.levelId
461
536
  const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
@@ -541,8 +616,52 @@ export function createWallOnCurrentLevel(
541
616
  })
542
617
 
543
618
  createNode(wall, currentLevelId)
619
+ const createdWall = useScene.getState().nodes[wall.id]
620
+ if (createdWall?.type === 'wall') {
621
+ const terrainBase = terrainSupportLift(
622
+ useScene.getState().nodes,
623
+ currentLevelId,
624
+ createdWall.start[0],
625
+ createdWall.start[1],
626
+ )
627
+ const preferredSupportSlabId =
628
+ options?.preferredSupportSlabId ??
629
+ (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null)
630
+ const supportPatch = resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, {
631
+ maxElevation: options?.supportCap ?? null,
632
+ preferredSlabId: preferredSupportSlabId,
633
+ })
634
+ const supportSlabId = supportPatch.supportSlabId
635
+ const sourceSupport = spatialGridManager.getSlabSupportForWall(
636
+ currentLevelId,
637
+ createdWall.start,
638
+ createdWall.end,
639
+ createdWall.curveOffset,
640
+ createdWall.thickness,
641
+ supportSlabId,
642
+ options?.supportCap ?? null,
643
+ )
644
+ const supportOffset =
645
+ options?.constructionElevation == null
646
+ ? undefined
647
+ : options.constructionElevation - sourceSupport.elevation
648
+ const preserveDraftHeight =
649
+ createdWall.height == null &&
650
+ options?.constructionHeight != null &&
651
+ options.constructionElevation != null &&
652
+ (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6)
653
+ useScene.getState().updateNode(createdWall.id, {
654
+ ...supportPatch,
655
+ height: preserveDraftHeight
656
+ ? (options?.constructionHeight ?? createdWall.height)
657
+ : createdWall.height,
658
+ supportOffset:
659
+ supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
660
+ })
661
+ }
544
662
  sfxEmitter.emit('sfx:structure-build')
545
663
 
546
- return wall
664
+ const committedWall = useScene.getState().nodes[wall.id]
665
+ return committedWall?.type === 'wall' ? committedWall : wall
547
666
  })
548
667
  }
@@ -48,6 +48,14 @@ describe('findWallSpecialPointSnap', () => {
48
48
  expect(result?.snap).toBe('intersection')
49
49
  expect(result?.point[0]).toBeCloseTo(1, 6)
50
50
  expect(result?.point[1]).toBeCloseTo(0, 6)
51
+ expect(result?.targetWallIds).toEqual(['a', 'b'])
52
+ })
53
+
54
+ test('keeps every wall that shares a snapped endpoint as provenance', () => {
55
+ const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([0, 0], [0, 4], 'b')]
56
+ const result = findWallSpecialPointSnap([0.1, 0.1], walls)
57
+ expect(result?.point).toEqual([0, 0])
58
+ expect(result?.targetWallIds).toEqual(['a', 'b'])
51
59
  })
52
60
 
53
61
  test('corner wins over a midpoint when both are in range', () => {
@@ -25,6 +25,12 @@ export type WallDraftSnapResult = {
25
25
  * the "magnetic" snap the beacon visualises; `null` for grid/angle-only.
26
26
  */
27
27
  snap: WallDraftSnapKind | null
28
+ /**
29
+ * Walls whose geometry produced the snap. Kept separate from `snap` so a
30
+ * caller can use geometry for XZ alignment while independently deciding
31
+ * whether the target is allowed to transfer its construction plane.
32
+ */
33
+ targetWallIds: string[]
28
34
  }
29
35
 
30
36
  export const WALL_JOIN_SNAP_RADIUS = 0.35
@@ -118,6 +124,53 @@ export function findWallSnapTarget(
118
124
  return bestTarget
119
125
  }
120
126
 
127
+ /**
128
+ * Wall ids that contain an already-resolved snap point.
129
+ *
130
+ * This is provenance, not another snap pass: the tolerance only absorbs float
131
+ * drift around a point the snap pipeline already chose.
132
+ */
133
+ export function wallIdsAtSnapPoint(
134
+ point: WallPlanPoint,
135
+ walls: WallNode[],
136
+ ignoreWallIds?: string[],
137
+ tolerance = 1e-6,
138
+ ): string[] {
139
+ const ignored = new Set(ignoreWallIds ?? [])
140
+ const toleranceSquared = tolerance * tolerance
141
+ const ids: string[] = []
142
+
143
+ for (const wall of walls) {
144
+ if (ignored.has(wall.id)) continue
145
+ if (
146
+ distanceSquared(point, wall.start) <= toleranceSquared ||
147
+ distanceSquared(point, wall.end) <= toleranceSquared
148
+ ) {
149
+ ids.push(wall.id)
150
+ continue
151
+ }
152
+
153
+ if (isCurvedWall(wall)) {
154
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
155
+ for (let index = 1; index < sampleCount; index += 1) {
156
+ const frame = getWallCurveFrameAt(wall, index / sampleCount)
157
+ if (distanceSquared(point, [frame.point.x, frame.point.y]) <= toleranceSquared) {
158
+ ids.push(wall.id)
159
+ break
160
+ }
161
+ }
162
+ continue
163
+ }
164
+
165
+ const projected = projectPointOntoWall(point, wall)
166
+ if (projected && distanceSquared(point, projected) <= toleranceSquared) {
167
+ ids.push(wall.id)
168
+ }
169
+ }
170
+
171
+ return ids
172
+ }
173
+
121
174
  /**
122
175
  * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
123
176
  * generous radius. Use this before `findWallSnapTarget` so the strong
@@ -320,12 +373,26 @@ export function findWallSpecialPointSnap(
320
373
  radii?: WallSnapRadii,
321
374
  ): WallDraftSnapResult | null {
322
375
  const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint)
323
- if (endpoint) return { point: endpoint, snap: 'endpoint' }
376
+ if (endpoint) {
377
+ return {
378
+ point: endpoint,
379
+ snap: 'endpoint',
380
+ targetWallIds: wallIdsAtSnapPoint(endpoint, walls, ignoreWallIds),
381
+ }
382
+ }
324
383
 
325
384
  const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint)
326
385
  const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection)
327
386
  return nearestCandidate(point, [
328
- midpoint && { point: midpoint, snap: 'midpoint' },
329
- intersection && { point: intersection, snap: 'intersection' },
387
+ midpoint && {
388
+ point: midpoint,
389
+ snap: 'midpoint',
390
+ targetWallIds: wallIdsAtSnapPoint(midpoint, walls, ignoreWallIds),
391
+ },
392
+ intersection && {
393
+ point: intersection,
394
+ snap: 'intersection',
395
+ targetWallIds: wallIdsAtSnapPoint(intersection, walls, ignoreWallIds),
396
+ },
330
397
  ])
331
398
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from './../../../lib/surface-plan-snap'
19
19
  import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
20
20
  import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
21
+ import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview'
21
22
  import { CursorSphere } from '../shared/cursor-sphere'
22
23
 
23
24
  const Y_OFFSET = 0.02
@@ -79,6 +80,20 @@ export const ZoneTool: React.FC = () => {
79
80
  levelY: 0,
80
81
  })
81
82
 
83
+ useEffect(() => {
84
+ useFloorplanDraftPreview.getState().setPolygonDraft('zone', preview.points)
85
+ }, [preview.points])
86
+ useEffect(
87
+ () => () => {
88
+ const draftPreview = useFloorplanDraftPreview.getState()
89
+ if (draftPreview.polygonDraftType === 'zone') {
90
+ draftPreview.setPolygonDraft(null, [])
91
+ }
92
+ draftPreview.setCursorPoint(null)
93
+ },
94
+ [],
95
+ )
96
+
82
97
  useEffect(() => {
83
98
  if (!currentLevelId) return
84
99
 
@@ -192,6 +207,7 @@ export const ZoneTool: React.FC = () => {
192
207
  event.localPosition[2],
193
208
  ])
194
209
  snappedCursorPosition = displayPoint
210
+ useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
195
211
 
196
212
  // Play snap sound when the snapped position changes during drawing — only
197
213
  // when a quantizing mode is active (off / lines move continuously).
@@ -72,7 +72,10 @@ export function ControlModes() {
72
72
  }
73
73
 
74
74
  const handleClick = (id: ControlId) => {
75
- // Exit site editing first if needed
75
+ // Exit site editing first if needed. Sculpting is a site-phase mode, so
76
+ // leaving the phase is exactly the right way to leave the brush — but the
77
+ // order matters: `setPhase` resets the mode, and setting the mode first
78
+ // would have it overwritten below.
76
79
  if (isSiteEditing) {
77
80
  setPhase('structure')
78
81
  setStructureLayer('elements')
@@ -1,12 +1,17 @@
1
1
  'use client'
2
2
 
3
- import { useViewer } from '@pascal-app/viewer'
3
+ import type {
4
+ ConstructionDimensionChainMode,
5
+ ConstructionDimensionMode,
6
+ } from '@pascal-app/core'
4
7
  import {
5
8
  Box,
6
9
  Check,
7
10
  ChevronDown,
8
- Eye,
9
- EyeOff,
11
+ CircleIcon,
12
+ Crosshair,
13
+ Grid2X2,
14
+ Minus,
10
15
  Ruler,
11
16
  ScanSearch,
12
17
  Square,
@@ -17,6 +22,7 @@ import { useState } from 'react'
17
22
  import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
18
23
  import { cn } from '../../../lib/utils'
19
24
  import useEditor from '../../../store/use-editor'
25
+ import useFloorplanMode from '../../../store/use-floorplan-mode'
20
26
  import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
21
27
  import { ActionButton } from './action-button'
22
28
 
@@ -37,27 +43,65 @@ const measurementMenuOptions = [
37
43
  ...measurementOptions,
38
44
  ] as const
39
45
 
46
+ const constructionDimensionOptions = [
47
+ { mode: 'linear', chainMode: 'point-to-point', label: 'Linear dimension', icon: Ruler },
48
+ { mode: 'linear', chainMode: 'continuous', label: 'Continuous dimension', icon: Waypoints },
49
+ { mode: 'radius', chainMode: 'point-to-point', label: 'Radius dimension', icon: CircleIcon },
50
+ { mode: 'diameter', chainMode: 'point-to-point', label: 'Diameter dimension', icon: CircleIcon },
51
+ { mode: 'center-mark', chainMode: 'point-to-point', label: 'Center mark', icon: Crosshair },
52
+ { mode: 'chord', chainMode: 'point-to-point', label: 'Chord dimension', icon: Minus },
53
+ { mode: 'arc-length', chainMode: 'point-to-point', label: 'Arc length', icon: CircleIcon },
54
+ { mode: 'angular', chainMode: 'point-to-point', label: 'Angular dimension', icon: Triangle },
55
+ { mode: 'coordinate', chainMode: 'continuous', label: 'Coordinate dimensions', icon: Grid2X2 },
56
+ ] as const satisfies readonly {
57
+ mode: ConstructionDimensionMode
58
+ chainMode: ConstructionDimensionChainMode
59
+ label: string
60
+ icon: typeof Ruler
61
+ }[]
62
+
40
63
  export function MeasurementControl() {
41
64
  const [isOpen, setIsOpen] = useState(false)
42
65
  const mode = useEditor((state) => state.mode)
43
66
  const tool = useEditor((state) => state.tool)
67
+ const floorplanMode = useFloorplanMode((state) => state.mode)
44
68
  const selectedKind = useEditor((state) => state.lastMeasurementKind)
45
69
  const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
70
+ const constructionDimensionChainMode = useEditor(
71
+ (state) => state.toolDefaults['construction-dimension']?.chainMode,
72
+ )
73
+ const constructionDimensionMode = useEditor(
74
+ (state) => state.toolDefaults['construction-dimension']?.mode,
75
+ )
46
76
  const setMode = useEditor((state) => state.setMode)
47
77
  const setPhase = useEditor((state) => state.setPhase)
48
78
  const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind)
49
79
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
50
80
  const setTool = useEditor((state) => state.setTool)
51
81
  const setToolDefaults = useEditor((state) => state.setToolDefaults)
52
- const showMeasurements = useViewer((state) => state.showMeasurements)
53
- const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
82
+ const setViewMode = useEditor((state) => state.setViewMode)
54
83
 
55
84
  const selectedOption =
56
85
  measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0]
57
86
  const isActive = mode === 'build' && tool === 'measurement'
87
+ const isConstructionDimensionActive = mode === 'build' && tool === 'construction-dimension'
88
+ const activeConstructionDimensionOption = constructionDimensionOptions.find(
89
+ (option) =>
90
+ option.mode === (constructionDimensionMode ?? 'linear') &&
91
+ option.chainMode === (constructionDimensionChainMode ?? 'point-to-point'),
92
+ )
93
+ const isControlActive = isActive || isConstructionDimensionActive
58
94
  const isSmartActive = isActive && activeToolKind === 'smart'
59
- const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon
60
- const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label
95
+ const SelectedIcon = isConstructionDimensionActive
96
+ ? (activeConstructionDimensionOption?.icon ?? Ruler)
97
+ : isSmartActive
98
+ ? ScanSearch
99
+ : selectedOption.icon
100
+ const selectedLabel = isConstructionDimensionActive
101
+ ? (activeConstructionDimensionOption?.label ?? 'Linear dimension')
102
+ : isSmartActive
103
+ ? 'Smart'
104
+ : selectedOption.label
61
105
 
62
106
  const activateMeasurement = (kind: CreatableMeasurementKind) => {
63
107
  setPhase('structure')
@@ -69,7 +113,7 @@ export function MeasurementControl() {
69
113
  }
70
114
 
71
115
  const handlePrimaryClick = () => {
72
- if (isActive) {
116
+ if (isControlActive) {
73
117
  setMode('select')
74
118
  return
75
119
  }
@@ -84,15 +128,31 @@ export function MeasurementControl() {
84
128
  setTool('measurement')
85
129
  }
86
130
 
131
+ const activateConstructionDimension = (
132
+ dimensionMode: ConstructionDimensionMode,
133
+ chainMode: ConstructionDimensionChainMode,
134
+ ) => {
135
+ if (useFloorplanMode.getState().mode !== 'expert') {
136
+ useFloorplanMode.getState().showExpertModeNotice('Construction Dimension')
137
+ return
138
+ }
139
+ setPhase('structure')
140
+ setStructureLayer('elements')
141
+ setViewMode('2d')
142
+ setToolDefaults('construction-dimension', { chainMode, mode: dimensionMode })
143
+ setMode('build')
144
+ setTool('construction-dimension')
145
+ }
146
+
87
147
  return (
88
148
  <Popover onOpenChange={setIsOpen} open={isOpen}>
89
149
  <div className="flex items-center">
90
150
  <ActionButton
91
151
  aria-label={`Measure: ${selectedLabel}`}
92
- aria-pressed={isActive}
152
+ aria-pressed={isControlActive}
93
153
  className={cn(
94
154
  'rounded-r-none p-0 text-muted-foreground',
95
- isActive
155
+ isControlActive
96
156
  ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
97
157
  : 'hover:bg-cyan-500/15 hover:text-cyan-400',
98
158
  )}
@@ -128,7 +188,7 @@ export function MeasurementControl() {
128
188
 
129
189
  <PopoverContent
130
190
  align="center"
131
- className="w-56 rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
191
+ className="max-h-[70vh] w-64 overflow-y-auto rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
132
192
  side="top"
133
193
  sideOffset={14}
134
194
  >
@@ -138,7 +198,7 @@ export function MeasurementControl() {
138
198
  const isSmart = option.kind === 'smart'
139
199
  const isSelected = isSmart
140
200
  ? isSmartActive
141
- : !isSmartActive && option.kind === selectedKind
201
+ : !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind
142
202
  return (
143
203
  <button
144
204
  aria-checked={isSelected}
@@ -164,23 +224,42 @@ export function MeasurementControl() {
164
224
  )
165
225
  })}
166
226
 
167
- <div className="my-1.5 h-px bg-border/60" />
227
+ {floorplanMode === 'expert' ? (
228
+ <>
229
+ <div className="my-1.5 h-px bg-border/60" />
230
+ <div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
231
+ Floor plan
232
+ </div>
168
233
 
169
- <button
170
- aria-checked={showMeasurements}
171
- className="flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-muted-foreground text-sm transition-colors hover:bg-white/8 hover:text-foreground"
172
- onClick={() => setShowMeasurements(!showMeasurements)}
173
- role="menuitemcheckbox"
174
- type="button"
175
- >
176
- {showMeasurements ? (
177
- <Eye aria-hidden="true" className="h-4 w-4" />
178
- ) : (
179
- <EyeOff aria-hidden="true" className="h-4 w-4" />
180
- )}
181
- <span>Show measurements</span>
182
- <span className="ml-auto text-xs">{showMeasurements ? 'On' : 'Off'}</span>
183
- </button>
234
+ {constructionDimensionOptions.map((option) => {
235
+ const OptionIcon = option.icon
236
+ const isSelected =
237
+ isConstructionDimensionActive && activeConstructionDimensionOption === option
238
+ return (
239
+ <button
240
+ aria-checked={isSelected}
241
+ className={cn(
242
+ 'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
243
+ isSelected
244
+ ? 'bg-white/10 text-foreground'
245
+ : 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
246
+ )}
247
+ key={`${option.mode}-${option.chainMode}`}
248
+ onClick={() => {
249
+ activateConstructionDimension(option.mode, option.chainMode)
250
+ setIsOpen(false)
251
+ }}
252
+ role="menuitemradio"
253
+ type="button"
254
+ >
255
+ <OptionIcon aria-hidden="true" className="h-4 w-4" />
256
+ <span>{option.label}</span>
257
+ {isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
258
+ </button>
259
+ )
260
+ })}
261
+ </>
262
+ ) : null}
184
263
  </div>
185
264
  </PopoverContent>
186
265
  </Popover>
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import type { AnyNodeId } from '@pascal-app/core'
4
- import { LevelNode, useScene } from '@pascal-app/core'
4
+ import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core'
5
5
  import { useViewer } from '@pascal-app/viewer'
6
6
  import {
7
7
  AppWindow,
@@ -22,6 +22,7 @@ import {
22
22
  Minimize2,
23
23
  Moon,
24
24
  MousePointer2,
25
+ Mountain,
25
26
  Package,
26
27
  PaintBucket,
27
28
  PencilLine,
@@ -168,6 +169,17 @@ export function EditorCommands() {
168
169
  setMode('material-paint')
169
170
  }),
170
171
  },
172
+ {
173
+ id: 'editor.mode.terrain-sculpt',
174
+ label: 'Sculpt Terrain',
175
+ group: 'Scene',
176
+ icon: <Mountain className="h-4 w-4" />,
177
+ keywords: ['terrain', 'ground', 'elevation', 'sculpt', 'hill', 'slope', 'grade', 'dig'],
178
+ shortcut: ['G'],
179
+ // No `setPhase`: `setMode` moves to the site phase itself, and doing it
180
+ // here would set the phase twice with a mode reset in between.
181
+ execute: () => run(() => setMode('terrain-sculpt')),
182
+ },
171
183
 
172
184
  // ── Levels ───────────────────────────────────────────────────────────
173
185
  {
@@ -196,6 +208,7 @@ export function EditorCommands() {
196
208
  ).length
197
209
  const newLevel = LevelNode.parse({
198
210
  level: levelCount,
211
+ height: DEFAULT_LEVEL_HEIGHT,
199
212
  children: [],
200
213
  parentId: building.id,
201
214
  })
@@ -27,7 +27,12 @@ import { SceneMaterialList } from './scene-material-list'
27
27
  * fixed control/category header, a single scrolling catalog grid, and a fixed
28
28
  * scene-material footer (always visible, with a `+` to add a custom material).
29
29
  */
30
- export function MaterialPaintPanel() {
30
+ export type MaterialPaintPanelProps = {
31
+ /** When provided, the catalog grid leads with a "New material" tile that invokes it. */
32
+ onCreateMaterialRequest?: () => void
33
+ }
34
+
35
+ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) {
31
36
  const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
32
37
  const activePaintTarget = useEditor((state) => state.activePaintTarget)
33
38
  const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
@@ -109,6 +114,7 @@ export function MaterialPaintPanel() {
109
114
  {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
110
115
  <div className="min-h-0 flex-1">
111
116
  <MaterialPicker
117
+ onCreateMaterialRequest={onCreateMaterialRequest}
112
118
  onSelectMaterialPreset={(materialPreset) => {
113
119
  setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
114
120
  }}