@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
@@ -6,7 +6,6 @@ import {
6
6
  type CeilingNode,
7
7
  ColumnNode,
8
8
  createSceneApi,
9
- DEFAULT_WALL_HEIGHT,
10
9
  DoorNode,
11
10
  ElevatorNode,
12
11
  emitter,
@@ -15,6 +14,7 @@ import {
15
14
  getActiveRoofHeight,
16
15
  getEffectiveNode,
17
16
  getWallCurveLength,
17
+ getWallEffectiveHeightForNodes,
18
18
  getWallThickness,
19
19
  ItemNode,
20
20
  isCurvedWall,
@@ -27,7 +27,6 @@ import {
27
27
  runAsSingleSceneHistoryStep,
28
28
  type SlabNode,
29
29
  SpawnNode,
30
- StairNode,
31
30
  StairSegmentNode,
32
31
  sceneRegistry,
33
32
  summarizeSystemFor,
@@ -42,15 +41,20 @@ import { useFrame } from '@react-three/fiber'
42
41
  import { useCallback, useMemo, useRef } from 'react'
43
42
  import * as THREE from 'three'
44
43
  import { useShallow } from 'zustand/react/shallow'
44
+ import { useReducedMotion } from '../../hooks/use-reduced-motion'
45
+ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
45
46
  import {
46
47
  createFreshPlacementSubtree,
47
48
  duplicatesAsFreshSubtree,
49
+ prepareFreshPlacementRootDuplicate,
48
50
  } from '../../lib/fresh-planar-placement'
49
51
  import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
50
52
  import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
53
+ import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
54
+ import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
51
55
  import { duplicateRoofSubtree } from '../../lib/roof-duplication'
52
56
  import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
53
- import { duplicateStairSubtree } from '../../lib/stair-duplication'
57
+ import { cn } from '../../lib/utils'
54
58
  import useEditor from '../../store/use-editor'
55
59
  import useInteractionScope, {
56
60
  useActiveHandleDrag,
@@ -207,26 +211,9 @@ function collectQuickActionNodes(
207
211
  ): Record<AnyNodeId, AnyNode> | null {
208
212
  if (!selectedId) return null
209
213
  const selected = nodes[selectedId as AnyNodeId]
210
- if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
211
-
212
- const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
213
- const add = (id: string | null | undefined) => {
214
- if (!id) return
215
- const node = nodes[id as AnyNodeId]
216
- if (node) collected[node.id as AnyNodeId] = node
217
- }
218
- const addChildren = (node: AnyNode | undefined) => {
219
- for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
220
- add(childId)
221
- }
222
- }
223
-
224
- add(selected.parentId ?? null)
225
- addChildren(selected)
226
- const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
227
- addChildren(parent)
228
-
229
- return collected
214
+ const def = selected ? nodeRegistry.get(selected.type) : undefined
215
+ if (!def?.quickActions) return null
216
+ return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
230
217
  }
231
218
 
232
219
  // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
@@ -283,7 +270,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
283
270
  } {
284
271
  if (node.type === 'wall') {
285
272
  return {
286
- height: node.height ?? DEFAULT_WALL_HEIGHT,
273
+ height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes),
287
274
  length: getWallCurveLength(node),
288
275
  thickness: getWallThickness(node),
289
276
  }
@@ -296,6 +283,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
296
283
  }
297
284
 
298
285
  export function FloatingActionMenu() {
286
+ const reducedMotion = useReducedMotion()
299
287
  const selectedIds = useViewer((s) => s.selection.selectedIds)
300
288
  const updateNode = useScene((s) => s.updateNode)
301
289
  const mode = useEditor((s) => s.mode)
@@ -424,7 +412,10 @@ export function FloatingActionMenu() {
424
412
  const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
425
413
  | { height?: number }
426
414
  | undefined
427
- const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
415
+ const fallbackHeight =
416
+ node.type === 'wall'
417
+ ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
418
+ : FENCE_DEFAULT_HEIGHT
428
419
  const liveHeight = override?.height ?? node.height ?? fallbackHeight
429
420
  pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
430
421
  }
@@ -514,7 +505,8 @@ export function FloatingActionMenu() {
514
505
  e.stopPropagation()
515
506
  if (!node) return
516
507
  sfxEmitter.emit('sfx:item-pick')
517
- setMovingNode(node as any)
508
+ const sceneNodes = useScene.getState().nodes
509
+ setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
518
510
  setSelection({ selectedIds: [] })
519
511
  },
520
512
  [node, setMovingNode, setSelection],
@@ -537,20 +529,26 @@ export function FloatingActionMenu() {
537
529
  useScene.temporal.getState().pause()
538
530
 
539
531
  if (duplicatesAsFreshSubtree(node as AnyNode)) {
540
- const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
541
- const draft = draftId ? useScene.getState().nodes[draftId] : null
542
- if (draft) {
543
- setMovingNode(draft as any)
544
- setSelection({ selectedIds: [] })
545
- return
532
+ let draftId: AnyNodeId | null = null
533
+ try {
534
+ draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
535
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
536
+ if (draft) {
537
+ setMovingNode(draft as any)
538
+ setSelection({ selectedIds: [] })
539
+ return
540
+ }
541
+ } catch (error) {
542
+ if (draftId && useScene.getState().nodes[draftId]) {
543
+ useScene.getState().deleteNode(draftId)
544
+ }
545
+ console.error('Failed to duplicate node subtree', error)
546
546
  }
547
547
  useScene.temporal.getState().resume()
548
548
  return
549
549
  }
550
550
 
551
- let duplicateInfo = structuredClone(node) as any
552
- delete duplicateInfo.id
553
- duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
551
+ const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any
554
552
 
555
553
  let duplicate: AnyNode | null = null
556
554
  try {
@@ -573,11 +571,6 @@ export function FloatingActionMenu() {
573
571
  } else if (node.type === 'roof-segment') {
574
572
  duplicateInfo.id = generateId('rseg')
575
573
  duplicate = RoofSegmentNode.parse(duplicateInfo)
576
- } else if (node.type === 'stair') {
577
- duplicateInfo.children = []
578
- duplicateInfo.metadata = { ...duplicateInfo.metadata }
579
- delete duplicateInfo.metadata?.isNew
580
- duplicate = StairNode.parse(duplicateInfo)
581
574
  } else if (node.type === 'stair-segment') {
582
575
  duplicate = StairSegmentNode.parse(duplicateInfo)
583
576
  } else if (node.type === 'spawn') {
@@ -615,11 +608,7 @@ export function FloatingActionMenu() {
615
608
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
616
609
  } else if (duplicate.type === 'fence') {
617
610
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
618
- } else if (
619
- duplicate.type === 'roof-segment' ||
620
- duplicate.type === 'stair' ||
621
- duplicate.type === 'stair-segment'
622
- ) {
611
+ } else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
623
612
  // Add small offset to make it visible
624
613
  if ('position' in duplicate) {
625
614
  duplicate.position = [
@@ -628,13 +617,7 @@ export function FloatingActionMenu() {
628
617
  duplicate.position[2] + 1,
629
618
  ]
630
619
  }
631
- if (node.type === 'stair' && duplicate.type === 'stair') {
632
- duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
633
- } else {
634
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
635
- }
636
-
637
- // Duplicate children for stair nodes
620
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
638
621
  } else if (
639
622
  duplicate.type === 'item' ||
640
623
  duplicate.type === 'chimney' ||
@@ -703,12 +686,8 @@ export function FloatingActionMenu() {
703
686
  nodeRegistry.has(duplicate.type)
704
687
  ) {
705
688
  setMovingNode(duplicate as any)
706
- } else if (duplicate.type === 'stair') {
707
- setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
708
- }
709
- if (duplicate.type !== 'stair') {
710
- setSelection({ selectedIds: [] })
711
689
  }
690
+ setSelection({ selectedIds: [] })
712
691
  }
713
692
  },
714
693
  [node, setMovingNode, setSelection],
@@ -776,9 +755,15 @@ export function FloatingActionMenu() {
776
755
  )
777
756
 
778
757
  const handleQuickAction = useCallback(
779
- (action: NodeQuickAction) => (e: React.MouseEvent) => {
758
+ (action: NodeQuickAction) => (e: React.MouseEvent<HTMLButtonElement>) => {
780
759
  e.stopPropagation()
781
- if (!node || action.disabled) return
760
+ if (!node) return
761
+ if (action.disabled) {
762
+ if (action.blockedFeedback) {
763
+ playBlockedQuickActionFeedback(e.currentTarget, reducedMotion)
764
+ }
765
+ return
766
+ }
782
767
  const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
783
768
  const result =
784
769
  action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
@@ -788,7 +773,7 @@ export function FloatingActionMenu() {
788
773
  sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
789
774
  }
790
775
  },
791
- [node, setSelection],
776
+ [node, reducedMotion, setSelection],
792
777
  )
793
778
 
794
779
  if (
@@ -851,18 +836,27 @@ export function FloatingActionMenu() {
851
836
  >
852
837
  {quickActions.map((action) => (
853
838
  <button
839
+ aria-disabled={action.disabled || undefined}
854
840
  aria-label={action.title ?? action.label}
855
- 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"
856
- disabled={action.disabled}
841
+ className={cn(
842
+ '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',
843
+ action.disabled &&
844
+ 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
845
+ )}
846
+ disabled={action.disabled && !action.blockedFeedback}
857
847
  key={action.id}
858
848
  onClick={handleQuickAction(action)}
859
849
  title={action.title ?? action.label}
860
850
  type="button"
861
851
  >
862
- <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
863
- <QuickActionIcon action={action} />
852
+ <span className="flex items-center gap-1.5" data-quick-action-feedback>
853
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
854
+ <QuickActionIcon action={action} />
855
+ </span>
856
+ <span className="whitespace-nowrap leading-none" data-quick-action-label>
857
+ {action.label}
858
+ </span>
864
859
  </span>
865
- <span className="whitespace-nowrap leading-none">{action.label}</span>
866
860
  </button>
867
861
  ))}
868
862
  </div>
@@ -10,8 +10,6 @@ const baseArgs = {
10
10
  modifierKeys: { meta: false, ctrl: false, shift: false },
11
11
  planPoint: [0, 0] as [number, number],
12
12
  structureLayer: 'elements',
13
- toPoint2D: ([x, y]: [number, number]) => ({ x, y }),
14
- visibleZonePolygons: [],
15
13
  }
16
14
 
17
15
  describe('resolveFloorplanBackgroundSelection', () => {
@@ -55,4 +53,20 @@ describe('resolveFloorplanBackgroundSelection', () => {
55
53
  preserveSelection: true,
56
54
  })
57
55
  })
56
+
57
+ test('uses the registry hit result for zone selection', () => {
58
+ const result = resolveFloorplanBackgroundSelection({
59
+ ...baseArgs,
60
+ canSelectElementFloorplanGeometry: false,
61
+ canSelectFloorplanZones: true,
62
+ getFloorplanHitIdAtPoint: () => 'zone_1',
63
+ structureLayer: 'zones',
64
+ })
65
+
66
+ expect(result).toEqual({
67
+ handled: true,
68
+ kind: 'select-zone',
69
+ zoneId: 'zone_1',
70
+ })
71
+ })
58
72
  })
@@ -1,7 +1,6 @@
1
1
  'use client'
2
2
 
3
- import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
4
- import { isPointInsidePolygon } from '../../lib/floorplan'
3
+ import type { ZoneNode as ZoneNodeType } from '@pascal-app/core'
5
4
  import type { WallPlanPoint } from '../tools/wall/wall-drafting'
6
5
 
7
6
  type ModifierKeys = {
@@ -10,13 +9,6 @@ type ModifierKeys = {
10
9
  shift: boolean
11
10
  }
12
11
 
13
- type ZoneHitEntry = {
14
- zone: {
15
- id: ZoneNodeType['id']
16
- }
17
- polygon: Point2D[]
18
- }
19
-
20
12
  type ResolveFloorplanBackgroundSelectionArgs = {
21
13
  canSelectElementFloorplanGeometry: boolean
22
14
  canSelectFloorplanZones: boolean
@@ -26,8 +18,6 @@ type ResolveFloorplanBackgroundSelectionArgs = {
26
18
  modifierKeys: ModifierKeys
27
19
  planPoint: WallPlanPoint
28
20
  structureLayer: string
29
- toPoint2D: (point: WallPlanPoint) => Point2D
30
- visibleZonePolygons: ZoneHitEntry[]
31
21
  }
32
22
 
33
23
  export type FloorplanBackgroundSelectionResult =
@@ -63,18 +53,14 @@ export function resolveFloorplanBackgroundSelection({
63
53
  modifierKeys,
64
54
  planPoint,
65
55
  structureLayer,
66
- toPoint2D,
67
- visibleZonePolygons,
68
56
  }: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
69
57
  if (canSelectFloorplanZones) {
70
- const zoneHit = visibleZonePolygons.find(({ polygon }) =>
71
- isPointInsidePolygon(toPoint2D(planPoint), polygon),
72
- )
73
- if (zoneHit) {
58
+ const zoneId = getFloorplanHitIdAtPoint(planPoint)
59
+ if (zoneId) {
74
60
  return {
75
61
  handled: true,
76
62
  kind: 'select-zone',
77
- zoneId: zoneHit.zone.id,
63
+ zoneId: zoneId as ZoneNodeType['id'],
78
64
  }
79
65
  }
80
66
  }
@@ -0,0 +1,249 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { CameraPose } from '@pascal-app/core'
3
+ import { publishInitialCameraPose } from '../../lib/camera-pose'
4
+ import type { NavigationSyncPose } from '../../store/use-editor'
5
+ import {
6
+ cameraPoseToFloorplanNavigationPose,
7
+ createFloorplanCameraNavigationChannel,
8
+ createFloorplanCameraSyncBridge,
9
+ floorplanNavigationPoseToCameraPose,
10
+ } from './floorplan-camera-sync'
11
+
12
+ const cameraPose: CameraPose = {
13
+ fov: 50,
14
+ position: [3, 4, 4],
15
+ projection: 'perspective',
16
+ target: [0, 1, 0],
17
+ viewWidth: 12,
18
+ }
19
+
20
+ function cameraPoseAtAzimuth(
21
+ azimuth: number,
22
+ target: [number, number, number] = [0, 1, 0],
23
+ ): CameraPose {
24
+ const horizontalDistance = 5
25
+ return {
26
+ ...cameraPose,
27
+ position: [
28
+ target[0] + Math.sin(azimuth) * horizontalDistance,
29
+ 4,
30
+ target[2] + Math.cos(azimuth) * horizontalDistance,
31
+ ],
32
+ target,
33
+ }
34
+ }
35
+
36
+ describe('floorplan camera sync', () => {
37
+ test('derives the floor-plan navigation pose from the generic camera pose', () => {
38
+ expect(
39
+ cameraPoseToFloorplanNavigationPose({
40
+ position: [10, 5, 0],
41
+ projection: 'perspective',
42
+ target: [0, 0, 0],
43
+ viewWidth: 20,
44
+ }),
45
+ ).toEqual({
46
+ source: '3d',
47
+ target: [0, 0, 0],
48
+ azimuth: Math.PI / 2,
49
+ viewWidth: 20,
50
+ })
51
+ })
52
+
53
+ test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => {
54
+ expect(
55
+ floorplanNavigationPoseToCameraPose(
56
+ {
57
+ source: '2d',
58
+ revision: 4,
59
+ target: [10, 2, 20],
60
+ azimuth: Math.PI / 2,
61
+ viewWidth: 8,
62
+ },
63
+ cameraPose,
64
+ ),
65
+ ).toEqual({
66
+ fov: 50,
67
+ position: [15, 5, 20],
68
+ projection: 'perspective',
69
+ target: [10, 2, 20],
70
+ viewWidth: 8,
71
+ })
72
+ })
73
+
74
+ test('owns two-way synchronization without echoing tiny camera changes', () => {
75
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
76
+ const applied: CameraPose[] = []
77
+ const bridge = createFloorplanCameraSyncBridge({
78
+ applyCameraPose: (pose) => applied.push(pose),
79
+ publishNavigationPose: (pose) => published.push(pose),
80
+ })
81
+
82
+ bridge.receiveCameraPose(cameraPose)
83
+ bridge.receiveCameraPose({
84
+ ...cameraPose,
85
+ position: [3.0001, 4, 4],
86
+ })
87
+ bridge.receiveNavigationPose({
88
+ source: '2d',
89
+ revision: 1,
90
+ target: [10, 2, 20],
91
+ azimuth: Math.PI / 2,
92
+ viewWidth: 8,
93
+ })
94
+
95
+ expect(published).toHaveLength(1)
96
+ expect(published[0]?.source).toBe('3d')
97
+ expect(applied).toHaveLength(1)
98
+ expect(applied[0]).toMatchObject({
99
+ fov: 50,
100
+ projection: 'perspective',
101
+ target: [10, 2, 20],
102
+ viewWidth: 8,
103
+ })
104
+ expect(applied[0]?.position[0]).toBeCloseTo(15)
105
+ expect(applied[0]?.position[1]).toBeCloseTo(5)
106
+ expect(applied[0]?.position[2]).toBeCloseTo(20)
107
+ })
108
+
109
+ test('publishes sub-degree camera rotation for a real-time compass', () => {
110
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
111
+ const bridge = createFloorplanCameraSyncBridge({
112
+ applyCameraPose: () => {},
113
+ publishNavigationPose: (pose) => published.push(pose),
114
+ })
115
+
116
+ bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
117
+ bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
118
+ expect(published).toHaveLength(2)
119
+ expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180)
120
+ })
121
+
122
+ test('keeps a sub-degree camera rotation when it arrives with a pan', () => {
123
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
124
+ const bridge = createFloorplanCameraSyncBridge({
125
+ applyCameraPose: () => {},
126
+ publishNavigationPose: (pose) => published.push(pose),
127
+ })
128
+
129
+ bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
130
+ bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0]))
131
+
132
+ expect(published).toHaveLength(2)
133
+ expect(published[1]).toMatchObject({
134
+ target: [1, 1, 0],
135
+ })
136
+ expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180)
137
+ })
138
+
139
+ test('keeps streaming live camera headings in 3D-only mode', () => {
140
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
141
+ const applied: CameraPose[] = []
142
+ const bridge = createFloorplanCameraSyncBridge({
143
+ active: false,
144
+ applyCameraPose: (pose) => applied.push(pose),
145
+ publishNavigationPose: (pose) => published.push(pose),
146
+ })
147
+ const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
148
+
149
+ bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
150
+ bridge.receiveCameraPose(latestPose)
151
+ bridge.receiveNavigationPose({
152
+ source: '2d',
153
+ revision: 1,
154
+ target: [10, 2, 20],
155
+ azimuth: Math.PI / 2,
156
+ viewWidth: 8,
157
+ })
158
+
159
+ expect(published).toEqual([
160
+ {
161
+ source: '3d',
162
+ target: cameraPose.target,
163
+ azimuth: 0,
164
+ viewWidth: 12,
165
+ },
166
+ {
167
+ source: '3d',
168
+ target: latestPose.target,
169
+ azimuth: Math.PI / 2,
170
+ viewWidth: 12,
171
+ },
172
+ ])
173
+ expect(applied).toEqual([])
174
+
175
+ bridge.setActive(true)
176
+ expect(published).toHaveLength(2)
177
+ expect(applied).toEqual([])
178
+ })
179
+
180
+ test('applies the north-alignment pose used by the compass in 3D view', () => {
181
+ const applied: CameraPose[] = []
182
+ const bridge = createFloorplanCameraSyncBridge({
183
+ applyCameraPose: (pose) => applied.push(pose),
184
+ publishNavigationPose: () => {},
185
+ })
186
+ const currentPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
187
+
188
+ bridge.receiveCameraPose(currentPose)
189
+ bridge.receiveNavigationPose({
190
+ source: '2d',
191
+ revision: 1,
192
+ target: [...currentPose.target],
193
+ azimuth: 0,
194
+ viewWidth: currentPose.viewWidth!,
195
+ })
196
+
197
+ expect(applied).toHaveLength(1)
198
+ expect(applied[0]).toMatchObject({
199
+ target: currentPose.target,
200
+ projection: currentPose.projection,
201
+ viewWidth: currentPose.viewWidth,
202
+ })
203
+ expect(applied[0]?.position[0]).toBeCloseTo(currentPose.target[0])
204
+ expect(applied[0]?.position[1]).toBeCloseTo(currentPose.position[1])
205
+ expect(applied[0]?.position[2]).toBeCloseTo(currentPose.target[2] + 5)
206
+ })
207
+
208
+ test('applies 2D-first navigation on initial load without a 3D interaction', () => {
209
+ const applied: CameraPose[] = []
210
+ const bridge = createFloorplanCameraSyncBridge({
211
+ applyCameraPose: (pose) => applied.push(pose),
212
+ publishNavigationPose: () => {},
213
+ })
214
+
215
+ bridge.receiveNavigationPose({
216
+ source: '2d',
217
+ revision: 1,
218
+ target: [10, 2, 20],
219
+ azimuth: Math.PI / 2,
220
+ viewWidth: 8,
221
+ })
222
+ expect(applied).toEqual([])
223
+
224
+ publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose))
225
+ expect(applied).toHaveLength(1)
226
+ })
227
+
228
+ test('delivers the live camera stream through transient subscribers', () => {
229
+ const channel = createFloorplanCameraNavigationChannel()
230
+ const received: NavigationSyncPose[] = []
231
+ const unsubscribe = channel.subscribe((pose) => received.push(pose))
232
+ const input = {
233
+ source: '3d' as const,
234
+ target: [0, 1, 2] as [number, number, number],
235
+ azimuth: 0.5,
236
+ viewWidth: 12,
237
+ }
238
+
239
+ channel.publish(input)
240
+ channel.publish({ ...input, azimuth: 0.75 })
241
+ unsubscribe()
242
+ channel.publish({ ...input, azimuth: 1 })
243
+
244
+ expect(received).toEqual([
245
+ { ...input, revision: 1 },
246
+ { ...input, azimuth: 0.75, revision: 2 },
247
+ ])
248
+ })
249
+ })