Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { GRID_LAYER, getLevelPresentationY, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
Expand Down Expand Up @@ -366,6 +366,7 @@ export const CustomCameraControls = () => {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection)
const levelMode = useViewer((s) => s.levelMode)
const cameraMode = useViewer((state) => state.cameraMode)
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
controls,
Expand Down Expand Up @@ -528,21 +529,24 @@ export const CustomCameraControls = () => {

useEffect(() => {
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
}
}
// Analytic destination, not `sceneRegistry` mesh position: a level created
// this frame still sits at y=0 (LevelSystem lerps it later), and a mode
// switch leaves every level mid-lerp — the camera must pan to where the
// level will settle, in the CURRENT presentation mode.
const targetY = currentLevelId
? getLevelPresentationY(currentLevelId, useScene.getState().nodes, levelMode)
: 0
if (!controls.current) return
if (firstLoad.current) {
firstLoad.current = false
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
}
controls.current.getTarget(currentTarget)
// Idempotence guard: skip when already there — also swallows the thumbnail
// generator's synchronous stacked→restore levelMode round-trip.
if (Math.abs(currentTarget.y - targetY) < 1e-3) return
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
}, [currentLevelId, levelMode, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])

useEffect(() => {
if (isFirstPersonMode || !controls.current) return
Expand Down
43 changes: 41 additions & 2 deletions packages/editor/src/components/editor/first-person-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@ export const FirstPersonControls = () => {
const suspendRef = useRef(false)
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
const [crouched, setCrouched] = useState(false)
const captureShutterHold = useEditor((state) => state.captureShutterHold)
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
const ridingElevatorRef = useRef<{
elevatorId: AnyNodeId
Expand Down Expand Up @@ -951,7 +952,10 @@ export const FirstPersonControls = () => {

const toggleInteractableTarget = useCallback(() => {
// Drone is a camera, not an avatar: the click that re-acquires pointer lock
// must not swing a door open under the shot being framed.
// must not swing a door open under the shot being framed. (In capture
// mode's walk camera the CLICK path is gated at handleMouseDown — there a
// locked-pointer click is the shutter — but E/R still open doors, so the
// photographer can stage the shot.)
if (isDroneMode) return

const target = interactableTargetRef.current ?? resolveInteractableTarget()
Expand Down Expand Up @@ -1154,6 +1158,8 @@ export const FirstPersonControls = () => {
const canvas = gl.domElement
const handleMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement !== canvas) return
// Shutter hold: the shot is rendering — a mouse twitch must not pan it.
if (useEditor.getState().captureShutterHold) return

yawRef.current -= e.movementX * LOOK_SENSITIVITY
pitchRef.current = Math.max(
Expand All @@ -1175,6 +1181,10 @@ export const FirstPersonControls = () => {
if (document.pointerLockElement !== canvas) return
if (event.button !== 0) return

// Capture mode: the locked-pointer click is the SHUTTER (the snapshot
// overlay's window-capture listener already fired); doors stay on E/R.
if (useEditor.getState().isCaptureMode) return

event.preventDefault()
event.stopPropagation()
toggleInteractableTargetRef.current()
Expand All @@ -1193,6 +1203,19 @@ export const FirstPersonControls = () => {
// clicking the canvas re-locks.
if (suspendRef.current) return

// Capture mode: Esc (the browser's own unlock — no keydown reaches us)
// acts like P. Dropping back to orbit would throw away the framed pose,
// which reads as a crash to anyone who never noticed P.
if (
hadPointerLockRef.current &&
useEditor.getState().isCaptureMode &&
useEditor.getState().isFirstPersonMode
) {
suspendRef.current = true
useViewer.getState().setWalkthroughSuspended(true)
return
}

if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
useEditor.getState().setFirstPersonMode(false)
}
Expand Down Expand Up @@ -1257,6 +1280,20 @@ export const FirstPersonControls = () => {
} else if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
// Capture mode, first Esc frees the cursor (see handlePointerLockChange
// — while locked the browser usually unlocks without delivering the
// keydown); with the cursor already free, Esc cancels the snapshot
// (setCaptureMode(false) also lands the camera back on orbit).
if (useEditor.getState().isCaptureMode) {
if (document.pointerLockElement === canvas) {
suspendRef.current = true
useViewer.getState().setWalkthroughSuspended(true)
document.exitPointerLock()
} else {
useEditor.getState().setCaptureMode(false)
}
return
}
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
Expand Down Expand Up @@ -1556,6 +1593,8 @@ export const FirstPersonControls = () => {
// rises, Q (or Ctrl) sinks, and Shift boosts.
useFrame((_, delta) => {
if (!isDroneMode) return
// Shutter hold: freeze the drone mid-air while the shot renders.
if (useEditor.getState().captureShutterHold) return

const step = Math.min(delta, 0.1)
const movement = movementInputRef.current
Expand Down Expand Up @@ -1698,7 +1737,7 @@ export const FirstPersonControls = () => {
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
maxSlope={1.2}
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
paused={isElevatorRideLocked}
paused={isElevatorRideLocked || captureShutterHold}
position={controllerStart.position}
ref={setControllerApi}
/>
Expand Down
109 changes: 96 additions & 13 deletions packages/editor/src/components/editor/snapshot-capture-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@ import {
X,
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useIsMobile } from '../../hooks/use-mobile'
import { triggerSFX } from '../../lib/sfx-bus'
import { requestWalkthroughPointerLock } from '../../lib/walkthrough-pointer-lock'
import useEditor, {
CAPTURE_FOV_MAX,
CAPTURE_FOV_MIN,
type FirstPersonMovementMode,
type SnapshotCropMode,
type SnapshotStandardAspect,
} from '../../store/use-editor'
import { useFirstPersonHud } from '../../store/use-first-person-hud'
import { Slider } from '../ui/slider'
import { WalkthroughCrosshair } from '../walkthrough-hud'

// Local alias — distinct from `useEditor.captureMode` (which describes *why*
// a capture is happening, e.g. `preset`). This one says HOW the captured
Expand Down Expand Up @@ -134,21 +138,29 @@ type CameraNavHint = {
keys: readonly string[]
}

function CaptureWalkthroughCrosshair() {
const interact = useFirstPersonHud((state) => state.interact)
return <WalkthroughCrosshair interact={interact} />
}

const CAMERA_NAV_HINTS: Record<CaptureCameraNav, readonly CameraNavHint[] | null> = {
orbit: null,
walk: [
{ keys: ['WASD'], action: 'move' },
{ keys: ['Space'], action: 'jump' },
{ keys: ['P'], action: 'free cursor' },
{ keys: ['Enter'], action: 'shoot' },
{ keys: ['E'], action: 'open' },
{ keys: ['Wheel'], action: 'lens' },
{ keys: ['P', 'Esc'], action: 'free cursor' },
{ keys: ['Click', 'Enter'], action: 'shoot' },
],
drone: [
{ keys: ['WASD'], action: 'move' },
{ keys: ['Space', 'E'], action: 'up' },
{ keys: ['Q'], action: 'down' },
{ keys: ['Shift'], action: 'boost' },
{ keys: ['P'], action: 'free cursor' },
{ keys: ['Enter'], action: 'shoot' },
{ keys: ['Wheel'], action: 'lens' },
{ keys: ['P', 'Esc'], action: 'free cursor' },
{ keys: ['Click', 'Enter'], action: 'shoot' },
],
}

Expand All @@ -163,9 +175,11 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const isPreset = captureMode.mode === 'preset'
const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined
const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined
// A host-preselected crop means the host needs that exact output shape
// (e.g. the publish-cover capture) — hide the crop/aspect switcher.
const isCropLocked = isPreset || requestedCrop !== undefined
// Only an explicit host lock hides the crop/aspect switcher (the publish
// cover needs its exact output shape). A plain preselected crop — the
// Studio capbar's choice — just seeds the pill and stays user-changeable.
const isCropLocked =
isPreset || (captureMode.mode === 'standard' && captureMode.lockCrop === true)

const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const firstPersonMovementMode = useEditor((s) => s.firstPersonMovementMode)
Expand All @@ -182,8 +196,22 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
editor.setFirstPersonMode(false)
return
}
editor.setFirstPersonMovementMode(next)
if (!editor.isFirstPersonMode) editor.setFirstPersonMode(true)
// Lock the pointer in the same click task (the gesture requirement):
// flush the mode flip so FirstPersonControls is mounted when the lock
// lands, instead of making the user click the canvas a second time.
flushSync(() => {
editor.setFirstPersonMovementMode(next)
if (!editor.isFirstPersonMode) editor.setFirstPersonMode(true)
})
requestWalkthroughPointerLock({
// Freeing the cursor in one camera and immediately picking the other
// hits the browser's re-lock cooldown; retry once it passes, as long as
// the user is still framing in a first-person camera.
retryWhile: () => {
const state = useEditor.getState()
return state.isCaptureMode && state.isFirstPersonMode
},
})
}, [])

const [mode, setMode] = useState<CropMode>('standard')
Expand Down Expand Up @@ -246,6 +274,14 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
return () => emitter.off('snapshot:saved', handler)
}, [setCaptureMode])

// From the shutter firing until the saved toast clears, walk / drone hold
// still: a late WASD tap or mouse twitch must not shift the frame out from
// under the shot the user just took.
useEffect(() => {
useEditor.getState().setCaptureShutterHold(captureState !== 'idle')
return () => useEditor.getState().setCaptureShutterHold(false)
}, [captureState])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shutter hold leaks after cancel

High Severity

captureShutterHold is tied only to captureState !== 'idle', and leaving capture via setCaptureMode(false) does not reset captureState. Canceling during capturing (X, Esc, or a shot that never reaches snapshot:saved) leaves the hold true, so the next walk or drone session cannot look or move until capture is opened again.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5f48afe. Configure here.


const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])

// Tracks whether the active drag is a "move entire rect" gesture
Expand Down Expand Up @@ -407,13 +443,17 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
})
}, [captureState, mode, drag, projectId, isPreset, standardAspect])

// Esc dismisses. Enter fires the shutter: walk and drone hold a pointer lock, so
// a keyboard shutter is the only way to shoot without leaving the camera first.
// Esc dismisses — in ORBIT only. In walk / drone, Esc means "free the
// cursor" (the browser's own pointer-lock exit; FirstPersonControls pauses
// instead of bailing) — reflexively dropping the whole capture with the
// framed pose would punish anyone who never noticed P. Enter fires the
// shutter: walk and drone hold a pointer lock, so a keyboard shutter works
// without leaving the camera.
useEffect(() => {
if (!isCaptureMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setCaptureMode(false)
if (cameraNav === 'orbit') setCaptureMode(false)
return
}
if (e.key !== 'Enter') return
Expand All @@ -423,7 +463,46 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [handleCapture, isCaptureMode, setCaptureMode])
}, [cameraNav, handleCapture, isCaptureMode, setCaptureMode])

// While walk / drone hold the pointer lock, the wheel drives the lens and a
// click fires the shutter. Both gate on the lock being HELD: the click that
// acquires it happens unlocked, so entering the camera never also shoots,
// and an unlocked wheel keeps scrolling whatever pane it's over.
useEffect(() => {
if (!isCaptureMode || cameraNav === 'orbit') return
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
if (!canvas) return
// `setCaptureFov` rounds to whole degrees; accumulate sub-degree trackpad
// deltas so slow scrolls still move the lens.
let pendingFovDelta = 0
const onWheel = (e: WheelEvent) => {
if (document.pointerLockElement !== canvas) return
e.preventDefault()
const pixels = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY
// Wheel-up narrows the lens (zoom in), matching the orbit dolly.
pendingFovDelta += pixels * 0.05
const whole = Math.trunc(pendingFovDelta)
if (whole === 0) return
pendingFovDelta -= whole
const editor = useEditor.getState()
if (editor.captureFov === null) return
editor.setCaptureFov(editor.captureFov + whole)
}
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0 || document.pointerLockElement !== canvas) return
handleCapture()
}
window.addEventListener('wheel', onWheel, { passive: false })
// Capture phase: FirstPersonControls' own document-capture mousedown
// handler stops propagation while locked, which a bubble listener never
// survives — window-capture runs first.
window.addEventListener('mousedown', onMouseDown, true)
return () => {
window.removeEventListener('wheel', onWheel)
window.removeEventListener('mousedown', onMouseDown, true)
}
}, [cameraNav, handleCapture, isCaptureMode])

