PaperDrawing / src /App.jsx
MAO
UI/UX Overhaul: v3.1 - Enhanced Drag & Drop, Compact Status Bar, and Interaction Improvements
2e8a9d1
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Upload, Plus, Eye, EyeOff, Trash2, Save, FolderOpen, Download, Target, MousePointer2, Info, Search } from 'lucide-react';
// --- Geometry Helper Functions ---
const dist = (p1, p2) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
/**
* Gets the closest point on a line segment [a, b] from point p.
*/
const getClosestPointOnSegment = (p, a, b) => {
if (!a || !b) return { x: 0, y: 0 };
const l2 = Math.pow(dist(a, b), 2);
if (l2 === 0) return a;
let t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2;
t = Math.max(0, Math.min(1, t));
return { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) };
};
const distToSegment = (p, a, b) => {
const closest = getClosestPointOnSegment(p, a, b);
return dist(p, closest);
};
const getRandomColor = () => {
const h = Math.floor(Math.random() * 360);
return `hsl(${h}, 75%, 60%)`;
};
// --- Magnifier Component ---
function Magnifier({ imgUrl, regions, selectedId, mousePos, side, imgSize, focusModeB, hoveredPoint, draggingPoint }) {
if (!imgUrl || !mousePos || mousePos.side !== side) return null;
const zoom = 2.5;
const size = 180;
const r = size / 2;
const visibleRegions = (side === 'B' && focusModeB)
? regions.filter(reg => reg.id === selectedId)
: regions;
const bgPosX = -mousePos.x * imgSize.width * zoom + r;
const bgPosY = -mousePos.y * imgSize.height * zoom + r;
return (
<div
className="absolute pointer-events-none rounded-full border-4 border-indigo-500 shadow-[0_0_30px_rgba(0,0,0,0.6)] overflow-hidden z-[100]"
style={{
width: size,
height: size,
left: mousePos.px - r,
top: mousePos.py - size - 40,
}}
>
<div
className="absolute inset-0 bg-[#111]"
style={{
backgroundImage: `url(${imgUrl})`,
backgroundSize: `${imgSize.width * zoom}px ${imgSize.height * zoom}px`,
backgroundPosition: `${bgPosX}px ${bgPosY}px`,
backgroundRepeat: 'no-repeat'
}}
/>
<svg className="absolute inset-0 w-full h-full overflow-visible" viewBox={`0 0 ${size} ${size}`}>
{visibleRegions.map(reg => {
const isSelected = reg.id === selectedId;
const points = side === 'A' ? reg.pointsA : reg.pointsB;
const localPoints = points.map(p => ({
x: (p.x - mousePos.x) * imgSize.width * zoom + r,
y: (p.y - mousePos.y) * imgSize.height * zoom + r
}));
const polyStr = localPoints.map(p => `${p.x},${p.y}`).join(' ');
return (
<g key={reg.id}>
<polygon
points={polyStr}
style={{
fill: reg.color,
fillOpacity: isSelected ? 0.35 : 0.1,
stroke: isSelected ? '#fff' : reg.color,
strokeWidth: 1.5,
}}
/>
{localPoints.map((p, idx) => {
const isDragging = draggingPoint?.regionId === reg.id && draggingPoint?.index === idx && draggingPoint.side === side;
const isHovered = !draggingPoint && hoveredPoint?.regionId === reg.id && hoveredPoint?.index === idx;
return (
<circle
key={idx}
cx={p.x} cy={p.y}
r={isSelected ? 3.5 : 1.5}
fill={(isDragging || isHovered) ? '#00ffff' : (isSelected ? '#fff' : reg.color)}
stroke="#000"
strokeWidth={0.5}
/>
);
})}
</g>
);
})}
</svg>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-[1px] bg-white/30 absolute" />
<div className="h-full w-[1px] bg-white/30 absolute" />
<div className="w-4 h-4 border border-indigo-400 rounded-full bg-indigo-500/10" />
</div>
</div>
);
}
// --- Main Application ---
export default function App() {
const [imgA, setImgA] = useState(null);
const [imgB, setImgB] = useState(null);
const [imgSizeA, setImgSizeA] = useState({ width: 0, height: 0 });
const [imgSizeB, setImgSizeB] = useState({ width: 0, height: 0 });
const [isDragOverA, setIsDragOverA] = useState(false);
const [isDragOverB, setIsDragOverB] = useState(false);
const [regions, setRegions] = useState([]);
const [selectedRegionId, setSelectedRegionId] = useState(null);
const [previewMode, setPreviewMode] = useState(false);
const [focusModeB, setFocusModeB] = useState(false);
const [hoveredEdge, setHoveredEdge] = useState(null);
const [hoveredPoint, setHoveredPoint] = useState(null);
const [draggingPoint, setDraggingPoint] = useState(null);
const [isZPressed, setIsZPressed] = useState(false);
const [magnifierPos, setMagnifierPos] = useState(null);
const imgRefA = useRef(null);
const imgRefB = useRef(null);
const canvasPreviewRef = useRef(null);
const selectedRegion = regions.find(r => r.id === selectedRegionId);
const processImageFile = (file, side) => {
if (file && file.type.startsWith('image/')) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
if (side === 'A') {
setImgA(url);
setImgSizeA({ width: img.width, height: img.height });
} else {
setImgB(url);
setImgSizeB({ width: img.width, height: img.height });
}
};
img.src = url;
}
};
const handleImageUpload = (e, side) => {
const file = e.target.files[0];
processImageFile(file, side);
};
const handleDrop = (e, side) => {
e.preventDefault();
side === 'A' ? setIsDragOverA(false) : setIsDragOverB(false);
const file = e.dataTransfer.files[0];
processImageFile(file, side);
};
const handleDragOver = (e, side) => {
e.preventDefault();
side === 'A' ? setIsDragOverA(true) : setIsDragOverB(true);
};
const handleDragLeave = (e, side) => {
e.preventDefault();
side === 'A' ? setIsDragOverA(false) : setIsDragOverB(false);
};
const handleJsonUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result);
if (Array.isArray(data)) {
setRegions(data);
if (data.length > 0) setSelectedRegionId(data[0].id);
}
} catch (err) { console.error(err); }
};
reader.readAsText(file);
e.target.value = '';
};
const addRegion = () => {
if (!imgA || !imgB) return;
const newId = crypto.randomUUID();
const newRegion = {
id: newId,
name: `Region ${regions.length + 1}`,
color: getRandomColor(),
pointsA: [{ x: 0.3, y: 0.3 }, { x: 0.7, y: 0.3 }, { x: 0.7, y: 0.7 }, { x: 0.3, y: 0.7 }],
pointsB: [{ x: 0.3, y: 0.3 }, { x: 0.7, y: 0.3 }, { x: 0.7, y: 0.7 }, { x: 0.3, y: 0.7 }]
};
setRegions([...regions, newRegion]);
setSelectedRegionId(newId);
};
const deleteRegion = (id) => {
setRegions(regions.filter(r => r.id !== id));
if (selectedRegionId === id) setSelectedRegionId(null);
setHoveredPoint(null);
setHoveredEdge(null);
};
const handlePointMouseDown = (e, side, regionId, index) => {
e.stopPropagation();
setSelectedRegionId(regionId);
setDraggingPoint({ regionId, index, side });
};
const handleMouseMove = (e, side) => {
const imgEl = side === 'A' ? imgRefA.current : imgRefB.current;
if (!imgEl) return;
const rect = imgEl.getBoundingClientRect();
let x = (e.clientX - rect.left) / rect.width;
let y = (e.clientY - rect.top) / rect.height;
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
const mousePos = { x, y };
setMagnifierPos({
x, y,
px: e.clientX - rect.left,
py: e.clientY - rect.top,
side
});
if (draggingPoint) {
if (draggingPoint.side === side) {
setRegions(prev => prev.map(r => {
if (r.id === draggingPoint.regionId) {
const key = side === 'A' ? 'pointsA' : 'pointsB';
const newPts = [...r[key]];
newPts[draggingPoint.index] = mousePos;
return { ...r, [key]: newPts };
}
return r;
}));
}
setHoveredPoint(null);
setHoveredEdge(null);
return;
}
if (selectedRegionId) {
const region = regions.find(r => r.id === selectedRegionId);
if (!region) return;
const points = side === 'A' ? region.pointsA : region.pointsB;
let foundPt = null;
for (let i = 0; i < points.length; i++) {
if (dist(mousePos, points[i]) < 0.025) {
foundPt = { regionId: region.id, index: i };
break;
}
}
if (foundPt) {
setHoveredPoint(foundPt);
setHoveredEdge(null);
} else {
setHoveredPoint(null);
let minD = Infinity;
let closestEdgeIdx = -1;
let closestProj = null;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
const d = distToSegment(mousePos, p1, p2);
if (d < minD) {
minD = d;
closestEdgeIdx = i;
closestProj = getClosestPointOnSegment(mousePos, p1, p2);
}
}
if (closestEdgeIdx !== -1) {
setHoveredEdge({
regionId: region.id,
index: closestEdgeIdx,
side,
point: closestProj,
isClose: minD < 0.025
});
} else {
setHoveredEdge(null);
}
}
} else {
setHoveredPoint(null);
setHoveredEdge(null);
}
};
const handleMouseUp = () => setDraggingPoint(null);
const handleMouseLeave = () => {
setMagnifierPos(null);
if (!draggingPoint) {
setHoveredEdge(null);
setHoveredPoint(null);
}
};
const handleDownloadResult = () => {
const canvas = canvasPreviewRef.current;
if (!canvas) return;
const link = document.createElement('a');
link.download = 'uv-wrapped-texture.png';
link.href = canvas.toDataURL('image/png');
link.click();
};
useEffect(() => {
const onKeyDown = (e) => {
const key = e.key.toLowerCase();
if (key === 'z') setIsZPressed(true);
if (key === 'a' && hoveredEdge) {
const { regionId, index, side, point, isClose } = hoveredEdge;
setRegions(prev => prev.map(r => {
if (r.id === regionId) {
const newPointsA = [...r.pointsA];
const newPointsB = [...r.pointsB];
// Check index safety
if (index >= newPointsA.length || index >= newPointsB.length) return r;
let finalPtA, finalPtB;
if (side === 'A') {
finalPtA = isClose ? point : { x: (newPointsA[index].x + newPointsA[(index + 1) % newPointsA.length].x) / 2, y: (newPointsA[index].y + newPointsA[(index + 1) % newPointsA.length].y) / 2 };
const b1 = newPointsB[index], b2 = newPointsB[(index + 1) % newPointsB.length];
finalPtB = { x: (b1.x + b2.x) / 2, y: (b1.y + b2.y) / 2 };
} else {
finalPtB = isClose ? point : { x: (newPointsB[index].x + newPointsB[(index + 1) % newPointsB.length].x) / 2, y: (newPointsB[index].y + newPointsB[(index + 1) % newPointsB.length].y) / 2 };
const a1 = newPointsA[index], a2 = newPointsA[(index + 1) % newPointsA.length];
finalPtA = { x: (a1.x + a2.x) / 2, y: (a1.y + a2.y) / 2 };
}
newPointsA.splice(index + 1, 0, finalPtA);
newPointsB.splice(index + 1, 0, finalPtB);
return { ...r, pointsA: newPointsA, pointsB: newPointsB };
}
return r;
}));
}
if ((key === 'd' || key === 'backspace' || key === 'delete') && hoveredPoint) {
const { regionId, index } = hoveredPoint;
setRegions(prev => prev.map(r => {
if (r.id === regionId && r.pointsA.length > 3) {
return {
...r,
pointsA: r.pointsA.filter((_, i) => i !== index),
pointsB: r.pointsB.filter((_, i) => i !== index)
};
}
return r;
}));
setHoveredPoint(null);
}
};
const onKeyUp = (e) => {
if (e.key.toLowerCase() === 'z') setIsZPressed(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [hoveredEdge, hoveredPoint]);
// --- Triangulation logic ---
const triangulate = (points) => {
if (points.length < 3) return [];
const indices = points.map((_, i) => i);
const result = [];
let area = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i], p2 = points[(i + 1) % points.length];
area += (p2.x - p1.x) * (p2.y + p1.y);
}
const clockwise = area > 0;
const isPointInTriangle = (p, a, b, c) => {
const areaOrig = Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y));
const area1 = Math.abs((a.x - p.x) * (b.y - p.y) - (b.x - p.x) * (a.y - p.y));
const area2 = Math.abs((b.x - p.x) * (c.y - p.y) - (c.x - p.x) * (b.y - p.y));
const area3 = Math.abs((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y));
return Math.abs(area1 + area2 + area3 - areaOrig) < 0.0001;
};
const isConvex = (pPrev, pCurr, pNext) => {
const val = (pCurr.x - pPrev.x) * (pNext.y - pPrev.y) - (pCurr.y - pPrev.y) * (pNext.x - pPrev.x);
return clockwise ? val < 0 : val > 0;
};
let limit = points.length * 10;
while (indices.length > 3 && limit > 0) {
limit--;
let earFound = false;
for (let i = 0; i < indices.length; i++) {
const prevIdx = indices[(i + indices.length - 1) % indices.length], currIdx = indices[i], nextIdx = indices[(i + 1) % indices.length];
const pPrev = points[prevIdx], pCurr = points[currIdx], pNext = points[nextIdx];
if (!isConvex(pPrev, pCurr, pNext)) continue;
let hasPointInside = false;
for (let j = 0; j < indices.length; j++) {
const pIdx = indices[j];
if (pIdx === prevIdx || pIdx === currIdx || pIdx === nextIdx) continue;
if (isPointInTriangle(points[pIdx], pPrev, pCurr, pNext)) {
hasPointInside = true; break;
}
}
if (!hasPointInside) {
result.push(prevIdx, currIdx, nextIdx); indices.splice(i, 1); earFound = true; break;
}
}
if (!earFound) break;
}
if (indices.length === 3) result.push(...indices);
return result;
};
const drawMapping = useCallback(() => {
if (!previewMode || !imgA || !imgB || !canvasPreviewRef.current) return;
const canvas = canvasPreviewRef.current, ctx = canvas.getContext('2d');
const imageA = new Image(), imageB = new Image();
let loaded = 0;
const onLoad = () => {
loaded++;
if (loaded === 2) {
canvas.width = imageA.width; canvas.height = imageA.height;
ctx.drawImage(imageA, 0, 0);
regions.forEach(region => {
if (region.pointsA.length < 3) return;
const ptsA = region.pointsA.map(p => ({ x: p.x * imageA.width, y: p.y * imageA.height }));
const ptsB = region.pointsB.map(p => ({ x: p.x * imageB.width, y: p.y * imageB.height }));
const tris = triangulate(ptsB);
for (let i = 0; i < tris.length; i += 3) {
drawTriangle(ctx, imageB, ptsA[tris[i]], ptsA[tris[i + 1]], ptsA[tris[i + 2]], ptsB[tris[i]], ptsB[tris[i + 1]], ptsB[tris[i + 2]]);
}
});
}
};
imageA.src = imgA; imageB.src = imgB;
imageA.onload = onLoad; imageB.onload = onLoad;
}, [previewMode, imgA, imgB, regions]);
useEffect(() => { drawMapping(); }, [drawMapping]);
const drawTriangle = (ctx, img, p0, p1, p2, t0, t1, t2) => {
ctx.save();
ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath(); ctx.clip();
const delta = (t0.x - t2.x) * (t1.y - t2.y) - (t1.x - t2.x) * (t0.y - t2.y);
if (Math.abs(delta) < 0.001) { ctx.restore(); return; }
const a = ((p0.x - p2.x) * (t1.y - t2.y) - (p1.x - p2.x) * (t0.y - t2.y)) / delta;
const b = ((p0.y - p2.y) * (t1.y - t2.y) - (p1.y - p2.y) * (t0.y - t2.y)) / delta;
const c = ((t0.x - t2.x) * (p1.x - p2.x) - (t1.x - t2.x) * (p0.x - p2.x)) / delta;
const d = ((t0.x - t2.x) * (p1.y - p2.y) - (t1.x - t2.x) * (p0.y - p2.y)) / delta;
const e = p2.x - a * t2.x - c * t2.y;
const f = p2.y - b * t2.x - d * t2.y;
ctx.setTransform(a, b, c, d, e, f); ctx.drawImage(img, 0, 0); ctx.restore();
};
const renderSVG = (side) => {
const visibleRegions = (side === 'B' && focusModeB)
? regions.filter(r => r.id === selectedRegionId)
: regions;
return (
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
{visibleRegions.map(r => {
const isSelected = r.id === selectedRegionId;
const points = side === 'A' ? r.pointsA : r.pointsB;
const polyStr = points.map(p => `${p.x * 100},${p.y * 100}`).join(' ');
return (
<g key={r.id}>
{/* Defensive check for hoveredEdge index safety */}
{isSelected && hoveredEdge && hoveredEdge.side === side && points[hoveredEdge.index] && (
<line
x1={points[hoveredEdge.index].x * 100} y1={points[hoveredEdge.index].y * 100}
x2={points[(hoveredEdge.index + 1) % points.length].x * 100} y2={points[(hoveredEdge.index + 1) % points.length].y * 100}
stroke="#00ffff" strokeWidth="2" strokeOpacity="0.9" vectorEffect="non-scaling-stroke"
/>
)}
<polygon
points={polyStr}
className="pointer-events-auto cursor-pointer"
onMouseDown={() => setSelectedRegionId(r.id)}
style={{
fill: r.color, fillOpacity: isSelected ? 0.35 : 0.1,
stroke: isSelected ? '#fff' : r.color, strokeWidth: isSelected ? 1 : 0.5,
vectorEffect: 'non-scaling-stroke'
}}
/>
{points.map((p, idx) => {
const isDragging = draggingPoint?.regionId === r.id && draggingPoint?.index === idx && draggingPoint.side === side;
const isHovered = !draggingPoint && hoveredPoint?.regionId === r.id && hoveredPoint?.index === idx;
const finalRadius = isSelected ? 0.75 : 0.25;
return (
<circle
key={idx} cx={p.x * 100} cy={p.y * 100} r={finalRadius}
className="pointer-events-auto cursor-move"
onMouseDown={(e) => handlePointMouseDown(e, side, r.id, idx)}
style={{
fill: (isDragging || isHovered) ? '#00ffff' : (isSelected ? '#fff' : r.color),
stroke: (isDragging || isHovered) ? '#fff' : '#000',
strokeWidth: 0.2, vectorEffect: 'non-scaling-stroke'
}}
/>
);
})}
{/* Ghost point preview */}
{isSelected && hoveredEdge && hoveredEdge.side === side && hoveredEdge.isClose && (
<circle cx={hoveredEdge.point.x * 100} cy={hoveredEdge.point.y * 100} r={1.2}
style={{ fill: '#fff', stroke: '#00ffff', strokeWidth: 0.5, vectorEffect: 'non-scaling-stroke', filter: 'drop-shadow(0 0 2px rgba(0,255,255,0.8))' }}
/>
)}
</g>
);
})}
</svg>
);
};
return (
<div className="flex flex-col h-screen bg-[#080808] text-white font-sans overflow-hidden" onMouseUp={handleMouseUp}>
<header className="flex items-center justify-between px-6 py-4 bg-[#111] border-b border-white/5 shadow-2xl z-20">
<div className="flex items-center gap-4">
<div className="p-2.5 bg-indigo-600 rounded-xl shadow-lg shadow-indigo-500/20"><Target size={22} /></div>
<div>
<h1 className="text-lg font-black tracking-tighter leading-none italic">UV WRAPPER</h1>
<span className="text-[9px] text-indigo-400 font-bold uppercase tracking-[0.3em]">Mapping Engine v3.1</span>
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={addRegion} className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-full text-sm font-bold transition-all active:scale-95">
<Plus size={18} /> Add Region
</button>
<div className="h-6 w-px bg-white/10 mx-2" />
<label className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-full text-sm font-semibold cursor-pointer border border-white/5">
<FolderOpen size={16} /> Import JSON
<input type="file" className="hidden" accept=".json" onChange={handleJsonUpload} />
</label>
<button onClick={() => {
const blob = new Blob([JSON.stringify(regions, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'uv-config.json'; a.click();
}} className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-full text-sm font-semibold border border-white/5">
<Download size={16} /> Export JSON
</button>
<button
onClick={() => setPreviewMode(!previewMode)}
className={`flex items-center gap-2 px-6 py-2 rounded-full text-sm font-black transition-all shadow-lg ${previewMode ? 'bg-emerald-500 text-white' : 'bg-white text-black hover:bg-zinc-200'}`}
>
{previewMode ? <EyeOff size={18} /> : <Eye size={18} />}
{previewMode ? 'Exit Preview' : 'Live Preview'}
</button>
</div>
</header>
<main className="flex-1 flex overflow-hidden relative">
{!previewMode ? (
<>
<div
className={`flex-1 flex flex-col border-r border-white/5 bg-[#080808] transition-colors ${isDragOverA ? 'bg-indigo-900/10' : ''}`}
onDragOver={(e) => handleDragOver(e, 'A')} onDragLeave={(e) => handleDragLeave(e, 'A')} onDrop={(e) => handleDrop(e, 'A')}
>
<div className="p-3 text-center text-[10px] font-black text-zinc-600 uppercase tracking-[0.4em] bg-zinc-900/40 flex items-center justify-center gap-2">
<Target size={12} /> Target Canvas (A)
</div>
<div className="flex-1 relative flex items-center justify-center p-12 overflow-hidden bg-[radial-gradient(circle_at_center,_#111_0%,_#080808_100%)]">
{!imgA ? (
<label className="flex flex-col items-center gap-4 cursor-pointer p-12 border-2 border-dashed border-zinc-800 rounded-3xl hover:bg-zinc-900/50 transition-all">
<Upload className="text-zinc-700" size={40} />
<span className="text-zinc-500 font-bold text-center">Upload or Drag UV Target</span>
<input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'A')} />
</label>
) : (
<div className="relative inline-block shadow-2xl border border-white/5" onMouseMove={(e) => handleMouseMove(e, 'A')} onMouseLeave={handleMouseLeave}>
<img ref={imgRefA} src={imgA} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
{renderSVG('A')}
{isZPressed && <Magnifier imgUrl={imgA} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="A" imgSize={imgSizeA} focusModeB={focusModeB} hoveredPoint={hoveredPoint} draggingPoint={draggingPoint} />}
</div>
)}
</div>
</div>
<div
className={`flex-1 flex flex-col bg-[#0b0b0b] transition-colors ${isDragOverB ? 'bg-indigo-900/10' : ''}`}
onDragOver={(e) => handleDragOver(e, 'B')} onDragLeave={(e) => handleDragLeave(e, 'B')} onDrop={(e) => handleDrop(e, 'B')}
>
<div className="p-2 px-4 flex items-center justify-between bg-zinc-900/40">
<span className="text-[10px] font-black text-zinc-600 uppercase tracking-[0.4em] flex items-center gap-2"><Search size={12} /> Source Texture (B)</span>
<button
onClick={() => setFocusModeB(!focusModeB)}
className={`flex items-center gap-2 px-4 py-1 rounded-full text-[10px] font-black tracking-widest transition-all border ${focusModeB ? 'bg-indigo-600 border-indigo-500 text-white shadow-lg shadow-indigo-600/30' : 'bg-zinc-800 border-white/5 text-zinc-500'}`}
>
<MousePointer2 size={12} /> Focus Mode: {focusModeB ? 'ON' : 'OFF'}
</button>
</div>
<div className="flex-1 relative flex items-center justify-center p-12 overflow-hidden bg-[radial-gradient(circle_at_center,_#141414_0%,_#0b0b0b_100%)]">
{!imgB ? (
<label className="flex flex-col items-center gap-4 cursor-pointer p-12 border-2 border-dashed border-zinc-800 rounded-3xl hover:bg-zinc-900/50 transition-all">
<Upload className="text-zinc-700" size={40} />
<span className="text-zinc-500 font-bold text-center">Upload or Drag Source Drawing</span>
<input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'B')} />
</label>
) : (
<div className="relative inline-block shadow-2xl border border-white/5" onMouseMove={(e) => handleMouseMove(e, 'B')} onMouseLeave={handleMouseLeave}>
<img ref={imgRefB} src={imgB} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
{renderSVG('B')}
{isZPressed && <Magnifier imgUrl={imgB} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="B" imgSize={imgSizeB} focusModeB={focusModeB} hoveredPoint={hoveredPoint} draggingPoint={draggingPoint} />}
</div>
)}
</div>
</div>
{/* Bottom Status Bar - Scaled Down */}
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 px-5 py-2 bg-[#111]/90 backdrop-blur-xl rounded-xl border border-white/10 flex gap-6 items-center shadow-2xl z-40 transform scale-90 origin-bottom">
<div className="flex items-center gap-3 pr-6 border-r border-white/10">
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Active</div>
{selectedRegion ? (
<div className="flex items-center gap-2.5">
<div className="w-3.5 h-3.5 rounded-full shadow border border-white/20" style={{ backgroundColor: selectedRegion.color }} />
<span className="text-[11px] font-black tracking-tight">{selectedRegion.name}</span>
<button onClick={(e) => { e.stopPropagation(); deleteRegion(selectedRegionId); }} className="p-1 text-zinc-600 hover:text-red-400 rounded-full transition-all"><Trash2 size={13} /></button>
</div>
) : (
<span className="text-[11px] font-bold text-zinc-700 italic">None Selected</span>
)}
</div>
<div className="flex gap-5 items-center text-[9px] font-bold text-zinc-500 uppercase tracking-[0.2em]">
<div className="flex items-center gap-2"><kbd className="px-1.5 py-0.5 bg-zinc-800 rounded border border-white/5 text-white">Z</kbd> Magnifier</div>
<div className="flex items-center gap-2"><kbd className="px-1.5 py-0.5 bg-zinc-800 rounded border border-white/5 text-white">A</kbd> Add Point</div>
<div className="flex items-center gap-2"><kbd className="px-1.5 py-0.5 bg-zinc-800 rounded border border-white/5 text-white">D</kbd> Delete</div>
</div>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center bg-[#050505] p-12 overflow-auto">
<div className="mb-6">
<button onClick={handleDownloadResult} className="flex items-center gap-2 px-8 py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-full font-black shadow-xl transition-all active:scale-95">
<Download size={20} /> Download Synthesis (.png)
</button>
</div>
<div className="bg-[#111] p-8 rounded-[3rem] shadow-[0_50px_100px_rgba(0,0,0,0.8)] border border-white/5 max-w-full max-h-full">
<canvas ref={canvasPreviewRef} className="max-w-full max-h-[70vh] block rounded-2xl" />
</div>
</div>
)}
</main>
</div>
);
}