@pascal-app/editor 1.0.0-beta.5 → 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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,15 +1,35 @@
1
1
  'use client'
2
2
 
3
- import { emitter } from '@pascal-app/core'
3
+ import { emitter, type SnapshotSavedEvent } from '@pascal-app/core'
4
4
  import { SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer'
5
- import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
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'
6
17
  import { useCallback, useEffect, useRef, useState } from 'react'
18
+ import { flushSync } from 'react-dom'
7
19
  import { useIsMobile } from '../../hooks/use-mobile'
8
20
  import { triggerSFX } from '../../lib/sfx-bus'
21
+ import { requestWalkthroughPointerLock } from '../../lib/walkthrough-pointer-lock'
9
22
  import useEditor, {
23
+ CAPTURE_FOV_MAX,
24
+ CAPTURE_FOV_MIN,
25
+ type FirstPersonMovementMode,
10
26
  type SnapshotCropMode,
11
27
  type SnapshotStandardAspect,
12
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'
13
33
 
14
34
  // Local alias — distinct from `useEditor.captureMode` (which describes *why*
15
35
  // a capture is happening, e.g. `preset`). This one says HOW the captured
@@ -17,6 +37,9 @@ import useEditor, {
17
37
  // user-dragged area. Hosts can preselect it via `captureMode.crop`.
18
38
  type CropMode = SnapshotCropMode
19
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
20
43
 
21
44
  interface DragPoint {
22
45
  x: number
@@ -99,7 +122,7 @@ function CornerAccents() {
99
122
  }
100
123
 
101
124
  const HUD_CHIP_CLASS =
102
- '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'
103
126
 
104
127
  const CROP_LABELS: Record<CropMode, string> = {
105
128
  standard: 'Standard',
@@ -107,6 +130,42 @@ const CROP_LABELS: Record<CropMode, string> = {
107
130
  area: 'Area',
108
131
  }
109
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
+
110
169
  export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
111
170
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
112
171
  const captureMode = useEditor((s) => s.captureMode)
@@ -118,9 +177,44 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
118
177
  const isPreset = captureMode.mode === 'preset'
119
178
  const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined
120
179
  const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined
121
- // A host-preselected crop means the host needs that exact output shape
122
- // (e.g. the publish-cover capture) hide the crop/aspect switcher.
123
- 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
+ }, [])
124
218
 
125
219
  const [mode, setMode] = useState<CropMode>('standard')
126
220
  const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
@@ -143,16 +237,6 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
143
237
  return () => observer.disconnect()
144
238
  }, [isCaptureMode])
145
239
 
146
- // Dismiss on Esc
147
- useEffect(() => {
148
- if (!isCaptureMode) return
149
- const onKey = (e: KeyboardEvent) => {
150
- if (e.key === 'Escape') setCaptureMode(false)
151
- }
152
- window.addEventListener('keydown', onKey)
153
- return () => window.removeEventListener('keydown', onKey)
154
- }, [isCaptureMode, setCaptureMode])
155
-
156
240
  // Reset local state when entering capture mode. Preset mode also
157
241
  // auto-stages a centered square crop sized to ~75% of the shorter
158
242
  // viewport dimension so the user can capture immediately — the
@@ -181,16 +265,31 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
181
265
 
182
266
  // Listen for snapshot saved to show feedback then exit
183
267
  useEffect(() => {
184
- 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
185
272
  setCaptureState('saved')
186
- setTimeout(() => {
273
+ clearTimeout(timer)
274
+ timer = setTimeout(() => {
187
275
  setCaptureMode(false)
188
276
  setCaptureState('idle')
189
277
  }, 1500)
190
278
  }
191
279
  emitter.on('snapshot:saved', handler)
192
- return () => emitter.off('snapshot:saved', handler)
193
- }, [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])
194
293
 
195
294
  const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
196
295
 
@@ -353,9 +452,82 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
353
452
  })
354
453
  }, [captureState, mode, drag, projectId, isPreset, standardAspect])
