@pascal-app/editor 0.9.1 → 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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -1,18 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import type { AssetInput } from '@pascal-app/core'
4
- import { resolveCdnUrl } from '@pascal-app/viewer'
5
- import Image from 'next/image'
4
+ import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
6
5
  import { useEffect } from 'react'
7
- import {
8
- Tooltip,
9
- TooltipContent,
10
- TooltipTrigger,
11
- } from './../../../components/ui/primitives/tooltip'
12
6
  import { triggerSFX } from './../../../lib/sfx-bus'
13
7
  import { cn } from './../../../lib/utils'
14
8
  import useEditor, { type CatalogCategory } from './../../../store/use-editor'
15
- import { CATALOG_ITEMS } from './catalog-items'
9
+ import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
10
+ import { CATALOG_ITEMS, type CatalogItem } from './catalog-items'
16
11
 
17
12
  export function ItemCatalog({
18
13
  category,
@@ -41,9 +36,9 @@ export function ItemCatalog({
41
36
  const setMode = useEditor((state) => state.setMode)
42
37
  const setTool = useEditor((state) => state.setTool)
43
38
 
44
- const sourceItems = itemsOverride ?? CATALOG_ITEMS
39
+ const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS
45
40
  // Server-provided results bypass all local filtering; otherwise filter by category/search/tags
46
- const filteredItems =
41
+ const filteredItems: CatalogItem[] =
47
42
  overrideItems ??
48
43
  (() => {
49
44
  const categoryItems = search
@@ -58,22 +53,6 @@ export function ItemCatalog({
58
53
  })
59
54
  })()
60
55
 
61
- const categoryItems = filteredItems
62
-
63
- // Auto-select first item if current selection is not in the filtered list
64
- useEffect(() => {
65
- const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
66
- if (!isCurrentItemInCategory && categoryItems.length > 0) {
67
- setSelectedItem(categoryItems[0] as AssetInput)
68
- }
69
- }, [categoryItems, selectedItem?.src, setSelectedItem])
70
-
71
- const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
72
- if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
73
- if (attachTo === 'ceiling') return '/icons/ceiling.png'
74
- return null
75
- }
76
-
77
56
  if (filteredItems.length === 0 && emptyState) {
78
57
  return <>{emptyState}</>
79
58
  }
@@ -86,7 +65,7 @@ export function ItemCatalog({
86
65
  {leadingTile}
87
66
  {filteredItems.map((item, index) => {
88
67
  const isSelected = selectedItem?.src === item?.src
89
- const attachmentIcon = getAttachmentIcon(item?.attachTo)
68
+ const snapTarget = resolveAssetSnapTarget(item?.attachTo)
90
69
  return (
91
70
  <button
92
71
  className={cn(
@@ -96,8 +75,12 @@ export function ItemCatalog({
96
75
  key={index}
97
76
  onClick={() => {
98
77
  triggerSFX('sfx:menu-click')
78
+ // Drop the current selection before arming placement — keeping
79
+ // it would route shortcuts (rotate & co) to both the ghost and
80
+ // the selected node.
81
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
99
82
  setSelectedItem(item)
100
- setTool('item')
83
+ setTool(item.tool ?? 'item')
101
84
  setMode('build')
102
85
  }}
103
86
  onMouseEnter={() => triggerSFX('sfx:menu-hover')}
@@ -110,14 +93,8 @@ export function ItemCatalog({
110
93
  loading="eager"
111
94
  src={resolveCdnUrl(item.thumbnail) || ''}
112
95
  />
113
- {attachmentIcon && (
114
- <div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
115
- <img
116
- alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
117
- className="h-4 w-4"
118
- src={attachmentIcon}
119
- />
120
- </div>
96
+ {snapTarget && (
97
+ <SnapTargetBadge className="absolute right-1 bottom-1" target={snapTarget} />
121
98
  )}
122
99
  </div>
123
100
  <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
@@ -3,7 +3,7 @@
3
3
  import type { LevelNode } from '@pascal-app/core'
4
4
  import { useEffect, useState } from 'react'
5
5
  import type { LevelDuplicatePreset } from '../../lib/level-duplication'
6
- import { getLevelDisplayName } from '../../lib/level-name'
6
+ import { getLevelDisplayName } from '@pascal-app/core'
7
7
  import { cn } from '../../lib/utils'
8
8
  import {
9
9
  Dialog,
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Copy, Trash2 } from 'lucide-react'
6
+ import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
7
+ import { ActionButton, ActionGroup } from '../controls/action-button'
8
+ import { PanelWrapper } from './panel-wrapper'
9
+ import { formatSelectionBreakdown } from './selection-breakdown'
10
+
11
+ /**
12
+ * Docked right-side panel for a MULTI-selection — the compact sibling of the
13
+ * single-node inspector, rendered by `PanelManager` when more than one node
14
+ * is selected. Same collapsed-by-default `PanelWrapper` shell (header always
15
+ * visible; the shared desktop collapse state carries across single ↔ multi
16
+ * swaps). Actions mirror the floating group pill: Duplicate clones the
17
+ * selection and picks the clones up, Delete removes the whole selection
18
+ * (including its bulk-delete confirm). Unlike the pill, the docked panel
19
+ * stays visible during interactions. `footer` is the host-injected slot
20
+ * (e.g. community's "Save to my catalog").
21
+ */
22
+ export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
23
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
24
+ const setSelection = useViewer((s) => s.setSelection)
25
+ // String selector — recomputed on scene ticks, but the === compare keeps
26
+ // unrelated mutations from re-rendering the panel.
27
+ const breakdown = useScene((s) =>
28
+ formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)),
29
+ )
30
+
31
+ return (
32
+ <PanelWrapper
33
+ footer={footer}
34
+ icon="/icons/select.webp"
35
+ onClose={() => setSelection({ selectedIds: [] })}
36
+ title={`${selectedIds.length} selected`}
37
+ width={320}
38
+ >
39
+ {breakdown && <div className="px-3 py-3 text-muted-foreground text-xs">{breakdown}</div>}
40
+ <div className="border-border/50 border-t p-3">
41
+ <ActionGroup>
42
+ <ActionButton
43
+ icon={<Copy className="h-4 w-4" />}
44
+ label="Duplicate"
45
+ onClick={() => duplicateSelectionAndPickUp()}
46
+ />
47
+ <ActionButton
48
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
49
+ icon={<Trash2 className="h-4 w-4" />}
50
+ label="Delete"
51
+ onClick={() => deleteSelection()}
52
+ />
53
+ </ActionGroup>
54
+ </div>
55
+ </PanelWrapper>
56
+ )
57
+ }
@@ -6,26 +6,26 @@ export type NodeDisplay = {
6
6
  }
7
7
 
8
8
  const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
9
- item: { icon: '/icons/furniture.png', label: 'Item' },
10
- wall: { icon: '/icons/wall.png', label: 'Wall' },
11
- door: { icon: '/icons/door.png', label: 'Door' },
12
- window: { icon: '/icons/window.png', label: 'Window' },
13
- slab: { icon: '/icons/floor.png', label: 'Slab' },
14
- ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
15
- column: { icon: '/icons/column.png', label: 'Column' },
16
- elevator: { icon: '/icons/elevator.png', label: 'Elevator' },
17
- fence: { icon: '/icons/fence.png', label: 'Fence' },
18
- roof: { icon: '/icons/roof.png', label: 'Roof' },
19
- 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
20
- stair: { icon: '/icons/stair.png', label: 'Stair' },
21
- 'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
22
- scan: { icon: '/icons/mesh.png', label: '3D Scan' },
23
- guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
9
+ item: { icon: '/icons/item.webp', label: 'Item' },
10
+ wall: { icon: '/icons/wall.webp', label: 'Wall' },
11
+ door: { icon: '/icons/door.webp', label: 'Door' },
12
+ window: { icon: '/icons/window.webp', label: 'Window' },
13
+ slab: { icon: '/icons/floor.webp', label: 'Slab' },
14
+ ceiling: { icon: '/icons/ceiling.webp', label: 'Ceiling' },
15
+ column: { icon: '/icons/column.webp', label: 'Column' },
16
+ elevator: { icon: '/icons/elevator.webp', label: 'Elevator' },
17
+ fence: { icon: '/icons/fence.webp', label: 'Fence' },
18
+ roof: { icon: '/icons/roof.webp', label: 'Roof' },
19
+ 'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' },
20
+ stair: { icon: '/icons/stairs.webp', label: 'Stair' },
21
+ 'stair-segment': { icon: '/icons/stairs.webp', label: 'Stair segment' },
22
+ scan: { icon: '/icons/mesh.webp', label: '3D Scan' },
23
+ guide: { icon: '/icons/floorplan.webp', label: 'Guide image' },
24
24
  }
25
25
 
26
26
  export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
27
- if (!node) return { icon: '/icons/select.png', label: 'Selection' }
28
- const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
27
+ if (!node) return { icon: '/icons/select.webp', label: 'Selection' }
28
+ const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type }
29
29
  // Item nodes carry an asset with its own thumbnail/name
30
30
  if (node.type === 'item') {
31
31
  return {
@@ -28,8 +28,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
28
28
  import useEditor from '../../../store/use-editor'
29
29
  import { MobilePanelSheet } from './mobile-panel-sheet'
30
30
  import { MobileSelectionBar } from './mobile-selection-bar'
31
+ import { MultiSelectionPanel } from './multi-selection-panel'
31
32
  import { getNodeDisplay } from './node-display'
32
- import { PaintPanel } from './paint-panel'
33
+ import { resetDesktopInspectorCollapsed } from './panel-wrapper'
33
34
  import { ParametricInspector } from './parametric-inspector'
34
35
  import { ReferencePanel } from './reference-panel'
35
36
 
@@ -168,15 +169,18 @@ function MobilePanelLayer({
168
169
  )
169
170
  }
170
171
 
171
- export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
172
+ export function PanelManager({
173
+ inspectorFooter,
174
+ multiSelectionFooter,
175
+ }: {
176
+ inspectorFooter?: React.ReactNode
177
+ multiSelectionFooter?: React.ReactNode
178
+ }) {
172
179
  const isMobile = useIsMobile()
173
180
  const selectedIds = useViewer((s) => s.selection.selectedIds)
174
181
  const selectedZoneId = useViewer((s) => s.selection.zoneId)
175
182
  const setSelection = useViewer((s) => s.setSelection)
176
183
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
177
- const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
178
- const mode = useEditor((s) => s.mode)
179
- const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
180
184
  // Only subscribe to the *type* of the single-selected node — string primitive
181
185
  // so we don't re-render on unrelated scene mutations.
182
186
  const selectedNodeType = useScene((s) => {
@@ -190,6 +194,27 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
190
194
  return id ? (s.nodes[id as AnyNodeId] ?? null) : null
191
195
  })
192
196
 
197
+ // Node and reference selection are mutually exclusive: selecting a guide
198
+ // clears the node selection (handleGuideSelect), but node selection never
199
+ // cleared a lingering reference — so clicking a wall with a floorplan
200
+ // selected kept showing the reference panel. Clear the stale reference the
201
+ // moment a scene selection appears.
202
+ const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
203
+ useEffect(() => {
204
+ if (selectedIds.length > 0 || selectedZoneId) {
205
+ setSelectedReferenceId(null)
206
+ }
207
+ }, [selectedIds, selectedZoneId, setSelectedReferenceId])
208
+
209
+ // The inspector's expanded state is shared across panel swaps, but a fresh
210
+ // selection after everything was deselected should open collapsed again.
211
+ const hasAnySelection = selectedIds.length > 0 || Boolean(selectedZoneId) || Boolean(selectedReferenceId)
212
+ useEffect(() => {
213
+ if (!hasAnySelection) {
214
+ resetDesktopInspectorCollapsed()
215
+ }
216
+ }, [hasAnySelection])
217
+
193
218
  if (isMobile) {
194
219
  if (selectedReferenceId) {
195
220
  return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
@@ -208,15 +233,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
208
233
  return <ReferencePanel />
209
234
  }
210
235
 
211
- if (
212
- isPaintPanelOpen &&
213
- mode === 'material-paint' &&
214
- activePaintMaterial?.material?.properties &&
215
- !activePaintMaterial.materialPreset
216
- ) {
217
- return <PaintPanel />
218
- }
219
-
220
236
  if (selectedZoneId && selectedIds.length === 0) {
221
237
  return (
222
238
  <ParametricInspector
@@ -228,5 +244,11 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
228
244
  )
229
245
  }
230
246
 
247
+ // Multi-selection: compact docked panel (desktop only — the mobile branch
248
+ // above keeps today's behavior and renders nothing for multi-selections).
249
+ if (selectedIds.length > 1) {
250
+ return <MultiSelectionPanel footer={multiSelectionFooter} />
251
+ }
252
+
231
253
  return panelForType(selectedNodeType, inspectorFooter)
232
254
  }
@@ -17,6 +17,14 @@ const DRAG_MARGIN = 8
17
17
  // Pointer travel (px) below which a header press is treated as a click
18
18
  // (toggles collapse) rather than a drag.
19
19
  const CLICK_SLOP = 4
20
+ let desktopInspectorCollapsed = true
21
+
22
+ /** Forget the shared expanded state. Called when the last selection clears so
23
+ * a fresh selection opens the inspector collapsed — the sharing is only meant
24
+ * to survive swaps between panels (roof ↔ segment), not a close/reopen. */
25
+ export function resetDesktopInspectorCollapsed() {
26
+ desktopInspectorCollapsed = true
27
+ }
20
28
 
21
29
  function clamp(value: number, min: number, max: number): number {
22
30
  return Math.min(Math.max(value, min), Math.max(min, max))
@@ -52,7 +60,7 @@ export const InspectorFooterContext = createContext<React.ReactNode>(null)
52
60
 
53
61
  interface PanelWrapperProps {
54
62
  title: string
55
- /** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
63
+ /** Either a URL path (legacy panels pass `/icons/floor.webp` etc.,
56
64
  * rendered via next/image) OR a React node (registry-driven
57
65
  * inspector renders `<Icon icon="lucide:fence" />` from
58
66
  * `def.presentation.icon`). */
@@ -85,8 +93,21 @@ export function PanelWrapper({
85
93
  const panelRef = useRef<HTMLDivElement>(null)
86
94
 
87
95
  // The whole panel is collapsed to just its header by default; the chevron
88
- // expands it to reveal the inspector body.
89
- const [collapsed, setCollapsed] = useState(true)
96
+ // expands it to reveal the inspector body. Keep the desktop value shared
97
+ // across inspector swaps (roof ↔ segment, etc.) so navigating between
98
+ // related panels preserves whether the user left the inspector open.
99
+ const [collapsed, setCollapsedState] = useState(desktopInspectorCollapsed)
100
+
101
+ const setCollapsed = useCallback(
102
+ (next: boolean | ((previous: boolean) => boolean)) => {
103
+ setCollapsedState((previous) => {
104
+ const resolved = typeof next === 'function' ? next(previous) : next
105
+ desktopInspectorCollapsed = resolved
106
+ return resolved
107
+ })
108
+ },
109
+ [],
110
+ )
90
111
 
91
112
  // Drag-to-reposition from the header. `offset` is a translation applied on
92
113
  // top of the default `top-20 right-4` anchor; null until first dragged.
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
14
14
  import { Icon } from '@iconify/react'
15
15
  import { Move, Trash2 } from 'lucide-react'
16
16
  import { type ComponentType, lazy, Suspense, useCallback } from 'react'
17
+ import { resolveMoveActionNode } from '../../../lib/direct-manipulation'
17
18
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
19
  import { collectZoneContentIds } from '../../../lib/zone-content'
19
20
  import useEditor from '../../../store/use-editor'
@@ -62,9 +63,22 @@ export function ParametricInspector({
62
63
  const handleUpdate = useCallback(
63
64
  (patch: Partial<AnyNode>) => {
64
65
  if (!selectedId) return
65
- useScene.getState().updateNode(selectedId, patch)
66
+ const scene = useScene.getState()
67
+ const node = scene.nodes[selectedId]
68
+ if (parametrics?.derive && node) {
69
+ const next = { ...node, ...patch } as AnyNode
70
+ patch = { ...patch, ...parametrics.derive(next, patch) }
71
+ }
72
+ // Bundle the edited node + any reconcile follow-ups into ONE
73
+ // updateNodes call so a single inspector edit is a single undo step.
74
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [{ id: selectedId, data: patch }]
75
+ if (parametrics?.reconcile && node) {
76
+ const next = { ...node, ...patch } as AnyNode
77
+ updates.push(...parametrics.reconcile(node as AnyNode, next))
78
+ }
79
+ scene.updateNodes(updates)
66
80
  },
67
- [selectedId],
81
+ [selectedId, parametrics],
68
82
  )
69
83
 
70
84
  const clearSelection = useCallback(() => {
@@ -77,10 +91,11 @@ export function ParametricInspector({
77
91
 
78
92
  const handleMove = useCallback(() => {
79
93
  if (!selectedId) return
80
- const node = useScene.getState().nodes[selectedId]
94
+ const sceneNodes = useScene.getState().nodes
95
+ const node = sceneNodes[selectedId]
81
96
  if (!node) return
82
97
  sfxEmitter.emit('sfx:item-pick')
83
- useEditor.getState().setMovingNode(node as any)
98
+ useEditor.getState().setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
84
99
  clearSelection()
85
100
  }, [selectedId, clearSelection])
86
101
 
@@ -22,6 +22,7 @@ import {
22
22
  import { useCallback, useEffect, useRef, useState } from 'react'
23
23
  import { guideEmitter } from '../../../lib/guide-events'
24
24
  import { getGuideImageName } from '../../../lib/local-guide-image'
25
+ import { cn } from '../../../lib/utils'
25
26
  import useEditor from '../../../store/use-editor'
26
27
  import { ActionButton, ActionGroup } from '../controls/action-button'
27
28
  import { PanelSection } from '../controls/panel-section'
@@ -60,6 +61,9 @@ export function ReferencePanel() {
60
61
  ? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
61
62
  : undefined,
62
63
  )
64
+ const isScaleFlowActive = useEditor(
65
+ (s) => s.referenceScaleActiveGuideId !== null && s.referenceScaleActiveGuideId === node?.id,
66
+ )
63
67
 
64
68
  const handleUpdate = useCallback(
65
69
  (updates: Partial<ReferenceNode>) => {
@@ -98,6 +102,9 @@ export function ReferencePanel() {
98
102
  } as Partial<GuideNode>,
99
103
  )
100
104
  setGuideScaleReferenceVisible(selectedReferenceId, true)
105
+ // The new image starts uncalibrated — drop the calibration auto-lock
106
+ // so it can be resized/rotated right away.
107
+ setGuideLocked(selectedReferenceId, false)
101
108
  } catch {
102
109
  setReplaceError('Could not replace that image.')
103
110
  } finally {
@@ -123,6 +130,13 @@ export function ReferencePanel() {
123
130
  return
124
131
  }
125
132
 
133
+ // The scale line is drawn on the 2D plan — starting from a 3D-only view
134
+ // would arm the flow invisibly inside the hidden floorplan panel.
135
+ const editor = useEditor.getState()
136
+ if (editor.viewMode === '3d') {
137
+ editor.setViewMode('2d')
138
+ }
139
+
126
140
  guideEmitter.emit('guide:set-reference-scale', { guideId: node.id })
127
141
  }, [node])
128
142
 
@@ -233,33 +247,54 @@ export function ReferencePanel() {
233
247
 
234
248
  <PanelSection title="Reference Scale">
235
249
  <div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
236
- <Ruler className="h-4 w-4 shrink-0 text-primary" />
250
+ <Ruler
251
+ className={cn(
252
+ 'h-4 w-4 shrink-0',
253
+ node.scaleReference ? 'text-primary' : 'text-amber-600 dark:text-amber-400',
254
+ )}
255
+ />
237
256
  <span className="truncate text-muted-foreground">{scaleStatus}</span>
238
257
  </div>
239
258
 
240
- <ActionGroup>
241
- <ActionButton
242
- label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
243
- onClick={handleStartScale}
244
- />
245
- <ActionButton label="Cancel" onClick={handleCancelScale} />
246
- </ActionGroup>
259
+ {!node.scaleReference && (
260
+ <p className="px-0.5 text-muted-foreground text-xs leading-snug">
261
+ {isScaleFlowActive
262
+ ? 'Click both ends of a known distance on the plan, then type its real length.'
263
+ : 'Draw a line over a known dimension on the plan, then type its real length to scale the image exactly.'}
264
+ </p>
265
+ )}
247
266
 
248
267
  <ActionGroup>
249
268
  <ActionButton
250
- disabled={!node.scaleReference}
251
- label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
252
- onClick={() => {
253
- if (!node.scaleReference) return
254
- setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
255
- }}
256
- />
257
- <ActionButton
258
- disabled={!node.scaleReference}
259
- label="Clear Scale"
260
- onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
269
+ className={cn(
270
+ !node.scaleReference &&
271
+ !isScaleFlowActive &&
272
+ 'border-primary/50 bg-primary/15 text-primary hover:bg-primary/25 active:bg-primary/25',
273
+ )}
274
+ label={
275
+ isScaleFlowActive ? 'Cancel' : node.scaleReference ? 'Edit Scale' : 'Set Scale'
276
+ }
277
+ onClick={isScaleFlowActive ? handleCancelScale : handleStartScale}
261
278
  />
262
279
  </ActionGroup>
280
+
281
+ {node.scaleReference && (
282
+ <ActionGroup>
283
+ <ActionButton
284
+ label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
285
+ onClick={() => setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)}
286
+ />
287
+ <ActionButton
288
+ label="Clear Scale"
289
+ onClick={() => {
290
+ handleUpdate({ scaleReference: null } as Partial<GuideNode>)
291
+ // Calibrating auto-locked the guide; clearing the scale
292
+ // returns it to a freely-editable reference.
293
+ setGuideLocked(node.id, false)
294
+ }}
295
+ />
296
+ </ActionGroup>
297
+ )}
263
298
  </PanelSection>
264
299
 
265
300
  <PanelSection title="Quick Actions">
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { formatSelectionBreakdown } from './selection-breakdown'
3
+
4
+ describe('formatSelectionBreakdown', () => {
5
+ test('counts per type in first-appearance order, pluralizing with +s', () => {
6
+ expect(formatSelectionBreakdown(['slab', 'stair', 'fence', 'fence'])).toBe(
7
+ '1 slab · 1 stair · 2 fences',
8
+ )
9
+ })
10
+
11
+ test('humanizes hyphenated kinds', () => {
12
+ expect(formatSelectionBreakdown(['roof-segment', 'roof-segment', 'wall'])).toBe(
13
+ '2 roof segments · 1 wall',
14
+ )
15
+ })
16
+
17
+ test('skips missing nodes', () => {
18
+ expect(formatSelectionBreakdown(['wall', undefined, null])).toBe('1 wall')
19
+ })
20
+
21
+ test('empty selection formats to an empty string', () => {
22
+ expect(formatSelectionBreakdown([])).toBe('')
23
+ })
24
+ })
@@ -0,0 +1,20 @@
1
+ /**
2
+ * "1 slab · 1 stair · 2 fences" — one entry per node type in first-appearance
3
+ * order so the line stays stable while shift-clicking. Labels derive from the
4
+ * type id ('roof-segment' → 'roof segment'); pluralization is a simple +s
5
+ * (the codebase has no pluralize helper and no current kind needs one).
6
+ * Missing nodes (stale ids) are skipped.
7
+ */
8
+ export function formatSelectionBreakdown(types: Array<string | null | undefined>): string {
9
+ const counts = new Map<string, number>()
10
+ for (const type of types) {
11
+ if (!type) continue
12
+ counts.set(type, (counts.get(type) ?? 0) + 1)
13
+ }
14
+ const parts: string[] = []
15
+ for (const [type, count] of counts) {
16
+ const label = type.replace(/-/g, ' ')
17
+ parts.push(`${count} ${count === 1 ? label : `${label}s`}`)
18
+ }
19
+ return parts.join(' · ')
20
+ }
@@ -22,6 +22,17 @@ const MOUSE_SHORTCUTS = {
22
22
  },
23
23
  } as const
24
24
 
25
+ // The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we
26
+ // render the symbol for the *current* device so the hint reads native (⌘ on Mac,
27
+ // Ctrl elsewhere) without implying only one of them works.
28
+ const COMMAND_VALUES = new Set(['Cmd/Ctrl', 'Cmd', 'Command', 'Meta'])
29
+
30
+ // Resolved once on the client at module load — the editor HUD is client-only, so
31
+ // there's no server render to mismatch against. `navigator.platform` is enough
32
+ // here and matches the detection used elsewhere (floorplan rotate hint).
33
+ const IS_MAC =
34
+ typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC')
35
+
25
36
  type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
26
37
  value: string
27
38
  displayValue?: string
@@ -30,16 +41,23 @@ type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
30
41
  function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) {
31
42
  const mouseShortcut =
32
43
  value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
44
+ const isCommand = COMMAND_VALUES.has(value)
45
+ const isShift = value === 'Shift'
46
+ const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
47
+ const commandLabel = IS_MAC ? 'Command' : 'Control'
33
48
 
34
49
  return (
35
50
  <kbd
36
- aria-label={mouseShortcut?.label ?? displayValue ?? value}
51
+ aria-label={
52
+ mouseShortcut?.label ??
53
+ (isCommand ? commandLabel : isShift ? 'Shift' : (displayValue ?? value))
54
+ }
37
55
  className={cn(
38
56
  'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground',
39
- mouseShortcut && 'justify-center px-1.5',
57
+ (mouseShortcut || isShift) && 'justify-center px-1.5',
40
58
  className,
41
59
  )}
42
- title={mouseShortcut?.label ?? value}
60
+ title={mouseShortcut?.label ?? (isCommand ? commandLabel : isShift ? 'Shift' : value)}
43
61
  {...props}
44
62
  >
45
63
  {mouseShortcut ? (
@@ -54,6 +72,24 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
54
72
  />
55
73
  <span className="sr-only">{mouseShortcut.label}</span>
56
74
  </>
75
+ ) : isShift ? (
76
+ // Icon rather than the ⇧ text glyph — the font renders the glyph's
77
+ // crossbar too high to read as the shift key symbol.
78
+ <>
79
+ <Icon
80
+ aria-hidden="true"
81
+ className="shrink-0"
82
+ color="currentColor"
83
+ height={13}
84
+ icon="ph:arrow-fat-up"
85
+ width={13}
86
+ />
87
+ <span className="sr-only">Shift</span>
88
+ </>
89
+ ) : isCommand ? (
90
+ // The ⌘ glyph reads small next to letters at the same font size, so bump
91
+ // it up a touch on Mac. "Ctrl" stays at the token's normal size.
92
+ <span className={IS_MAC ? 'text-[13px] leading-none' : undefined}>{commandDisplay}</span>
57
93
  ) : (
58
94
  (displayValue ?? value)
59
95
  )}
@@ -63,6 +63,7 @@ export const useSidebarStore = create<SidebarStore>()(
63
63
  {
64
64
  name: 'sidebar-preferences',
65
65
  partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }),
66
+ skipHydration: true,
66
67
  },
67
68
  ),
68
69
  )
@@ -16,6 +16,7 @@ import useEditor from './../../../store/use-editor'
16
16
  import { type ExtraPanel, IconRail } from './icon-rail'
17
17
  import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
18
18
  import { SitePanel, type SitePanelProps } from './panels/site-panel'
19
+ import { useHostPanels } from './use-plugin-panels'
19
20
 
20
21
  interface AppSidebarProps {
21
22
  appMenuButton?: ReactNode
@@ -31,9 +32,12 @@ export function AppSidebar({
31
32
  sidebarTop,
32
33
  settingsPanelProps,
33
34
  sitePanelProps,
34
- extraPanels,
35
+ extraPanels: hostExtraPanels,
35
36
  commandPaletteEmptyAction,
36
37
  }: AppSidebarProps) {
38
+ // Host-provided panels merged with registered host panels — the icon rail and
39
+ // content area treat both identically.
40
+ const extraPanels = useHostPanels(hostExtraPanels)
37
41
  const activePanel = useEditor((s) => s.activeSidebarPanel)
38
42
  const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
39
43
  const hasActivePanel =