@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
@@ -7,15 +7,26 @@ import { Html } from '@react-three/drei'
7
7
  import { createPortal, useFrame, useThree } from '@react-three/fiber'
8
8
  import { useCallback, useMemo, useRef, useState } from 'react'
9
9
  import { type Camera, type Object3D, Vector3 } from 'three'
10
+ import { groundHeightAt } from '../../lib/ground-surface'
11
+ import { formatLinearMeasurement } from '../../lib/measurements'
10
12
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
11
- import useEditor from '../../store/use-editor'
12
- import { formatMeasurement } from './measurement-pill'
13
+ import { useActiveHandleDrag } from '../../store/use-interaction-scope'
13
14
 
14
15
  type ViewportSize = {
15
16
  width: number
16
17
  height: number
17
18
  }
18
19
 
20
+ /**
21
+ * How far the label floats above the ground under it.
22
+ *
23
+ * Was a bare `0.5` in the position literal, which on flat ground is the same thing.
24
+ * Named now because it is no longer the label's *height* but its clearance: on a
25
+ * sculpted lot the number underneath moves, and half a metre is what keeps the text
26
+ * clear of a rim the boundary line is draped over.
27
+ */
28
+ const LABEL_LIFT = 0.5
29
+
19
30
  const htmlPosition = new Vector3()
20
31
 
21
32
  function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) {
@@ -37,7 +48,7 @@ export function SiteEdgeLabels() {
37
48
  const node = state.nodes[firstRoot]
38
49
  return node?.type === 'site' ? (node as SiteNode) : null
39
50
  })
40
- const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
51
+ const activeHandleDrag = useActiveHandleDrag()
41
52
  const unit = useViewer((state) => state.unit)
42
53
  const cameraMode = useViewer((state) => state.cameraMode)
43
54
  const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
@@ -78,14 +89,25 @@ export function SiteEdgeLabels() {
78
89
  if (obj) setSiteObj(obj)
79
90
  })
80
91
 
92
+ // No live-terrain subscription here, deliberately: activating a site-boundary
93
+ // handle exits sculpt mode before the handle-drag scope begins, so the ground
94
+ // cannot move while these labels are on screen. The drag itself rewrites
95
+ // `polygon` every frame, which re-runs this memo against the current field.
81
96
  const edges = useMemo(() => {
82
97
  if (polygon.length < 2) return []
83
98
  return polygon.map(([x1, z1], i) => {
84
99
  const [x2, z2] = polygon[(i + 1) % polygon.length]!
85
100
  const midX = (x1! + x2) / 2
86
101
  const midZ = (z1! + z2) / 2
102
+ // Length stays the *plan* length. A property line is a horizontal measurement
103
+ // — a deed says 30 m whether the lot is flat or a hillside — and reporting the
104
+ // slope distance would make the same lot appear to grow as it is sculpted.
105
+ // Only the label's Y follows the ground.
87
106
  const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
88
- return { midX, midZ, dist }
107
+ // `?? 0` covers both no-terrain cases, and 0 *is* the datum, so this reduces
108
+ // to the flat constant it replaced whenever the lot is unsculpted.
109
+ const groundY = groundHeightAt(midX, midZ, 0) ?? 0
110
+ return { midX, midY: groundY + LABEL_LIFT, midZ, dist }
89
111
  })
90
112
  }, [polygon])
91
113
 
@@ -99,7 +121,7 @@ export function SiteEdgeLabels() {
99
121
  calculatePosition={calculateLabelPosition}
100
122
  key={`${cameraMode}-${camera.uuid}-edge-${i}`}
101
123
  occlude
102
- position={[edge.midX, 0.5, edge.midZ]}
124
+ position={[edge.midX, edge.midY, edge.midZ]}
103
125
  style={{ pointerEvents: 'none', userSelect: 'none' }}
104
126
  zIndexRange={[10, 0]}
105
127
  >
@@ -110,7 +132,7 @@ export function SiteEdgeLabels() {
110
132
  textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
111
133
  }}
112
134
  >
113
- {formatMeasurement(edge.dist, unit)}
135
+ {formatLinearMeasurement(edge.dist, unit)}
114
136
  </div>
