import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; import { cn } from "@/lib/utils"; import "./canvas-angle-scene.css"; /** 预览交互范围对齐 recombyn:俯仰 ±60;水平保留 ±180 以便「背面」绕到主体后方。 */ const rotateMin = -180; const rotateMax = 180; const tiltMin = -60; const tiltMax = 60; const rotateStep = 5; const tiltStep = 5; const orbitRingY = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165] as const; const orbitRingX = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165] as const; const cameraOrbitRadius = 75; const cameraSightLine = 67; const subjectMaxBox = 64; const cubeSize = 100; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function snapInt(value: number, min: number, max: number) { return Math.round(clamp(value, min, max)); } function visualScale(distance: number) { if (distance <= 3) return 0.78; if (distance >= 7) return 1; return 0.88; } function subjectBoxSize(naturalW: number, naturalH: number) { const scale = Math.min(subjectMaxBox / Math.max(1, naturalW), subjectMaxBox / Math.max(1, naturalH)); return { width: Math.max(20, Math.round(naturalW * scale)), height: Math.max(20, Math.round(naturalH * scale)) }; } export function CanvasAngleScene({ mode, rotate, tilt, distance, imageSrc, faceLabels, dirLabels, onRotateChange, onTiltChange, }: { mode: "camera" | "skybox"; rotate: number; tilt: number; distance: number; imageSrc: string; faceLabels: { back: string; right: string; left: string; top: string; bottom: string }; dirLabels: { up: string; down: string; left: string; right: string }; onRotateChange: (value: number) => void; onTiltChange: (value: number) => void; }) { const scale = visualScale(distance); const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); const [dragging, setDragging] = useState(false); const sceneRef = useRef(null); const dragPointer = useRef(null); const dragStart = useRef<{ x: number; y: number; rotate: number; tilt: number } | null>(null); const rotateRef = useRef(onRotateChange); const tiltRef = useRef(onTiltChange); const modeRef = useRef(mode); rotateRef.current = onRotateChange; tiltRef.current = onTiltChange; modeRef.current = mode; useEffect(() => { setNaturalSize(null); let cancelled = false; const image = new Image(); image.onload = () => { if (cancelled) return; const w = image.naturalWidth || image.width; const h = image.naturalHeight || image.height; if (w > 0 && h > 0) setNaturalSize({ w, h }); }; image.src = imageSrc; return () => { cancelled = true; }; }, [imageSrc]); const subjectSize = useMemo(() => { if (!naturalSize) return { width: subjectMaxBox, height: subjectMaxBox }; return subjectBoxSize(naturalSize.w, naturalSize.h); }, [naturalSize]); const screenBg = useMemo(() => ({ backgroundImage: `url("${imageSrc}")`, backgroundSize: "contain", backgroundPosition: "center", backgroundRepeat: "no-repeat", }), [imageSrc]); useEffect(() => { if (!dragging) return; const onMove = (event: PointerEvent) => { if (dragPointer.current == null || event.pointerId !== dragPointer.current || !dragStart.current) return; const dx = event.clientX - dragStart.current.x; const dy = event.clientY - dragStart.current.y; rotateRef.current(snapInt(dragStart.current.rotate + dx / 1.8, rotateMin, rotateMax)); const tiltDelta = modeRef.current === "skybox" ? dy / 2.2 : -dy / 2.2; tiltRef.current(snapInt(dragStart.current.tilt + tiltDelta, tiltMin, tiltMax)); }; const onUp = (event: PointerEvent) => { if (dragPointer.current == null || event.pointerId !== dragPointer.current) return; const start = dragStart.current; if (start) { const dx = event.clientX - start.x; const dy = event.clientY - start.y; rotateRef.current(snapInt(start.rotate + dx / 1.8, rotateMin, rotateMax)); const tiltDelta = modeRef.current === "skybox" ? dy / 2.2 : -dy / 2.2; tiltRef.current(snapInt(start.tilt + tiltDelta, tiltMin, tiltMax)); } dragPointer.current = null; dragStart.current = null; setDragging(false); const el = sceneRef.current; if (el?.hasPointerCapture?.(event.pointerId)) { try { el.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; }, [dragging]); const onPointerDown = (event: ReactPointerEvent) => { if ((event.target as HTMLElement).closest(".canvas-angle-dir")) return; event.preventDefault(); event.stopPropagation(); dragPointer.current = event.pointerId; dragStart.current = { x: event.clientX, y: event.clientY, rotate, tilt }; setDragging(true); try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* ignore */ } }; const bumpRotate = (delta: number) => onRotateChange(snapInt(rotate + delta, rotateMin, rotateMax)); const bumpTilt = (delta: number) => onTiltChange(snapInt(tilt + delta, tiltMin, tiltMax)); const cubeStyle = { width: cubeSize, height: cubeSize, ["--angle-cube-half" as string]: `${cubeSize / 2}px` } as CSSProperties; const dirButtons = ( <> ); if (mode === "skybox") { return (
{faceLabels.back}
{faceLabels.right}
{faceLabels.left}
{faceLabels.top}
{faceLabels.bottom}
); } return (
{orbitRingY.map((deg) =>
)} {orbitRingX.map((deg) =>
)}
{dirButtons}
); }