Spaces:
Running
Running
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> | |
| ); | |
| } |