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

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 +236 -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
@@ -1,12 +1,15 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ type AnyNode,
4
5
  type AnyNodeId,
5
- DEFAULT_WALL_HEIGHT,
6
6
  type FenceNode,
7
+ getWallBaseElevationForNodes,
7
8
  getWallCurveFrameAt,
9
+ getWallEffectiveHeightForNodes,
8
10
  getWallThickness,
9
11
  isCurvedWall,
12
+ MIN_WALL_HEIGHT,
10
13
  sceneRegistry,
11
14
  useLiveNodeOverrides,
12
15
  useScene,
@@ -32,19 +35,27 @@ import {
32
35
  } from 'three'
33
36
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
34
37
  import { MeshBasicNodeMaterial } from 'three/webgpu'
38
+ import {
39
+ clearStructuralElevationGuide,
40
+ publishStructuralElevationGuide,
41
+ resolveStructuralElevationSnap,
42
+ } from '../../lib/elevation-guides'
35
43
  import { isHistoryShortcut } from '../../lib/history'
36
44
  import { endpointReshapeScope } from '../../lib/interaction/scope'
37
45
  import { sfxEmitter } from '../../lib/sfx-bus'
38
- import useEditor from '../../store/use-editor'
46
+ import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
39
47
  import useInteractionScope, {
40
48
  useEndpointReshape,
41
49
  useIsCurveReshape,
42
50
  useMovingNode,
43
51
  } from '../../store/use-interaction-scope'
44
52
  import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
53
+ import { resolveResizeSnapValue } from './handles/resize-snap'
54
+ import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
45
55
  import {
46
56
  createArrowHitAreaGeometry,
47
57
  createEndpointHitAreaGeometry,
58
+ HandleArrow,
48
59
  InvisibleHandleHitArea,
49
60
  NO_RAYCAST,
50
61
  useInvisibleHitAreaMaterial,
@@ -55,7 +66,6 @@ const HANDLE_MIN_OFFSET = 0.33
55
66
  const HANDLE_MIN_HEIGHT = 0.4
56
67
  const HANDLE_TOP_INSET = 0.08
57
68
  const HEIGHT_HANDLE_OFFSET = 0.26
58
- const MIN_WALL_HEIGHT = 0.5
59
69
  const ARROW_COLOR = '#8381ed'
60
70
  const ARROW_HOVER_COLOR = '#a5b4fc'
61
71
  // Match the door arrows: scale the rendered chevron down to ~two-thirds
@@ -154,6 +164,7 @@ export function WallMoveSideHandles() {
154
164
  }
155
165
 
156
166
  function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
167
+ const nodes = useScene((state) => state.nodes)
157
168
  // Merge the in-flight drag override so every handle (side-move arrows,
158
169
  // height arrow, corner leaders) tracks the live height in real time
159
170
  // during a height drag — the scene store stays at the pre-drag value
@@ -197,24 +208,34 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
197
208
  }
198
209
  }, [wall.parentId])
199
210
 
200
- const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall])
211
+ const baseElevation = getWallBaseElevationForNodes(effectiveWall, nodes)
212
+ const handles = useMemo(() => getWallMoveHandles(effectiveWall, nodes), [effectiveWall, nodes])
201
213
 
202
214
  if (!levelObject || handles.length === 0) return null
203
215
 
204
216
  return createPortal(
205
- <group>
206
- {handles.map((handle) => (
207
- <WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
208
- ))}
209
- <WallHeightArrowHandle wall={effectiveWall} />
210
- <WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
211
- <WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
212
- </group>,
217
+ <>
218
+ <group position={[0, baseElevation, 0]}>
219
+ {handles.map((handle) => (
220
+ <WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
221
+ ))}
222
+ <WallHeightArrowHandle wall={effectiveWall} />
223
+ <WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
224
+ <WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
225
+ </group>
226
+ <WallBaseElevationHandle
227
+ baseElevation={baseElevation}
228
+ levelObject={levelObject}
229
+ wall={effectiveWall}
230
+ />
231
+ </>,
213
232
  levelObject,
214
233
  )
215
234
  }
216
235
 
