diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 3c0d98b2..543a0c4b 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,6 +1,7 @@ 'use client' import { + type AnyNodeId, type CameraControlEvent, type CameraControlFitSceneEvent, emitter, @@ -357,15 +358,44 @@ export const CustomCameraControls = () => { tempBox.getSize(tempSize) // Distance heuristic: fit the subject inside the 75%-of-shorter- - // side square crop. Multiplier 1.6 keeps a small breathing margin - // around the bounds so the dashed frame doesn't kiss the geometry. + // side square crop with comfortable padding. Multiplier 2.4 leaves + // ~25-30% margin around the bounds so the user can frame without + // immediately needing to zoom out, but isn't so far away that the + // subject reads as small in the thumbnail. const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) - const distance = Math.max(maxDim * 1.6, 3) + const distance = Math.max(maxDim * 2.4, 4) + // Frame the subject from a 3/4 view of its front face. The node's + // local +Z is its forward axis in this scene's authoring convention + // (the face the user sets up to be photographed). When a single + // subtree is isolated we read its yaw and rotate the camera around + // the bounds center so the framing follows the user's authored + // orientation; for multi-isolate sets we fall back to world +Z. + // The 3/4 view offsets the camera by 35° to the right of dead-on + // so both the front face and a side are visible — the "nice angle" + // that reads as a product shot rather than a flat elevation. ~25° + // elevation keeps the top visible without going isometric. + const SIDE_OFFSET_RAD = (35 * Math.PI) / 180 + const ELEVATION_RAD = (25 * Math.PI) / 180 + let yaw = 0 + if (ids.length === 1) { + const node = useScene.getState().nodes[ids[0] as AnyNodeId] + if (node && 'rotation' in node) { + const r = (node as { rotation?: unknown }).rotation + if (typeof r === 'number') yaw = r + else if (Array.isArray(r)) yaw = (r as [number, number, number])[1] ?? 0 + } + } + // World-space direction the camera should sit *along* relative to + // bounds center: in front (object's local +Z under yaw) + a right + // offset around Y for the 3/4 read. + const viewAngle = yaw + SIDE_OFFSET_RAD + const horizontal = distance * Math.cos(ELEVATION_RAD) + const elevation = distance * Math.sin(ELEVATION_RAD) controls.current.setLookAt( - tempCenter.x + distance * 0.7, - tempCenter.y + Math.max(tempSize.y * 0.4, distance * 0.4), - tempCenter.z + distance * 0.7, + tempCenter.x + Math.sin(viewAngle) * horizontal, + tempCenter.y + elevation, + tempCenter.z + Math.cos(viewAngle) * horizontal, tempCenter.x, tempCenter.y, tempCenter.z, @@ -514,12 +544,20 @@ export const CustomCameraControls = () => { return null } + // Preset capture mode frames a single subtree (often a 0.3–2m preset), + // so the default 10m minDistance prevents the user from getting close + // enough to compose a good thumbnail. Relax the clamp to 0.5m while + // capturing presets; reset on exit so general editing keeps the looser + // navigation guardrails. + const isPresetCapture = captureMode.mode === 'preset' + const minDistance = isPresetCapture ? 0.5 : 10 + return (