Files
titan_react_node_sqllite/src/components/UVEditor.tsx
T

501 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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[];
}
const CANVAS_SIZE = 1024;
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<Layer[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [mappingType, setMappingType] = useState<string>(targetObject?.materialProps?.mappingType || 'uv');
const [bgColor, setBgColor] = useState(initBg);
const [bgTransparent, setBgTransparent] = useState(initTrans);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragRef = useRef<{ handle: Handle; startX: number; startY: number; layer: Layer } | null>(null);
useEffect(() => {
const saved = targetObject?.materialProps?.uvLayers;
if (saved?.length) {
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 = () => {
// Automatically scale the image so it fits the 1024x1024 canvas exactly
const scaleX = CANVAS_SIZE / img.width;
const scaleY = CANVAS_SIZE / img.height;
const layer: Layer = {
id: `l-base`,
url,
img,
x: CANVAS_SIZE / 2,
y: CANVAS_SIZE / 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 = c.height = CANVAS_SIZE;
const ctx = c.getContext('2d')!;
if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); }
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 {
return c.toDataURL(transparent ? 'image/png' : 'image/jpeg');
} catch (e) {
console.error("Canvas render error (likely CORS taint):", e);
return "";
}
}, []);
useEffect(() => {
setPreviewUrl(renderCanvas(layers, bgTransparent, bgColor));
}, [layers, bgTransparent, bgColor]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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: CANVAS_SIZE / 2, y: CANVAS_SIZE / 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<Layer>) => 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 ? CANVAS_SIZE / 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(CANVAS_SIZE, nx));
ny = Math.max(0, Math.min(CANVAS_SIZE, 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 - CANVAS_SIZE / 2) < SNAP_PX) nx = CANVAS_SIZE / 2;
if (Math.abs(ny - CANVAS_SIZE / 2) < SNAP_PX) ny = CANVAS_SIZE / 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 <mesh><boxGeometry /><meshStandardMaterial /></mesh>;
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 <mesh><boxGeometry args={[1, 1, 1]} /><meshStandardMaterial map={tex ?? undefined} /></mesh>;
}
return <ModelViewer object={cloned} sceneObjects={mockObjs} />;
};
// 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 pct = (v: number) => `${(v / CANVAS_SIZE) * 100}%`;
const w = (iw * layer.scaleX / CANVAS_SIZE) * 100;
const h = (ih * layer.scaleY / CANVAS_SIZE) * 100;
const lx = (layer.x / CANVAS_SIZE) * 100;
const ly = (layer.y / CANVAS_SIZE) * 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 (
<div style={{
position: 'absolute',
left: `${lx}%`, top: `${ly}%`,
width: `${w}%`, height: `${h}%`,
transform: `translate(-50%,-50%) rotate(${layer.rotation}deg)`,
border: '1.5px solid #3b82f6',
pointerEvents: 'none',
zIndex: 5,
}}>
{/* Corners - scale */}
{(['NW','NE','SW','SE'] as const).map(c => (
<div key={c}
style={{
...cornerStyle(
c.includes('W') ? '0%' : '100%',
c.includes('N') ? '0%' : '100%',
c === 'NW' || c === 'SE' ? 'nwse-resize' : 'nesw-resize'
), pointerEvents: 'all'
}}
onMouseDown={e => 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 }) => (
<div key={id} style={{ ...edgeStyle(cx, cy, cur), pointerEvents: 'all' }}
onMouseDown={e => onHandleDown(e, `scale${id}` as Handle, layer)} />
))}
{/* Rotate handle - top center above box */}
<div style={{
position: 'absolute', left: '50%', top: -28,
transform: 'translateX(-50%)',
width: 14, height: 14,
background: '#f59e0b', border: '2px solid #fff', borderRadius: '50%',
cursor: 'grab', zIndex: 10, pointerEvents: 'all',
}} onMouseDown={e => onHandleDown(e, 'rotate', layer)} title="Rotate" />
{/* Line from box to rotate handle */}
<div style={{ position: 'absolute', left: '50%', top: -20, width: 2, height: 20, background: '#3b82f6', transform: 'translateX(-50%)', pointerEvents: 'none' }} />
</div>
);
};
return (
<div className="fixed inset-0 z-[200] flex flex-col text-zinc-100" style={{ fontFamily: 'Inter,sans-serif', background: '#0f0f13' }}>
{/* Top Bar */}
<div className="h-11 border-b border-zinc-800 bg-zinc-900/95 flex items-center justify-between px-4 flex-shrink-0 backdrop-blur">
<div className="flex items-center gap-3">
<button onClick={onClose} className="p-1.5 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors">
<X size={16} />
</button>
<div className="w-px h-4 bg-zinc-700" />
<span className="text-sm font-semibold">UV Map</span>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-zinc-400 cursor-pointer select-none">
<input type="checkbox" checked={bgTransparent} onChange={e => setBgTransparent(e.target.checked)} className="accent-blue-500 w-3 h-3" />
Transparent
</label>
{!bgTransparent && (
<label className="relative flex items-center gap-1.5 cursor-pointer">
<div className="w-5 h-5 rounded border border-zinc-600 overflow-hidden relative">
<div className="absolute inset-0" style={{ background: bgColor }} />
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} className="opacity-0 absolute inset-0 w-full h-full cursor-pointer" />
</div>
<span className="text-[10px] font-mono text-zinc-400">{bgColor}</span>
</label>
)}
<button onClick={handleSave} className="px-4 py-1.5 bg-blue-500 hover:bg-blue-600 rounded-lg text-sm font-semibold transition-colors">
Apply to Model
</button>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left sidebar */}
<div className="w-60 bg-zinc-900 border-r border-zinc-800 flex flex-col overflow-y-auto flex-shrink-0">
<div className="p-4 flex flex-col gap-3">
<input type="file" ref={fileInputRef} className="hidden" accept=".jpg,.jpeg,.png,.webp,.svg" onChange={handleFileUpload} />
<button onClick={() => fileInputRef.current?.click()}
className="w-full py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 border border-zinc-700 transition-colors">
<Upload size={14} /> Add Image
</button>
<div className="h-px bg-zinc-800" />
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Layers</p>
{layers.length === 0 && (
<div className="p-5 text-center text-zinc-600 text-xs border-2 border-dashed border-zinc-800 rounded-xl">Upload an image to start</div>
)}
{[...layers].reverse().map((layer, i) => (
<div key={layer.id} onClick={() => 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' : ''}`}>
<img src={layer.url} alt="" className="w-8 h-8 object-contain rounded bg-zinc-900 border border-zinc-700 flex-shrink-0" />
<span className="text-xs text-zinc-300 flex-1 truncate">Layer {layers.length - i}</span>
<button onClick={e => { e.stopPropagation(); updateLayer(layer.id, { visible: !layer.visible }); }}
className="p-1 text-zinc-600 hover:text-blue-400 rounded" title={layer.visible ? 'Hide layer' : 'Show layer'}>
{layer.visible !== false ? <Eye size={11} /> : <EyeOff size={11} />}
</button>
<button onClick={e => { e.stopPropagation(); setLayers(ls => ls.filter(l => l.id !== layer.id)); if (selectedId === layer.id) setSelectedId(null); }}
className="p-1 text-zinc-600 hover:text-red-400 rounded">
<Trash2 size={11} />
</button>
</div>
))}
{/* Layer props */}
{selectedLayer && (
<div className="mt-1 flex flex-col gap-2 p-3 rounded-xl border border-zinc-800 bg-zinc-800/20">
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Transform</p>
<div className="grid grid-cols-2 gap-2">
{(['x', 'y'] as const).map(ax => (
<div key={ax}>
<p className="text-[9px] text-zinc-500 uppercase mb-0.5">{ax}</p>
<input type="number" step="1" value={Math.round(selectedLayer[ax])}
onChange={e => 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" />
</div>
))}
</div>
<div className="grid grid-cols-2 gap-2">
{(['scaleX', 'scaleY'] as const).map(ax => (
<div key={ax}>
<p className="text-[9px] text-zinc-500 uppercase mb-0.5">{ax === 'scaleX' ? 'Scl X' : 'Scl Y'}</p>
<input type="number" step="0.01" min="0.01" value={selectedLayer[ax].toFixed(2)}
onChange={e => 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" />
</div>
))}
</div>
<div>
<div className="flex justify-between text-[9px] text-zinc-500 uppercase mb-1">
<span>Rotation</span><span className="text-blue-400 font-mono">{Math.round(selectedLayer.rotation)}°</span>
</div>
<input type="range" min="-180" max="180" step="1" value={selectedLayer.rotation}
onChange={e => updateLayer(selectedLayer.id, { rotation: +e.target.value })}
className="w-full h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-blue-500" />
</div>
<div className="h-px bg-zinc-700 my-1" />
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Crop</p>
{(['cropL', 'cropT', 'cropR', 'cropB'] as const).map(s => (
<div key={s} className="flex items-center gap-2">
<span className="text-[9px] text-zinc-500 w-6 uppercase">{s.slice(4)}</span>
<input type="range" min="0" max="0.49" step="0.01" value={selectedLayer[s]}
onChange={e => updateLayer(selectedLayer.id, { [s]: +e.target.value })}
className="flex-1 h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-orange-500" />
<span className="text-[9px] font-mono text-orange-400 w-6 text-right">{Math.round(selectedLayer[s] * 100)}%</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Canvas area */}
<div className="flex-1 flex items-center justify-center overflow-hidden relative"
style={{ background: 'radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f13 100%)' }}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onClick={() => setSelectedId(null)}>
<div ref={containerRef} className="relative shadow-2xl border border-zinc-700/50"
style={{
width: 'min(68vh, 90%)', aspectRatio: '1/1',
background: bgTransparent
? 'repeating-conic-gradient(#27272a 0% 25%, #18181b 0% 50%) 0 0 / 16px 16px'
: bgColor
}}>
{/* 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 (
<div key={layer.id}
onMouseDown={e => { e.stopPropagation(); onHandleDown(e, 'move', layer); }}
className="absolute cursor-move"
style={{
left: `${(layer.x / CANVAS_SIZE) * 100}%`,
top: `${(layer.y / CANVAS_SIZE) * 100}%`,
width: `${(iw / CANVAS_SIZE) * 100}%`,
height: `${(ih / CANVAS_SIZE) * 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 && (
<div style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
<img src={layer.url} alt=""
style={{
position: 'relative',
left: `${-layer.cropL * 100}%`,
top: `${-layer.cropT * 100}%`,
width: `${(1 / (1 - layer.cropL - layer.cropR)) * 100}%`,
height: `${(1 / (1 - layer.cropT - layer.cropB)) * 100}%`,
display: 'block',
pointerEvents: 'none',
imageRendering: 'auto'
}} />
</div>
)}
</div>
);
})}
{/* Bounding box overlays - only for visible selected layer */}
{layers.map(layer => selectedId === layer.id && layer.visible !== false && <BoundingBox key={`bb-${layer.id}`} layer={layer} />)}
<div className="absolute bottom-2 left-2 bg-black/50 text-[10px] text-zinc-400 px-2 py-0.5 rounded font-mono select-none">
{CANVAS_SIZE}×{CANVAS_SIZE}
</div>
</div>
</div>
{/* Right panel - preview */}
<div className="w-72 bg-zinc-900 border-l border-zinc-800 flex flex-col p-4 gap-4 flex-shrink-0 overflow-y-auto">
<div>
<div className="flex items-center gap-2 mb-2">
<RotateCw size={11} className="text-zinc-500" />
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">3D Preview</span>
</div>
<div className="w-full aspect-square bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800">
<Canvas shadows camera={{ position: [2, 2, 2], fov: 45 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
<Environment preset="studio" />
<Suspense fallback={null}><PreviewModel /></Suspense>
<OrbitControls autoRotate autoRotateSpeed={1.5} />
</Canvas>
</div>
</div>
<div className="p-3 bg-zinc-800/30 rounded-xl border border-zinc-800 text-zinc-500">
<p className="text-[10px] font-bold uppercase tracking-widest mb-2">Legend</p>
<div className="flex flex-col gap-1.5 text-[10px]">
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-white border-2 border-blue-500" /><span>Drag corner/edge uniform scale</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-blue-400 border-2 border-blue-500 flex items-center justify-center text-[7px] font-bold text-white"></div><span>Shift + drag free X/Y stretch</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-amber-400" /><span>Drag top handle rotate</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-zinc-600 border border-zinc-500 cursor-move" /><span>Drag layer move (Shift snaps)</span></div>
</div>
</div>
</div>
</div>
</div>
);
}