fix: Restore UVEditor and implement AI texture editing
Build and Deploy / build-and-push (push) Successful in 1m21s
Build and Deploy / build-and-push (push) Successful in 1m21s
This commit is contained in:
@@ -196,6 +196,338 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
|
|||||||
console.error("Canvas render error (likely CORS taint):", e);
|
console.error("Canvas render error (likely CORS taint):", e);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
}, [canvasW, canvasH]);
|
||||||
|
|
||||||
|
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: 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<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 ? 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 <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 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 (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* AI Layer Edit */}
|
||||||
|
<div className="h-px bg-zinc-700 my-1" />
|
||||||
|
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center justify-between">
|
||||||
|
AI Texture Edit
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{aiPreviewUrl ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-[10px] text-zinc-400">Review AI Generation:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleApproveEdit} className="flex-1 bg-green-500 hover:bg-green-600 text-white py-1.5 rounded text-xs font-semibold">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button onClick={handleRejectEdit} className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-xs font-semibold">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleAiEdit(true)} disabled={isEditingLayer} className="w-full bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 py-1.5 rounded text-xs font-semibold flex items-center justify-center gap-2">
|
||||||
|
{isEditingLayer ? <Loader2 size={12} className="animate-spin" /> : null} Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={aiPrompt}
|
||||||
|
onChange={e => setAiPrompt(e.target.value)}
|
||||||
|
placeholder="e.g. Turn into a rusty metal texture"
|
||||||
|
className="w-full bg-zinc-900 border border-zinc-700 rounded p-2 text-xs text-zinc-200 placeholder-zinc-600 resize-none h-16 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAiEdit(false)}
|
||||||
|
disabled={isEditingLayer || !aiPrompt.trim()}
|
||||||
|
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white py-1.5 rounded text-xs font-semibold flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isEditingLayer ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
|
Generate Edit
|
||||||
|
</button>
|
||||||
|
</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%)' }}
|
style={{ background: 'radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f13 100%)' }}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
|
|||||||
Reference in New Issue
Block a user