355
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
+
356
516
  if (!isCaptureMode) return null
357
517
 
358
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]
359
531
 
360
532
  // Standard mode framing: the output is a center-crop of the canvas to the
361
533
  // chosen aspect (see ThumbnailGenerator) — show exactly that region as a
@@ -394,6 +566,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
394
566
 
395
567
  return (
396
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 />}
397
573
  {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
398
574
  {standardFrame && (
399
575
  <div
@@ -421,13 +597,13 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
421
597
  {mode === 'area' && (
422
598
  <div
423
599
  className={
424
- isPreset
600
+ frameLocked
425
601
  ? 'pointer-events-none absolute inset-0'
426
602
  : 'pointer-events-auto absolute inset-0 bg-black/30'
427
603
  }
428
- onPointerDown={isPreset ? undefined : onPointerDown}
604
+ onPointerDown={frameLocked ? undefined : onPointerDown}
429
605
  onPointerMove={
430
- isPreset
606
+ frameLocked
431
607
  ? undefined
432
608
  : (e) => {
433
609
  onPointerMove(e)
@@ -445,16 +621,18 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
445
621
  }
446
622
  }
447
623
  }
448
- onPointerUp={isPreset ? undefined : onPointerUp}
449
- style={isPreset ? undefined : { cursor: 'crosshair' }}
624
+ onPointerUp={frameLocked ? undefined : onPointerUp}
625
+ style={frameLocked ? undefined : { cursor: 'crosshair' }}
450
626
  >
451
627
  {/* "No selection" hint — only when the user has to draw the
452
628
  area themselves (`standard` capture). Preset mode always
453
629
  has a pre-staged square, so we never show it there. */}
454
630
  {!selectionStyle && !isPreset && (
455
631
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
456
- <span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white backdrop-blur-md">
457
- 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'}
458
636
  </span>
459
637
  </div>
460
638
  )}
@@ -478,8 +656,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
478
656
  <CornerAccents />
479
657
  {/* Corner handles — preset mode locks the frame to the
480
658
  auto-staged centered square; the user adjusts the
481
- camera instead. */}
482
- {!isPreset &&
659
+ camera instead. Walk / drone lock it for the same reason. */}
660
+ {!frameLocked &&
483
661
  (
484
662
  [
485
663
  { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
@@ -536,7 +714,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
536
714
  <div className="pointer-events-auto absolute top-4 right-4">
537
715
  <button
538
716
  aria-label="Close capture mode"
539
- 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"
540
718
  onClick={dismiss}
541
719
  type="button"
542
720
  >
@@ -548,10 +726,71 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
548
726
  {/* Subtle scrim so the bottom controls stay readable on bright scenes */}
549
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" />
550
728
 
551
- {/* Bottom-center: crop switcher, caption + shutter */}
729
+ {/* Bottom-center: camera row, crop switcher, caption + shutter */}
552
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
+
553
792
  {!isCropLocked && (
554
- <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">
555
794
  {/* Clicking Standard while it's active opens the aspect picker */}
556
795
  <ModeButton
557
796
  active={mode === 'standard'}
@@ -566,7 +805,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
566
805
  }}
567
806
  />
568
807
  {aspectMenuOpen && mode === 'standard' && (
569
- <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">
570
809
  {(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => (
571
810
  <button
572
811
  className={`rounded-full px-2.5 py-1 font-mono text-[11px] transition-colors ${
@@ -615,12 +854,32 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
615
854
 
616
855
  {/* Preset captures carry their own "Frame your item" banner — the
617
856
  snapshot pitch only applies to the studio/reference flow. */}
618
- {!isMobile && !isPreset && (
619
- <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">
620
- A <b className="font-semibold text-white">snapshot</b>
621
- {' freezes this exact camera angle as a reusable reference for renders & videos.'}
622
- </span>
623
- )}
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
+ ))}
624
883
 
625
884
  <button
626
885
  aria-label={isPreset ? 'Capture' : 'Take snapshot'}