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 (
);
}
// --- 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 (
);
};
return (
{!previewMode ? (
<>
handleDragOver(e, 'A')} onDragLeave={(e) => handleDragLeave(e, 'A')} onDrop={(e) => handleDrop(e, 'A')}
>
Target Canvas (A)
{!imgA ? (
) : (
handleMouseMove(e, 'A')} onMouseLeave={handleMouseLeave}>

{renderSVG('A')}
{isZPressed &&
}
)}
handleDragOver(e, 'B')} onDragLeave={(e) => handleDragLeave(e, 'B')} onDrop={(e) => handleDrop(e, 'B')}
>
Source Texture (B)
{!imgB ? (
) : (
handleMouseMove(e, 'B')} onMouseLeave={handleMouseLeave}>

{renderSVG('B')}
{isZPressed &&
}
)}
{/* Bottom Status Bar - Scaled Down */}
Active
{selectedRegion ? (
{selectedRegion.name}
) : (
None Selected
)}
Z Magnifier
A Add Point
D Delete
>
) : (
)}
);
}