MAO commited on
Commit
2e8a9d1
·
1 Parent(s): 8708521

UI/UX Overhaul: v3.1 - Enhanced Drag & Drop, Compact Status Bar, and Interaction Improvements

Browse files
Files changed (1) hide show
  1. src/App.jsx +153 -116
src/App.jsx CHANGED
@@ -9,6 +9,7 @@ const dist = (p1, p2) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2
9
  * Gets the closest point on a line segment [a, b] from point p.
10
  */
11
  const getClosestPointOnSegment = (p, a, b) => {
 
12
  const l2 = Math.pow(dist(a, b), 2);
13
  if (l2 === 0) return a;
14
  let t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2;
@@ -27,7 +28,7 @@ const getRandomColor = () => {
27
  };
28
 
29
  // --- Magnifier Component ---
30
- function Magnifier({ imgUrl, regions, selectedId, mousePos, side, imgSize, focusModeB, hoveredPoint }) {
31
  if (!imgUrl || !mousePos || mousePos.side !== side) return null;
32
 
33
  const zoom = 2.5;
@@ -85,13 +86,15 @@ function Magnifier({ imgUrl, regions, selectedId, mousePos, side, imgSize, focus
85
  }}
86
  />
87
  {localPoints.map((p, idx) => {
88
- const isHovered = hoveredPoint?.regionId === reg.id && hoveredPoint?.index === idx;
 
 
89
  return (
90
  <circle
91
  key={idx}
92
  cx={p.x} cy={p.y}
93
- r={isSelected ? 4 : 2}
94
- fill={isHovered ? '#00ffff' : (isSelected ? '#fff' : reg.color)}
95
  stroke="#000"
96
  strokeWidth={0.5}
97
  />
@@ -118,6 +121,8 @@ export default function App() {
118
  const [imgB, setImgB] = useState(null);
119
  const [imgSizeA, setImgSizeA] = useState({ width: 0, height: 0 });
120
  const [imgSizeB, setImgSizeB] = useState({ width: 0, height: 0 });
 
 
121
 
122
  const [regions, setRegions] = useState([]);
123
  const [selectedRegionId, setSelectedRegionId] = useState(null);
@@ -137,9 +142,8 @@ export default function App() {
137
 
138
  const selectedRegion = regions.find(r => r.id === selectedRegionId);
139
 
140
- const handleImageUpload = (e, side) => {
141
- const file = e.target.files[0];
142
- if (file) {
143
  const url = URL.createObjectURL(file);
144
  const img = new Image();
145
  img.onload = () => {
@@ -155,6 +159,28 @@ export default function App() {
155
  }
156
  };
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  const handleJsonUpload = (e) => {
159
  const file = e.target.files[0];
160
  if (!file) return;
@@ -189,6 +215,8 @@ export default function App() {
189
  const deleteRegion = (id) => {
190
  setRegions(regions.filter(r => r.id !== id));
191
  if (selectedRegionId === id) setSelectedRegionId(null);
 
 
192
  };
193
 
194
  const handlePointMouseDown = (e, side, regionId, index) => {
@@ -216,26 +244,31 @@ export default function App() {
216
  side
217
  });
218
 
219
- if (draggingPoint && draggingPoint.side === side) {
220
- setRegions(prev => prev.map(r => {
221
- if (r.id === draggingPoint.regionId) {
222
- const key = side === 'A' ? 'pointsA' : 'pointsB';
223
- const newPts = [...r[key]];
224
- newPts[draggingPoint.index] = mousePos;
225
- return { ...r, [key]: newPts };
226
- }
227
- return r;
228
- }));
 
 
 
 
229
  return;
230
  }
231
 
232
  if (selectedRegionId) {
233
  const region = regions.find(r => r.id === selectedRegionId);
 
234
  const points = side === 'A' ? region.pointsA : region.pointsB;
235
 
236
  let foundPt = null;
237
  for (let i = 0; i < points.length; i++) {
238
- if (dist(mousePos, points[i]) < 0.03) {
239
  foundPt = { regionId: region.id, index: i };
240
  break;
241
  }
@@ -246,24 +279,48 @@ export default function App() {
246
  setHoveredEdge(null);
247
  } else {
248
  setHoveredPoint(null);
249
- let foundEdge = null;
 
 
 
 
250
  for (let i = 0; i < points.length; i++) {
251
  const p1 = points[i];
252
  const p2 = points[(i + 1) % points.length];
253
  const d = distToSegment(mousePos, p1, p2);
254
- if (d < 0.025) {
255
- const projPoint = getClosestPointOnSegment(mousePos, p1, p2);
256
- foundEdge = { regionId: region.id, index: i, side, point: projPoint };
257
- break;
258
  }
259
  }
260
- setHoveredEdge(foundEdge);
 
 
 
 
 
 
 
 
 
 
 
261
  }
 
 
 
262
  }
263
  };
264
 
265
  const handleMouseUp = () => setDraggingPoint(null);
266
- const handleMouseLeave = () => setMagnifierPos(null);
 
 
 
 
 
 
267
 
268
  const handleDownloadResult = () => {
269
  const canvas = canvasPreviewRef.current;
@@ -281,21 +338,27 @@ export default function App() {
281
  if (key === 'z') setIsZPressed(true);
282
 
283
  if (key === 'a' && hoveredEdge) {
284
- const { regionId, index, side, point } = hoveredEdge;
285
  setRegions(prev => prev.map(r => {
286
  if (r.id === regionId) {
287
  const newPointsA = [...r.pointsA];
288
  const newPointsB = [...r.pointsB];
 
 
 
 
289
  let finalPtA, finalPtB;
 
290
  if (side === 'A') {
291
- finalPtA = point;
292
  const b1 = newPointsB[index], b2 = newPointsB[(index + 1) % newPointsB.length];
293
  finalPtB = { x: (b1.x + b2.x) / 2, y: (b1.y + b2.y) / 2 };
294
  } else {
295
- finalPtB = point;
296
  const a1 = newPointsA[index], a2 = newPointsA[(index + 1) % newPointsA.length];
297
  finalPtA = { x: (a1.x + a2.x) / 2, y: (a1.y + a2.y) / 2 };
298
  }
 
299
  newPointsA.splice(index + 1, 0, finalPtA);
300
  newPointsB.splice(index + 1, 0, finalPtB);
301
  return { ...r, pointsA: newPointsA, pointsB: newPointsB };
@@ -332,21 +395,17 @@ export default function App() {
332
  };
333
  }, [hoveredEdge, hoveredPoint]);
334
 
335
- // --- Texture Mapping Core (Improved Ear Clipping) ---
336
  const triangulate = (points) => {
337
  if (points.length < 3) return [];
338
  const indices = points.map((_, i) => i);
339
  const result = [];
340
-
341
- // Signed area for orientation
342
  let area = 0;
343
  for (let i = 0; i < points.length; i++) {
344
- const p1 = points[i];
345
- const p2 = points[(i + 1) % points.length];
346
  area += (p2.x - p1.x) * (p2.y + p1.y);
347
  }
348
  const clockwise = area > 0;
349
-
350
  const isPointInTriangle = (p, a, b, c) => {
351
  const areaOrig = Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y));
352
  const area1 = Math.abs((a.x - p.x) * (b.y - p.y) - (b.x - p.x) * (a.y - p.y));
@@ -354,42 +413,28 @@ export default function App() {
354
  const area3 = Math.abs((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y));
355
  return Math.abs(area1 + area2 + area3 - areaOrig) < 0.0001;
356
  };
357
-
358
  const isConvex = (pPrev, pCurr, pNext) => {
359
  const val = (pCurr.x - pPrev.x) * (pNext.y - pPrev.y) - (pCurr.y - pPrev.y) * (pNext.x - pPrev.x);
360
  return clockwise ? val < 0 : val > 0;
361
  };
362
-
363
  let limit = points.length * 10;
364
  while (indices.length > 3 && limit > 0) {
365
  limit--;
366
  let earFound = false;
367
  for (let i = 0; i < indices.length; i++) {
368
- const prevIdx = indices[(i + indices.length - 1) % indices.length];
369
- const currIdx = indices[i];
370
- const nextIdx = indices[(i + 1) % indices.length];
371
-
372
- const pPrev = points[prevIdx];
373
- const pCurr = points[currIdx];
374
- const pNext = points[nextIdx];
375
-
376
  if (!isConvex(pPrev, pCurr, pNext)) continue;
377
-
378
  let hasPointInside = false;
379
  for (let j = 0; j < indices.length; j++) {
380
  const pIdx = indices[j];
381
  if (pIdx === prevIdx || pIdx === currIdx || pIdx === nextIdx) continue;
382
  if (isPointInTriangle(points[pIdx], pPrev, pCurr, pNext)) {
383
- hasPointInside = true;
384
- break;
385
  }
386
  }
387
-
388
  if (!hasPointInside) {
389
- result.push(prevIdx, currIdx, nextIdx);
390
- indices.splice(i, 1);
391
- earFound = true;
392
- break;
393
  }
394
  }
395
  if (!earFound) break;
@@ -400,16 +445,13 @@ export default function App() {
400
 
401
  const drawMapping = useCallback(() => {
402
  if (!previewMode || !imgA || !imgB || !canvasPreviewRef.current) return;
403
- const canvas = canvasPreviewRef.current;
404
- const ctx = canvas.getContext('2d');
405
- const imageA = new Image();
406
- const imageB = new Image();
407
  let loaded = 0;
408
  const onLoad = () => {
409
  loaded++;
410
  if (loaded === 2) {
411
- canvas.width = imageA.width;
412
- canvas.height = imageA.height;
413
  ctx.drawImage(imageA, 0, 0);
414
  regions.forEach(region => {
415
  if (region.pointsA.length < 3) return;
@@ -430,9 +472,7 @@ export default function App() {
430
 
431
  const drawTriangle = (ctx, img, p0, p1, p2, t0, t1, t2) => {
432
  ctx.save();
433
- ctx.beginPath();
434
- ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath();
435
- ctx.clip();
436
  const delta = (t0.x - t2.x) * (t1.y - t2.y) - (t1.x - t2.x) * (t0.y - t2.y);
437
  if (Math.abs(delta) < 0.001) { ctx.restore(); return; }
438
  const a = ((p0.x - p2.x) * (t1.y - t2.y) - (p1.x - p2.x) * (t0.y - t2.y)) / delta;
@@ -441,9 +481,7 @@ export default function App() {
441
  const d = ((t0.x - t2.x) * (p1.y - p2.y) - (t1.x - t2.x) * (p0.y - p2.y)) / delta;
442
  const e = p2.x - a * t2.x - c * t2.y;
443
  const f = p2.y - b * t2.x - d * t2.y;
444
- ctx.setTransform(a, b, c, d, e, f);
445
- ctx.drawImage(img, 0, 0);
446
- ctx.restore();
447
  };
448
 
449
  const renderSVG = (side) => {
@@ -460,44 +498,48 @@ export default function App() {
460
 
461
  return (
462
  <g key={r.id}>
 
 
 
 
 
 
 
 
 
463
  <polygon
464
  points={polyStr}
465
  className="pointer-events-auto cursor-pointer"
466
  onMouseDown={() => setSelectedRegionId(r.id)}
467
  style={{
468
- fill: r.color,
469
- fillOpacity: isSelected ? 0.35 : 0.1,
470
- stroke: isSelected ? '#fff' : r.color,
471
- strokeWidth: isSelected ? 1 : 0.5,
472
  vectorEffect: 'non-scaling-stroke'
473
  }}
474
  />
475
  {points.map((p, idx) => {
476
- const isHoveredOrDragging = (hoveredPoint?.regionId === r.id && hoveredPoint?.index === idx) ||
477
- (draggingPoint?.regionId === r.id && draggingPoint?.index === idx && draggingPoint.side === side);
478
 
479
- // Point size logic: increased selected point size by 0.25
480
  const finalRadius = isSelected ? 0.75 : 0.25;
481
 
482
  return (
483
  <circle
484
- key={idx}
485
- cx={p.x * 100} cy={p.y * 100}
486
- r={finalRadius}
487
  className="pointer-events-auto cursor-move"
488
  onMouseDown={(e) => handlePointMouseDown(e, side, r.id, idx)}
489
  style={{
490
- fill: isHoveredOrDragging ? '#00ffff' : (isSelected ? '#fff' : r.color),
491
- stroke: isHoveredOrDragging ? '#fff' : '#000',
492
- strokeWidth: 0.2,
493
- vectorEffect: 'non-scaling-stroke'
494
  }}
495
  />
496
  );
497
  })}
498
- {isSelected && hoveredEdge && hoveredEdge.side === side && (
 
499
  <circle cx={hoveredEdge.point.x * 100} cy={hoveredEdge.point.y * 100} r={1.2}
500
- style={{ fill: '#fff', stroke: '#00e5ff', strokeWidth: 0.5, vectorEffect: 'non-scaling-stroke', filter: 'drop-shadow(0 0 2px rgba(0,229,255,0.8))' }}
501
  />
502
  )}
503
  </g>
@@ -514,7 +556,7 @@ export default function App() {
514
  <div className="p-2.5 bg-indigo-600 rounded-xl shadow-lg shadow-indigo-500/20"><Target size={22} /></div>
515
  <div>
516
  <h1 className="text-lg font-black tracking-tighter leading-none italic">UV WRAPPER</h1>
517
- <span className="text-[9px] text-indigo-400 font-bold uppercase tracking-[0.3em]">Mapping Engine v2.8</span>
518
  </div>
519
  </div>
520
 
@@ -547,7 +589,10 @@ export default function App() {
547
  <main className="flex-1 flex overflow-hidden relative">
548
  {!previewMode ? (
549
  <>
550
- <div className="flex-1 flex flex-col border-r border-white/5 bg-[#080808]">
 
 
 
551
  <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">
552
  <Target size={12} /> Target Canvas (A)
553
  </div>
@@ -555,84 +600,76 @@ export default function App() {
555
  {!imgA ? (
556
  <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">
557
  <Upload className="text-zinc-700" size={40} />
558
- <span className="text-zinc-500 font-bold">Upload UV Target</span>
559
  <input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'A')} />
560
  </label>
561
  ) : (
562
- <div className="relative inline-block shadow-2xl border border-white/5"
563
- onMouseMove={(e) => handleMouseMove(e, 'A')}
564
- onMouseLeave={handleMouseLeave}
565
- >
566
  <img ref={imgRefA} src={imgA} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
567
  {renderSVG('A')}
568
- {isZPressed && <Magnifier imgUrl={imgA} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="A" imgSize={imgSizeA} focusModeB={focusModeB} hoveredPoint={hoveredPoint} />}
569
  </div>
570
  )}
571
  </div>
572
  </div>
573
 
574
- <div className="flex-1 flex flex-col bg-[#0b0b0b]">
 
 
 
575
  <div className="p-2 px-4 flex items-center justify-between bg-zinc-900/40">
576
  <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>
577
  <button
578
  onClick={() => setFocusModeB(!focusModeB)}
579
  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'}`}
580
  >
581
- <MousePointer2 size={12} />
582
- Focus Mode: {focusModeB ? 'ON' : 'OFF'}
583
  </button>
584
  </div>
585
  <div className="flex-1 relative flex items-center justify-center p-12 overflow-hidden bg-[radial-gradient(circle_at_center,_#141414_0%,_#0b0b0b_100%)]">
586
  {!imgB ? (
587
  <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">
588
  <Upload className="text-zinc-700" size={40} />
589
- <span className="text-zinc-500 font-bold">Upload Source Drawing</span>
590
  <input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'B')} />
591
  </label>
592
  ) : (
593
- <div className="relative inline-block shadow-2xl border border-white/5"
594
- onMouseMove={(e) => handleMouseMove(e, 'B')}
595
- onMouseLeave={handleMouseLeave}
596
- >
597
  <img ref={imgRefB} src={imgB} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
598
  {renderSVG('B')}
599
- {isZPressed && <Magnifier imgUrl={imgB} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="B" imgSize={imgSizeB} focusModeB={focusModeB} hoveredPoint={hoveredPoint} />}
600
  </div>
601
  )}
602
  </div>
603
  </div>
604
 
605
- {/* Bottom Status Bar */}
606
- <div className="absolute bottom-8 left-1/2 -translate-x-1/2 px-10 py-5 bg-[#111]/90 backdrop-blur-xl rounded-[2rem] border border-white/10 flex gap-12 items-center shadow-2xl z-40">
607
- <div className="flex items-center gap-5 pr-10 border-r border-white/10">
608
- <div className="text-[9px] font-black text-zinc-500 uppercase tracking-widest">Selection</div>
609
  {selectedRegion ? (
610
- <div className="flex items-center gap-4">
611
- <div className="w-6 h-6 rounded-full shadow-lg border-2 border-white/20" style={{ backgroundColor: selectedRegion.color }} />
612
- <span className="text-sm font-black tracking-tight">{selectedRegion.name}</span>
613
- <button onClick={() => deleteRegion(selectedRegionId)} className="p-2 text-zinc-600 hover:text-red-400 hover:bg-red-400/10 rounded-full transition-all"><Trash2 size={16} /></button>
614
  </div>
615
  ) : (
616
- <span className="text-sm font-bold text-zinc-700 italic">No Active Selection</span>
617
  )}
618
  </div>
619
 
620
- <div className="flex gap-8 items-center text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em]">
621
- <div className="flex items-center gap-2"><kbd className="px-2 py-1 bg-zinc-800 rounded-md border border-white/5 text-white">Z</kbd> Hold for Magnifier</div>
622
- <div className="flex items-center gap-2"><kbd className="px-2 py-1 bg-zinc-800 rounded-md border border-white/5 text-white">A</kbd> Add Point</div>
623
- <div className="flex items-center gap-2"><kbd className="px-2 py-1 bg-zinc-800 rounded-md border border-white/5 text-white">D</kbd> Delete Point</div>
624
  </div>
625
  </div>
626
  </>
627
  ) : (
628
  <div className="flex-1 flex flex-col items-center justify-center bg-[#050505] p-12 overflow-auto">
629
  <div className="mb-6">
630
- <button
631
- onClick={handleDownloadResult}
632
- 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 shadow-emerald-600/20 transition-all active:scale-95"
633
- >
634
- <Download size={20} />
635
- Download Result (.png)
636
  </button>
637
  </div>
638
  <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">
 
9
  * Gets the closest point on a line segment [a, b] from point p.
10
  */
11
  const getClosestPointOnSegment = (p, a, b) => {
12
+ if (!a || !b) return { x: 0, y: 0 };
13
  const l2 = Math.pow(dist(a, b), 2);
14
  if (l2 === 0) return a;
15
  let t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2;
 
28
  };
29
 
30
  // --- Magnifier Component ---
31
+ function Magnifier({ imgUrl, regions, selectedId, mousePos, side, imgSize, focusModeB, hoveredPoint, draggingPoint }) {
32
  if (!imgUrl || !mousePos || mousePos.side !== side) return null;
33
 
34
  const zoom = 2.5;
 
86
  }}
87
  />
88
  {localPoints.map((p, idx) => {
89
+ const isDragging = draggingPoint?.regionId === reg.id && draggingPoint?.index === idx && draggingPoint.side === side;
90
+ const isHovered = !draggingPoint && hoveredPoint?.regionId === reg.id && hoveredPoint?.index === idx;
91
+
92
  return (
93
  <circle
94
  key={idx}
95
  cx={p.x} cy={p.y}
96
+ r={isSelected ? 3.5 : 1.5}
97
+ fill={(isDragging || isHovered) ? '#00ffff' : (isSelected ? '#fff' : reg.color)}
98
  stroke="#000"
99
  strokeWidth={0.5}
100
  />
 
121
  const [imgB, setImgB] = useState(null);
122
  const [imgSizeA, setImgSizeA] = useState({ width: 0, height: 0 });
123
  const [imgSizeB, setImgSizeB] = useState({ width: 0, height: 0 });
124
+ const [isDragOverA, setIsDragOverA] = useState(false);
125
+ const [isDragOverB, setIsDragOverB] = useState(false);
126
 
127
  const [regions, setRegions] = useState([]);
128
  const [selectedRegionId, setSelectedRegionId] = useState(null);
 
142
 
143
  const selectedRegion = regions.find(r => r.id === selectedRegionId);
144
 
145
+ const processImageFile = (file, side) => {
146
+ if (file && file.type.startsWith('image/')) {
 
147
  const url = URL.createObjectURL(file);
148
  const img = new Image();
149
  img.onload = () => {
 
159
  }
160
  };
161
 
162
+ const handleImageUpload = (e, side) => {
163
+ const file = e.target.files[0];
164
+ processImageFile(file, side);
165
+ };
166
+
167
+ const handleDrop = (e, side) => {
168
+ e.preventDefault();
169
+ side === 'A' ? setIsDragOverA(false) : setIsDragOverB(false);
170
+ const file = e.dataTransfer.files[0];
171
+ processImageFile(file, side);
172
+ };
173
+
174
+ const handleDragOver = (e, side) => {
175
+ e.preventDefault();
176
+ side === 'A' ? setIsDragOverA(true) : setIsDragOverB(true);
177
+ };
178
+
179
+ const handleDragLeave = (e, side) => {
180
+ e.preventDefault();
181
+ side === 'A' ? setIsDragOverA(false) : setIsDragOverB(false);
182
+ };
183
+
184
  const handleJsonUpload = (e) => {
185
  const file = e.target.files[0];
186
  if (!file) return;
 
215
  const deleteRegion = (id) => {
216
  setRegions(regions.filter(r => r.id !== id));
217
  if (selectedRegionId === id) setSelectedRegionId(null);
218
+ setHoveredPoint(null);
219
+ setHoveredEdge(null);
220
  };
221
 
222
  const handlePointMouseDown = (e, side, regionId, index) => {
 
244
  side
245
  });
246
 
247
+ if (draggingPoint) {
248
+ if (draggingPoint.side === side) {
249
+ setRegions(prev => prev.map(r => {
250
+ if (r.id === draggingPoint.regionId) {
251
+ const key = side === 'A' ? 'pointsA' : 'pointsB';
252
+ const newPts = [...r[key]];
253
+ newPts[draggingPoint.index] = mousePos;
254
+ return { ...r, [key]: newPts };
255
+ }
256
+ return r;
257
+ }));
258
+ }
259
+ setHoveredPoint(null);
260
+ setHoveredEdge(null);
261
  return;
262
  }
263
 
264
  if (selectedRegionId) {
265
  const region = regions.find(r => r.id === selectedRegionId);
266
+ if (!region) return;
267
  const points = side === 'A' ? region.pointsA : region.pointsB;
268
 
269
  let foundPt = null;
270
  for (let i = 0; i < points.length; i++) {
271
+ if (dist(mousePos, points[i]) < 0.025) {
272
  foundPt = { regionId: region.id, index: i };
273
  break;
274
  }
 
279
  setHoveredEdge(null);
280
  } else {
281
  setHoveredPoint(null);
282
+
283
+ let minD = Infinity;
284
+ let closestEdgeIdx = -1;
285
+ let closestProj = null;
286
+
287
  for (let i = 0; i < points.length; i++) {
288
  const p1 = points[i];
289
  const p2 = points[(i + 1) % points.length];
290
  const d = distToSegment(mousePos, p1, p2);
291
+ if (d < minD) {
292
+ minD = d;
293
+ closestEdgeIdx = i;
294
+ closestProj = getClosestPointOnSegment(mousePos, p1, p2);
295
  }
296
  }
297
+
298
+ if (closestEdgeIdx !== -1) {
299
+ setHoveredEdge({
300
+ regionId: region.id,
301
+ index: closestEdgeIdx,
302
+ side,
303
+ point: closestProj,
304
+ isClose: minD < 0.025
305
+ });
306
+ } else {
307
+ setHoveredEdge(null);
308
+ }
309
  }
310
+ } else {
311
+ setHoveredPoint(null);
312
+ setHoveredEdge(null);
313
  }
314
  };
315
 
316
  const handleMouseUp = () => setDraggingPoint(null);
317
+ const handleMouseLeave = () => {
318
+ setMagnifierPos(null);
319
+ if (!draggingPoint) {
320
+ setHoveredEdge(null);
321
+ setHoveredPoint(null);
322
+ }
323
+ };
324
 
325
  const handleDownloadResult = () => {
326
  const canvas = canvasPreviewRef.current;
 
338
  if (key === 'z') setIsZPressed(true);
339
 
340
  if (key === 'a' && hoveredEdge) {
341
+ const { regionId, index, side, point, isClose } = hoveredEdge;
342
  setRegions(prev => prev.map(r => {
343
  if (r.id === regionId) {
344
  const newPointsA = [...r.pointsA];
345
  const newPointsB = [...r.pointsB];
346
+
347
+ // Check index safety
348
+ if (index >= newPointsA.length || index >= newPointsB.length) return r;
349
+
350
  let finalPtA, finalPtB;
351
+
352
  if (side === 'A') {
353
+ 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 };
354
  const b1 = newPointsB[index], b2 = newPointsB[(index + 1) % newPointsB.length];
355
  finalPtB = { x: (b1.x + b2.x) / 2, y: (b1.y + b2.y) / 2 };
356
  } else {
357
+ 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 };
358
  const a1 = newPointsA[index], a2 = newPointsA[(index + 1) % newPointsA.length];
359
  finalPtA = { x: (a1.x + a2.x) / 2, y: (a1.y + a2.y) / 2 };
360
  }
361
+
362
  newPointsA.splice(index + 1, 0, finalPtA);
363
  newPointsB.splice(index + 1, 0, finalPtB);
364
  return { ...r, pointsA: newPointsA, pointsB: newPointsB };
 
395
  };
396
  }, [hoveredEdge, hoveredPoint]);
397
 
398
+ // --- Triangulation logic ---
399
  const triangulate = (points) => {
400
  if (points.length < 3) return [];
401
  const indices = points.map((_, i) => i);
402
  const result = [];
 
 
403
  let area = 0;
404
  for (let i = 0; i < points.length; i++) {
405
+ const p1 = points[i], p2 = points[(i + 1) % points.length];
 
406
  area += (p2.x - p1.x) * (p2.y + p1.y);
407
  }
408
  const clockwise = area > 0;
 
409
  const isPointInTriangle = (p, a, b, c) => {
410
  const areaOrig = Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y));
411
  const area1 = Math.abs((a.x - p.x) * (b.y - p.y) - (b.x - p.x) * (a.y - p.y));
 
413
  const area3 = Math.abs((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y));
414
  return Math.abs(area1 + area2 + area3 - areaOrig) < 0.0001;
415
  };
 
416
  const isConvex = (pPrev, pCurr, pNext) => {
417
  const val = (pCurr.x - pPrev.x) * (pNext.y - pPrev.y) - (pCurr.y - pPrev.y) * (pNext.x - pPrev.x);
418
  return clockwise ? val < 0 : val > 0;
419
  };
 
420
  let limit = points.length * 10;
421
  while (indices.length > 3 && limit > 0) {
422
  limit--;
423
  let earFound = false;
424
  for (let i = 0; i < indices.length; i++) {
425
+ const prevIdx = indices[(i + indices.length - 1) % indices.length], currIdx = indices[i], nextIdx = indices[(i + 1) % indices.length];
426
+ const pPrev = points[prevIdx], pCurr = points[currIdx], pNext = points[nextIdx];
 
 
 
 
 
 
427
  if (!isConvex(pPrev, pCurr, pNext)) continue;
 
428
  let hasPointInside = false;
429
  for (let j = 0; j < indices.length; j++) {
430
  const pIdx = indices[j];
431
  if (pIdx === prevIdx || pIdx === currIdx || pIdx === nextIdx) continue;
432
  if (isPointInTriangle(points[pIdx], pPrev, pCurr, pNext)) {
433
+ hasPointInside = true; break;
 
434
  }
435
  }
 
436
  if (!hasPointInside) {
437
+ result.push(prevIdx, currIdx, nextIdx); indices.splice(i, 1); earFound = true; break;
 
 
 
438
  }
439
  }
440
  if (!earFound) break;
 
445
 
446
  const drawMapping = useCallback(() => {
447
  if (!previewMode || !imgA || !imgB || !canvasPreviewRef.current) return;
448
+ const canvas = canvasPreviewRef.current, ctx = canvas.getContext('2d');
449
+ const imageA = new Image(), imageB = new Image();
 
 
450
  let loaded = 0;
451
  const onLoad = () => {
452
  loaded++;
453
  if (loaded === 2) {
454
+ canvas.width = imageA.width; canvas.height = imageA.height;
 
455
  ctx.drawImage(imageA, 0, 0);
456
  regions.forEach(region => {
457
  if (region.pointsA.length < 3) return;
 
472
 
473
  const drawTriangle = (ctx, img, p0, p1, p2, t0, t1, t2) => {
474
  ctx.save();
475
+ ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath(); ctx.clip();
 
 
476
  const delta = (t0.x - t2.x) * (t1.y - t2.y) - (t1.x - t2.x) * (t0.y - t2.y);
477
  if (Math.abs(delta) < 0.001) { ctx.restore(); return; }
478
  const a = ((p0.x - p2.x) * (t1.y - t2.y) - (p1.x - p2.x) * (t0.y - t2.y)) / delta;
 
481
  const d = ((t0.x - t2.x) * (p1.y - p2.y) - (t1.x - t2.x) * (p0.y - p2.y)) / delta;
482
  const e = p2.x - a * t2.x - c * t2.y;
483
  const f = p2.y - b * t2.x - d * t2.y;
484
+ ctx.setTransform(a, b, c, d, e, f); ctx.drawImage(img, 0, 0); ctx.restore();
 
 
485
  };
486
 
487
  const renderSVG = (side) => {
 
498
 
499
  return (
500
  <g key={r.id}>
501
+ {/* Defensive check for hoveredEdge index safety */}
502
+ {isSelected && hoveredEdge && hoveredEdge.side === side && points[hoveredEdge.index] && (
503
+ <line
504
+ x1={points[hoveredEdge.index].x * 100} y1={points[hoveredEdge.index].y * 100}
505
+ x2={points[(hoveredEdge.index + 1) % points.length].x * 100} y2={points[(hoveredEdge.index + 1) % points.length].y * 100}
506
+ stroke="#00ffff" strokeWidth="2" strokeOpacity="0.9" vectorEffect="non-scaling-stroke"
507
+ />
508
+ )}
509
+
510
  <polygon
511
  points={polyStr}
512
  className="pointer-events-auto cursor-pointer"
513
  onMouseDown={() => setSelectedRegionId(r.id)}
514
  style={{
515
+ fill: r.color, fillOpacity: isSelected ? 0.35 : 0.1,
516
+ stroke: isSelected ? '#fff' : r.color, strokeWidth: isSelected ? 1 : 0.5,
 
 
517
  vectorEffect: 'non-scaling-stroke'
518
  }}
519
  />
520
  {points.map((p, idx) => {
521
+ const isDragging = draggingPoint?.regionId === r.id && draggingPoint?.index === idx && draggingPoint.side === side;
522
+ const isHovered = !draggingPoint && hoveredPoint?.regionId === r.id && hoveredPoint?.index === idx;
523
 
 
524
  const finalRadius = isSelected ? 0.75 : 0.25;
525
 
526
  return (
527
  <circle
528
+ key={idx} cx={p.x * 100} cy={p.y * 100} r={finalRadius}
 
 
529
  className="pointer-events-auto cursor-move"
530
  onMouseDown={(e) => handlePointMouseDown(e, side, r.id, idx)}
531
  style={{
532
+ fill: (isDragging || isHovered) ? '#00ffff' : (isSelected ? '#fff' : r.color),
533
+ stroke: (isDragging || isHovered) ? '#fff' : '#000',
534
+ strokeWidth: 0.2, vectorEffect: 'non-scaling-stroke'
 
535
  }}
536
  />
537
  );
538
  })}
539
+ {/* Ghost point preview */}
540
+ {isSelected && hoveredEdge && hoveredEdge.side === side && hoveredEdge.isClose && (
541
  <circle cx={hoveredEdge.point.x * 100} cy={hoveredEdge.point.y * 100} r={1.2}
542
+ style={{ fill: '#fff', stroke: '#00ffff', strokeWidth: 0.5, vectorEffect: 'non-scaling-stroke', filter: 'drop-shadow(0 0 2px rgba(0,255,255,0.8))' }}
543
  />
544
  )}
545
  </g>
 
556
  <div className="p-2.5 bg-indigo-600 rounded-xl shadow-lg shadow-indigo-500/20"><Target size={22} /></div>
557
  <div>
558
  <h1 className="text-lg font-black tracking-tighter leading-none italic">UV WRAPPER</h1>
559
+ <span className="text-[9px] text-indigo-400 font-bold uppercase tracking-[0.3em]">Mapping Engine v3.1</span>
560
  </div>
561
  </div>
562
 
 
589
  <main className="flex-1 flex overflow-hidden relative">
590
  {!previewMode ? (
591
  <>
592
+ <div
593
+ className={`flex-1 flex flex-col border-r border-white/5 bg-[#080808] transition-colors ${isDragOverA ? 'bg-indigo-900/10' : ''}`}
594
+ onDragOver={(e) => handleDragOver(e, 'A')} onDragLeave={(e) => handleDragLeave(e, 'A')} onDrop={(e) => handleDrop(e, 'A')}
595
+ >
596
  <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">
597
  <Target size={12} /> Target Canvas (A)
598
  </div>
 
600
  {!imgA ? (
601
  <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">
602
  <Upload className="text-zinc-700" size={40} />
603
+ <span className="text-zinc-500 font-bold text-center">Upload or Drag UV Target</span>
604
  <input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'A')} />
605
  </label>
606
  ) : (
607
+ <div className="relative inline-block shadow-2xl border border-white/5" onMouseMove={(e) => handleMouseMove(e, 'A')} onMouseLeave={handleMouseLeave}>
 
 
 
608
  <img ref={imgRefA} src={imgA} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
609
  {renderSVG('A')}
610
+ {isZPressed && <Magnifier imgUrl={imgA} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="A" imgSize={imgSizeA} focusModeB={focusModeB} hoveredPoint={hoveredPoint} draggingPoint={draggingPoint} />}
611
  </div>
612
  )}
613
  </div>
614
  </div>
615
 
616
+ <div
617
+ className={`flex-1 flex flex-col bg-[#0b0b0b] transition-colors ${isDragOverB ? 'bg-indigo-900/10' : ''}`}
618
+ onDragOver={(e) => handleDragOver(e, 'B')} onDragLeave={(e) => handleDragLeave(e, 'B')} onDrop={(e) => handleDrop(e, 'B')}
619
+ >
620
  <div className="p-2 px-4 flex items-center justify-between bg-zinc-900/40">
621
  <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>
622
  <button
623
  onClick={() => setFocusModeB(!focusModeB)}
624
  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'}`}
625
  >
626
+ <MousePointer2 size={12} /> Focus Mode: {focusModeB ? 'ON' : 'OFF'}
 
627
  </button>
628
  </div>
629
  <div className="flex-1 relative flex items-center justify-center p-12 overflow-hidden bg-[radial-gradient(circle_at_center,_#141414_0%,_#0b0b0b_100%)]">
630
  {!imgB ? (
631
  <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">
632
  <Upload className="text-zinc-700" size={40} />
633
+ <span className="text-zinc-500 font-bold text-center">Upload or Drag Source Drawing</span>
634
  <input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'B')} />
635
  </label>
636
  ) : (
637
+ <div className="relative inline-block shadow-2xl border border-white/5" onMouseMove={(e) => handleMouseMove(e, 'B')} onMouseLeave={handleMouseLeave}>
 
 
 
638
  <img ref={imgRefB} src={imgB} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
639
  {renderSVG('B')}
640
+ {isZPressed && <Magnifier imgUrl={imgB} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="B" imgSize={imgSizeB} focusModeB={focusModeB} hoveredPoint={hoveredPoint} draggingPoint={draggingPoint} />}
641
  </div>
642
  )}
643
  </div>
644
  </div>
645
 
646
+ {/* Bottom Status Bar - Scaled Down */}
647
+ <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">
648
+ <div className="flex items-center gap-3 pr-6 border-r border-white/10">
649
+ <div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Active</div>
650
  {selectedRegion ? (
651
+ <div className="flex items-center gap-2.5">
652
+ <div className="w-3.5 h-3.5 rounded-full shadow border border-white/20" style={{ backgroundColor: selectedRegion.color }} />
653
+ <span className="text-[11px] font-black tracking-tight">{selectedRegion.name}</span>
654
+ <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>
655
  </div>
656
  ) : (
657
+ <span className="text-[11px] font-bold text-zinc-700 italic">None Selected</span>
658
  )}
659
  </div>
660
 
661
+ <div className="flex gap-5 items-center text-[9px] font-bold text-zinc-500 uppercase tracking-[0.2em]">
662
+ <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>
663
+ <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>
664
+ <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>
665
  </div>
666
  </div>
667
  </>
668
  ) : (
669
  <div className="flex-1 flex flex-col items-center justify-center bg-[#050505] p-12 overflow-auto">
670
  <div className="mb-6">
671
+ <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">
672
+ <Download size={20} /> Download Synthesis (.png)
 
 
 
 
673
  </button>
674
  </div>
675
  <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">