@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -7,9 +7,9 @@ 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 { formatLinearMeasurement } from '../../lib/measurements'
10
11
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
11
- import useEditor from '../../store/use-editor'
12
- import { formatMeasurement } from './measurement-pill'
12
+ import { useActiveHandleDrag } from '../../store/use-interaction-scope'
13
13
 
14
14
  type ViewportSize = {
15
15
  width: number
@@ -37,7 +37,7 @@ export function SiteEdgeLabels() {
37
37
  const node = state.nodes[firstRoot]
38
38
  return node?.type === 'site' ? (node as SiteNode) : null
39
39
  })
40
- const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
40
+ const activeHandleDrag = useActiveHandleDrag()
41
41
  const unit = useViewer((state) => state.unit)
42
42
  const cameraMode = useViewer((state) => state.cameraMode)
43
43
  const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
@@ -110,7 +110,7 @@ export function SiteEdgeLabels() {
110
110
  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
111
  }}
112
112
  >
113
- {formatMeasurement(edge.dist, unit)}
113
+ {formatLinearMeasurement(edge.dist, unit)}
114
114
  </div>
115
115
  </Html>
116
116
  ))}
@@ -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,29 @@ 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
61
112
 
62
113
  const [mode, setMode] = useState<CropMode>('standard')
114
+ const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
115
+ const [aspectMenuOpen, setAspectMenuOpen] = useState(false)
63
116
  const [drag, setDrag] = useState<Drag | null>(null)
64
117
  const [isDragging, setIsDragging] = useState(false)
65
118
  const [captureState, setCaptureState] = useState<CaptureState>('idle')
66
119
  const overlayRef = useRef<HTMLDivElement>(null)
120
+ // Overlay size drives the computed standard-frame rect + resolution HUD.
121
+ const [overlaySize, setOverlaySize] = useState<{ w: number; h: number } | null>(null)
122
+
123
+ useEffect(() => {
124
+ if (!isCaptureMode) return
125
+ const el = overlayRef.current
126
+ if (!el) return
127
+ const update = () => setOverlaySize({ w: el.clientWidth, h: el.clientHeight })
128
+ update()
129
+ const observer = new ResizeObserver(update)
130
+ observer.observe(el)
131
+ return () => observer.disconnect()
132
+ }, [isCaptureMode])
67
133
 
68
134
  // Dismiss on Esc
69
135
  useEffect(() => {
@@ -82,7 +148,9 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
82
148
  // tweak the framing, but they don't have to draw the rect first.
83
149
  useEffect(() => {
84
150
  if (!isCaptureMode) return
85
- setMode(isPreset ? 'area' : 'standard')
151
+ setMode(isPreset ? 'area' : (requestedCrop ?? 'standard'))
152
+ setStandardAspect(requestedAspect ?? '16:9')
153
+ setAspectMenuOpen(false)
86
154
  setIsDragging(false)
87
155
  setCaptureState('idle')
88
156
  if (isPreset && overlayRef.current) {
@@ -97,7 +165,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
97
165
  } else {
98
166
  setDrag(null)
99
167
  }
100
- }, [isCaptureMode, isPreset])
168
+ }, [isCaptureMode, isPreset, requestedCrop, requestedAspect])
101
169
 
102
170
  // Listen for snapshot saved to show feedback then exit
103
171
  useEffect(() => {
@@ -265,16 +333,36 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
265
333
  projectId,
266
334
  captureMode: mode,
267
335
  cropRegion,
336
+ standardSize: mode === 'standard' ? STANDARD_SIZES[standardAspect] : undefined,
268
337
  // In preset mode, the ThumbnailGenerator should keep the alpha
269
338
  // channel transparent so the saved preset thumbnail composes
270
339
  // cleanly onto any palette background.
271
340
  transparent: isPreset,
272
341
  })
273
- }, [captureState, mode, drag, projectId, isPreset])
342
+ }, [captureState, mode, drag, projectId, isPreset, standardAspect])
274
343
 
275
344
  if (!isCaptureMode) return null
276
345
 
