@pascal-app/editor 0.9.1 → 0.9.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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -2,28 +2,39 @@
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
4
  import {
5
+ getCatalogMaterialById,
6
+ getLibraryMaterialIdFromRef,
7
+ getSceneMaterialIdFromRef,
5
8
  initSpaceDetectionSync,
6
9
  initSpatialGridSync,
7
10
  spatialGridManager,
8
11
  useScene,
9
12
  } from '@pascal-app/core'
10
- import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
13
+ import {
14
+ type HoverStyles,
15
+ InteractiveSystem,
16
+ SceneEnvironment,
17
+ useViewer,
18
+ Viewer,
19
+ } from '@pascal-app/viewer'
11
20
  import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
12
21
  import { ViewerOverlay } from '../../components/viewer-overlay'
13
22
  import { ViewerZoneSystem } from '../../components/viewer-zone-system'
14
23
  import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
15
24
  import { useKeyboard } from '../../hooks/use-keyboard'
25
+ import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
16
26
  import {
17
27
  applySceneGraphToEditor,
18
28
  loadSceneFromLocalStorage,
19
29
  type SceneGraph,
20
30
  writePersistedSelection,
21
31
  } from '../../lib/scene'
22
- import { initSFXBus } from '../../lib/sfx-bus'
32
+ import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
23
33
  import useEditor from '../../store/use-editor'
24
34
  import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
25
35
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
26
36
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
37
+ import { SelectionAffordanceManager } from '../systems/selection-affordance-manager'
27
38
  import { StairEditSystem } from '../systems/stair/stair-edit-system'
28
39
  import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
29
40
  import { ZoneSystem } from '../systems/zone/zone-system'
@@ -44,17 +55,22 @@ import type { ExtraPanel } from '../ui/sidebar/icon-rail'
44
55
  import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
45
56
  import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
46
57
  import type { SidebarTab } from '../ui/sidebar/tab-bar'
58
+ import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
47
59
  import { CustomCameraControls } from './custom-camera-controls'
48
60
  import { EditorLayoutV2 } from './editor-layout-v2'
49
61
  import { ExportManager } from './export-manager'
62
+ import { FenceTangentLines3D } from './fence-tangent-lines-3d'
50
63
  import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
51
64
  import { FloatingActionMenu } from './floating-action-menu'
52
65
  import { FloatingBuildingActionMenu } from './floating-building-action-menu'
53
66
  import { FloorplanPanel } from './floorplan-panel'
54
67
  import { Grid } from './grid'
55
- import { GroupMoveHandle } from './group-move-handle'
68
+ import { GroupFloatingActionMenu } from './group-floating-action-menu'
56
69
  import { GroupRotateHandle } from './group-rotate-handle'
70
+ import { GroupSelectionBox3D } from './group-selection-box-3d'
57
71
  import { NodeArrowHandles } from './node-arrow-handles'
72
+ import { QuickMeasurementHud } from './quick-measurement-hud'
73
+ import { RiserDiagramPanel } from './riser-diagram-panel'
58
74
  import { SelectionManager } from './selection-manager'
59
75
  import { SiteEdgeLabels } from './site-edge-labels'
60
76
  import { SlabHoleHighlights } from './slab-hole-highlights'
@@ -72,6 +88,8 @@ const PAINT_CURSOR_BADGE_COLOR = '#818cf8'
72
88
  const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
73
89
  const PAINT_CURSOR_BADGE_OFFSET_X = 14
74
90
  const PAINT_CURSOR_BADGE_OFFSET_Y = 14
