@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
@@ -6,6 +6,7 @@ import {
6
6
  bboxCornerAnchors,
7
7
  collectAlignmentAnchors,
8
8
  DEFAULT_ANGLE_STEP,
9
+ type FloorplanGeometry,
9
10
  type FloorplanPalette,
10
11
  pauseSceneHistory,
11
12
  pauseSpaceDetection,
@@ -22,13 +23,16 @@ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contex
22
23
  import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
23
24
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
24
25
  import { isHistoryShortcut } from '../../lib/history'
26
+ import { formatLinearMeasurement } from '../../lib/measurements'
25
27
  import { sfxEmitter } from '../../lib/sfx-bus'
26
28
  import useAlignmentGuides from '../../store/use-alignment-guides'
27
29
  import useEditor, {
28
30
  isAlignmentGuideActive,
31
+ isAngleSnapActive,
29
32
  isGridSnapActive,
30
33
  isMagneticSnapActive,
31
34
  } from '../../store/use-editor'
35
+ import useFloorplanMode from '../../store/use-floorplan-mode'
32
36
  import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
33
37
  import {
34
38
  classifyParticipant,
@@ -44,6 +48,8 @@ import {
44
48
  } from '../editor/group-transform-shared'
45
49
  import { swallowNextClick } from '../editor/handles/use-handle-drag'
46
50
  import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
51
+ import { useFloorplanSceneRotation } from './floorplan-render-context'
52
+ import { FloorplanDimensionRenderer } from './renderers/floorplan-dimension-renderer'
47
53
 
48
54
  // 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
49
55
  // any selected element of a multi-selection slides the whole selection
@@ -441,9 +447,9 @@ export function startFloorplanGroupRotate(event: {
441
447
  let delta = angleOf([plan[0], plan[1]]) - initialAngle
442
448
  while (delta > Math.PI) delta -= 2 * Math.PI
443
449
  while (delta < -Math.PI) delta += 2 * Math.PI
444
- // 15° increments by default; Shift rotates freely — the same contract
445
- // as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
446
- if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
450
+ if (isAngleSnapActive()) {
451
+ delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
452
+ }
447
453
 
448
454
  const entries = rotateGroupPatches(starts, links, pivot, delta)
449
455
  const patchById = new Map(entries)
@@ -586,11 +592,15 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
586
592
  }) {
587
593
  const selectedIds = useViewer((s) => s.selection.selectedIds)
588
594
  const levelId = useViewer((s) => s.selection.levelId)
595
+ const unit = useViewer((s) => s.unit)
596
+ const metricNotation = useViewer((s) => s.metricNotation)
589
597
  const nodes = useScene((s) => s.nodes)
590
598
  const delta = useFloorplanGroupDrag((s) => s.delta)
591
599
  const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
592
600
  const movingNode = useMovingNode()
593
601
  const mode = useEditor((s) => s.mode)
602
+ const floorplanMode = useFloorplanMode((s) => s.mode)
603
+ const sceneRotationDeg = useFloorplanSceneRotation()
594
604
 
595
605
  // While a selection modifier is held the box steps aside so clicks reach
596
606
  // the entries underneath (toggle membership) instead of starting a drag.
@@ -613,6 +623,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
613
623
  // which lag the `nodes` commit by a frame or two.
614
624
  const meshEpoch = useMeshSettleEpoch(nodes)
615
625
  const box = useMemo(() => {
626
+ void meshEpoch
616
627
  if (selectedIds.length < 2 || !levelId) return null
617
628
  const participantIds = selectedIds.filter(
618
629
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
@@ -630,7 +641,6 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
630
641
  width: Math.abs(max.x - min.x),
631
642
  depth: Math.abs(max.z - min.z),
632
643
  }
633
- // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
634
644
  }, [selectedIds, levelId, nodes, meshEpoch])
635
645
 
636
646
  if (!box || movingNode || mode === 'delete') return null
@@ -638,6 +648,27 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
638
648
  const pad = 6 * unitsPerPixel
639
649
  const stroke = palette?.selectedStroke ?? '#3b82f6'
640
650
  const interactive = !modifierHeld && !!onPointerDown
651
+ const dimensionOffset = pad + 0.28
652
+ const widthDimension = {
653
+ kind: 'dimension',
654
+ start: [box.x, box.z + box.depth],
655
+ end: [box.x + box.width, box.z + box.depth],
656
+ offsetNormal: [0, 1],
657
+ offsetDistance: dimensionOffset,
658
+ extensionOvershoot: 0.08,
659
+ text: formatLinearMeasurement(box.width, unit, metricNotation),
660
+ stroke,
661
+ } satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
662
+ const depthDimension = {
663
+ kind: 'dimension',
664
+ start: [box.x + box.width, box.z],
665
+ end: [box.x + box.width, box.z + box.depth],
666
+ offsetNormal: [1, 0],
667
+ offsetDistance: dimensionOffset,
668
+ extensionOvershoot: 0.08,
669
+ text: formatLinearMeasurement(box.depth, unit, metricNotation),
670
+ stroke,
671
+ } satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
641
672
  // Mid-gesture the box rides the live delta (group move) or spins around the
642
673
  // rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
643
674
  // point, and positive matches the atan2 x→z sense on the y-down plan.
@@ -664,6 +695,18 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
664
695
  x={box.x - pad}
665
696
  y={box.z - pad}
666
697
  />
698
+ {floorplanMode === 'default' ? (
699
+ <g pointerEvents="none">
700
+ <FloorplanDimensionRenderer
701
+ geometry={widthDimension}
702
+ sceneRotationDeg={sceneRotationDeg}
703
+ />
704
+ <FloorplanDimensionRenderer
705
+ geometry={depthDimension}
706
+ sceneRotationDeg={sceneRotationDeg}
707
+ />
708
+ </g>
709
+ ) : null}
667
710
  {/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
668
711
  drag a corner to spin the group (15° steps, Shift = free). */}
669
712
  {interactive && onRotatePointerDown
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
786
786
  const draftLevelId = useMeasurementDraft((state) => state.levelId)
787
787
  const activeLevelId = useViewer((state) => state.selection.levelId)
788
788
  const unit = useViewer((state) => state.unit)
789
+ const metricNotation = useViewer((state) => state.metricNotation)
789
790
 
790
791
  useEffect(() => {
791
792
  if (active) {
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
1224
1225
  angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
1225
1226
  point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
1226
1227
  screenUpright: false,
1227
- text: formatLinearMeasurement(measurementDistance(start, end), unit),
1228
+ text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
1228
1229
  }
1229
1230
  } else if (kind === 'angle' && livePoints.length >= 3) {
1230
1231
  const anglePoints = livePoints.slice(0, 3) as [
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
1248
1249
  text:
1249
1250
  kind === 'area'
1250
1251
  ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
1251
- : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
1252
+ : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
1252
1253
  }
1253
1254
  }
1254
1255
  } else if (kind === 'volume' && center && baseNormal) {
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
1280
1281
  (segmentStart[1] + segmentEnd[1]) / 2,
1281
1282
  (segmentStart[2] + segmentEnd[2]) / 2,
1282
1283
  ],
1283
- text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
1284
+ text: formatLinearMeasurement(
1285
+ measurementDistance(segmentStart, segmentEnd),
1286
+ unit,
1287
+ metricNotation,
1288
+ ),
1284
1289
  }
1285
1290
  }
1286
1291
  }
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
1298
1303
  polygonPoints,
1299
1304
  segmentLabel,
1300
1305
  }