277
- const resolution = getResolution(mode, overlayRef.current, drag)
346
+ const resolution = getResolution(mode, overlayRef.current, drag, standardAspect)
347
+
348
+ // Standard mode framing: the output is a center-crop of the canvas to the
349
+ // chosen aspect (see ThumbnailGenerator) — show exactly that region as a
350
+ // letterboxed frame.
351
+ const standardFrame =
352
+ mode === 'standard' && overlaySize
353
+ ? (() => {
354
+ const size = STANDARD_SIZES[standardAspect]
355
+ const targetRatio = size.w / size.h
356
+ const w = Math.min(overlaySize.w, overlaySize.h * targetRatio)
357
+ const h = w / targetRatio
358
+ return {
359
+ left: (overlaySize.w - w) / 2,
360
+ top: (overlaySize.h - h) / 2,
361
+ width: w,
362
+ height: h,
363
+ }
364
+ })()
365
+ : null
278
366
 
279
367
  // Area selection rect (CSS px, relative to overlay)
280
368
  const selectionStyle =
@@ -294,6 +382,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
294
382
 
295
383
  return (
296
384
  <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
385
+ {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
386
+ {standardFrame && (
387
+ <div
388
+ className="pointer-events-none absolute rounded-md border-[1.5px] border-white/85"
389
+ style={{
390
+ ...standardFrame,
391
+ boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
392
+ }}
393
+ >
394
+ <ThirdsGrid />
395
+ <CornerAccents />
396
+ </div>
397
+ )}
398
+
399
+ {/* Viewport mode: the whole canvas is the frame — thirds only */}
400
+ {mode === 'viewport' && <ThirdsGrid />}
401
+
297
402
  {/* Area mode: dim layer + crosshair cursor + drag-to-select.
298
403
  *
299
404
  * Preset mode reuses the same DOM but stays click-through: the
@@ -336,7 +441,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
336
441
  has a pre-staged square, so we never show it there. */}
337
442
  {!selectionStyle && !isPreset && (
338
443
  <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">
444
+ <span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white backdrop-blur-md">
340
445
  Drag the area you want to capture
341
446
  </span>
342
447
  </div>
@@ -345,6 +450,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
345
450
  {/* Selection rect */}
346
451
  {selectionStyle && (
347
452
  <div
453
+ className="rounded-sm border-[1.5px] border-white/85"
348
454
  style={{
349
455
  position: 'absolute',
350
456
  left: selectionStyle.left,
@@ -352,11 +458,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
352
458
  width: selectionStyle.width,
353
459
  height: selectionStyle.height,
354
460
  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)',
461
+ boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
462
+ background: 'rgba(255,255,255,0.03)',
358
463
  }}
359
464
  >
465
+ {hasSelection && <ThirdsGrid />}
466
+ <CornerAccents />
360
467
  {/* Corner handles — preset mode locks the frame to the
361
468
  auto-staged centered square; the user adjusts the
362
469
  camera instead. */}
@@ -390,11 +497,34 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
390
497
  </div>
391
498
  )}
392
499
 
500
+ {/* Top-center HUD — what the shot will be. Preset captures are a fixed
501
+ square and carry their own "Frame your item" banner up there. */}
502
+ {!isMobile && !isPreset && (
503
+ <div className="pointer-events-none absolute top-4 left-1/2 flex -translate-x-1/2 gap-2">
504
+ <div className={HUD_CHIP_CLASS}>
505
+ <span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
506
+ Crop
507
+ </span>
508
+ <span className="font-semibold text-white text-xs">
509
+ {isPreset ? 'Preset · square' : CROP_LABELS[mode]}
510
+ </span>
511
+ </div>
512
+ <div className={HUD_CHIP_CLASS}>
513
+ <span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
514
+ Format
515
+ </span>
516
+ <span className="font-semibold text-white text-xs tabular-nums">
517
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
518
+ </span>
519
+ </div>
520
+ </div>
521
+ )}
522
+
393
523
  {/* Top-right dismiss button (icon-only on mobile) */}
394
524
  <div className="pointer-events-auto absolute top-4 right-4">
395
525
  <button
396
526
  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"
527
+ 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
528
  onClick={dismiss}
399
529
  type="button"
400
530
  >
@@ -403,104 +533,114 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
403
533
  </button>
404
534
  </div>
405
535
 
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>
536
+ {/* Subtle scrim so the bottom controls stay readable on bright scenes */}
537
+ <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" />
538
+
539
+ {/* Bottom-center: crop switcher, caption + shutter */}
540
+ <div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
541
+ {!isPreset && (
542
+ <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">
543
+ {/* Clicking Standard while it's active opens the aspect picker */}
544
+ <ModeButton
545
+ active={mode === 'standard'}
546
+ badge={standardAspect}
547
+ icon={<Monitor className="h-3.5 w-3.5" />}
548
+ label={isMobile ? undefined : 'Standard'}
549
+ onClick={() => {
550
+ if (mode === 'standard') setAspectMenuOpen((v) => !v)
551
+ else setAspectMenuOpen(false)
552
+ setMode('standard')
553
+ setDrag(null)
554
+ }}
555
+ />
556
+ {aspectMenuOpen && mode === 'standard' && (
557
+ <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">
558
+ {(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => (
559
+ <button
560
+ className={`rounded-full px-2.5 py-1 font-mono text-[11px] transition-colors ${
561
+ aspect === standardAspect
562
+ ? 'bg-white/15 text-white ring-1 ring-white/20'
563
+ : 'text-white/55 hover:text-white/90'
564
+ }`}
565
+ key={aspect}
566
+ onClick={() => {
567
+ setStandardAspect(aspect)
568
+ setAspectMenuOpen(false)
569
+ }}
570
+ type="button"
571
+ >
572
+ {aspect}
573
+ </button>
574
+ ))}
490
575
  </div>
491
- )
492
- }
576
+ )}
577
+ <ModeButton
578
+ active={mode === 'viewport'}
579
+ icon={<Maximize2 className="h-3.5 w-3.5" />}
580
+ label={isMobile ? undefined : 'Viewport'}
581
+ onClick={() => {
582
+ setMode('viewport')
583
+ setDrag(null)
584
+ setAspectMenuOpen(false)
585
+ }}
586
+ />
587
+ <ModeButton
588
+ active={mode === 'area'}
589
+ icon={<Crop className="h-3.5 w-3.5" />}
590
+ label={isMobile ? undefined : 'Area'}
591
+ onClick={() => {
592
+ setMode('area')
593
+ setAspectMenuOpen(false)
594
+ }}
595
+ />
596
+ {isMobile && (
597
+ <span className="px-2 text-white/50 text-xs tabular-nums">
598
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
599
+ </span>
600
+ )}
601
+ </div>
602
+ )}
603
+
604
+ {/* Preset captures carry their own "Frame your item" banner — the
605
+ snapshot pitch only applies to the studio/reference flow. */}
606
+ {!isMobile && !isPreset && (
607
+ <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">
608
+ A <b className="font-semibold text-white">snapshot</b>
609
+ {' freezes this exact camera angle as a reusable reference for renders & videos.'}
610
+ </span>
611
+ )}
493
612
 
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
- })()}
613
+ <button
614
+ aria-label={isPreset ? 'Capture' : 'Take snapshot'}
615
+ className="group pointer-events-auto relative grid h-14 w-14 place-items-center rounded-full disabled:opacity-50"
616
+ disabled={captureDisabled}
617
+ onClick={handleCapture}
618
+ type="button"
619
+ >
620
+ <span className="absolute inset-0 rounded-full border-[3px] border-white shadow-lg" />
621
+ <span
622
+ className={`h-10 w-10 rounded-full transition-transform duration-100 ${
623
+ captureState === 'capturing'
624
+ ? 'scale-[0.55] bg-primary'
625
+ : 'bg-white group-hover:scale-90 group-active:scale-75'
626
+ }`}
627
+ />
628
+ {captureState === 'capturing' && (
629
+ <Loader2 className="absolute h-4 w-4 animate-spin text-white" />
630
+ )}
631
+ {captureState === 'saved' && (
632
+ <Check className="absolute h-5 w-5 stroke-3 text-neutral-900" />
633
+ )}
634
+ </button>
635
+ <span className="pointer-events-none font-mono text-[10.5px] text-white uppercase tracking-[0.12em] drop-shadow">
636
+ {captureState === 'capturing'
637
+ ? 'Capturing…'
638
+ : captureState === 'saved'
639
+ ? 'Saved'
640
+ : isPreset
641
+ ? 'Capture'
642
+ : 'Take snapshot'}
643
+ </span>
504
644
  </div>
505
645
  </div>
506
646
  )
@@ -515,7 +655,7 @@ function ModeButton({
515
655
  }: {
516
656
  active: boolean
517
657
  icon: React.ReactNode
518
- label: string
658
+ label?: string
519
659
  badge?: string
520
660
  onClick: () => void
521
661
  }) {
@@ -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
+ }