if (!isCaptureMode) return null

Expand Down Expand Up @@ -478,6 +557,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {

return (
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
{/* Walk / drone keep the walkthrough's centered pointer (the ring means
E opens the door / window under it) — the capture overlay replaces
the walkthrough HUD, so the crosshair rides along here. */}
{cameraOwnsPointer && <CaptureWalkthroughCrosshair />}
{/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
{standardFrame && (
<div
Expand Down
20 changes: 0 additions & 20 deletions packages/editor/src/components/tools/item/placement-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,26 +81,6 @@ export function getDetachedAttachmentPreviewLift(
return attachTo ? 0.45 : 0
}

/**
* Calculate cursor rotation in WORLD space from wall normal and orientation.
*/
export function calculateCursorRotation(
normal: [number, number, number] | undefined,
wallStart: [number, number],
wallEnd: [number, number],
): number {
if (!normal) return 0

// Wall direction angle in world XZ plane
const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])

// In local wall space, front face has normal.z < 0, back face has normal.z > 0
if (normal[2] < 0) {
return -wallAngle
}
return Math.PI - wallAngle
}

/**
* Calculate item rotation in WALL-LOCAL space from normal.
* Items are children of the wall mesh, so their rotation is relative to wall's local space.
Expand Down
Loading
Loading