115
137
  </Html>
116
138
  ))}
@@ -25,7 +25,9 @@ import {
25
25
  } from 'three'
26
26
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
27
27
  import { EDITOR_LAYER } from '../../lib/constants'
28
+ import { holeEditScope } from '../../lib/interaction/scope'
28
29
  import useEditor from '../../store/use-editor'
30
+ import useInteractionScope, { useEditingHole } from '../../store/use-interaction-scope'
29
31
  import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
30
32
  import { swallowNextClick } from './handles/use-handle-drag'
31
33
 
@@ -239,7 +241,9 @@ export function SlabHoleHighlights() {
239
241
  }
240
242
  }, [hoveredHole, selectedIds, setHoveredHole])
241
243
 
242
- if (selectedIds.length === 0) return null
244
+ // Sole selection only hole outlines are click-to-edit chrome, and a
245
+ // multi-selection shows highlight only (the group transforms as one piece).
246
+ if (selectedIds.length !== 1) return null
243
247
 
244
248
  return (
245
249
  <>
@@ -254,7 +258,7 @@ function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
254
258
  const node = useScene((state) => state.nodes[slabId as AnyNodeId])
255
259
  const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
256
260
  const hoveredHole = useEditor((state) => state.hoveredHole)
257
- const editingHole = useEditor((state) => state.editingHole)
261
+ const editingHole = useEditingHole()
258
262
  const setHoveredHole = useEditor((state) => state.setHoveredHole)
259
263
 
260
264
  const slab = node?.type === 'slab' ? (node as SlabNode) : null
@@ -389,21 +393,25 @@ function SlabHoleHighlight({
389
393
  // user edits the source rather than the synced hole. Everything else —
390
394
  // manual holes and holes that predate holeMetadata — opens the editor.
391
395
  if (metadata?.source === 'stair' && metadata.stairId) {
392
- useEditor.getState().setEditingHole(null)
396
+ useInteractionScope
397
+ .getState()
398
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
393
399
  useEditor.getState().setHoveredHole(null)
394
400
  resetPointerCursor()
395
401
  selectOwnedNode(metadata.stairId, 'stair')
396
402
  return
397
403
  }
398
404
  if (metadata?.source === 'elevator' && metadata.elevatorId) {
399
- useEditor.getState().setEditingHole(null)
405
+ useInteractionScope
406
+ .getState()
407
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
400
408
  useEditor.getState().setHoveredHole(null)
401
409
  resetPointerCursor()
402
410
  selectOwnedNode(metadata.elevatorId, 'elevator')
403
411
  return
404
412
  }
405
413
 
406
- useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex })
414
+ useInteractionScope.getState().begin(holeEditScope({ nodeId: slabId, holeIndex }))
407
415
  useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
408
416
  },
409
417
  [holeIndex, metadata, slabId],
@@ -1,17 +1,20 @@
1
1
  'use client'
2
2
 
3
3
  import { emitter } from '@pascal-app/core'
4
- import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
4
+ import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
5
5
  import { useCallback, useEffect, useRef, useState } from 'react'
6
6
  import { useIsMobile } from '../../hooks/use-mobile'
7
7
  import { triggerSFX } from '../../lib/sfx-bus'
8
- import useEditor from '../../store/use-editor'
9
-
10
- // Local crop-mode enum — distinct from `useEditor.captureMode` (which
11
- // describes *why* a capture is happening, e.g. `preset`). This one says
12
- // HOW the captured pixels are cropped: full-frame 16:9 (`standard`),
13
- // raw canvas viewport, or user-dragged area.
14
- type CropMode = 'standard' | 'viewport' | 'area'
8
+ import useEditor, {
9
+ type SnapshotCropMode,
10
+ type SnapshotStandardAspect,
11
+ } from '../../store/use-editor'
12
+
13
+ // Local alias distinct from `useEditor.captureMode` (which describes *why*
14
+ // a capture is happening, e.g. `preset`). This one says HOW the captured
15
+ // pixels are cropped: full-frame 16:9 (`standard`), raw canvas viewport, or
16
+ // user-dragged area. Hosts can preselect it via `captureMode.crop`.
17
+ type CropMode = SnapshotCropMode
15
18
  type CaptureState = 'idle' | 'capturing' | 'saved'
