@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.
- package/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +284 -237
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +135 -2
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +9 -12
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +22 -5
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +1 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- 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 {
|
|
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
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
193
|
-
|
|
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
|
-
|
|
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={
|
|
604
|
+
onPointerDown={frameLocked ? undefined : onPointerDown}
|
|
429
605
|
onPointerMove={
|
|
430
|
-
|
|
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={
|
|
449
|
-
style={
|
|
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
|
|
457
|
-
|
|
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
|
-
{!
|
|
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
|
|
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
|
|
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
|
|
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 &&
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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'}
|