@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,14 +1,35 @@
1
1
  'use client'
2
2
 
3
- import { emitter } from '@pascal-app/core'
4
- import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
3
+ import { emitter, type SnapshotSavedEvent } from '@pascal-app/core'
4
+ import { SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer'
5
+ import {
6
+ Check,
7
+ Crop,
8
+ Drone,
9
+ Footprints,
10
+ Loader2,
11
+ Maximize2,
12
+ Monitor,
13
+ Orbit,
14
+ RotateCcw,
15
+ X,
16
+ } from 'lucide-react'
5
17
  import { useCallback, useEffect, useRef, useState } from 'react'
18
+ import { flushSync } from 'react-dom'
6
19
  import { useIsMobile } from '../../hooks/use-mobile'
7
20
  import { triggerSFX } from '../../lib/sfx-bus'
21
+ import { requestWalkthroughPointerLock } from '../../lib/walkthrough-pointer-lock'
8
22
  import useEditor, {
23
+ CAPTURE_FOV_MAX,
24
+ CAPTURE_FOV_MIN,
25
+ type FirstPersonMovementMode,
9
26
  type SnapshotCropMode,
10
27
  type SnapshotStandardAspect,
11
28
  } from '../../store/use-editor'
29
+ import { useFirstPersonHud } from '../../store/use-first-person-hud'
30
+ import { Slider } from '../ui/slider'
31
+ import { WalkthroughCrosshair } from '../walkthrough-hud'
32
+ import { isOverlaySnapshotSave } from './snapshot-capture'
12
33
 
13
34
  // Local alias — distinct from `useEditor.captureMode` (which describes *why*
14
35
  // a capture is happening, e.g. `preset`). This one says HOW the captured
@@ -16,6 +37,9 @@ import useEditor, {
16
37
  // user-dragged area. Hosts can preselect it via `captureMode.crop`.
17
38
  type CropMode = SnapshotCropMode
18
39
  type CaptureState = 'idle' | 'capturing' | 'saved'
40
+ /** Which camera the shot is framed with: the editor's orbit camera, or one of
41
+ * the two first-person controllers. */
42
+ type CaptureCameraNav = 'orbit' | FirstPersonMovementMode
19
43
 
20
44
  interface DragPoint {
21
45
  x: number
@@ -37,6 +61,14 @@ const STANDARD_SIZES: Record<SnapshotStandardAspect, { w: number; h: number }> =
37
61
  }
38
62
  type StandardAspect = SnapshotStandardAspect
39
63
 
64
+ function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
65
+ const maxEdge = Math.max(width, height)
66
+ if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
67
+
68
+ const scale = SNAPSHOT_MAX_EDGE / maxEdge
69
+ return { w: Math.round(width * scale), h: Math.round(height * scale) }
70
+ }
71
+
40
72
  function getResolution(
41
73
  mode: CropMode,
42
74
  overlayEl: HTMLDivElement | null,
@@ -50,14 +82,14 @@ function getResolution(
50
82
  const dpr = Math.min(window.devicePixelRatio, 1.5)
51
83
 
52
84
  if (mode === 'viewport') {
53
- return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
85
+ return clampSnapshotSize(Math.round(rect.width * dpr), Math.round(rect.height * dpr))
54
86
  }
55
87
 
56
88
  if (mode === 'area' && drag) {
57
89
  const w = Math.abs(drag.end.x - drag.start.x)
58
90
  const h = Math.abs(drag.end.y - drag.start.y)
59
91
  if (w < 4 || h < 4) return null
60
- return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
92
+ return clampSnapshotSize(Math.round(w * dpr), Math.round(h * dpr))
61
93
  }
62
94
 
63
95
  return null
@@ -90,7 +122,7 @@ function CornerAccents() {
90
122
  }
91
123
 
92
124
  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'
125
+ 'flex flex-col gap-px rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5'
94
126
 
95
127
  const CROP_LABELS: Record<CropMode, string> = {
96
128
  standard: 'Standard',
@@ -98,6 +130,42 @@ const CROP_LABELS: Record<CropMode, string> = {
98
130
  area: 'Area',
99
131
  }
100
132
 
133
+ // Dark-HUD skin for the shared slider, which is themed for the light editor chrome.
134
+ const FOV_SLIDER_CLASS =
135
+ 'w-24 [&_[data-slot=slider-track]]:h-1 [&_[data-slot=slider-track]]:bg-white/20 [&_[data-slot=slider-range]]:bg-white [&_[data-slot=slider-thumb]]:size-3 [&_[data-slot=slider-thumb]]:border-white/50 [&_[data-slot=slider-thumb]]:bg-white [&_[data-slot=slider-thumb]]:ring-white/30'
136
+
137
+ type CameraNavHint = {
138
+ action: string
139
+ keys: readonly string[]
140
+ }
141
+
142
+ function CaptureWalkthroughCrosshair() {
143
+ const interact = useFirstPersonHud((state) => state.interact)
144
+ return <WalkthroughCrosshair interact={interact} />
145
+ }
146
+
147
+ const CAMERA_NAV_HINTS: Record<CaptureCameraNav, readonly CameraNavHint[] | null> = {
148
+ orbit: null,
149
+ walk: [
150
+ { keys: ['WASD'], action: 'move' },
151
+ { keys: ['Space'], action: 'jump' },
152
+ { keys: ['E'], action: 'open' },
153
+ { keys: ['Wheel'], action: 'lens' },
154
+ { keys: ['P', 'Esc'], action: 'free cursor' },
155
+ { keys: ['Click', 'Enter'], action: 'shoot' },
156
+ ],
157
+ drone: [
158
+ { keys: ['WASD'], action: 'move' },
159
+ { keys: ['Space', 'E'], action: 'up' },
160
+ { keys: ['Q'], action: 'down' },
161
+ { keys: ['Shift'], action: 'boost' },
162
+ { keys: ['Alt'], action: 'slow' },
163
+ { keys: ['Wheel'], action: 'lens' },
164
+ { keys: ['P', 'Esc'], action: 'free cursor' },
165
+ { keys: ['Click', 'Enter'], action: 'shoot' },
166
+ ],
167
+ }
168
+
101
169
  export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
102
170
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
103
171
  const captureMode = useEditor((s) => s.captureMode)
@@ -109,9 +177,44 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
109
177
  const isPreset = captureMode.mode === 'preset'
110
178
  const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined
111
179
  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
180
+ // Only an explicit host lock hides the crop/aspect switcher (the publish
181
+ // cover needs its exact output shape). A plain preselected crop — the
182
+ // Studio capbar's choice just seeds the pill and stays user-changeable.
183
+ const isCropLocked =
184
+ isPreset || (captureMode.mode === 'standard' && captureMode.lockCrop === true)
185
+
186
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
187
+ const firstPersonMovementMode = useEditor((s) => s.firstPersonMovementMode)
188
+ const captureFov = useEditor((s) => s.captureFov)
189
+ const captureFovBaseline = useEditor((s) => s.captureFovBaseline)
190
+ const setCaptureFov = useEditor((s) => s.setCaptureFov)
191
+ // Orbit is just "first person off", so deriving the segmented control's value
192
+ // from the walkthrough flag keeps the two from drifting when walkthrough ends
193
+ // on its own (Esc, or a lost pointer lock).
194
+ const cameraNav: CaptureCameraNav = isFirstPersonMode ? firstPersonMovementMode : 'orbit'
195
+ const setCameraNav = useCallback((next: CaptureCameraNav) => {
196
+ const editor = useEditor.getState()
197
+ if (next === 'orbit') {
198
+ editor.setFirstPersonMode(false)
199
+ return
200
+ }
201
+ // Lock the pointer in the same click task (the gesture requirement):
202
+ // flush the mode flip so FirstPersonControls is mounted when the lock
203
+ // lands, instead of making the user click the canvas a second time.
204
+ flushSync(() => {
205
+ editor.setFirstPersonMovementMode(next)
206
+ if (!editor.isFirstPersonMode) editor.setFirstPersonMode(true)
207
+ })
208
+ requestWalkthroughPointerLock({
209
+ // Freeing the cursor in one camera and immediately picking the other
210
+ // hits the browser's re-lock cooldown; retry once it passes, as long as
211
+ // the user is still framing in a first-person camera.
212
+ retryWhile: () => {
213
+ const state = useEditor.getState()
214
+ return state.isCaptureMode && state.isFirstPersonMode
215
+ },
216
+ })
217
+ }, [])
115
218
 
116
219
  const [mode, setMode] = useState<CropMode>('standard')
117
220
  const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
@@ -134,16 +237,6 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
134
237
  return () => observer.disconnect()
135
238
  }, [isCaptureMode])
136
239
 
137
- // Dismiss on Esc
138
- useEffect(() => {
139
- if (!isCaptureMode) return
140
- const onKey = (e: KeyboardEvent) => {
141
- if (e.key === 'Escape') setCaptureMode(false)
142
- }
143
- window.addEventListener('keydown', onKey)
144
- return () => window.removeEventListener('keydown', onKey)
145
- }, [isCaptureMode, setCaptureMode])
146
-
147
240
  // Reset local state when entering capture mode. Preset mode also
148
241
  // auto-stages a centered square crop sized to ~75% of the shorter
149
242
  // viewport dimension so the user can capture immediately — the
@@ -172,16 +265,31 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
172
265
 
173
266
  // Listen for snapshot saved to show feedback then exit
174
267
  useEffect(() => {
175
- const handler = () => {
268
+ if (!isCaptureMode) return
269
+ let timer: ReturnType<typeof setTimeout> | undefined
270
+ const handler = (event: SnapshotSavedEvent | undefined) => {
271
+ if (!isOverlaySnapshotSave(event, projectId)) return
176
272
  setCaptureState('saved')
177
- setTimeout(() => {
273
+ clearTimeout(timer)
274
+ timer = setTimeout(() => {
178
275
  setCaptureMode(false)
179
276
  setCaptureState('idle')
180
277
  }, 1500)
181
278
  }
182
279
  emitter.on('snapshot:saved', handler)
183
- return () => emitter.off('snapshot:saved', handler)
184
- }, [setCaptureMode])
280
+ return () => {
281
+ emitter.off('snapshot:saved', handler)
282
+ clearTimeout(timer)
283
+ }
284
+ }, [isCaptureMode, projectId, setCaptureMode])
285
+
286
+ // From the shutter firing until the saved toast clears, walk / drone hold
287
+ // still: a late WASD tap or mouse twitch must not shift the frame out from
288
+ // under the shot the user just took.
289
+ useEffect(() => {
290
+ useEditor.getState().setCaptureShutterHold(isCaptureMode && captureState !== 'idle')
291
+ return () => useEditor.getState().setCaptureShutterHold(false)
292
+ }, [captureState, isCaptureMode])
185
293
 
186
294
  const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
187
295
 
@@ -344,9 +452,82 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
344
452
  })
345
453
  }, [captureState, mode, drag, projectId, isPreset, standardAspect])