16
19
 
17
20
  interface DragPoint {
@@ -24,12 +27,23 @@ interface Drag {
24
27
  end: DragPoint
25
28
  }
26
29
 
30
+ // Output presets for `standard` captures — long edge stays near 1920.
31
+ const STANDARD_SIZES: Record<SnapshotStandardAspect, { w: number; h: number }> = {
32
+ '16:9': { w: 1920, h: 1080 },
33
+ '9:16': { w: 1080, h: 1920 },
34
+ '4:3': { w: 1920, h: 1440 },
35
+ '3:4': { w: 1440, h: 1920 },
36
+ '1:1': { w: 1440, h: 1440 },
37
+ }
38
+ type StandardAspect = SnapshotStandardAspect
39
+
27
40
  function getResolution(
28
41
  mode: CropMode,
29
42
  overlayEl: HTMLDivElement | null,
30
43
  drag: Drag | null,
44
+ standardAspect: StandardAspect,
31
45
  ): { w: number; h: number } | null {
32
- if (mode === 'standard') return { w: 1920, h: 1080 }
46
+ if (mode === 'standard') return STANDARD_SIZES[standardAspect]
33
47
 
34
48
  if (!overlayEl) return null
35
49
  const rect = overlayEl.getBoundingClientRect()
@@ -49,6 +63,41 @@ function getResolution(
49
63
  return null
50
64
  }
51
65
 
66
+ /** Rule-of-thirds guide rendered inside a framing surface. */
67
+ function ThirdsGrid() {
68
+ return (
69
+ <div
70
+ className="pointer-events-none absolute inset-0"
71
+ style={{
72
+ background: `
73
+ linear-gradient(to right, transparent calc(33.33% - 0.5px), rgba(255,255,255,0.16) 33.33%, transparent calc(33.33% + 0.5px)),
74
+ linear-gradient(to right, transparent calc(66.66% - 0.5px), rgba(255,255,255,0.16) 66.66%, transparent calc(66.66% + 0.5px)),
75
+ linear-gradient(to bottom, transparent calc(33.33% - 0.5px), rgba(255,255,255,0.16) 33.33%, transparent calc(33.33% + 0.5px)),
76
+ linear-gradient(to bottom, transparent calc(66.66% - 0.5px), rgba(255,255,255,0.16) 66.66%, transparent calc(66.66% + 0.5px))`,
77
+ }}
78
+ />
79
+ )
80
+ }
81
+
82
+ /** Accented corner brackets on the framing rect. */
83
+ function CornerAccents() {
84
+ return (
85
+ <>
86
+ <span className="pointer-events-none absolute -top-0.5 -left-0.5 h-5 w-5 rounded-tl-md border-primary border-t-[2.5px] border-l-[2.5px]" />
87
+ <span className="pointer-events-none absolute -right-0.5 -bottom-0.5 h-5 w-5 rounded-br-md border-primary border-r-[2.5px] border-b-[2.5px]" />
88
+ </>
89
+ )
90
+ }
91
+
92
+ const HUD_CHIP_CLASS =
93
+ 'flex flex-col gap-px rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 backdrop-blur-md'
94
+
95
+ const CROP_LABELS: Record<CropMode, string> = {
96
+ standard: 'Standard',
97
+ viewport: 'Viewport',
98
+ area: 'Area',
99
+ }
100
+
52
101
  export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
53
102
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
54
103
  const captureMode = useEditor((s) => s.captureMode)
@@ -58,12 +107,32 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
58
107
  // a transparent background — the user picks framing but not the
59
108
  // crop shape. Matches the unified preset-thumbnail capture flow.
60
109
  const isPreset = captureMode.mode === 'preset'
110
+ const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined
111
+ const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined
112
+ // A host-preselected crop means the host needs that exact output shape
113
+ // (e.g. the publish-cover capture) — hide the crop/aspect switcher.
114
+ const isCropLocked = isPreset || requestedCrop !== undefined
61
115
 
62
116
  const [mode, setMode] = useState<CropMode>('standard')
117
+ const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
118
+ const [aspectMenuOpen, setAspectMenuOpen] = useState(false)
63
119
  const [drag, setDrag] = useState<Drag | null>(null)
64
120
  const [isDragging, setIsDragging] = useState(false)
65
121
  const [captureState, setCaptureState] = useState<CaptureState>('idle')
66
122
  const overlayRef = useRef<HTMLDivElement>(null)
123
+ // Overlay size drives the computed standard-frame rect + resolution HUD.
124
+ const [overlaySize, setOverlaySize] = useState<{ w: number; h: number } | null>(null)
125
+
126
+ useEffect(() => {
127
+ if (!isCaptureMode) return
128
+ const el = overlayRef.current
129
+ if (!el) return
130
+ const update = () => setOverlaySize({ w: el.clientWidth, h: el.clientHeight })
131
+ update()
132
+ const observer = new ResizeObserver(update)
133
+ observer.observe(el)
134
+ return () => observer.disconnect()
135
+ }, [isCaptureMode])
67
136
 
68
137
  // Dismiss on Esc
69
138
  useEffect(() => {
@@ -82,7 +151,9 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
82
151
  // tweak the framing, but they don't have to draw the rect first.
83
152
  useEffect(() => {
84
153
  if (!isCaptureMode) return
85
- setMode(isPreset ? 'area' : 'standard')
154
+ setMode(isPreset ? 'area' : (requestedCrop ?? 'standard'))
155
+ setStandardAspect(requestedAspect ?? '16:9')
156
+ setAspectMenuOpen(false)
86
157
  setIsDragging(false)
87
158
  setCaptureState('idle')
88
159
  if (isPreset && overlayRef.current) {
@@ -97,7 +168,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
97
168
  } else {
98
169
  setDrag(null)
99
170
  }
100
- }, [isCaptureMode, isPreset])
171
+ }, [isCaptureMode, isPreset, requestedCrop, requestedAspect])
101
172
 
102
173
  // Listen for snapshot saved to show feedback then exit
103
174
  useEffect(() => {
@@ -265,16 +336,36 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
265
336
  projectId,
266
337
  captureMode: mode,
267
338
  cropRegion,
339
+ standardSize: mode === 'standard' ? STANDARD_SIZES[standardAspect] : undefined,
268
340
  // In preset mode, the ThumbnailGenerator should keep the alpha
269
341
  // channel transparent so the saved preset thumbnail composes
270
342
  // cleanly onto any palette background.
271
343
  transparent: isPreset,
272
344
  })
273
- }, [captureState, mode, drag, projectId, isPreset])
345
+ }, [captureState, mode, drag, projectId, isPreset, standardAspect])
274
346
 
