import React, { useState, useRef, useEffect, Suspense, useMemo, useCallback } from 'react'; import { X, Upload, Trash2, RotateCw, Eye, EyeOff } from 'lucide-react'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Environment } from '@react-three/drei'; import * as THREE from 'three'; import { ModelViewer } from './ModelViewer'; interface Layer { id: string; url: string; img?: HTMLImageElement; x: number; y: number; scaleX: number; scaleY: number; rotation: number; cropL: number; cropT: number; cropR: number; cropB: number; visible: boolean; } type Handle = 'move' | 'rotate' | 'scaleNW' | 'scaleNE' | 'scaleSW' | 'scaleSE' | 'scaleN' | 'scaleS' | 'scaleE' | 'scaleW'; interface UVEditorProps { onClose: () => void; onSave: (dataUrl: string, mappingType: string, layers: any[], bgColor: string, bgTransparent: boolean) => void; targetObject?: any; loadedModel?: THREE.Group | null; sceneObjects?: any[]; } function deg2rad(d: number) { return d * Math.PI / 180; } export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObjects }: UVEditorProps) { const initBg = targetObject?.materialProps?.uvBackground ?? '#1a1a2e'; const initTrans = targetObject?.materialProps?.uvTransparent ?? false; const [layers, setLayers] = useState([]); const [selectedId, setSelectedId] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [mappingType, setMappingType] = useState(targetObject?.materialProps?.mappingType || 'uv'); const [bgColor, setBgColor] = useState(initBg); const [bgTransparent, setBgTransparent] = useState(initTrans); const [canvasW, setCanvasW] = useState(1024); const [canvasH, setCanvasH] = useState(1024); const containerRef = useRef(null); const fileInputRef = useRef(null); const dragRef = useRef<{ handle: Handle; startX: number; startY: number; layer: Layer } | null>(null); useEffect(() => { const saved = targetObject?.materialProps?.uvLayers; if (saved?.length) { setCanvasW(1024); setCanvasH(1024); let n = 0; const init: Layer[] = saved.map((l: any) => ({ ...l })); init.forEach(layer => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { layer.img = img; n++; if (n === init.length) setLayers([...init]); }; img.src = layer.url; }); } else if (targetObject?.materialProps?.map) { // If no saved layers but there's a base map texture, load it as the initial layer const url = targetObject.materialProps.map; const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { // Reduced MAX size from 2048 to 1024 to prevent huge base64 strings // that trigger the 1MB Nginx reverse proxy 413 Payload Too Large limit. const MAX = 1024; let scale = 1; if (img.width > MAX || img.height > MAX) { scale = MAX / Math.max(img.width, img.height); } const cw = Math.round(img.width * scale); const ch = Math.round(img.height * scale); setCanvasW(cw); setCanvasH(ch); const scaleX = cw / img.width; const scaleY = ch / img.height; const layer: Layer = { id: `l-base`, url, img, x: cw / 2, y: ch / 2, scaleX, scaleY, rotation: 0, cropL: 0, cropT: 0, cropR: 0, cropB: 0, visible: true }; setLayers([layer]); setSelectedId(layer.id); }; img.src = url; } }, [targetObject]); const renderCanvas = useCallback((lrs: Layer[], transparent: boolean, bg: string): string => { const c = document.createElement('canvas'); c.width = canvasW; c.height = canvasH; const ctx = c.getContext('2d')!; if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, canvasW, canvasH); } lrs.forEach(layer => { if (!layer.img || layer.visible === false) return; const iw = layer.img.width, ih = layer.img.height; const sx = layer.cropL * iw, sy = layer.cropT * ih; const sw = (1 - layer.cropL - layer.cropR) * iw; const sh = (1 - layer.cropT - layer.cropB) * ih; ctx.save(); ctx.translate(layer.x, layer.y); ctx.rotate(deg2rad(layer.rotation)); ctx.scale(layer.scaleX, layer.scaleY); ctx.drawImage(layer.img, sx, sy, sw, sh, -sw / 2, -sh / 2, sw, sh); ctx.restore(); }); try { // Use JPEG with 0.75 quality to heavily compress the image size // to avoid hitting the 1MB Nginx reverse proxy limit. return c.toDataURL(transparent ? 'image/png' : 'image/jpeg', 0.75); } catch (e) { console.error("Canvas render error (likely CORS taint):", e); return ""; } }, [canvasW, canvasH]); useEffect(() => { setPreviewUrl(renderCanvas(layers, bgTransparent, bgColor)); }, [layers, bgTransparent, bgColor]); const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { const url = ev.target?.result as string; const img = new Image(); img.onload = () => { const nl: Layer = { id: `l-${Date.now()}`, url, img, x: canvasW / 2, y: canvasH / 2, scaleX: 1, scaleY: 1, rotation: 0, cropL: 0, cropT: 0, cropR: 0, cropB: 0, visible: true }; setLayers(p => [...p, nl]); setSelectedId(nl.id); }; img.src = url; }; reader.readAsDataURL(file); e.target.value = ''; }; const updateLayer = (id: string, u: Partial) => setLayers(p => p.map(l => l.id === id ? { ...l, ...u } : l)); const selectedLayer = layers.find(l => l.id === selectedId) ?? null; const getSF = () => { const r = containerRef.current?.getBoundingClientRect(); return r ? canvasW / r.width : 1; }; const onHandleDown = (e: React.MouseEvent, handle: Handle, layer: Layer) => { e.stopPropagation(); setSelectedId(layer.id); dragRef.current = { handle, startX: e.clientX, startY: e.clientY, layer: { ...layer } }; e.preventDefault(); }; // Snap helper: snap value to nearest multiple of snapSize const snap = (v: number, snapSize: number) => Math.round(v / snapSize) * snapSize; const onMouseMove = (e: React.MouseEvent) => { const d = dragRef.current; if (!d) return; const sf = getSF(); const dx = (e.clientX - d.startX) * sf; const dy = (e.clientY - d.startY) * sf; const isShift = e.shiftKey; const SNAP_PX = 16; // grid snap in canvas units if (d.handle === 'move') { let nx = d.layer.x + dx; let ny = d.layer.y + dy; // Clamp to canvas bounds with soft margin nx = Math.max(0, Math.min(canvasW, nx)); ny = Math.max(0, Math.min(canvasH, ny)); // Hold Shift to snap to grid if (isShift) { nx = snap(nx, SNAP_PX); ny = snap(ny, SNAP_PX); } // Center snapping: snap near canvas center if (Math.abs(nx - canvasW / 2) < SNAP_PX) nx = canvasW / 2; if (Math.abs(ny - canvasH / 2) < SNAP_PX) ny = canvasH / 2; updateLayer(d.layer.id, { x: nx, y: ny }); } else if (d.handle === 'rotate') { let rot = d.layer.rotation + dx / 2; // Snap rotation to 15° increments when Shift held if (isShift) rot = snap(rot, 15); updateLayer(d.layer.id, { rotation: rot }); } else if (d.handle.startsWith('scale')) { if (isShift) { // Shift held: stretch X and Y independently const sx = Math.max(0.05, d.layer.scaleX + dx / 200); const sy = Math.max(0.05, d.layer.scaleY + dy / 200); updateLayer(d.layer.id, { scaleX: sx, scaleY: sy }); } else { // No shift: uniform scale driven by the larger axis const delta = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / 200; const sx = Math.max(0.05, d.layer.scaleX + delta); const sy = Math.max(0.05, d.layer.scaleY + delta); updateLayer(d.layer.id, { scaleX: sx, scaleY: sy }); } } }; const onMouseUp = () => { dragRef.current = null; }; const handleSave = () => { if (previewUrl) { const serialized = layers.map(({ img, ...rest }) => rest); onSave(previewUrl, mappingType, serialized, bgColor, bgTransparent); } onClose(); }; const PreviewModel = () => { const mockObjs = useMemo(() => { if (!sceneObjects) return []; return sceneObjects.map(obj => obj.id === targetObject?.id ? { ...obj, materialProps: { ...obj.materialProps, map: previewUrl, mappingType } } : obj ); }, [sceneObjects, previewUrl, mappingType]); const cloned = useMemo(() => loadedModel?.clone() ?? null, [loadedModel]); if (!targetObject) return ; if (targetObject.type === 'cube') { const tex = previewUrl ? (() => { const t = new THREE.TextureLoader().load(previewUrl); t.colorSpace = THREE.SRGBColorSpace; t.flipY = false; return t; })() : null; return ; } return ; }; // Bounding box overlay rendered on canvas div const BoundingBox = ({ layer }: { layer: Layer }) => { const iw = (layer.img?.width ?? 200) * (1 - layer.cropL - layer.cropR); const ih = (layer.img?.height ?? 200) * (1 - layer.cropT - layer.cropB); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return null; const w = (iw * layer.scaleX / canvasW) * 100; const h = (ih * layer.scaleY / canvasH) * 100; const lx = (layer.x / canvasW) * 100; const ly = (layer.y / canvasH) * 100; const cornerStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({ position: 'absolute', left: cx, top: cy, width: 10, height: 10, background: '#ffffff', border: '2px solid #3b82f6', borderRadius: 2, transform: 'translate(-50%,-50%)', cursor, zIndex: 10, touchAction: 'none' }); const edgeStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({ position: 'absolute', left: cx, top: cy, width: 8, height: 8, background: '#3b82f6', borderRadius: '50%', transform: 'translate(-50%,-50%)', cursor, zIndex: 10 }); return (
{/* Corners - scale */} {(['NW','NE','SW','SE'] as const).map(c => (
onHandleDown(e, `scale${c}` as Handle, layer)} /> ))} {/* Edge midpoints - scale */} {[ { id: 'N', cx: '50%', cy: '0%', cur: 'ns-resize' }, { id: 'S', cx: '50%', cy: '100%', cur: 'ns-resize' }, { id: 'E', cx: '100%', cy: '50%', cur: 'ew-resize' }, { id: 'W', cx: '0%', cy: '50%', cur: 'ew-resize' }, ].map(({ id, cx, cy, cur }) => (
onHandleDown(e, `scale${id}` as Handle, layer)} /> ))} {/* Rotate handle - top center above box */}
onHandleDown(e, 'rotate', layer)} title="Rotate" /> {/* Line from box to rotate handle */}
); }; return (
{/* Top Bar */}
UV Map
{!bgTransparent && (
{/* Left sidebar */}

Layers

{layers.length === 0 && (
Upload an image to start
)} {[...layers].reverse().map((layer, i) => (
setSelectedId(layer.id)} className={`flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all ${selectedId === layer.id ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-800 hover:border-zinc-600 bg-zinc-800/20'} ${!layer.visible ? 'opacity-40' : ''}`}> Layer {layers.length - i}
))} {/* Layer props */} {selectedLayer && (

Transform

{(['x', 'y'] as const).map(ax => (

{ax}

updateLayer(selectedLayer.id, { [ax]: +e.target.value })} className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" />
))}
{(['scaleX', 'scaleY'] as const).map(ax => (

{ax === 'scaleX' ? 'Scl X' : 'Scl Y'}

updateLayer(selectedLayer.id, { [ax]: Math.max(0.01, +e.target.value) })} className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" />
))}
Rotation{Math.round(selectedLayer.rotation)}°
updateLayer(selectedLayer.id, { rotation: +e.target.value })} className="w-full h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-blue-500" />

Crop

{(['cropL', 'cropT', 'cropR', 'cropB'] as const).map(s => (
{s.slice(4)} updateLayer(selectedLayer.id, { [s]: +e.target.value })} className="flex-1 h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-orange-500" /> {Math.round(selectedLayer[s] * 100)}%
))}
)}
{/* Canvas area */}
setSelectedId(null)}>
{/* Layers */} {layers.map(layer => { if (layer.visible === false) return null; const iw = (layer.img?.width ?? 200) * (1 - layer.cropL - layer.cropR); const ih = (layer.img?.height ?? 200) * (1 - layer.cropT - layer.cropB); const isSelected = selectedId === layer.id; return (
{ e.stopPropagation(); onHandleDown(e, 'move', layer); }} className="absolute cursor-move" style={{ left: `${(layer.x / canvasW) * 100}%`, top: `${(layer.y / canvasH) * 100}%`, width: `${(iw / canvasW) * 100}%`, height: `${(ih / canvasH) * 100}%`, transform: `translate(-50%,-50%) rotate(${layer.rotation}deg) scale(${layer.scaleX},${layer.scaleY})`, zIndex: isSelected ? 4 : 2, transition: 'transform 0.04s linear', }}> {layer.img && (
)}
); })} {/* Bounding box overlays - only for visible selected layer */} {layers.map(layer => selectedId === layer.id && layer.visible !== false && )}
{canvasW}x{canvasH}
{/* Right panel - preview */}
3D Preview

Legend

Drag corner/edge — uniform scale
Shift + drag — free X/Y stretch
Drag top handle — rotate
Drag layer — move (Shift snaps)
); }