1301
- }, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
1306
+ }, [
1307
+ axisGuide,
1308
+ baseNormal,
1309
+ extrusionHeight,
1310
+ hover,
1311
+ kind,
1312
+ metricNotation,
1313
+ points,
1314
+ stage,
1315
+ unit,
1316
+ vertexDrag,
1317
+ ])
1302
1318
 
1303
1319
  if (smartActive) return <FloorplanQuickMeasureLayer />
1304
1320
  if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
1595
1611
  text={`${hover.semantic.label}${
1596
1612
  hover.semantic.length === null
1597
1613
  ? ''
1598
- : ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
1614
+ : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
1599
1615
  }`}
1600
1616
  textColor={labelText}
1601
1617
  unitsPerPixel={unitsPerPixel}
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
6
+ import {
7
+ type FloorplanToolContext,
8
+ getFloorplanNodeExtension,
9
+ } from '../../lib/floorplan/floorplan-extension'
10
+ import {
11
+ type FloorplanMode,
12
+ isFloorplanToolAvailableInMode,
13
+ } from '../../lib/floorplan/floorplan-mode'
14
+ import useEditor from '../../store/use-editor'
15
+ import useFloorplanMode from '../../store/use-floorplan-mode'
16
+
17
+ const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
18
+
19
+ function registeredFloorplanTool(
20
+ tool: string | null,
21
+ mode: FloorplanMode,
22
+ ): ComponentType<FloorplanToolContext> | null {
23
+ if (!tool) return null
24
+ const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
25
+ if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
26
+ const loader = extension?.tool
27
+ if (!loader) return null
28
+ const cached = lazyToolCache.get(loader)
29
+ if (cached) return cached
30
+ const component = lazy(loader)
31
+ lazyToolCache.set(loader, component)
32
+ return component
33
+ }
34
+
35
+ export function FloorplanRegisteredToolLayer() {
36
+ const mode = useEditor((state) => state.mode)
37
+ const tool = useEditor((state) => state.tool)
38
+ const floorplanMode = useFloorplanMode((state) => state.mode)
39
+ const gridSnapStep = useEditor((state) => state.gridSnapStep)
40
+ const toolDefaults = useEditor((state) =>
41
+ state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
42
+ )
43
+ const activeLevelId = useViewer((state) => state.selection.levelId)
44
+ const unit = useViewer((state) => state.unit)
45
+ const metricNotation = useViewer((state) => state.metricNotation)
46
+ const sceneApi = useMemo(() => createSceneApi(useScene), [])
47
+ const selectNode = useCallback(
48
+ (id: Parameters<FloorplanToolContext['selectNode']>[0]) =>
49
+ useViewer.getState().setSelection({ selectedIds: [id] }),
50
+ [],
51
+ )
52
+ const finishTool = useCallback(() => {
53
+ useEditor.getState().setTool(null)
54
+ useEditor.getState().setMode('select')
55
+ }, [])
56
+ if (mode !== 'build') return null
57
+ const Tool = registeredFloorplanTool(tool, floorplanMode)
58
+ return Tool ? (
59
+ <Suspense fallback={null}>
60
+ <Tool
61
+ activeLevelId={activeLevelId}
62
+ finishTool={finishTool}
63
+ gridSnapStep={gridSnapStep}
64
+ metricNotation={metricNotation}
65
+ sceneApi={sceneApi}
66
+ selectNode={selectNode}
67
+ toolDefaults={toolDefaults}
68
+ unit={unit}
69
+ />
70
+ </Suspense>
71
+ ) : null
72
+ }
@@ -15,16 +15,27 @@ import {
15
15
  type WallNode,
16
16
  } from '@pascal-app/core'
