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 (
{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 ( {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 ( ); })} ); })}
); } // --- 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 ( {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 ( {/* Defensive check for hoveredEdge index safety */} {isSelected && hoveredEdge && hoveredEdge.side === side && points[hoveredEdge.index] && ( )} 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 ( 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 && ( )} ); })} ); }; return (

UV WRAPPER

Mapping Engine v3.1
{!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
) : (
)}
); }