346
454
 
455
+ // Esc dismisses — in ORBIT only. In walk / drone, Esc means "free the
456
+ // cursor" (the browser's own pointer-lock exit; FirstPersonControls pauses
457
+ // instead of bailing) — reflexively dropping the whole capture with the
458
+ // framed pose would punish anyone who never noticed P. Enter fires the
459
+ // shutter: walk and drone hold a pointer lock, so a keyboard shutter works
460
+ // without leaving the camera.
461
+ useEffect(() => {
462
+ if (!isCaptureMode) return
463
+ const onKey = (e: KeyboardEvent) => {
464
+ if (e.key === 'Escape') {
465
+ if (cameraNav === 'orbit') setCaptureMode(false)
466
+ return
467
+ }
468
+ if (e.key !== 'Enter') return
469
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
470
+ e.preventDefault()
471
+ handleCapture()
472
+ }
473
+ window.addEventListener('keydown', onKey)
474
+ return () => window.removeEventListener('keydown', onKey)
475
+ }, [cameraNav, handleCapture, isCaptureMode, setCaptureMode])
476
+
477
+ // While walk / drone hold the pointer lock, the wheel drives the lens and a
478
+ // click fires the shutter. Both gate on the lock being HELD: the click that
479
+ // acquires it happens unlocked, so entering the camera never also shoots,
480
+ // and an unlocked wheel keeps scrolling whatever pane it's over.
481
+ useEffect(() => {
482
+ if (!isCaptureMode || cameraNav === 'orbit') return
483
+ const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
484
+ if (!canvas) return
485
+ // `setCaptureFov` rounds to whole degrees; accumulate sub-degree trackpad
486
+ // deltas so slow scrolls still move the lens.
487
+ let pendingFovDelta = 0
488
+ const onWheel = (e: WheelEvent) => {
489
+ if (document.pointerLockElement !== canvas) return
490
+ e.preventDefault()
491
+ const pixels = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY
492
+ // Wheel-up narrows the lens (zoom in), matching the orbit dolly.
493
+ pendingFovDelta += pixels * 0.05
494
+ const whole = Math.trunc(pendingFovDelta)
495
+ if (whole === 0) return
496
+ pendingFovDelta -= whole
497
+ const editor = useEditor.getState()
498
+ if (editor.captureFov === null) return
499
+ editor.setCaptureFov(editor.captureFov + whole)
500
+ }
501
+ const onMouseDown = (e: MouseEvent) => {
502
+ if (e.button !== 0 || document.pointerLockElement !== canvas) return
503
+ handleCapture()
504
+ }
505
+ window.addEventListener('wheel', onWheel, { passive: false })
506
+ // Capture phase: FirstPersonControls' own document-capture mousedown
507
+ // handler stops propagation while locked, which a bubble listener never
508
+ // survives — window-capture runs first.
509
+ window.addEventListener('mousedown', onMouseDown, true)
510
+ return () => {
511
+ window.removeEventListener('wheel', onWheel)
512
+ window.removeEventListener('mousedown', onMouseDown, true)
513
+ }
514
+ }, [cameraNav, handleCapture, isCaptureMode])
515
+
347
516
  if (!isCaptureMode) return null