17
17
  import { useViewer } from '@pascal-app/viewer'
18
- import { useEffect, useState } from 'react'
18
+ import { type MouseEvent, useEffect, useState } from 'react'
19
19
  import { createPortal } from 'react-dom'
20
20
  import { useShallow } from 'zustand/react/shallow'
21
+ import { useReducedMotion } from '../../hooks/use-reduced-motion'
22
+ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
23
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
21
24
  import {
22
25
  createFreshPlacementSubtree,
23
26
  duplicatesAsFreshSubtree,
27
+ prepareFreshPlacementRootDuplicate,
24
28
  } from '../../lib/fresh-planar-placement'
29
+ import { curveReshapeScope } from '../../lib/interaction/scope'
30
+ import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
31
+ import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
25
32
  import { sfxEmitter } from '../../lib/sfx-bus'
33
+ import { cn } from '../../lib/utils'
26
34
  import useEditor from '../../store/use-editor'
27
- import { useMovingNode } from '../../store/use-interaction-scope'
35
+ import useInteractionScope, {
36
+ useIsCurveReshape,
37
+ useMovingNode,
38
+ } from '../../store/use-interaction-scope'
28
39
  import { NodeActionMenu } from '../editor/node-action-menu'
29
40
  import { IconRefGlyph } from '../ui/icon-ref'
30
41
 