275
347
  if (!isCaptureMode) return null
276
348
 
277
- const resolution = getResolution(mode, overlayRef.current, drag)
349
+ const resolution = getResolution(mode, overlayRef.current, drag, standardAspect)
350
+
351
+ // Standard mode framing: the output is a center-crop of the canvas to the
352
+ // chosen aspect (see ThumbnailGenerator) — show exactly that region as a
353
+ // letterboxed frame.
354
+ const standardFrame =
355
+ mode === 'standard' && overlaySize
356
+ ? (() => {
357
+ const size = STANDARD_SIZES[standardAspect]
358
+ const targetRatio = size.w / size.h
359
+ const w = Math.min(overlaySize.w, overlaySize.h * targetRatio)
360
+ const h = w / targetRatio
361
+ return {
362
+ left: (overlaySize.w - w) / 2,
363
+ top: (overlaySize.h - h) / 2,
364
+ width: w,
365
+ height: h,
366
+ }
367
+ })()
368
+ : null
278
369
 
279
370
  // Area selection rect (CSS px, relative to overlay)
280
371
  const selectionStyle =
@@ -294,6 +385,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
294
385
 
295
386
  return (
296
387
  <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
388
+ {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
389
+ {standardFrame && (
390
+ <div
391
+ className="pointer-events-none absolute rounded-md border-[1.5px] border-white/85"
392
+ style={{
393
+ ...standardFrame,
394
+ boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
395
+ }}
396
+ >
397
+ <ThirdsGrid />
398
+ <CornerAccents />
399
+ </div>
400
+ )}
401
+
402
+ {/* Viewport mode: the whole canvas is the frame — thirds only */}
403
+ {mode === 'viewport' && <ThirdsGrid />}
404
+
297
405
  {/* Area mode: dim layer + crosshair cursor + drag-to-select.
298
406
  *
299
407
  * Preset mode reuses the same DOM but stays click-through: the
@@ -336,7 +444,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
336
444
  has a pre-staged square, so we never show it there. */}
337
445
  {!selectionStyle && !isPreset && (
338
446
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
339
- <span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
447
+ <span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white backdrop-blur-md">
340
448
  Drag the area you want to capture
341
449
  </span>
342
450
  </div>
@@ -345,6 +453,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
345
453
  {/* Selection rect */}
346
454
  {selectionStyle && (
347
455
  <div
456
+ className="rounded-sm border-[1.5px] border-white/85"
348
457
  style={{
349
458
  position: 'absolute',
350
459
  left: selectionStyle.left,
@@ -352,11 +461,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
352
461
  width: selectionStyle.width,
353
462
  height: selectionStyle.height,
354
463
  pointerEvents: 'none',
355
- boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
356
- border: '2px dashed rgba(255,255,255,0.85)',
357
- background: 'rgba(255,255,255,0.04)',
464
+ boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
465
+ background: 'rgba(255,255,255,0.03)',
358
466
  }}
359
467
  >
468
+ {hasSelection && <ThirdsGrid />}
469
+ <CornerAccents />
360
470
  {/* Corner handles — preset mode locks the frame to the
361
471
  auto-staged centered square; the user adjusts the
362
472
  camera instead. */}
@@ -390,11 +500,34 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
390
500
  </div>
391
501
  )}
392
502
 
503
+ {/* Top-center HUD — what the shot will be. Preset captures are a fixed
504
+ square and carry their own "Frame your item" banner up there. */}
505
+ {!isMobile && !isPreset && (
506
+ <div className="pointer-events-none absolute top-4 left-1/2 flex -translate-x-1/2 gap-2">
507
+ <div className={HUD_CHIP_CLASS}>
508
+ <span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
509
+ Crop
510
+ </span>
511
+ <span className="font-semibold text-white text-xs">
512
+ {isPreset ? 'Preset · square' : CROP_LABELS[mode]}
513
+ </span>
514
+ </div>
515
+ <div className={HUD_CHIP_CLASS}>
516
+ <span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
517
+ Format
518
+ </span>
519
+ <span className="font-semibold text-white text-xs tabular-nums">
520
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
521
+ </span>
522
+ </div>
523
+ </div>
524
+ )}
525
+
393
526
  {/* Top-right dismiss button (icon-only on mobile) */}
394
527
  <div className="pointer-events-auto absolute top-4 right-4">
395
528
  <button
396
529
  aria-label="Close capture mode"
397
- className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
530
+ className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 text-white/80 text-xs backdrop-blur-md transition-colors hover:bg-neutral-950 hover:text-white"
398
531
  onClick={dismiss}
399
532
  type="button"
400
533
  >
@@ -403,104 +536,114 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
403
536
  </button>
404
537
  </div>
405
538
 
406
- {/* Bottom-center mode toolbar */}
407
- <div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
408
- {(() => {
409
- // Preset capture mode locks both the crop shape (square) and
410
- // the transparent-background output hide the per-shape
411
- // mode buttons so the user has nothing to second-guess.
412
- const modeButtons = isPreset ? null : (
413
- <>
414
- <ModeButton
415
- active={mode === 'standard'}
416
- badge="16:9"
417
- icon={<Monitor className="h-3.5 w-3.5" />}
418
- label="Standard"
419
- onClick={() => {
420
- setMode('standard')
421
- setDrag(null)
422
- }}
423
- />
424
- <ModeButton
425
- active={mode === 'viewport'}
426
- icon={<Maximize2 className="h-3.5 w-3.5" />}
427
- label="Viewport"
428
- onClick={() => {
429
- setMode('viewport')
430
- setDrag(null)
431
- }}
432
- />
433
- <ModeButton
434
- active={mode === 'area'}
435
- icon={<Crop className="h-3.5 w-3.5" />}
436
- label="Area"
437
- onClick={() => setMode('area')}
438
- />
439
- </>
440
- )
441
-
442
- const resolutionDisplay = (
443
- <span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
444
- {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
445
- </span>
446
- )
447
-
448
- const captureButton = (
449
- <button
450
- className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
451
- disabled={captureDisabled}
452
- onClick={handleCapture}
453
- type="button"
454
- >
455
- {captureState === 'capturing' ? (
456
- <>
457
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
458
- Capturing
459
- </>
460
- ) : captureState === 'saved' ? (
461
- <>
462
- <Check className="h-3.5 w-3.5" />
463
- Saved
464
- </>
465
- ) : (
466
- <>
467
- <Camera className="h-3.5 w-3.5" />
468
- Capture
469
- </>
470
- )}
471
- </button>
472
- )
473
-
474
- if (isMobile) {
475
- return (
476
- <div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
477
- {modeButtons && (
478
- <div className="flex items-center justify-center gap-1">{modeButtons}</div>
479
- )}
480
- <div
481
- className={
482
- modeButtons
483
- ? 'flex items-center justify-center gap-2 border-white/10 border-t pt-2'
484
- : 'flex items-center justify-center gap-2'
485
- }
486
- >
487
- {resolutionDisplay}
488
- {captureButton}
489
- </div>
539
+ {/* Subtle scrim so the bottom controls stay readable on bright scenes */}
540
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/45 via-black/15 to-transparent" />
541
+
542
+ {/* Bottom-center: crop switcher, caption + shutter */}
543
+ <div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
544
+ {!isCropLocked && (
545
+ <div className="pointer-events-auto relative flex items-center gap-1 rounded-full border border-white/10 bg-neutral-950/85 px-1.5 py-1.5 shadow-xl backdrop-blur-md">
546
+ {/* Clicking Standard while it's active opens the aspect picker */}
547
+ <ModeButton
548
+ active={mode === 'standard'}
549
+ badge={standardAspect}
550
+ icon={<Monitor className="h-3.5 w-3.5" />}
551
+ label={isMobile ? undefined : 'Standard'}
552
+ onClick={() => {
553
+ if (mode === 'standard') setAspectMenuOpen((v) => !v)
554
+ else setAspectMenuOpen(false)
555
+ setMode('standard')
556
+ setDrag(null)
557
+ }}
558
+ />
559
+ {aspectMenuOpen && mode === 'standard' && (
560
+ <div className="absolute bottom-[calc(100%+8px)] left-0 flex gap-1 rounded-full border border-white/10 bg-neutral-950/90 p-1.5 shadow-xl backdrop-blur-md">
561
+ {(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => (
562
+ <button
563
+ className={`rounded-full px-2.5 py-1 font-mono text-[11px] transition-colors ${
564
+ aspect === standardAspect
565
+ ? 'bg-white/15 text-white ring-1 ring-white/20'
566
+ : 'text-white/55 hover:text-white/90'
567
+ }`}
568
+ key={aspect}
569
+ onClick={() => {
570
+ setStandardAspect(aspect)
571
+ setAspectMenuOpen(false)
572
+ }}
573
+ type="button"
574
+ >
575
+ {aspect}
576
+ </button>
577
+ ))}
490
578
  </div>
491
- )
492
- }
579
+ )}
580
+ <ModeButton
581
+ active={mode === 'viewport'}
582
+ icon={<Maximize2 className="h-3.5 w-3.5" />}
583
+ label={isMobile ? undefined : 'Viewport'}
584
+ onClick={() => {
585
+ setMode('viewport')
586
+ setDrag(null)
587
+ setAspectMenuOpen(false)
588
+ }}
589
+ />
590
+ <ModeButton
591
+ active={mode === 'area'}
592
+ icon={<Crop className="h-3.5 w-3.5" />}
593
+ label={isMobile ? undefined : 'Area'}
594
+ onClick={() => {
595
+ setMode('area')
596
+ setAspectMenuOpen(false)
597
+ }}
598
+ />
599
+ {isMobile && (
600
+ <span className="px-2 text-white/50 text-xs tabular-nums">
601
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
602
+ </span>
603
+ )}
604
+ </div>
605
+ )}
606
+
607
+ {/* Preset captures carry their own "Frame your item" banner — the
608
+ snapshot pitch only applies to the studio/reference flow. */}
609
+ {!isMobile && !isPreset && (
610
+ <span className="pointer-events-none max-w-90 rounded-lg border border-white/10 bg-neutral-950/85 px-3.5 py-1.5 text-center text-[11.5px] text-white/85 leading-relaxed backdrop-blur-md">
611
+ A <b className="font-semibold text-white">snapshot</b>
612
+ {' freezes this exact camera angle as a reusable reference for renders & videos.'}
613
+ </span>
614
+ )}
493
615
 
494
- return (
495
- <div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
496
- {modeButtons}
497
- {modeButtons && <div className="mx-1 h-4 w-px bg-white/10" />}
498
- {resolutionDisplay}
499
- <div className="mx-1 h-4 w-px bg-white/10" />
500
- {captureButton}
501
- </div>
502
- )
503
- })()}
616
+ <button
617
+ aria-label={isPreset ? 'Capture' : 'Take snapshot'}
618
+ className="group pointer-events-auto relative grid h-14 w-14 place-items-center rounded-full disabled:opacity-50"
619
+ disabled={captureDisabled}
620
+ onClick={handleCapture}
621
+ type="button"
622
+ >
623
+ <span className="absolute inset-0 rounded-full border-[3px] border-white shadow-lg" />
624
+ <span
625
+ className={`h-10 w-10 rounded-full transition-transform duration-100 ${
626
+ captureState === 'capturing'
627
+ ? 'scale-[0.55] bg-primary'
628
+ : 'bg-white group-hover:scale-90 group-active:scale-75'
629
+ }`}
630
+ />
631
+ {captureState === 'capturing' && (
632
+ <Loader2 className="absolute h-4 w-4 animate-spin text-white" />
633
+ )}
634
+ {captureState === 'saved' && (
635
+ <Check className="absolute h-5 w-5 stroke-3 text-neutral-900" />
636
+ )}
637
+ </button>
638
+ <span className="pointer-events-none font-mono text-[10.5px] text-white uppercase tracking-[0.12em] drop-shadow">
639
+ {captureState === 'capturing'
640
+ ? 'Capturing…'
641
+ : captureState === 'saved'
642
+ ? 'Saved'
643
+ : isPreset
644
+ ? 'Capture'
645
+ : 'Take snapshot'}
646
+ </span>
504
647
  </div>
505
648
  </div>
506
649
  )
@@ -515,7 +658,7 @@ function ModeButton({
515
658
  }: {
516
659
  active: boolean
517
660
  icon: React.ReactNode
518
- label: string
661
+ label?: string
519
662
  badge?: string
520
663
  onClick: () => void
521
664
  }) {
@@ -0,0 +1,22 @@
1
+ import type { Camera, Raycaster } from 'three'
2
+
3
+ // Escape hatch for DOM-level interaction sessions (group pick-up move) that
4
+ // need to raycast the 3D view without living inside the R3F tree. The
5
+ // editor's SelectionManager (always mounted with the canvas) publishes the
6
+ // live camera / raycaster / canvas here; consumers must handle `null`
7
+ // (canvas not mounted yet, or torn down).
8
+ export type EditorThreeContext = {
9
+ camera: Camera
10
+ raycaster: Raycaster
11
+ domElement: HTMLCanvasElement
12
+ }
13
+
14
+ let current: EditorThreeContext | null = null
15
+
16
+ export function setEditorThreeContext(ctx: EditorThreeContext | null) {
17
+ current = ctx
18
+ }
19
+
20
+ export function getEditorThreeContext(): EditorThreeContext | null {
21
+ return current
22
+ }