348
517
 
349
518
  const resolution = getResolution(mode, overlayRef.current, drag, standardAspect)
519
+ // Walk and drone need the canvas to receive the click that grants pointer lock,
520
+ // so the area-drag surface steps aside — same treatment as preset mode, whose
521
+ // frame is fixed and camera-driven.
522
+ const cameraOwnsPointer = cameraNav !== 'orbit'
523
+ const frameLocked = isPreset || cameraOwnsPointer
524
+ // Preset captures are a constrained flow (fixed square, host-owned banner);
525
+ // they keep the plain orbit camera. Walk / drone need a keyboard, so they stay
526
+ // off touch. The fov control is armed by the capture rig only on a
527
+ // perspective camera — orthographic captures have no lens to drive.
528
+ const showCameraNav = !(isPreset || isMobile)
529
+ const fovValue = isPreset ? null : captureFov
530
+ const cameraHint = CAMERA_NAV_HINTS[cameraNav]
350
531
 
351
532
  // Standard mode framing: the output is a center-crop of the canvas to the
352
533
  // chosen aspect (see ThumbnailGenerator) — show exactly that region as a
@@ -385,6 +566,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
385
566
 
386
567
  return (
387
568
  <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
569
+ {/* Walk / drone keep the walkthrough's centered pointer (the ring means
570
+ E opens the door / window under it) — the capture overlay replaces
571
+ the walkthrough HUD, so the crosshair rides along here. */}
572
+ {cameraOwnsPointer && <CaptureWalkthroughCrosshair />}
388
573
  {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
389
574
  {standardFrame && (
390
575
  <div
@@ -412,13 +597,13 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
412
597
  {mode === 'area' && (
413
598
  <div
414
599
  className={
415
- isPreset
600
+ frameLocked
416
601
  ? 'pointer-events-none absolute inset-0'
417
602
  : 'pointer-events-auto absolute inset-0 bg-black/30'
418
603
  }
419
- onPointerDown={isPreset ? undefined : onPointerDown}
604
+ onPointerDown={frameLocked ? undefined : onPointerDown}
420
605
  onPointerMove={
421
- isPreset
606
+ frameLocked
422
607
  ? undefined
423
608
  : (e) => {
424
609
  onPointerMove(e)
@@ -436,16 +621,18 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
436
621
  }
437
622
  }
438
623
  }
439
- onPointerUp={isPreset ? undefined : onPointerUp}
440
- style={isPreset ? undefined : { cursor: 'crosshair' }}
624
+ onPointerUp={frameLocked ? undefined : onPointerUp}
625
+ style={frameLocked ? undefined : { cursor: 'crosshair' }}
441
626
  >
442
627
  {/* "No selection" hint — only when the user has to draw the
443
628
  area themselves (`standard` capture). Preset mode always
444
629
  has a pre-staged square, so we never show it there. */}
445
630
  {!selectionStyle && !isPreset && (
446
631
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
447
- <span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white backdrop-blur-md">
448
- Drag the area you want to capture
632
+ <span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white">
633
+ {cameraOwnsPointer
634
+ ? 'Switch back to orbit to drag a capture area'
635
+ : 'Drag the area you want to capture'}
449
636
  </span>
450
637
  </div>
451
638
  )}
@@ -469,8 +656,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
469
656
  <CornerAccents />
470
657
  {/* Corner handles — preset mode locks the frame to the
471
658
  auto-staged centered square; the user adjusts the
472
- camera instead. */}
473
- {!isPreset &&
659
+ camera instead. Walk / drone lock it for the same reason. */}
660
+ {!frameLocked &&
474
661
  (
475
662
  [
476
663
  { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
@@ -527,7 +714,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
527
714
  <div className="pointer-events-auto absolute top-4 right-4">
528
715
  <button
529
716
  aria-label="Close capture mode"
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"
717
+ 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 transition-colors hover:bg-neutral-950 hover:text-white"
531
718
  onClick={dismiss}
532
719
  type="button"
533
720
  >
@@ -539,10 +726,71 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
539
726
  {/* Subtle scrim so the bottom controls stay readable on bright scenes */}
540
727
  <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
728
 
542
- {/* Bottom-center: crop switcher, caption + shutter */}
729
+ {/* Bottom-center: camera row, crop switcher, caption + shutter */}
543
730
  <div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
731
+ {/* How you move while composing, and the lens. Both are capture-scoped:
732
+ leaving capture puts the camera back on orbit at its entry fov. */}
733
+ {(showCameraNav || fovValue !== null) && (
734
+ <div className="pointer-events-auto flex items-center gap-2">
735
+ {showCameraNav && (
736
+ <div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-950/85 px-1.5 py-1.5 shadow-xl">
737
+ <ModeButton
738
+ active={cameraNav === 'orbit'}
739
+ icon={<Orbit className="h-3.5 w-3.5" />}
740
+ label="Orbit"
741
+ onClick={() => setCameraNav('orbit')}
742
+ />
743
+ <ModeButton
744
+ active={cameraNav === 'walk'}
745
+ icon={<Footprints className="h-3.5 w-3.5" />}
746
+ label="Walk"
747
+ onClick={() => setCameraNav('walk')}
748
+ />
749
+ <ModeButton
750
+ active={cameraNav === 'drone'}
751
+ icon={<Drone className="h-3.5 w-3.5" />}
752
+ label="Drone"
753
+ onClick={() => setCameraNav('drone')}
754
+ />
755
+ </div>
756
+ )}
757
+ {fovValue !== null && (
758
+ <div className="flex items-center gap-2.5 rounded-full border border-white/10 bg-neutral-950/85 py-1.5 pr-1.5 pl-3 shadow-xl">
759
+ <span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
760
+ Lens
761
+ </span>
762
+ <Slider
763
+ aria-label="Field of view"
764
+ className={FOV_SLIDER_CLASS}
765
+ max={CAPTURE_FOV_MAX}
766
+ min={CAPTURE_FOV_MIN}
767
+ onValueChange={([next]) => {
768
+ if (next !== undefined) setCaptureFov(next)
769
+ }}
770
+ step={1}
771
+ value={[fovValue]}
772
+ />
773
+ <span className="w-8 text-right font-semibold text-white text-xs tabular-nums">
774
+ {fovValue}°
775
+ </span>
776
+ <button
777
+ aria-label="Reset field of view"
778
+ className="grid h-6 w-6 place-items-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-white/50"
779
+ disabled={captureFovBaseline === null || fovValue === captureFovBaseline}
780
+ onClick={() => {
781
+ if (captureFovBaseline !== null) setCaptureFov(captureFovBaseline)
782
+ }}
783
+ type="button"
784
+ >
785
+ <RotateCcw className="h-3 w-3" />
786
+ </button>
787
+ </div>
788
+ )}
789
+ </div>
790
+ )}
791
+
544
792
  {!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">
793
+ <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">
546
794
  {/* Clicking Standard while it's active opens the aspect picker */}
547
795
  <ModeButton
548
796
  active={mode === 'standard'}
@@ -557,7 +805,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
557
805
  }}
558
806
  />
559
807
  {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">
808
+ <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">
561
809
  {(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => (
562
810
  <button
563
811
  className={`rounded-full px-2.5 py-1 font-mono text-[11px] transition-colors ${
@@ -606,12 +854,32 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
606
854
 
607
855
  {/* Preset captures carry their own "Frame your item" banner — the
608
856
  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
- )}
857
+ {!isMobile &&
858
+ !isPreset &&
859
+ (cameraHint ? (
860
+ <div className="pointer-events-none flex max-w-lg flex-wrap items-center justify-center gap-x-2.5 gap-y-1 rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 text-[10px]">
861
+ {cameraHint.map(({ keys, action }) => (
862
+ <span className="inline-flex items-center gap-1 whitespace-nowrap" key={action}>
863
+ <span className="inline-flex items-center gap-0.5">
864
+ {keys.map((key, index) => (
865
+ <span className="inline-flex items-center gap-0.5" key={key}>
866
+ {index > 0 && <span className="text-white/25">/</span>}
867
+ <kbd className="rounded border border-white/15 bg-white/5 px-1.5 py-0.5 font-mono text-[9px] text-white/80 leading-none shadow-sm">
868
+ {key}
869
+ </kbd>
870
+ </span>
871
+ ))}
872
+ </span>
873
+ <span className="text-white/55">{action}</span>
874
+ </span>
875
+ ))}
876
+ </div>
877
+ ) : (
878
+ <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">
879
+ A <b className="font-semibold text-white">snapshot</b>
880
+ {' freezes this exact camera angle as a reusable reference for renders & videos.'}
881
+ </span>
882
+ ))}
615
883
 
616
884
  <button
617
885
  aria-label={isPreset ? 'Capture' : 'Take snapshot'}