@@ -80,26 +91,9 @@ function collectQuickActionNodes(
80
91
  ): Record<AnyNodeId, AnyNode> | null {
81
92
  if (!selectedId) return null
82
93
  const selected = nodes[selectedId as AnyNodeId]
83
- if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
84
-
85
- const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
86
- const add = (id: string | null | undefined) => {
87
- if (!id) return
88
- const node = nodes[id as AnyNodeId]
89
- if (node) collected[node.id as AnyNodeId] = node
90
- }
91
- const addChildren = (node: AnyNode | undefined) => {
92
- for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
93
- add(childId)
94
- }
95
- }
96
-
97
- add(selected.parentId ?? null)
98
- addChildren(selected)
99
- const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
100
- addChildren(parent)
101
-
102
- return collected
94
+ const def = selected ? nodeRegistry.get(selected.type) : undefined
95
+ if (!def?.quickActions) return null
96
+ return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
103
97
  }
104
98
 
105
99
  /**
@@ -118,23 +112,27 @@ function collectQuickActionNodes(
118
112
  * `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
119
113
  * Walls are excluded — their move is reached via the side-arrow
120
114
  * handles emitted from `def.floorplan`, not via a menu button.
115
+ * - Curve (wall only): enters curve reshape mode. The selected wall's
116
+ * midpoint curve handle remains visible so it can be dragged in plan.
121
117
  * - Add hole (slab + ceiling only): inserts a small default-square
122
118
  * hole at the polygon centroid via `updateNode`. Mirrors the legacy
123
119
  * `handleAddHole` in `floating-action-menu.tsx`.
124
- * - Duplicate: deep-clones the node, marks it new, sets it as the
125
- * movingNode (placement cursor) same UX pattern as 3D duplicate.
120
+ * - Duplicate: creates a fresh subtree when the kind opts in, otherwise a
121
+ * root-only copy, then hands that real draft to the placement cursor.
126
122
  * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
127
123
  * `relations.cascadeDelete` if declared on the def.
128
124
  *
129
- * Hidden while in a move state (so we don't show buttons over a ghost).
125
+ * Hidden while moving or curving so the menu does not compete with the active affordance.
130
126
  */