217
236
  function buildDashedVerticalGeometry(height: number) {
237
+ if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry()
238
+
218
239
  // Build each dash as a thin cylinder section so thickness is
219
240
  // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU.
220
241
  const dashes: BufferGeometry[] = []
@@ -227,7 +248,10 @@ function buildDashedVerticalGeometry(height: number) {
227
248
  dashes.push(cylinder)
228
249
  y = end + CORNER_GAP_SIZE
229
250
  }
230
- const merged = mergeGeometries(dashes, false) ?? new BufferGeometry()
251
+ const merged =
252
+ dashes.length > 0
253
+ ? (mergeGeometries(dashes, false) ?? new BufferGeometry())
254
+ : new BufferGeometry()
231
255
  for (const dash of dashes) dash.dispose()
232
256
  return merged
233
257
  }
@@ -244,7 +268,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
244
268
  const corner = endpoint === 'start' ? wall.start : wall.end
245
269
  const x = corner[0]
246
270
  const z = corner[1]
247
- const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
271
+ const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
248
272
 
249
273
  const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
250
274
  const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
@@ -381,6 +405,155 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
381
405
  )
382
406
  }
383
407
 
408
+ function WallBaseElevationHandle({
409
+ wall,
410
+ baseElevation,
411
+ levelObject,
412
+ }: {
413
+ wall: WallNode
414
+ baseElevation: number
415
+ levelObject: Object3D
416
+ }) {
417
+ const [isHovered, setIsHovered] = useState(false)
418
+ const [isDragging, setIsDragging] = useState(false)
419
+ const { camera } = useThree()
420
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
421
+ const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
422
+ const midpoint: [number, number] = curveFrame
423
+ ? [curveFrame.point.x, curveFrame.point.y]
424
+ : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
425
+ const elevationGuideSource = {
426
+ nodeId: wall.id,
427
+ levelId: wall.parentId,
428
+ anchor: midpoint,
429
+ }
430
+ const leaderBottom = Math.min(0, baseElevation)
431
+ const leaderHeight = Math.abs(baseElevation)
432
+ const dashedGeometry = useMemo(
433
+ () => (leaderHeight > 0.0001 ? buildDashedVerticalGeometry(leaderHeight) : null),
434
+ [leaderHeight],
435
+ )
436
+ const dashMaterial = useMemo(
437
+ () =>
438
+ new MeshBasicNodeMaterial({
439
+ color: new Color(ARROW_COLOR),
440
+ transparent: true,
441
+ opacity: 0.85,
442
+ depthTest: false,
443
+ depthWrite: false,
444
+ }),
445
+ [],
446
+ )
447
+
448
+ const isActive = isHovered || isDragging
449
+ useEffect(() => {
450
+ dashMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR)
451
+ }, [dashMaterial, isActive])
452
+ useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry])
453
+ useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
454
+ const dragControls = useMemo<HandleDragControls>(
455
+ () => ({
456
+ onStart: () => {},
457
+ onEnd: () => {},
458
+ }),
459
+ [],
460
+ )
461
+
462
+ const activateElevationMove = useHandleDrag({
463
+ kind: 'drag',
464
+ cursor: 'ns-resize',
465
+ dragControls,
466
+ handleIndex: 0,
467
+ node: wall,
468
+ rideObject: levelObject,
469
+ setIsDragging,
470
+ onStart: ({ event, initialNode, intersectPlane, nodeId, sceneApi }) => {
471
+ if (initialNode.type !== 'wall') return null
472
+
473
+ levelObject.updateWorldMatrix(true, false)
474
+ const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes())
475
+ const supportBase = initialBase - (initialNode.supportOffset ?? 0)
476
+ const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes())
477
+ const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4(
478
+ levelObject.matrixWorld,
479
+ )
480
+ const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
481
+ if (planeNormal.lengthSq() === 0) return null
482
+ planeNormal.normalize()
483
+ const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
484
+ const initialHit = new Vector3()
485
+ if (
486
+ !intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, initialHit)
487
+ ) {
488
+ return null
489
+ }
490
+ const initialPointerY = levelObject.worldToLocal(initialHit).y
491
+
492
+ return {
493
+ onBegin: () => {
494
+ useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId, handle: 'elevation' })
495
+ },
496
+ onEnd: () => {
497
+ useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag')
498
+ clearStructuralElevationGuide(initialNode.id)
499
+ },
500
+ move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
501
+ const hit = new Vector3()
502
+ if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
503
+ const pointerY = levelObject.worldToLocal(hit).y
504
+ const nextBase = resolveResizeSnapValue({
505
+ rawValue: initialBase + pointerY - initialPointerY,
506
+ gridSnapEnabled: true,
507
+ gridSnapActive: isGridSnapActive(),
508
+ gridSnapStep: useEditor.getState().gridSnapStep,
509
+ magneticSnapActive: isMagneticSnapActive(),
510
+ magneticSnap: (value) =>
511
+ resolveStructuralElevationSnap(elevationGuideSource, value, sceneApi.nodes()),
512
+ })
513
+ publishStructuralElevationGuide(elevationGuideSource, nextBase, sceneApi.nodes())
514
+ if (Math.abs(nextBase - initialBase) < 1e-6) {
515
+ return {
516
+ supportOffset: initialNode.supportOffset,
517
+ height: initialNode.height,
518
+ }
519
+ }
520
+ const nextOffset = nextBase - supportBase
521
+ return {
522
+ supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset,
523
+ height: initialHeight,
524
+ }
525
+ },
526
+ }
527
+ },
528
+ })
529
+
530
+ return (
531
+ <>
532
+ {dashedGeometry ? (
533
+ <mesh
534
+ frustumCulled={false}
535
+ geometry={dashedGeometry}
536
+ material={dashMaterial}
537
+ position={[midpoint[0], leaderBottom, midpoint[1]]}
538
+ renderOrder={1001}
539
+ />
540
+ ) : null}
541
+ <HandleArrow
542
+ cursor="ns-resize"
543
+ hover={isActive}
544
+ hoverScale={1.25}
545
+ onHoverChange={setIsHovered}
546
+ onPointerDown={activateElevationMove}
547
+ placement={{
548
+ position: [midpoint[0], baseElevation, midpoint[1]],
549
+ baseScale: zoom,
550
+ }}
551
+ shape="tracker"
552
+ />
553
+ </>
554
+ )
555
+ }
556
+
384
557
  function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