91
+ const SCENE_READY_FALLBACK_MS = 8000
92
+ type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
75
93
  const EDITOR_HOVER_STYLES: HoverStyles = {
76
94
  default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
77
95
  delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -102,6 +120,7 @@ function initializeEditorRuntime(): () => void {
102
120
  unsubscribeSpaceDetection?.()
103
121
 
104
122
  spatialGridManager.clear()
123
+ disposeSFXBus()
105
124
 
106
125
  const outliner = useViewer.getState().outliner
107
126
  outliner.selectedObjects.length = 0
@@ -121,6 +140,12 @@ export interface EditorProps {
121
140
  sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
122
141
  viewerToolbarLeft?: ReactNode
123
142
  viewerToolbarRight?: ReactNode
143
+ /**
144
+ * Full-bleed surface swapped in over the 3D canvas (v2) — e.g. the studio
145
+ * gallery. The canvas stays mounted underneath (no WebGL re-init) and the
146
+ * viewer toolbar stays on top so the host's stage switch remains reachable.
147
+ */
148
+ stageOverlay?: ReactNode
124
149
  /**
125
150
  * Docked below the node inspector (v2). Hosts mount the "save as preset"
126
151
  * affordance here so it reads as part of the inspector surface and shows
@@ -143,6 +168,10 @@ export interface EditorProps {
143
168
  // Loading indicator (e.g. project fetching in community mode)
144
169
  isLoading?: boolean
145
170
 
171
+ // Fires when the full-screen scene loader shows/hides — lets hosts measure
172
+ // open-to-interactive time without reaching into internal loader state.
173
+ onLoaderChange?: (visible: boolean) => void
174
+
146
175
  // Thumbnail
147
176
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
148
177
 
@@ -526,14 +555,70 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
526
555
  )
527
556
  }
528
557
 
558
+ function getActivePaintMaterialSwatchColor(
559
+ material: ActivePaintMaterial | null,
560
+ sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
561
+ ) {
562
+ const directColor = material?.material?.properties?.color
563
+ if (directColor) return directColor
564
+
565
+ const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
566
+ if (sceneMaterialId) {
567
+ const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
568
+ const sceneColor = sceneMaterial?.material.properties?.color
569
+ if (sceneColor) return sceneColor
570
+ }
571
+
572
+ const catalogId =
573
+ getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
574
+ const catalogMaterial = getCatalogMaterialById(catalogId)
575
+ return (
576
+ catalogMaterial?.previewColor ??
577
+ catalogMaterial?.preset.mapProperties.color ??
578
+ PAINT_CURSOR_BADGE_COLOR
579
+ )
580
+ }
581
+
582
+ function getActivePaintMaterialSwatchImageUrl(
583
+ material: ActivePaintMaterial | null,
584
+ sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
585
+ ) {
586
+ const directTextureUrl = material?.material?.texture?.url
587
+ if (directTextureUrl) return directTextureUrl
588
+
589
+ const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
590
+ if (sceneMaterialId) {
591
+ const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
592
+ const sceneTextureUrl = sceneMaterial?.material.texture?.url
593
+ if (sceneTextureUrl) return sceneTextureUrl
594
+ }
595
+
596
+ const catalogId =
597
+ getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
598
+ const catalogMaterial = getCatalogMaterialById(catalogId)
599
+ return catalogMaterial?.previewThumbnailUrl ?? catalogMaterial?.preset.maps.albedoMap
600
+ }
601
+
529
602
  function PaintCursorBadge({
530
603
  position,
531
- disabled,
604
+ state,
605
+ swatchColor,
606
+ swatchImageUrl,
607
+ isEraser,
532
608
  }: {
533
609
  position: { x: number; y: number }
534
- disabled: boolean
610
+ state: PaintCursorBadgeState
611
+ swatchColor: string
612
+ swatchImageUrl?: string
613
+ isEraser: boolean
535
614
  }) {
536
- const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
615
+ const accentColor =
616
+ state === 'ready'
617
+ ? isEraser
618
+ ? PAINT_CURSOR_BADGE_COLOR
619
+ : swatchColor
620
+ : PAINT_CURSOR_BADGE_DISABLED_COLOR
621
+ const iconOpacity = state === 'ready' ? 1 : state === 'blocked' ? 0.62 : 0.42
537
622
  const lineHeight = 18
538
623
 
539
624
  return (
@@ -566,8 +651,49 @@ function PaintCursorBadge({
566
651
  alt=""
567
652
  aria-hidden="true"
568
653
  className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
569
- src="/icons/paint.png"
654
+ src="/icons/paint.webp"
655
+ style={{
656
+ filter: state === 'ready' ? undefined : 'grayscale(1)',
657
+ opacity: iconOpacity,
658
+ }}
570
659
  />
660
+ {state === 'ready' ? (
661
+ isEraser ? (
662
+ <span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/35 bg-zinc-950 text-white shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
663
+ <Icon
664
+ aria-hidden="true"
665
+ color="currentColor"
666
+ height={10}
667
+ icon="mdi:eraser-variant"
668
+ width={10}
669
+ />
670
+ </span>
671
+ ) : (
672
+ <span
673
+ className="-right-1 -bottom-1 absolute h-3.5 w-3.5 rounded-full border border-white/70 bg-cover bg-center shadow-[0_2px_6px_rgba(0,0,0,0.45)]"
674
+ style={{
675
+ backgroundColor: swatchColor,
676
+ backgroundImage: swatchImageUrl
677
+ ? `url(${JSON.stringify(swatchImageUrl)})`
678
+ : undefined,
679
+ }}
680
+ />
681
+ )
682
+ ) : state === 'blocked' ? (
683
+ <span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 text-rose-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
684
+ <Icon
685
+ aria-hidden="true"
686
+ color="currentColor"
687
+ height={12}
688
+ icon="mdi:cancel"
689
+ width={12}
690
+ />
691
+ </span>
692
+ ) : (
693
+ <span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 font-semibold text-[9px] text-slate-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
694
+ ?
695
+ </span>
696
+ )}
571
697
  </div>
572
698
  </div>
573
699
  )
@@ -598,24 +724,32 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
598
724
  }) {
599
725
  // Studio mode is a clean render/snapshot surface — no selection or editing
600
726
  // affordances. It mirrors version-preview's chrome gating on the canvas.
601
- const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode
727
+ // Capture (snapshot) mode is camera-only for the same reason: suppress
728
+ // selection, editing handles, and the tool manager (which mounts the site
729
+ // boundary flags) so the framed shot stays clean.
730
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
731
+ const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode
602
732
  return (
603
733
  <>
604
- {!(isFirstPersonMode || isStudioMode) && <SelectionManager />}
734
+ <SceneEnvironment />
735
+ {!(isFirstPersonMode || isStudioMode || isCaptureMode) && <SelectionManager />}
605
736
  {!noEditing && <BoxSelectTool />}
606
737
  {!noEditing && <NodeArrowHandles />}
607
738
  {!noEditing && <GroupRotateHandle />}
608
- {!noEditing && <GroupMoveHandle />}
739
+ {!noEditing && <GroupSelectionBox3D />}
609
740
  {!noEditing && <WallOpeningHighlights />}
610
741
  {!noEditing && <SlabHoleHighlights />}
611
742
  {!noEditing && <WallMoveSideHandles />}
743
+ {!noEditing && <FenceTangentLines3D />}
612
744
  {!noEditing && <FloatingActionMenu />}
745
+ {!noEditing && <GroupFloatingActionMenu />}
613
746
  {!noEditing && <FloatingBuildingActionMenu />}
614
747
  {!isFirstPersonMode && <WallMeasurementLabel />}
615
748
  <ExportManager />
616
749
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
617
750
  <CeilingSystem />
618
751
  <CeilingSelectionAffordanceSystem />
752
+ {!noEditing && <SelectionAffordanceManager />}
619
753
  <RoofEditSystem />
620
754
  <StairEditSystem />
621
755
  {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
@@ -715,6 +849,9 @@ function PaintCursorLayer({
715
849
  }) {
716
850
  const mode = useEditor((s) => s.mode)
717
851
  const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
852
+ const paintEraser = useEditor((s) => s.paintEraser)
853
+ const paintHover = useEditor((s) => s.paintHover)
854
+ const sceneMaterials = useScene((s) => s.materials)
718
855
  const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
719
856
  const active = mode === 'material-paint' && !isVersionPreviewMode
720
857
 
@@ -764,11 +901,14 @@ function PaintCursorLayer({
764
901
  }
765
902
  }, [active, containerRef])
766
903
 
767
- const hasMaterial = Boolean(
768
- activePaintMaterial &&
769
- (activePaintMaterial.material !== undefined ||
770
- activePaintMaterial.materialPreset !== undefined),
771
- )
904
+ const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial)
905
+ const badgeState: PaintCursorBadgeState = !hasPaint
906
+ ? 'empty'
907
+ : paintHover != null
908
+ ? 'ready'
909
+ : 'blocked'
910
+ const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials)
911
+ const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials)
772
912
 
773
913
  if (!active || !position) return null
774
914
 
@@ -777,7 +917,13 @@ function PaintCursorLayer({
777
917
  className="pointer-events-none absolute z-40"
778
918
  style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
779
919
  >
780
- <PaintCursorBadge disabled={!hasMaterial} position={{ x: 0, y: 0 }} />
920
+ <PaintCursorBadge
921
+ isEraser={paintEraser}
922
+ position={{ x: 0, y: 0 }}
923
+ state={badgeState}
924
+ swatchColor={swatchColor}
925
+ swatchImageUrl={swatchImageUrl}
926
+ />
781
927
  </div>
782
928
  )
783
929
  }
@@ -816,6 +962,13 @@ const ViewerCanvas = memo(function ViewerCanvas({
816
962
  )
817
963
 
818
964
  const viewerAreaRef = useRef<HTMLDivElement>(null)
965
+ // State mirror of `viewerAreaRef` so the floorplan compass portal re-renders
966
+ // once the container exists (a plain ref mutation wouldn't trigger it).
967
+ const [viewerAreaEl, setViewerAreaEl] = useState<HTMLDivElement | null>(null)
968
+ const setViewerAreaNode = useCallback((el: HTMLDivElement | null) => {
969
+ viewerAreaRef.current = el
970
+ setViewerAreaEl(el)
971
+ }, [])
819
972
  const viewer3dRef = useRef<HTMLDivElement>(null)
820
973
  const isResizingFloorplan = useRef(false)
821
974
 
@@ -861,7 +1014,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
861
1014
 
862
1015
  return (
863
1016
  <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
864
- <div className="flex h-full" ref={viewerAreaRef}>
1017
+ {/* `relative` so the floorplan compass (portaled here to stay visible in
1018
+ 2d / 3d / split alike) can anchor to this container's bottom-left. */}
1019
+ <div className="relative flex h-full" ref={setViewerAreaNode}>
1020
+ <QuickMeasurementHud />
865
1021
  {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
866
1022
  <div
867
1023
  className="relative h-full flex-shrink-0"
@@ -871,7 +1027,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
871
1027
  }}
872
1028
  >
873
1029
  <div className="h-full w-full overflow-hidden">
874
- <FloorplanPanel />
1030
+ <FloorplanPanel compassHost={viewerAreaEl} />
875
1031
  </div>
876
1032
  {viewMode === 'split' && (
877
1033
  <div
@@ -886,6 +1042,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
886
1042
  {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
887
1043
  <div
888
1044
  className="relative min-w-0 flex-1 overflow-hidden"
1045
+ data-pascal-viewer-3d
889
1046
  ref={viewer3dRef}
890
1047
  style={{ display: show3d ? undefined : 'none' }}
891
1048
  >
@@ -935,6 +1092,7 @@ export default function Editor({
935
1092
  sidebarTabs,
936
1093
  viewerToolbarLeft,
937
1094
  viewerToolbarRight,
1095
+ stageOverlay,
938
1096
  inspectorFooter,
939
1097
  projectId,
940
1098
  onLoad,
@@ -944,6 +1102,7 @@ export default function Editor({
944
1102
  previewScene,
945
1103
  isVersionPreviewMode = false,
946
1104
  isLoading = false,
1105
+ onLoaderChange,
947
1106
  onThumbnailCapture,
948
1107
  sidebarOverlay,
949
1108
  viewerBanner,
@@ -974,11 +1133,22 @@ export default function Editor({
974
1133
  const sidebarWidth = useSidebarStore((s) => s.width)
975
1134
  const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
976
1135
 
1136
+ // Host-registered rail panels. Called unconditionally so
1137
+ // hook order is stable across the v1 / v2 layout branches below; the v1
1138
+ // AppSidebar path merges its own copy internally, the v2 path merges these
1139
+ // into its tab bar.
1140
+ const hostRailPanels = useHostPanels()
1141
+
977
1142
  useEffect(() => {
978
1143
  const teardown = initializeEditorRuntime()
979
1144
  return teardown
980
1145
  }, [])
981
1146
 
1147
+ useEffect(() => {
1148
+ void useEditor.persist.rehydrate()
1149
+ void useSidebarStore.persist.rehydrate()
1150
+ }, [])
1151
+
982
1152
  useEffect(() => {
983
1153
  useViewer.getState().setProjectId(projectId ?? null)
984
1154
 
@@ -1059,8 +1229,25 @@ export default function Editor({
1059
1229
  setIsViewerSceneReady(ready)
1060
1230
  }, [])
1061
1231
 
1232
+ useEffect(() => {
1233
+ if (isLoading || isSceneLoading || !hasLoadedInitialScene || isViewerSceneReady) return
1234
+
1235
+ const timer = window.setTimeout(() => {
1236
+ console.warn('[editor] viewer scene readiness timed out; showing editor shell anyway', {
1237
+ sceneReadyKey,
1238
+ })
1239
+ setIsViewerSceneReady(true)
1240
+ }, SCENE_READY_FALLBACK_MS)
1241
+
1242
+ return () => window.clearTimeout(timer)
1243
+ }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey])
1244
+
1062
1245
  const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
1063
1246
 
1247
+ useEffect(() => {
1248
+ onLoaderChange?.(showLoader)
1249
+ }, [showLoader, onLoaderChange])
1250
+
1064
1251
  const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
1065
1252
  const wasFirstPersonModeRef = useRef(isFirstPersonMode)
1066
1253
 
@@ -1109,6 +1296,7 @@ export default function Editor({
1109
1296
  <CeilingSystem />
1110
1297
  <RoofEditSystem />
1111
1298
  <StairEditSystem />
1299
+ {isFirstPersonMode && <FirstPersonControls />}
1112
1300
  <CustomCameraControls />
1113
1301
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
1114
1302
  <InteractiveSystem />
@@ -1131,7 +1319,16 @@ export default function Editor({
1131
1319
 
1132
1320
  // ── V2 layout ──
1133
1321
  if (layoutVersion === 'v2') {
1134
- const tabMap = new Map(sidebarTabs?.map((t) => [t.id, t]) ?? [])
1322
+ // Registered host panels join the host's `sidebarTabs` as first-class tabs.
1323
+ // Explicit tabs keep precedence because they are already in the map first.
1324
+ const tabMap = new Map<string, SidebarTab & { component: React.ComponentType }>(
1325
+ sidebarTabs?.map((t) => [t.id, t]) ?? [],
1326
+ )
1327
+ for (const p of hostRailPanels) {
1328
+ if (!tabMap.has(p.id)) {
1329
+ tabMap.set(p.id, { id: p.id, label: p.label, icon: p.icon, component: p.component })
1330
+ }
1331
+ }
1135
1332
 
1136
1333
  const renderTabContent = (tabId: string) => {
1137
1334
  // Built-in panels
@@ -1148,14 +1345,24 @@ export default function Editor({
1148
1345
  return <Component />
1149
1346
  }
1150
1347
 
1151
- const tabBarTabs =
1152
- sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({
1348
+ const tabBarTabs = [
1349
+ ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({
1153
1350
  id,
1154
1351
  label,
1155
1352
  mobileDefaultSnap,
1156
1353
  mobileIcon,
1157
1354
  icon,
1158
- })) ?? []
1355
+ })) ?? []),
1356
+ // Host panels appear after the explicit tabs in the rail. The icon
1357
+ // doubles as the mobile icon; a half-height sheet is a sensible default.
1358
+ ...hostRailPanels.map((p) => ({
1359
+ id: p.id,
1360
+ label: p.label,
1361
+ mobileDefaultSnap: 0.5,
1362
+ mobileIcon: p.icon,
1363
+ icon: p.icon,
1364
+ })),
1365
+ ]
1159
1366
 
1160
1367
  return (
1161
1368
  <>
@@ -1167,8 +1374,14 @@ export default function Editor({
1167
1374
 
1168
1375
  {!isLoading && isPreviewMode ? (
1169
1376
  <div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
1170
- <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1171
- <div className="h-full w-full">{previewViewerContent}</div>
1377
+ {isFirstPersonMode ? (
1378
+ <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1379
+ ) : (
1380
+ <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1381
+ )}
1382
+ <div className="h-full w-full" data-pascal-viewer-3d>
1383
+ {previewViewerContent}
1384
+ </div>
1172
1385
  </div>
1173
1386
  ) : (
1174
1387
  <>
@@ -1176,7 +1389,7 @@ export default function Editor({
1176
1389
  navbarSlot={navbarSlot}
1177
1390
  overlays={
1178
1391
  <>
1179
- {!isCaptureMode && <FloatingLevelSelector />}
1392
+ {!(isCaptureMode || stageOverlay) && <FloatingLevelSelector />}
1180
1393
  {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
1181
1394
  <div className="pointer-events-auto">
1182
1395
  <ActionMenu />
@@ -1204,6 +1417,7 @@ export default function Editor({
1204
1417
  renderTabContent={renderTabContent}
1205
1418
  sidebarOverlay={sidebarOverlay}
1206
1419
  sidebarTabs={tabBarTabs}
1420
+ stageOverlay={stageOverlay}
1207
1421
  viewerContent={viewerCanvas}
1208
1422
  viewerToolbarLeft={viewerToolbarLeft}
1209
1423
  viewerToolbarRight={viewerToolbarRight}
@@ -1232,8 +1446,14 @@ export default function Editor({
1232
1446
 
1233
1447
  {!isLoading && isPreviewMode ? (
1234
1448
  <>
1235
- <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1236
- <div className="h-full w-full">{previewViewerContent}</div>
1449
+ {isFirstPersonMode ? (
1450
+ <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1451
+ ) : (
1452
+ <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1453
+ )}
1454
+ <div className="h-full w-full" data-pascal-viewer-3d>
1455
+ {previewViewerContent}
1456
+ </div>
1237
1457
  </>
1238
1458
  ) : (
1239
1459
  <>
@@ -1263,6 +1483,7 @@ export default function Editor({
1263
1483
  <div className="pointer-events-auto">
1264
1484
  <HelperManager />
1265
1485
  </div>
1486
+ <RiserDiagramPanel />
1266
1487
  {isFirstPersonMode && (
1267
1488
  <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1268
1489
  )}
@@ -23,11 +23,64 @@ const PART_ORDER: { key: MeasurePart; prefix: string }[] = [
23
23
  { key: 'thickness', prefix: 'T' },
24
24
  ]
25
25
 
26
+ export interface DimensionPillPart {
27
+ key: string
28
+ prefix: string
29
+ value: number
30
+ /** Render an explicit +/- sign — for deltas rather than absolute sizes. */
31
+ signed?: boolean
32
+ }
33
+
34
+ /**
35
+ * Generic floating dimension pill: a row of `prefix value` readouts with the
36
+ * active one emphasised. Styled to match the top-center floating info bar
37
+ * (rounded-full, design-token colours) so it tracks the app theme.
38
+ *
39
+ * `primaryRef` points at the primary value's `<span>` so a caller driving a
40
+ * per-frame drag can rewrite its text imperatively without a React re-render.
41
+ */
42
+ export function DimensionPill({
43
+ parts,
44
+ unit,
45
+ primary,
46
+ primaryRef,
47
+ }: {
48
+ parts: DimensionPillPart[]
49
+ unit: 'metric' | 'imperial'
50
+ primary?: string
51
+ primaryRef?: ForwardedRef<HTMLSpanElement>
52
+ }) {
53
+ return (
54
+ <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
55
+ {parts.map((part, index) => {
56
+ const text = part.signed
57
+ ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}`
58
+ : formatMeasurement(part.value, unit)
59
+ return (
60
+ <Fragment key={part.key}>
61
+ {index > 0 ? (
62
+ <span aria-hidden className="text-muted-foreground">
63
+ ·
64
+ </span>
65
+ ) : null}
66
+ <span
67
+ className={
68
+ part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
69
+ }
70
+ ref={part.key === primary ? primaryRef : undefined}
71
+ >
72
+ {`${part.prefix} ${text}`}
73
+ </span>
74
+ </Fragment>
75
+ )
76
+ })}
77
+ </div>
78
+ )
79
+ }
80
+
26
81
  /**
27
82
  * Floating dimension pill shown during wall / fence drags: `H · L · T` with
28
- * the actively-dragged dimension emphasised. Styled to match the top-center
29
- * floating info bar (rounded-full, design-token colours) so it tracks the
30
- * app theme.
83
+ * the actively-dragged dimension emphasised.
31
84
  *
32
85
  * The forwarded ref points at the `primary` value's `<span>` so a caller
33
86
  * driving a per-frame drag (the height arrow) can rewrite its text
@@ -52,24 +105,11 @@ export const MeasurementPill = forwardRef(function MeasurementPill(
52
105
  ) {
53
106
  const values: Record<MeasurePart, number> = { height, length, thickness }
54
107
  return (
55
- <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
56
- {PART_ORDER.map((part, index) => (
57
- <Fragment key={part.key}>
58
- {index > 0 ? (
59
- <span aria-hidden className="text-muted-foreground">
60
- ·
61
- </span>
62
- ) : null}
63
- <span
64
- className={
65
- part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
66
- }
67
- ref={part.key === primary ? primaryRef : undefined}
68
- >
69
- {`${part.prefix} ${formatMeasurement(values[part.key], unit)}`}
70
- </span>
71
- </Fragment>
72
- ))}
73
- </div>
108
+ <DimensionPill
109
+ parts={PART_ORDER.map((part) => ({ ...part, value: values[part.key] }))}
110
+ primary={primary}
111
+ primaryRef={primaryRef}
112
+ unit={unit}
113
+ />
74
114
  )
75
115
  })
@@ -1,10 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Move, Spline, Trash2 } from 'lucide-react'
4
+ import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
6
 
7
7
  type NodeActionMenuProps = {
8
+ onFind?: MouseEventHandler<HTMLButtonElement>
8
9
  onAddHole?: MouseEventHandler<HTMLButtonElement>
9
10
  onDelete?: MouseEventHandler<HTMLButtonElement>
10
11
  onDuplicate?: MouseEventHandler<HTMLButtonElement>
@@ -17,6 +18,7 @@ type NodeActionMenuProps = {
17
18
  }
18
19
 
19
20
  export function NodeActionMenu({
21
+ onFind,
20
22
  onAddHole,
21
23
  onDelete,
22
24
  onDuplicate,
@@ -35,6 +37,17 @@ export function NodeActionMenu({
35
37
  onPointerLeave={onPointerLeave}
36
38
  onPointerUp={onPointerUp}
37
39
  >
40
+ {onFind && (
41
+ <button
42
+ aria-label="Find in catalog"
43
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
44
+ onClick={onFind}
45
+ title="Find in catalog"
46
+ type="button"
47
+ >
48
+ <Search className="h-4 w-4" />
49
+ </button>
50
+ )}
38
51
  {onMove && (
39
52
  <button
40
53
  aria-label="Move"