import { useEffect, useRef, useState } from "react"; import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection"; export interface CubePose { rotationX: number; rotationY: number; rotationZ: number; } const VIEW_W = 132; const VIEW_H = 112; const CX = VIEW_W / 2; const CY = 54; const RADIUS = 26; // The cube mirrors the element's orientation 1:1 — no decorative viewing camera, // so at rotation 0/0/0 it faces front (flat) exactly like the un-rotated element. // The X/Y/Z axis gizmo keeps the flat-at-rest state readable. const SENSITIVITY = 0.6; // degrees per pixel of drag /** * Draggable 3D-orientation cube. Drag to tilt (X/Y); Shift-drag to roll (Z). * Presentational only: emits a live draft pose while dragging and a final pose * on release — the parent owns live-previewing and committing to GSAP props. */ // transformPerspective (px) drives the cube's weak-perspective projection; // 0 = off → flattest (largest projection distance). const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14); export function Transform3DCube({ pose, perspective = 0, defaultPerspective = 0, z = 0, onPoseDraft, onPoseCommit, onDepthDraft, onDepthCommit, onRecenter, onKeyframe, keyframed, }: { pose: CubePose; /** Element's transformPerspective (px); drives the cube's foreshortening. */ perspective?: number; /** Comp-derived lens used for depth feedback before a perspective is committed. */ defaultPerspective?: number; /** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */ z?: number; /** Fires on every drag move with the in-progress pose (parent live-previews). */ onPoseDraft?: (pose: CubePose) => void; /** Fires once on pointer release with the final pose (commit). */ onPoseCommit: (pose: CubePose) => void; /** Live depth (translateZ px) during a scroll; parent live-previews it. */ onDepthDraft?: (z: number) => void; /** Committed depth (translateZ px) once a scroll burst settles. */ onDepthCommit?: (z: number) => void; /** Reset to identity orientation. */ onRecenter?: () => void; /** Toggle keyframing the 3D transform (convert the static set → keyframes). */ onKeyframe?: () => void; /** Whether the 3D transform is already keyframed (drives the toggle's state). */ keyframed?: boolean; }) { const [draft, setDraft] = useState(null); const [depthDraft, setDepthDraft] = useState(null); const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null); const shown = draft ?? pose; const shownZ = depthDraft ?? z; // Scroll over the cube to push the element along Z (depth) — matches the // studio's "scroll = z depth" gesture-recording convention. A non-passive // listener is required so preventDefault can stop the panel from scrolling. const svgRef = useRef(null); // Perspective lens (committed, else the comp-derived default the panel will // apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth // can't cross the lens. Defined here so the wheel handler can read it via the ref. const lens = perspective > 0 ? perspective : defaultPerspective; const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens }); depthRef.current = { z, onDepthDraft, onDepthCommit, lens }; useEffect(() => { const el = svgRef.current; if (!el) return; let pending: number | null = null; let timer: ReturnType | null = null; const onWheel = (e: WheelEvent) => { const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current; if (!commit) return; e.preventDefault(); // ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if // it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer. let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25); // Clamp depth in front of the perspective lens. At z ≥ lens the element sits // at/behind the virtual camera and the projection lens/(lens−z) blows up or // inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short // of the lens; allow pushing well back (smaller) but not absurdly far. const L = depthRef.current.lens; if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4)); pending = next; draft?.(pending); setDepthDraft(pending); // live-scale the cube while scrolling if (timer) clearTimeout(timer); timer = setTimeout(() => { if (pending != null) commit(pending); pending = null; setDepthDraft(null); // fall back to the committed z prop }, 160); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => { el.removeEventListener("wheel", onWheel); if (timer) clearTimeout(timer); }; }, []); // Depth feedback: the cube scales like the element would — translateZ(z) under // a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger, // farther (z<0) smaller. Use the committed perspective, else the comp-derived // lens the panel is about to apply — same value in both, so the cube doesn't // jump when the commit lands. If neither is known, skip the scale (no lens). const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1; const projOpts = { cx: CX, cy: CY, r: RADIUS * depthScale, persp: pxToProjPersp(lens), }; // The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX // and RotateZ act in planes that contain Y, so they read inverted in the gizmo // unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the // cube's orientation a true mirror of the element. const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts); const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts); const onPointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); dragRef.current = { x: e.clientX, y: e.clientY, pose: shown }; setDraft(shown); }; const onPointerMove = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; const dx = e.clientX - d.x; const dy = e.clientY - d.y; // dy→rotationX and shift dx→rotationZ are negated to match the projection's // sign flip (above), so the cube's response to a drag is unchanged while the // element now rotates in lock-step with it. const next: CubePose = e.shiftKey ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) } : { rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY), rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY), rotationZ: d.pose.rotationZ, }; setDraft(next); onPoseDraft?.(next); }; const onPointerUp = () => { if (!dragRef.current) return; dragRef.current = null; if (draft) onPoseCommit(draft); setDraft(null); }; return (
{/* Soft halo so the cube floats; SourceGraphic stays crisp on top. */} {/* Grounding shadow under the cube. */} {/* Away-facing axes are drawn behind the cube, dimmed. */} {axes .filter((a) => !a.front) .map((a) => ( ))} {faces.map((f) => ( ))} {/* Toward-facing axes on top, with a tip dot + X/Y/Z label. */} {axes .filter((a) => a.front) .map((a) => ( {a.id.toUpperCase()} ))} {onRecenter && ( )} {onKeyframe && ( )}
); }