385
558
  const [isHovered, setIsHovered] = useState(false)
386
559
  const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
@@ -433,7 +606,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
433
606
  const wallAngle = Math.atan2(-dirZ, dirX)
434
607
  // `wall` is the override-merged effective wall (see
435
608
  // WallMoveSideHandlesForWall), so this height is already live during a drag.
436
- const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
609
+ const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
437
610
  const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
438
611
 
439
612
  const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
@@ -466,7 +639,9 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
466
639
  const hit = new Vector3()
467
640
  if (!raycaster.ray.intersectPlane(plane, hit)) return
468
641
 
469
- const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT
642
+ // Dragging the top makes the wall custom-height; seed from the resolved
643
+ // effective height so a plane-bound wall's drag starts at its real top.
644
+ const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
470
645
  const initialY = hit.y
471
646
  const wallId = wall.id as AnyNodeId
472
647
  let pendingHeight = initialHeight
@@ -758,7 +933,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
758
933
  )
759
934
  }
760
935
 
761
- function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
936
+ function getWallMoveHandles(wall: WallNode, nodes: Record<string, AnyNode>): WallMoveHandle[] {
762
937
  const dx = wall.end[0] - wall.start[0]
763
938
  const dz = wall.end[1] - wall.start[1]
764
939
  const length = Math.hypot(dx, dz)
@@ -774,7 +949,7 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
774
949
  const midpoint: [number, number] = frame
775
950
  ? [frame.point.x, frame.point.y]
776
951
  : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
777
- const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
952
+ const wallHeight = getWallEffectiveHeightForNodes(wall, nodes)
778
953
  const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
779
954
  const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
780
955
 
@@ -3,8 +3,16 @@
3
3
  import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import { createPortal, useFrame, useThree } from '@react-three/fiber'
6
- import { useEffect, useMemo, useRef } from 'react'
7
- import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
6
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
7
+ import {
8
+ BoxGeometry,
9
+ type BufferGeometry,
10
+ EdgesGeometry,
11
+ type Group,
12
+ type Mesh,
13
+ MeshBasicMaterial,
14
+ Vector3,
15
+ } from 'three'
8
16
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
9
17
  import { EDITOR_LAYER } from '../../lib/constants'
10
18
 
@@ -41,6 +49,13 @@ const fillMaterial = new MeshBasicNodeMaterial({
41
49
  depthWrite: false,
42
50
  })
43
51
 
52
+ // Keep the cutout in the render list for the outliner's override material
53
+ // without letting its source material alter the normal scene pass.
54
+ const outlineProxyMaterial = new MeshBasicMaterial({
55
+ colorWrite: false,
56
+ depthWrite: false,
57
+ })
58
+
44
59
  function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry {
45
60
  const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD)
46
61
  const edges = new EdgesGeometry(box)
@@ -60,26 +75,60 @@ function makeOutlineGeometry(width: number, height: number, depth: number): Buff
60
75
  */
61
76
  export function WallOpeningHighlights() {
62
77
  const selectedIds = useViewer((state) => state.selection.selectedIds)
78
+ const hoveredId = useViewer((state) => state.hoveredId)
63
79
  const { scene } = useThree()
80
+ const outlineProxyIds = Array.from(new Set(hoveredId ? [...selectedIds, hoveredId] : selectedIds))
64
81
 
65
- if (selectedIds.length === 0) return null
82
+ if (selectedIds.length === 0 && !hoveredId) return null
66
83
 
67
- return createPortal(
84
+ return (
68
85
  <>
69
- {selectedIds.map((id) => (
70
- <SelectionOpeningHighlights key={id} selectedId={id} />
86
+ {outlineProxyIds.map((id) => (
87
+ <OpeningOutlineProxy key={id} openingId={id} />
71
88
  ))}
72
- </>,
73
- scene,
89
+ {selectedIds.length > 0 &&
90
+ createPortal(
91
+ <>
92
+ {selectedIds.map((id) => (
93
+ <SelectionOpeningHighlights key={id} selectedId={id} />
94
+ ))}
95
+ </>,
96
+ scene,
97
+ )}
98
+ </>
74
99
  )
75
100
  }
76
101
 
77
- // Resolves a selected node into the opening highlight(s) to draw:
78
- // - a selected wall a hint over each door / window it hosts ("editable
79
- // child here").
80
- // - a directly-selected frameless opening (a `door` whose `openingKind` is
81
- // `'opening'`) a fill over its own cutout, so the selection reads as
82
- // occupied even though the opening renders no geometry of its own.
102
+ function OpeningOutlineProxy({ openingId }: { openingId: string }) {
103
+ const node = useScene((state) => state.nodes[openingId as AnyNodeId])
104
+ const geometryRevision = useViewer((state) => state.geometryRevision)
105
+ const proxyRef = useRef<Mesh | null>(null)
106
+ const isFramelessOpening = node?.type === 'door' && node.openingKind === 'opening'
107
+
108
+ useLayoutEffect(() => {
109
+ void geometryRevision
110
+ if (!isFramelessOpening) return
111
+ const root = sceneRegistry.nodes.get(openingId as AnyNodeId)
112
+ const proxy = root?.getObjectByName('cutout') as Mesh | undefined
113
+ if (!proxy) return
114
+
115
+ proxy.material = outlineProxyMaterial
116
+ proxy.visible = true
117
+ proxyRef.current = proxy
118
+
119
+ return () => {
120
+ proxy.visible = false
121
+ proxyRef.current = null
122
+ }
123
+ }, [geometryRevision, isFramelessOpening, openingId])
124
+
125
+ useFrame(() => {
126
+ if (proxyRef.current) proxyRef.current.visible = true
127
+ })
128
+
129
+ return null
130
+ }
131
+
83
132
  function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) {
84
133
  const node = useScene((state) => state.nodes[selectedId as AnyNodeId])
85
134
 
@@ -94,27 +143,9 @@ function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) {
94
143
  )
95
144
  }
96
145
 
97
- if (node?.type === 'door' && node.openingKind === 'opening') {
98
- return <SelectedOpeningHighlight openingId={selectedId} parentId={node.parentId} />
99
- }
100
-
101
146
  return null
102
147
  }
103
148
 
104
- // A frameless opening selected on its own. Pulls the cutout depth from its
105
- // host wall's thickness so the fill block matches the wall it sits in.
106
- function SelectedOpeningHighlight({
107
- openingId,
108
- parentId,
109
- }: {
110
- openingId: string
111
- parentId: string | null
112
- }) {
113
- const parent = useScene((state) => (parentId ? state.nodes[parentId as AnyNodeId] : undefined))
114
- const depth = parent?.type === 'wall' ? (parent.thickness ?? 0.1) : 0.1
115
- return <OpeningHighlight depth={depth} openingId={openingId} />
116
- }
117
-
118
149
  function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
119
150
  const node = useScene((state) => state.nodes[openingId as AnyNodeId])
120
151
  // Resize arrows publish width/height to the live-override store during the
@@ -3,12 +3,13 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
- DEFAULT_WALL_HEIGHT,
7
6
  getWallCurveFrameAt,
8
7
  getWallCurveLength,
8
+ getWallPlaneTop,
9
9
  getWallThickness,
10
10
  isCurvedWall,
11
11
  resolveLevelId,
12
+ resolveWallTop,
12
13
  sceneRegistry,
13
14
  spatialGridManager,
14
15
  useScene,
@@ -147,15 +148,16 @@ type WallTopHighlightSegment = {
147
148
 
148
149
  function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
149
150
  const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
150
- const slabElevation = spatialGridManager.getSlabElevationForWall(
151
+ const support = spatialGridManager.getSlabSupportForWall(
151
152
  levelId,
152
153
  wall.start,
153
154
  wall.end,
154
155
  wall.curveOffset ?? 0,
155
156
  wall.thickness,
157
+ wall.supportSlabId,
156
158
  )
157
- const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
158
- return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT
159
+ const planeTop = getWallPlaneTop(wall, levelId, nodes as Record<string, AnyNode>)
160
+ return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT
159
161
  }
160
162
 
161
163
  function buildHighlightSegment(start: [number, number], end: [number, number]) {
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
28
28
  export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
29
29
  const guides = useAlignmentGuides((s) => s.guides)
30
30
  const unit = useViewer((s) => s.unit)
31
+ const metricNotation = useViewer((s) => s.metricNotation)
31
32
  const ctx = useFloorplanRender()
32
33
 
33
34
  if (guides.length === 0) return null
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
61
62
  // offset along X.
62
63
  const pillX = axis === 'x' ? midX + pillOffset : midX
63
64
  const pillZ = axis === 'z' ? midZ + pillOffset : midZ
64
- const distLabel = formatMeasurement(distMeters, unit)
65
+ const distLabel = formatMeasurement(distMeters, unit, metricNotation)
65
66
  const charWidth = pillFontSize * 0.55
66
67
  const pillWidth = distLabel.length * charWidth + pillPadX * 2
67
68
  const pillHeight = pillFontSize + pillPadY * 2
@@ -1,15 +1,16 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { memo, useMemo } from 'react'
4
+ import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
5
5
  import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
6
+ import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
6
7
  import { furnishTools } from '../ui/action-menu/furnish-tools'
7
8
  import { tools as structureTools } from '../ui/action-menu/structure-tools'
8
-
9
- type SvgPoint = {
10
- x: number
11
- y: number
12
- }
9
+ import {
10
+ type FloorplanCursorPoint,
11
+ projectFloorplanCursorPoint,
12
+ resolveFloorplanCursorIndicatorPosition,
13
+ } from './floorplan-cursor-indicator-position'
13
14
 
14
15
  type FloorplanCursorIndicator =
15
16
  | {
@@ -22,7 +23,7 @@ type FloorplanCursorIndicator =
22
23
  }
23
24
 
24
25
  type FloorplanCursorIndicatorOverlayProps = {
25
- cursorPosition: SvgPoint | null
26
+ cursorPosition: FloorplanCursorPoint | null
26
27
  floorplanSelectionTool: FloorplanSelectionTool
27
28
  movingOpeningType: 'door' | 'window' | null
28
29
  isPanning: boolean
@@ -46,6 +47,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
46
47
  const tool = useEditor((state) => state.tool)
47
48
  const structureLayer = useEditor((state) => state.structureLayer)
48
49
  const catalogCategory = useEditor((state) => state.catalogCategory)
50
+ const cursorPoint = useFloorplanDraftPreview((state) => state.cursorPoint)
51
+ const anchorRef = useRef<HTMLDivElement>(null)
52
+ const [projectedCursorPosition, setProjectedCursorPosition] =
53
+ useState<FloorplanCursorPoint | null>(null)
49
54
 
50
55
  const activeFloorplanToolConfig = useMemo(() => {
51
56
  if (movingOpeningType) {
@@ -83,7 +88,32 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
83
88
  return null
84
89
  }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
85
90
 
86
- const position = cursorPosition
91
+ useLayoutEffect(() => {
92
+ const anchor = anchorRef.current
93
+ const overlayHost = anchor?.parentElement
94
+ const scene = overlayHost?.querySelector<SVGGElement>('[data-floorplan-scene]')
95
+ const sceneToViewport = scene?.getScreenCTM()
96
+
97
+ if (!(cursorPoint && cursorPosition && overlayHost && sceneToViewport)) {
98
+ setProjectedCursorPosition(null)
99
+ return
100
+ }
101
+
102
+ const overlayRect = overlayHost.getBoundingClientRect()
103
+ const nextPosition = projectFloorplanCursorPoint(cursorPoint, sceneToViewport, {
104
+ x: overlayRect.left,
105
+ y: overlayRect.top,
106
+ })
107
+ setProjectedCursorPosition((currentPosition) =>
108
+ currentPosition &&
109
+ currentPosition.x === nextPosition.x &&
110
+ currentPosition.y === nextPosition.y
111
+ ? currentPosition
112
+ : nextPosition,
113
+ )
114
+ }, [cursorPoint, cursorPosition])
115
+
116
+ const position = resolveFloorplanCursorIndicatorPosition(cursorPosition, projectedCursorPosition)
87
117
 
88
118
  if (!(indicator && position) || isPanning) {
89
119
  return null
@@ -93,6 +123,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
93
123
  <div
94
124
  aria-hidden="true"
95
125
  className="pointer-events-none absolute z-20"
126
+ ref={anchorRef}
96
127
  style={{ left: position.x, top: position.y }}
97
128
  >
98
129
  {mode === 'delete' ? (
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ projectFloorplanCursorPoint,
4
+ resolveFloorplanCursorIndicatorPosition,
5
+ } from './floorplan-cursor-indicator-position'
6
+
7
+ describe('projectFloorplanCursorPoint', () => {
8
+ test('projects a snapped plan point into overlay-local screen coordinates', () => {
9
+ expect(
10
+ projectFloorplanCursorPoint(
11
+ [3, 4],
12
+ {
13
+ a: 0,
14
+ b: 10,
15
+ c: -10,
16
+ d: 0,
17
+ e: 128,
18
+ f: 40,
19
+ },
20
+ { x: 30, y: 20 },
21
+ ),
22
+ ).toEqual({ x: 58, y: 50 })
23
+ })
24
+
25
+ test('anchors the placement pin to the projected snap point instead of the raw pointer', () => {
26
+ expect(resolveFloorplanCursorIndicatorPosition({ x: 88, y: 97 }, { x: 58, y: 70 })).toEqual({
27
+ x: 58,
28
+ y: 70,
29
+ })
30
+ })
31
+ })
@@ -0,0 +1,39 @@
1
+ export type FloorplanCursorPoint = {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ type Matrix2D = {
7
+ a: number
8
+ b: number
9
+ c: number
10
+ d: number
11
+ e: number
12
+ f: number
13
+ }
14
+
15
+ export function projectFloorplanCursorPoint(
16
+ point: readonly [number, number],
17
+ sceneToViewport: Matrix2D,
18
+ overlayOrigin: FloorplanCursorPoint,
19
+ ): FloorplanCursorPoint {
20
+ return {
21
+ x:
22
+ sceneToViewport.a * point[0] +
23
+ sceneToViewport.c * point[1] +
24
+ sceneToViewport.e -
25
+ overlayOrigin.x,
26
+ y:
27
+ sceneToViewport.b * point[0] +
28
+ sceneToViewport.d * point[1] +
29
+ sceneToViewport.f -
30
+ overlayOrigin.y,
31
+ }
32
+ }
33
+
34
+ export function resolveFloorplanCursorIndicatorPosition(
35
+ cursorPosition: FloorplanCursorPoint | null,
36
+ projectedCursorPosition: FloorplanCursorPoint | null,
37
+ ): FloorplanCursorPoint | null {
38
+ return projectedCursorPosition ?? cursorPosition
39
+ }