131
127
  export function FloorplanRegistryActionMenu() {
128
+ const reducedMotion = useReducedMotion()
132
129
  // Sole selection only — a multi-selection gets the group menu
133
130
  // (`FloorplanGroupActionMenu`), whose actions target the whole selection.
134
131
  const selectedId = useViewer((s) =>
135
132
  s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
136
133
  ) as AnyNodeId | undefined
137
134
  const movingNode = useMovingNode()
135
+ const isCurveReshape = useIsCurveReshape()
138
136
  const setMovingNode = useEditor((s) => s.setMovingNode)
139
137
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
140
138
  // Gate on floorplan hover so this 2D menu never coexists with the 3D
@@ -150,10 +148,28 @@ export function FloorplanRegistryActionMenu() {
150
148
  // Only show for registered kinds (skip legacy kinds — they have their
151
149
  // own FloorplanActionMenuLayer entries).
152
150
  const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
151
+ const canCurve = useScene((s) => {
152
+ if (!selectedId) return false
153
+ const selectedNode = s.nodes[selectedId]
154
+ if (!selectedNode) return false
155
+ const definition = nodeRegistry.get(selectedNode.type)
156
+ const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
157
+ return (
158
+ !!definition?.floorplanAffordances?.curve &&
159
+ !!canCurveNode?.({
160
+ node: selectedNode as never,
161
+ nodes: s.nodes,
162
+ })
163
+ )
164
+ })
153
165
  const def = selectedKind ? nodeRegistry.get(selectedKind) : null
154
166
  const isRegistryKind = !!def
155
167
  const isVisible =
156
- isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
168
+ isRegistryKind &&
169
+ def?.presentation?.actionMenu !== false &&
170
+ !movingNode &&
171
+ !isCurveReshape &&
172
+ isFloorplanHovered
157
173
  const isWall = selectedKind === 'wall'
158
174
  const quickActionNodes = useScene(
159
175
  useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
@@ -241,7 +257,8 @@ export function FloorplanRegistryActionMenu() {
241
257
 
242
258
  const handleMove = () => {
243
259
  sfxEmitter.emit('sfx:item-pick')
244
- setMovingNode(node as never)
260
+ const sceneNodes = useScene.getState().nodes
261
+ setMovingNode(resolveMoveActionNode(node, sceneNodes) as never)
245
262
  // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
246
263
  // Mark the origin (after `setMovingNode`, which resets it to null) so
247
264
  // `ToolManager` keeps the 3D affordance mover from also adopting the node
@@ -294,38 +311,40 @@ export function FloorplanRegistryActionMenu() {
294
311
  )
295
312
  }
296
313
 
314
+ const handleCurve = () => {
315
+ if (!canCurve) return
316
+ sfxEmitter.emit('sfx:item-pick')
317
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
318
+ }
319
+
297
320
  const handleDuplicate = () => {
298
321
  if (!node.parentId) return
299
322
  sfxEmitter.emit('sfx:item-pick')
300
323
  useScene.temporal.getState().pause()
301
- if (duplicatesAsFreshSubtree(node as AnyNode)) {
302
- const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
303
- const draft = draftId ? useScene.getState().nodes[draftId] : null
304
- if (draft) {
324
+ let draftId: AnyNodeId | null = null
325
+ try {
326
+ if (duplicatesAsFreshSubtree(node as AnyNode)) {
327
+ draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
328
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
329
+ if (!draft) return
305
330
  setMovingNode(draft as never)
306
- setMovingNodeOrigin('2d')
307
- useScene.temporal.getState().resume()
308
- return
331
+ } else {
332
+ const cloned = prepareFreshPlacementRootDuplicate(node as AnyNode)
333
+ const parsed = def.schema.parse(cloned) as AnyNode
334
+ draftId = parsed.id as AnyNodeId
335
+ useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
336
+ setMovingNode(parsed as never)
337
+ }
338
+ setMovingNodeOrigin('2d')
339
+ useViewer.getState().setSelection({ selectedIds: [] })
340
+ } catch (error) {
341
+ if (draftId && useScene.getState().nodes[draftId]) {
342
+ useScene.getState().deleteNode(draftId)
309
343
  }
344
+ console.error('Failed to duplicate node', error)
345
+ } finally {
310
346
  useScene.temporal.getState().resume()
311
- return
312
347
  }
313
- const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
314
- delete (cloned as { id?: AnyNodeId }).id
315
- const prevMeta =
316
- cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
317
- ? (cloned.metadata as Record<string, unknown>)
318
- : {}
319
- // Mark fresh + hand to the placement cursor so the copy follows the
320
- // pointer and only lands on the next click — same gesture for every
321
- // kind. Polyline runs (duct / pipe / lineset) ride the same path:
322
- // `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
323
- // no longer need the old "offset + drop already-placed" special case.
324
- cloned.metadata = { ...prevMeta, isNew: true }
325
- const parsed = def.schema.parse(cloned) as AnyNode
326
- useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
327
- setMovingNode(parsed as never)
328
- useScene.temporal.getState().resume()
329
348
  }
330
349
 
331
350
  const handleDelete = () => {
@@ -334,8 +353,13 @@ export function FloorplanRegistryActionMenu() {
334
353
  useViewer.getState().setSelection({ selectedIds: [] })
335
354
  }
336
355
 
337
- const handleQuickAction = (action: NodeQuickAction) => {
338
- if (action.disabled) return
356
+ const handleQuickAction = (action: NodeQuickAction, event: MouseEvent<HTMLButtonElement>) => {
357
+ if (action.disabled) {
358
+ if (action.blockedFeedback) {
359
+ playBlockedQuickActionFeedback(event.currentTarget, reducedMotion)
360
+ }
361
+ return
362
+ }
339
363
  const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
340
364
  const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
341
365
  if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
@@ -356,6 +380,7 @@ export function FloorplanRegistryActionMenu() {
356
380
  >
357
381
  <NodeActionMenu
358
382
  onAddHole={canAddHole ? handleAddHole : undefined}
383
+ onCurve={canCurve ? handleCurve : undefined}
359
384
  onDelete={canDelete ? handleDelete : undefined}
360
385
  onDuplicate={canDuplicate ? handleDuplicate : undefined}
361
386
  onMove={canMove ? handleMove : undefined}
@@ -370,18 +395,27 @@ export function FloorplanRegistryActionMenu() {
370
395
  >
371
396
  {quickActions.map((action) => (
372
397
  <button
398
+ aria-disabled={action.disabled || undefined}
373
399
  aria-label={action.title ?? action.label}
374
- className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
375
- disabled={action.disabled}
400
+ className={cn(
401
+ 'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
402
+ action.disabled &&
403
+ 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
404
+ )}
405
+ disabled={action.disabled && !action.blockedFeedback}
376
406
  key={action.id}
377
- onClick={() => handleQuickAction(action)}
407
+ onClick={(event) => handleQuickAction(action, event)}
378
408
  title={action.title ?? action.label}
379
409
  type="button"
380
410
  >
381
- <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
382
- <QuickActionIcon action={action} />
411
+ <span className="flex items-center gap-1.5" data-quick-action-feedback>
412
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
413
+ <QuickActionIcon action={action} />
414
+ </span>
415
+ <span className="whitespace-nowrap leading-none" data-quick-action-label>
416
+ {action.label}
417
+ </span>
383
418
  </span>
384
- <span className="whitespace-nowrap leading-none">{action.label}</span>
385
419
  </button>
386
420
  ))}
387
421
  </div>
@@ -197,6 +197,10 @@ export function FloorplanRegistryMoveOverlay() {
197
197
 
198
198
  const commitFinalStateOrRevert = () => {
199
199
  const commitValid = session.canCommit()
200
+ const freshPlacement = isFreshPlacementMetadata(
201
+ (useScene.getState().nodes[movingNode.id] as { metadata?: unknown } | undefined)
202
+ ?.metadata,
203
+ )
200
204
 
201
205
  // Claim ownership of the drag teardown so the 3D move tool's
202
206
  // unmount-time cleanup skips its restore-from-snapshot — see
@@ -210,6 +214,29 @@ export function FloorplanRegistryMoveOverlay() {
210
214
  // planner). For those we still do Phase 1 (revert to baseline)
211
215
  // and Phase 2's resume — but Phase 2's write is delegated, and
212
216
  // we skip the snapshot-diff finalUpdates path.
217
+ if (commitValid && freshPlacement) {
218
+ session.commit?.()
219
+ const stagedNode = useScene.getState().nodes[movingNode.id]
220
+ const committedId = stagedNode
221
+ ? commitFreshPlacementSubtree(
222
+ movingNode.id as AnyNodeId,
223
+ {
224
+ metadata: stripPlacementMetadataFlags(stagedNode.metadata),
225
+ visible: true,
226
+ } as Partial<AnyNode>,
227
+ )
228
+ : null
229
+ if (historyPaused) {
230
+ resumeSceneHistory(useScene)
231
+ historyPaused = false
232
+ }
233
+ if (committedId) {
234
+ sfxEmitter.emit('sfx:item-place')
235
+ useViewer.getState().setSelection({ selectedIds: [committedId] })
236
+ }
237
+ return
238
+ }
239
+
213
240
  if (commitValid && session.commit) {
214
241
  useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
215
242
  if (historyPaused) {
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createFloorplanRenderScaleReference } from './floorplan-render-context'
3
+
4
+ describe('floorplan render context', () => {
5
+ test('updates the live scale without changing the registry-facing reader', () => {
6
+ const scale = createFloorplanRenderScaleReference(0.02)
7
+ const read = scale.read
8
+
9
+ scale.update(0.01)
10
+
11
+ expect(scale.read).toBe(read)
12
+ expect(read()).toBe(0.01)
13
+ })
14
+
15
+ test('notifies selected-handle renderers when a hidden floorplan becomes visible', () => {
16
+ const scale = createFloorplanRenderScaleReference(30)
17
+ let renderedCurveHandleRadius = 8 * scale.read()
18
+ const unsubscribe = scale.subscribe(() => {
19
+ renderedCurveHandleRadius = 8 * scale.read()
20
+ })
21
+
22
+ scale.update(0.02)
23
+
24
+ expect(renderedCurveHandleRadius).toBe(0.16)
25
+ unsubscribe()
26
+ })
27
+ })