MAO commited on
Commit
d504d0f
·
1 Parent(s): 1e7f25c

Enhance UI/UX: Add Magnifier, improve geometry helpers, and refactor layout

Browse files
Files changed (1) hide show
  1. src/App.jsx +380 -394
src/App.jsx CHANGED
@@ -1,56 +1,160 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import { Upload, Plus, Eye, EyeOff, Trash2, Save, FolderOpen, Download, Search } from 'lucide-react';
3
 
4
- // --- Math & Geometry Helpers ---
5
 
6
  const dist = (p1, p2) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
7
 
8
- const distToSegment = (p, a, b) => {
 
 
 
9
  const l2 = Math.pow(dist(a, b), 2);
10
- if (l2 === 0) return dist(p, a);
11
  let t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2;
12
  t = Math.max(0, Math.min(1, t));
13
- return dist(p, { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) });
 
 
 
 
 
14
  };
15
 
16
  const getRandomColor = () => {
17
- const letters = '0123456789ABCDEF';
18
- let color = '#';
19
- for (let i = 0; i < 6; i++) {
20
- color += letters[Math.floor(Math.random() * 16)];
21
- }
22
- return color;
23
  };
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  // --- Main Application ---
26
 
27
  export default function App() {
28
  const [imgA, setImgA] = useState(null);
29
  const [imgB, setImgB] = useState(null);
 
 
 
30
  const [regions, setRegions] = useState([]);
31
  const [selectedRegionId, setSelectedRegionId] = useState(null);
32
  const [previewMode, setPreviewMode] = useState(false);
33
- const [hideUnselected, setHideUnselected] = useState(false);
34
 
 
35
  const [hoveredEdge, setHoveredEdge] = useState(null);
36
  const [hoveredPoint, setHoveredPoint] = useState(null);
37
  const [draggingPoint, setDraggingPoint] = useState(null);
 
38
 
39
- // Zoom feature states
40
- const [isZooming, setIsZooming] = useState(false);
41
- const [mousePos, setMousePos] = useState({ x: 0, y: 0, relX: 0, relY: 0, rect: null });
42
 
43
  const imgRefA = useRef(null);
44
  const imgRefB = useRef(null);
45
  const canvasPreviewRef = useRef(null);
46
 
47
- const zoomFactor = 4; // Zoom multiplier
48
 
49
- const handleImageUpload = (e, setImg) => {
50
  const file = e.target.files[0];
51
  if (file) {
52
  const url = URL.createObjectURL(file);
53
- setImg(url);
 
 
 
 
 
 
 
 
 
 
54
  }
55
  };
56
 
@@ -63,11 +167,9 @@ export default function App() {
63
  const data = JSON.parse(event.target.result);
64
  if (Array.isArray(data)) {
65
  setRegions(data);
66
- setSelectedRegionId(data.length > 0 ? data[0].id : null);
67
  }
68
- } catch (err) {
69
- console.error("JSON parse error", err);
70
- }
71
  };
72
  reader.readAsText(file);
73
  e.target.value = '';
@@ -78,6 +180,7 @@ export default function App() {
78
  const newId = crypto.randomUUID();
79
  const newRegion = {
80
  id: newId,
 
81
  color: getRandomColor(),
82
  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 }],
83
  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 }]
@@ -93,7 +196,6 @@ export default function App() {
93
 
94
  const handlePointMouseDown = (e, side, regionId, index) => {
95
  e.stopPropagation();
96
- e.preventDefault();
97
  setSelectedRegionId(regionId);
98
  setDraggingPoint({ regionId, index, side });
99
  };
@@ -105,19 +207,25 @@ export default function App() {
105
  const rect = imgEl.getBoundingClientRect();
106
  let x = (e.clientX - rect.left) / rect.width;
107
  let y = (e.clientY - rect.top) / rect.height;
108
-
109
  x = Math.max(0, Math.min(1, x));
110
  y = Math.max(0, Math.min(1, y));
111
 
112
- setMousePos({ x: e.clientX, y: e.clientY, relX: x, relY: y, rect });
 
 
 
 
 
 
 
113
 
114
  if (draggingPoint && draggingPoint.side === side) {
115
  setRegions(prev => prev.map(r => {
116
  if (r.id === draggingPoint.regionId) {
117
- const pointsKey = side === 'A' ? 'pointsA' : 'pointsB';
118
- const newPoints = [...r[pointsKey]];
119
- newPoints[draggingPoint.index] = { x, y };
120
- return { ...r, [pointsKey]: newPoints };
121
  }
122
  return r;
123
  }));
@@ -126,80 +234,79 @@ export default function App() {
126
 
127
  if (selectedRegionId) {
128
  const region = regions.find(r => r.id === selectedRegionId);
129
- if (region) {
130
- const points = side === 'A' ? region.pointsA : region.pointsB;
131
- let foundPoint = null;
132
- for (let i = 0; i < points.length; i++) {
133
- if (dist({ x, y }, points[i]) < 0.02) {
134
- foundPoint = { regionId: region.id, index: i };
135
- break;
136
- }
137
- }
138
 
139
- if (foundPoint) {
140
- setHoveredPoint(foundPoint);
141
- setHoveredEdge(null);
142
- return;
143
- } else {
144
- setHoveredPoint(null);
145
  }
 
146
 
147
- let closestDist = Infinity;
148
- let closestIndex = -1;
 
 
 
 
149
  for (let i = 0; i < points.length; i++) {
150
  const p1 = points[i];
151
  const p2 = points[(i + 1) % points.length];
152
- const d = distToSegment({ x, y }, p1, p2);
153
- if (d < 0.02) {
154
- if (d < closestDist) {
155
- closestDist = d;
156
- closestIndex = i;
157
- }
158
  }
159
  }
160
- setHoveredEdge(closestIndex !== -1 ? { regionId: region.id, index: closestIndex, side } : null);
161
  }
162
- } else {
163
- setHoveredPoint(null);
164
- setHoveredEdge(null);
165
  }
166
  };
167
 
168
- const handleGlobalMouseUp = () => {
169
- setDraggingPoint(null);
170
- };
171
 
172
  useEffect(() => {
173
- const handleKeyDown = (e) => {
174
- if (e.key.toLowerCase() === 'z') {
175
- setIsZooming(true);
176
- }
177
 
178
- if ((e.key === 'a' || e.key === 'A') && hoveredEdge && !hoveredPoint) {
179
- const { regionId, index } = hoveredEdge;
180
  setRegions(prev => prev.map(r => {
181
  if (r.id === regionId) {
182
- const insertPoint = (pts) => {
183
- const p1 = pts[index], p2 = pts[(index + 1) % pts.length];
184
- const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
185
- const newPts = [...pts];
186
- newPts.splice(index + 1, 0, mid);
187
- return newPts;
188
- };
189
- return { ...r, pointsA: insertPoint(r.pointsA), pointsB: insertPoint(r.pointsB) };
 
 
 
 
 
 
 
190
  }
191
  return r;
192
  }));
193
- setHoveredEdge(null);
194
  }
195
 
196
- if ((e.key === 'd' || e.key === 'D' || e.key === 'Backspace') && hoveredPoint) {
197
  const { regionId, index } = hoveredPoint;
198
  setRegions(prev => prev.map(r => {
199
  if (r.id === regionId && r.pointsA.length > 3) {
200
- const newPtsA = r.pointsA.filter((_, i) => i !== index);
201
- const newPtsB = r.pointsB.filter((_, i) => i !== index);
202
- return { ...r, pointsA: newPtsA, pointsB: newPtsB };
 
 
203
  }
204
  return r;
205
  }));
@@ -207,392 +314,271 @@ export default function App() {
207
  }
208
  };
209
 
210
- const handleKeyUp = (e) => {
211
- if (e.key.toLowerCase() === 'z') {
212
- setIsZooming(false);
213
- }
214
  };
215
 
216
- window.addEventListener('keydown', handleKeyDown);
217
- window.addEventListener('keyup', handleKeyUp);
218
- window.addEventListener('mouseup', handleGlobalMouseUp);
219
  return () => {
220
- window.removeEventListener('keydown', handleKeyDown);
221
- window.removeEventListener('keyup', handleKeyUp);
222
- window.removeEventListener('mouseup', handleGlobalMouseUp);
223
  };
224
  }, [hoveredEdge, hoveredPoint]);
225
 
226
- // --- Ear Clipping Triangulation ---
227
- const crossWidth = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
228
-
229
- const isPointInTriangle = (p, a, b, c) => {
230
- const u = crossWidth(b, c, p);
231
- const v = crossWidth(c, a, p);
232
- const w = crossWidth(a, b, p);
233
- return (u >= 0 && v >= 0 && w >= 0) || (u <= 0 && v <= 0 && w <= 0);
234
- };
235
-
236
- const triangulatePolygon = (points) => {
237
- if (points.length < 3) return [];
238
- const indices = [];
239
- let area = 0;
240
- for (let i = 0; i < points.length; i++) {
241
- indices.push(i);
242
- const p1 = points[i];
243
- const p2 = points[(i + 1) % points.length];
244
- area += (p2.x - p1.x) * (p2.y + p1.y);
245
- }
246
- let safeCount = 0;
247
  const result = [];
248
- while (indices.length > 3 && safeCount++ < 1000) {
249
- let earFound = false;
250
- const n = indices.length;
251
- for (let i = 0; i < n; i++) {
252
- const iPrev = (i - 1 + n) % n;
253
- const iNext = (i + 1) % n;
254
- const idxPrev = indices[iPrev], idxCurr = indices[i], idxNext = indices[iNext];
255
- const pPrev = points[idxPrev], pCurr = points[idxCurr], pNext = points[idxNext];
256
- const cross = crossWidth(pPrev, pCurr, pNext);
257
- if (Math.abs(cross) > 0.000001 && Math.sign(cross) === Math.sign(area)) continue;
258
- let isEar = true;
259
- for (let k = 0; k < n; k++) {
260
- if (k === iPrev || k === i || k === iNext) continue;
261
- if (isPointInTriangle(points[indices[k]], pPrev, pCurr, pNext)) {
262
- isEar = false;
263
- break;
264
- }
265
- }
266
- if (isEar) {
267
- result.push(idxPrev, idxCurr, idxNext);
268
- indices.splice(i, 1);
269
- earFound = true;
270
- break;
271
- }
272
  }
273
- if (!earFound) break;
274
- }
275
- if (indices.length === 3) result.push(indices[0], indices[1], indices[2]);
276
- else if (indices.length > 3) {
277
- for (let i = 1; i < indices.length - 1; i++) result.push(indices[0], indices[i], indices[i + 1]);
278
  }
 
279
  return result;
280
  };
281
 
282
- useEffect(() => {
283
  if (!previewMode || !imgA || !imgB || !canvasPreviewRef.current) return;
284
  const canvas = canvasPreviewRef.current;
285
  const ctx = canvas.getContext('2d');
286
  const imageA = new Image();
287
  const imageB = new Image();
288
- let isComponentMounted = true;
289
- let loadedCount = 0;
290
- const onImageLoad = () => {
291
- if (!isComponentMounted) return;
292
- loadedCount++;
293
- if (loadedCount === 2) {
294
  canvas.width = imageA.width;
295
  canvas.height = imageA.height;
296
- ctx.clearRect(0, 0, canvas.width, canvas.height);
297
  ctx.drawImage(imageA, 0, 0);
298
  regions.forEach(region => {
299
  if (region.pointsA.length < 3) return;
300
  const ptsA = region.pointsA.map(p => ({ x: p.x * imageA.width, y: p.y * imageA.height }));
301
  const ptsB = region.pointsB.map(p => ({ x: p.x * imageB.width, y: p.y * imageB.height }));
302
- const indices = triangulatePolygon(ptsB);
303
- for (let i = 0; i < indices.length; i += 3) {
304
- textureMapTriangle(ctx, imageB, ptsA[indices[i]], ptsA[indices[i + 1]], ptsA[indices[i + 2]], ptsB[indices[i]], ptsB[indices[i + 1]], ptsB[indices[i + 2]]);
305
  }
306
  });
307
  }
308
  };
309
- imageA.onload = onImageLoad;
310
- imageB.onload = onImageLoad;
311
- imageA.src = imgA;
312
- imageB.src = imgB;
313
- return () => { isComponentMounted = false; };
314
  }, [previewMode, imgA, imgB, regions]);
315
 
316
- const exportJson = (e) => {
317
- e.preventDefault();
318
- const data = JSON.stringify(regions, null, 2);
319
- const blob = new Blob([data], { type: 'application/json' });
320
- const url = URL.createObjectURL(blob);
321
- const link = document.createElement('a');
322
- link.href = url;
323
- link.download = 'texture-mapping-config.json';
324
- link.click();
325
- URL.revokeObjectURL(url);
 
 
 
 
 
 
 
 
326
  };
327
 
328
- const downloadPreview = (e) => {
329
- if (e) e.preventDefault();
330
- const canvas = canvasPreviewRef.current;
331
- if (!canvas) return;
332
- try {
333
- canvas.toBlob((blob) => {
334
- if (!blob) return;
335
- const url = URL.createObjectURL(blob);
336
- const link = document.createElement('a');
337
- link.download = 'texture-mapper-result.png';
338
- link.href = url;
339
- link.click();
340
- URL.revokeObjectURL(url);
341
- }, 'image/png');
342
- } catch (err) {
343
- console.error("Download error:", err);
344
- }
345
- };
346
-
347
- const renderOverlay = (side, isZoomView = false) => {
348
- const pointsKey = side === 'A' ? 'pointsA' : 'pointsB';
349
- const visibleRegions = (side === 'B' && hideUnselected && selectedRegionId)
350
  ? regions.filter(r => r.id === selectedRegionId)
351
  : regions;
352
 
353
- const baseStrokeWidth = 1.5;
354
- const selectedStrokeWidth = 3;
355
- const baseCircleRadius = 0.5;
356
-
357
- const currentStrokeW = isZoomView ? (baseStrokeWidth / zoomFactor) : baseStrokeWidth;
358
- const currentSelectedStrokeW = isZoomView ? (selectedStrokeWidth / zoomFactor) : selectedStrokeWidth;
359
- const currentCircleRadius = isZoomView ? (baseCircleRadius / zoomFactor) : baseCircleRadius;
360
-
361
  return (
362
- <svg
363
- className="absolute top-0 left-0 w-full h-full pointer-events-none"
364
- style={{ zIndex: 10, overflow: 'visible' }}
365
- viewBox="0 0 100 100"
366
- preserveAspectRatio="none"
367
- >
368
  {visibleRegions.map(r => {
369
  const isSelected = r.id === selectedRegionId;
370
- const points = r[pointsKey];
371
- const pointsStr = points.map(p => `${p.x * 100},${p.y * 100}`).join(' ');
372
 
373
  return (
374
- <g key={`poly-group-${r.id}`}>
375
  <polygon
376
- points={pointsStr}
 
 
377
  style={{
378
- vectorEffect: 'non-scaling-stroke',
379
  fill: r.color,
380
- fillOpacity: isSelected ? 0.4 : 0.1,
381
- stroke: isSelected ? '#00f2ff' : r.color,
382
- strokeWidth: isSelected ? currentSelectedStrokeW : currentStrokeW,
383
- strokeDasharray: isSelected ? "none" : (isZoomView ? `${0.5 / zoomFactor}, ${0.5 / zoomFactor}` : "2, 2")
384
  }}
385
- className={isZoomView ? "" : "pointer-events-auto cursor-pointer"}
386
- onMouseDown={isZoomView ? undefined : (e) => { e.stopPropagation(); setSelectedRegionId(r.id); }}
387
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  </g>
389
  );
390
  })}
391
-
392
- {visibleRegions.map(reg => {
393
- const isRegSelected = reg.id === selectedRegionId;
394
- return reg[pointsKey].map((p, idx) => {
395
- let strokeColor = isRegSelected ? '#fff200' : 'rgba(0, 0, 0, 0.4)';
396
- let fillColor = reg.color;
397
- let radius = isRegSelected ? (currentCircleRadius * 1.5) : currentCircleRadius;
398
- let pointStrokeW = isZoomView ? (0.5 / zoomFactor) : (isRegSelected ? 1 : 0.5);
399
-
400
- const isDragging = !isZoomView && draggingPoint && draggingPoint.regionId === reg.id && draggingPoint.index === idx;
401
- const isHovering = !isZoomView && hoveredPoint && hoveredPoint.regionId === reg.id && hoveredPoint.index === idx;
402
-
403
- if (isDragging || isHovering) {
404
- strokeColor = isDragging ? '#ff0000' : '#0066ff';
405
- fillColor = 'white';
406
- radius = currentCircleRadius * 2;
407
- pointStrokeW = isZoomView ? (1 / zoomFactor) : 2;
408
- }
409
-
410
- return (
411
- <circle
412
- key={`node-${reg.id}-${idx}`}
413
- cx={p.x * 100} cy={p.y * 100} r={radius}
414
- style={{
415
- vectorEffect: 'non-scaling-stroke',
416
- fill: fillColor,
417
- fillOpacity: (isRegSelected || isHovering) ? 1 : 0.8,
418
- stroke: strokeColor,
419
- strokeWidth: pointStrokeW,
420
- cursor: isZoomView ? 'default' : 'move',
421
- pointerEvents: isZoomView ? 'none' : 'auto'
422
- }}
423
- onMouseDown={isZoomView ? undefined : (e) => handlePointMouseDown(e, side, reg.id, idx)}
424
- />
425
- );
426
- });
427
- })}
428
  </svg>
429
  );
430
  };
431
 
432
- const renderMagnifier = (side, imageSrc, rect) => {
433
- if (!isZooming || !mousePos.relX || !rect) return null;
434
-
435
- const viewSize = 240;
436
- const centerX = viewSize / 2;
437
- const centerY = viewSize / 2;
438
-
439
- const translateX = centerX - (mousePos.relX * rect.width * zoomFactor);
440
- const translateY = centerY - (mousePos.relY * rect.height * zoomFactor);
441
-
442
- return (
443
- <div
444
- className="absolute pointer-events-none border-2 border-blue-500 shadow-2xl rounded-full overflow-hidden bg-black z-[100]"
445
- style={{
446
- left: `${mousePos.relX * 100}%`,
447
- top: `${mousePos.relY * 100}%`,
448
- width: `${viewSize}px`,
449
- height: `${viewSize}px`,
450
- transform: 'translate(-50%, -115%)'
451
- }}
452
- >
453
- <div
454
- className="absolute origin-top-left"
455
- style={{
456
- width: rect.width,
457
- height: rect.height,
458
- transform: `translate(${translateX}px, ${translateY}px) scale(${zoomFactor})`,
459
- }}
460
- >
461
- <div className="relative w-full h-full">
462
- <img src={imageSrc} className="block w-full h-full object-contain" draggable={false} />
463
- {renderOverlay(side, true)}
464
- </div>
465
- </div>
466
-
467
- {/* Navigation Crosshair */}
468
- <div className="absolute top-1/2 left-0 w-full h-px bg-blue-400/50"></div>
469
- <div className="absolute left-1/2 top-0 w-px h-full bg-blue-400/50"></div>
470
- <div className="absolute inset-0 rounded-full border-[4px] border-black/20 pointer-events-none shadow-inner"></div>
471
- </div>
472
- );
473
- };
474
-
475
  return (
476
- <div className="flex flex-col h-screen bg-gray-900 text-gray-100 font-sans select-none">
477
- <div className="flex items-center justify-between p-4 bg-gray-800 border-b border-gray-700 shadow-md z-50">
478
- <h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-emerald-400 bg-clip-text text-transparent tracking-tight">UV Texture Mapper</h1>
479
-
480
- <div className="flex items-center space-x-2 text-[10px] text-gray-500 mr-auto ml-6 font-medium">
481
- <span className="bg-gray-700/50 border border-gray-600 px-1.5 py-0.5 rounded text-gray-300">A</span> Add Node
482
- <span className="bg-gray-700/50 border border-gray-600 px-1.5 py-0.5 rounded text-gray-300 ml-1">D</span> Delete Node
483
- <span className="bg-gray-700/50 border border-gray-600 px-1.5 py-0.5 rounded text-gray-300 ml-1">Hold Z</span> Zoom
484
- </div>
485
-
486
- <div className="flex items-center space-x-3">
487
-
488
- <div className="flex space-x-2">
489
- <label className="flex items-center gap-2 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded cursor-pointer text-xs transition-colors border border-gray-600">
490
- <Upload size={14} /> <span>Image A</span>
491
- <input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, setImgA)} />
492
- </label>
493
- <label className="flex items-center gap-2 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded cursor-pointer text-xs transition-colors border border-gray-600">
494
- <Upload size={14} /> <span>Image B</span>
495
- <input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, setImgB)} />
496
- </label>
497
- </div>
498
- <div className="w-px h-6 bg-gray-700 mx-1"></div>
499
- <button onClick={addRegion} className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 rounded text-xs font-medium transition-all"><Plus size={14} /> New Region</button>
500
- {selectedRegionId && <button onClick={() => deleteRegion(selectedRegionId)} className="flex items-center gap-2 px-3 py-1.5 bg-red-900/30 text-red-300 hover:bg-red-900/50 rounded border border-red-800/40 text-xs transition-colors"><Trash2 size={14} /> Delete</button>}
501
-
502
- <div className="flex space-x-2">
503
- <label className="flex items-center gap-2 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded cursor-pointer text-xs transition-colors">
504
- <FolderOpen size={14} /> <span>Import JSON</span>
505
- <input type="file" accept=".json" className="hidden" onChange={handleJsonUpload} />
506
- </label>
507
- <button onClick={exportJson} className="flex items-center gap-2 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs transition-colors"><Save size={14} /> Export JSON</button>
508
  </div>
 
509
 
510
-
511
- <div className="w-px h-6 bg-gray-700 mx-1"></div>
512
-
513
- <button onClick={() => setPreviewMode(!previewMode)} className={`flex items-center gap-2 px-4 py-1.5 rounded text-xs font-bold transition-all ${previewMode ? 'bg-emerald-600 text-white shadow-lg' : 'bg-gray-700 hover:bg-gray-600'}`}>
514
- {previewMode ? <EyeOff size={14} /> : <Eye size={14} />} {previewMode ? 'Back to Edit' : 'Preview Result'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  </button>
516
  </div>
517
- </div>
518
 
519
- <div className="flex-1 flex overflow-hidden relative">
520
  {!previewMode ? (
521
- <div className="flex w-full h-full">
522
- <div className="flex-1 flex flex-col border-r border-gray-800 bg-gray-950 p-6 relative">
523
- <div className="mb-3 text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 text-center">Target Canvas (Side A)</div>
524
- <div className="relative flex-1 bg-black/40 rounded-xl border border-gray-800 overflow-hidden select-none flex items-center justify-center shadow-inner" onMouseMove={(e) => handleMouseMove(e, 'A')} onMouseDown={() => setSelectedRegionId(null)}>
525
- {imgA ? (
526
- <div className="relative" style={{ width: 'fit-content', height: 'fit-content', maxWidth: '100%', maxHeight: '100%' }}>
527
- <img ref={imgRefA} src={imgA} className="block w-auto h-auto max-w-full max-h-full shadow-lg" draggable={false} />
528
- {renderOverlay('A')}
529
- {renderMagnifier('A', imgA, mousePos.rect)}
 
 
 
 
 
 
 
 
 
 
 
530
  </div>
531
- ) : <div className="text-gray-700 text-sm italic">Please upload Image A</div>}
532
  </div>
533
  </div>
534
 
535
- <div className="flex-1 flex flex-col bg-gray-950 p-6 relative">
536
- <div className="mb-3 text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 text-center flex items-center justify-center gap-4">
537
- Texture Source (Side B)
538
- <label className="flex items-center gap-2 cursor-pointer normal-case tracking-normal text-gray-400 hover:text-gray-200 transition-colors ml-4 border border-gray-800 px-2 py-0.5 rounded bg-gray-900/50">
539
- <input type="checkbox" className="w-3 h-3 rounded border-gray-700 bg-gray-800 text-blue-600 focus:ring-0" checked={hideUnselected} onChange={(e) => setHideUnselected(e.target.checked)} />
540
- <span className="text-[10px] font-medium">Selection Only</span>
541
- </label>
 
 
 
542
  </div>
543
- <div className="relative flex-1 bg-black/40 rounded-xl border border-gray-800 overflow-hidden select-none flex items-center justify-center shadow-inner" onMouseMove={(e) => handleMouseMove(e, 'B')} onMouseDown={() => setSelectedRegionId(null)}>
544
- {imgB ? (
545
- <div className="relative" style={{ width: 'fit-content', height: 'fit-content', maxWidth: '100%', maxHeight: '100%' }}>
546
- <img ref={imgRefB} src={imgB} className="block w-auto h-auto max-w-full max-h-full shadow-lg" draggable={false} />
547
- {renderOverlay('B')}
548
- {renderMagnifier('B', imgB, mousePos.rect)}
 
 
 
 
 
 
 
 
 
549
  </div>
550
- ) : <div className="text-gray-700 text-sm italic">Please upload Image B</div>}
551
  </div>
552
  </div>
553
- </div>
554
- ) : (
555
- <div className="absolute inset-0 z-20 bg-gray-950 flex flex-col items-center justify-center p-8 overflow-auto">
556
- <div className="mb-6 flex gap-4">
557
- <button type="button" onClick={downloadPreview} className="flex items-center gap-2 px-6 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-full font-bold shadow-xl transition-all active:scale-95">
558
- <Download size={18} /> Download Result (.png)
559
- </button>
560
- <button onClick={() => setPreviewMode(false)} className="flex items-center gap-2 px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-full font-bold transition-all">
561
- Back to Edit
562
- </button>
 
 
 
 
 
 
 
 
 
 
 
563
  </div>
564
- <div className="relative shadow-2xl rounded-2xl overflow-hidden border border-gray-800 bg-black max-w-full max-h-[80vh]">
565
- <canvas ref={canvasPreviewRef} className="block max-w-full max-h-[75vh] mx-auto" />
 
 
 
566
  </div>
567
  </div>
568
  )}
569
- </div>
570
  </div>
571
  );
572
- }
573
-
574
- function textureMapTriangle(ctx, img, p0, p1, p2, t0, t1, t2) {
575
- ctx.save();
576
- const midX = (p0.x + p1.x + p2.x) / 3, midY = (p0.y + p1.y + p2.y) / 3;
577
- const expand = (p) => {
578
- const dx = p.x - midX, dy = p.y - midY;
579
- const mag = Math.sqrt(dx * dx + dy * dy) || 1;
580
- return { x: p.x + (dx / mag) * 0.5, y: p.y + (dy / mag) * 0.5 };
581
- };
582
- const ep0 = expand(p0), ep1 = expand(p1), ep2 = expand(p2);
583
- ctx.beginPath();
584
- ctx.moveTo(ep0.x, ep0.y); ctx.lineTo(ep1.x, ep1.y); ctx.lineTo(ep2.x, ep2.y);
585
- ctx.closePath(); ctx.clip();
586
- const x0 = t0.x, y0 = t0.y, x1 = t1.x, y1 = t1.y, x2 = t2.x, y2 = t2.y;
587
- const u0 = p0.x, v0 = p0.y, u1 = p1.x, v1 = p1.y, u2 = p2.x, v2 = p2.y;
588
- const delta = (x0 - x2) * (y1 - y2) - (x1 - x2) * (y0 - y2);
589
- if (Math.abs(delta) < 0.0001) { ctx.restore(); return; }
590
- const a = ((u0 - u2) * (y1 - y2) - (u1 - u2) * (y0 - y2)) / delta;
591
- const b = ((v0 - v2) * (y1 - y2) - (v1 - v2) * (y0 - v2)) / delta;
592
- const c = ((x0 - x2) * (u1 - u2) - (x1 - x2) * (u0 - u2)) / delta;
593
- const d = ((x0 - x2) * (v1 - v2) - (x1 - x2) * (v0 - v2)) / delta;
594
- const e = u2 - a * x2 - c * y2, f = v2 - b * x2 - d * y2;
595
- ctx.setTransform(a, b, c, d, e, f);
596
- ctx.drawImage(img, 0, 0, img.width, img.height);
597
- ctx.restore();
598
  }
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { Upload, Plus, Eye, EyeOff, Trash2, Save, FolderOpen, Download, Target, MousePointer2, Info, Search } from 'lucide-react';
3
 
4
+ // --- Geometry Helper Functions ---
5
 
6
  const dist = (p1, p2) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
7
 
8
+ /**
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;
15
  t = Math.max(0, Math.min(1, t));
16
+ return { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) };
17
+ };
18
+
19
+ const distToSegment = (p, a, b) => {
20
+ const closest = getClosestPointOnSegment(p, a, b);
21
+ return dist(p, closest);
22
  };
23
 
24
  const getRandomColor = () => {
25
+ const h = Math.floor(Math.random() * 360);
26
+ return `hsl(${h}, 75%, 60%)`;
 
 
 
 
27
  };
28
 
29
+ // --- Magnifier Component ---
30
+ function Magnifier({ imgUrl, regions, selectedId, mousePos, side, imgSize, focusModeB }) {
31
+ if (!imgUrl || !mousePos || mousePos.side !== side) return null;
32
+
33
+ const zoom = 2.5;
34
+ const size = 180;
35
+ const r = size / 2;
36
+
37
+ // Filtering regions based on side and Focus Mode
38
+ const visibleRegions = (side === 'B' && focusModeB)
39
+ ? regions.filter(reg => reg.id === selectedId)
40
+ : regions;
41
+
42
+ // Background offset logic to keep mouse pointer at center
43
+ const bgPosX = -mousePos.x * imgSize.width * zoom + r;
44
+ const bgPosY = -mousePos.y * imgSize.height * zoom + r;
45
+
46
+ return (
47
+ <div
48
+ 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]"
49
+ style={{
50
+ width: size,
51
+ height: size,
52
+ left: mousePos.px - r,
53
+ top: mousePos.py - size - 40,
54
+ }}
55
+ >
56
+ {/* Background Image Source */}
57
+ <div
58
+ className="absolute inset-0 bg-[#111]"
59
+ style={{
60
+ backgroundImage: `url(${imgUrl})`,
61
+ backgroundSize: `${imgSize.width * zoom}px ${imgSize.height * zoom}px`,
62
+ backgroundPosition: `${bgPosX}px ${bgPosY}px`,
63
+ backgroundRepeat: 'no-repeat'
64
+ }}
65
+ />
66
+
67
+ {/* SVG Overlay inside Magnifier */}
68
+ <svg className="absolute inset-0 w-full h-full overflow-visible" viewBox={`0 0 ${size} ${size}`}>
69
+ {visibleRegions.map(reg => {
70
+ const isSelected = reg.id === selectedId;
71
+ const points = side === 'A' ? reg.pointsA : reg.pointsB;
72
+
73
+ const localPoints = points.map(p => ({
74
+ x: (p.x - mousePos.x) * imgSize.width * zoom + r,
75
+ y: (p.y - mousePos.y) * imgSize.height * zoom + r
76
+ }));
77
+
78
+ const polyStr = localPoints.map(p => `${p.x},${p.y}`).join(' ');
79
+
80
+ return (
81
+ <g key={reg.id}>
82
+ <polygon
83
+ points={polyStr}
84
+ style={{
85
+ fill: reg.color,
86
+ fillOpacity: isSelected ? 0.35 : 0.1,
87
+ stroke: isSelected ? '#fff' : reg.color,
88
+ strokeWidth: 1.5,
89
+ }}
90
+ />
91
+ {localPoints.map((p, idx) => (
92
+ <circle
93
+ key={idx}
94
+ cx={p.x} cy={p.y}
95
+ r={isSelected ? 4 : 2}
96
+ fill={isSelected ? '#fff' : reg.color}
97
+ stroke="#000"
98
+ strokeWidth={0.5}
99
+ />
100
+ ))}
101
+ </g>
102
+ );
103
+ })}
104
+ </svg>
105
+
106
+ {/* Reticle */}
107
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
108
+ <div className="w-full h-[1px] bg-white/30 absolute" />
109
+ <div className="h-full w-[1px] bg-white/30 absolute" />
110
+ <div className="w-4 h-4 border border-indigo-400 rounded-full bg-indigo-500/10" />
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
  // --- Main Application ---
117
 
118
  export default function App() {
119
  const [imgA, setImgA] = useState(null);
120
  const [imgB, setImgB] = useState(null);
121
+ const [imgSizeA, setImgSizeA] = useState({ width: 0, height: 0 });
122
+ const [imgSizeB, setImgSizeB] = useState({ width: 0, height: 0 });
123
+
124
  const [regions, setRegions] = useState([]);
125
  const [selectedRegionId, setSelectedRegionId] = useState(null);
126
  const [previewMode, setPreviewMode] = useState(false);
127
+ const [focusModeB, setFocusModeB] = useState(false);
128
 
129
+ // Interaction & Keys State
130
  const [hoveredEdge, setHoveredEdge] = useState(null);
131
  const [hoveredPoint, setHoveredPoint] = useState(null);
132
  const [draggingPoint, setDraggingPoint] = useState(null);
133
+ const [isZPressed, setIsZPressed] = useState(false);
134
 
135
+ const [magnifierPos, setMagnifierPos] = useState(null);
 
 
136
 
137
  const imgRefA = useRef(null);
138
  const imgRefB = useRef(null);
139
  const canvasPreviewRef = useRef(null);
140
 
141
+ const selectedRegion = regions.find(r => r.id === selectedRegionId);
142
 
143
+ const handleImageUpload = (e, side) => {
144
  const file = e.target.files[0];
145
  if (file) {
146
  const url = URL.createObjectURL(file);
147
+ const img = new Image();
148
+ img.onload = () => {
149
+ if (side === 'A') {
150
+ setImgA(url);
151
+ setImgSizeA({ width: img.width, height: img.height });
152
+ } else {
153
+ setImgB(url);
154
+ setImgSizeB({ width: img.width, height: img.height });
155
+ }
156
+ };
157
+ img.src = url;
158
  }
159
  };
160
 
 
167
  const data = JSON.parse(event.target.result);
168
  if (Array.isArray(data)) {
169
  setRegions(data);
170
+ if (data.length > 0) setSelectedRegionId(data[0].id);
171
  }
172
+ } catch (err) { console.error(err); }
 
 
173
  };
174
  reader.readAsText(file);
175
  e.target.value = '';
 
180
  const newId = crypto.randomUUID();
181
  const newRegion = {
182
  id: newId,
183
+ name: `Region ${regions.length + 1}`,
184
  color: getRandomColor(),
185
  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 }],
186
  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 }]
 
196
 
197
  const handlePointMouseDown = (e, side, regionId, index) => {
198
  e.stopPropagation();
 
199
  setSelectedRegionId(regionId);
200
  setDraggingPoint({ regionId, index, side });
201
  };
 
207
  const rect = imgEl.getBoundingClientRect();
208
  let x = (e.clientX - rect.left) / rect.width;
209
  let y = (e.clientY - rect.top) / rect.height;
 
210
  x = Math.max(0, Math.min(1, x));
211
  y = Math.max(0, Math.min(1, y));
212
 
213
+ const mousePos = { x, y };
214
+
215
+ setMagnifierPos({
216
+ x, y,
217
+ px: e.clientX - rect.left,
218
+ py: e.clientY - rect.top,
219
+ side
220
+ });
221
 
222
  if (draggingPoint && draggingPoint.side === side) {
223
  setRegions(prev => prev.map(r => {
224
  if (r.id === draggingPoint.regionId) {
225
+ const key = side === 'A' ? 'pointsA' : 'pointsB';
226
+ const newPts = [...r[key]];
227
+ newPts[draggingPoint.index] = mousePos;
228
+ return { ...r, [key]: newPts };
229
  }
230
  return r;
231
  }));
 
234
 
235
  if (selectedRegionId) {
236
  const region = regions.find(r => r.id === selectedRegionId);
237
+ const points = side === 'A' ? region.pointsA : region.pointsB;
 
 
 
 
 
 
 
 
238
 
239
+ let foundPt = null;
240
+ for (let i = 0; i < points.length; i++) {
241
+ if (dist(mousePos, points[i]) < 0.03) {
242
+ foundPt = { regionId: region.id, index: i };
243
+ break;
 
244
  }
245
+ }
246
 
247
+ if (foundPt) {
248
+ setHoveredPoint(foundPt);
249
+ setHoveredEdge(null);
250
+ } else {
251
+ setHoveredPoint(null);
252
+ let foundEdge = null;
253
  for (let i = 0; i < points.length; i++) {
254
  const p1 = points[i];
255
  const p2 = points[(i + 1) % points.length];
256
+ const d = distToSegment(mousePos, p1, p2);
257
+ if (d < 0.025) {
258
+ const projPoint = getClosestPointOnSegment(mousePos, p1, p2);
259
+ foundEdge = { regionId: region.id, index: i, side, point: projPoint };
260
+ break;
 
261
  }
262
  }
263
+ setHoveredEdge(foundEdge);
264
  }
 
 
 
265
  }
266
  };
267
 
268
+ const handleMouseUp = () => setDraggingPoint(null);
269
+ const handleMouseLeave = () => setMagnifierPos(null);
 
270
 
271
  useEffect(() => {
272
+ const onKeyDown = (e) => {
273
+ const key = e.key.toLowerCase();
274
+
275
+ if (key === 'z') setIsZPressed(true);
276
 
277
+ if (key === 'a' && hoveredEdge) {
278
+ const { regionId, index, side, point } = hoveredEdge;
279
  setRegions(prev => prev.map(r => {
280
  if (r.id === regionId) {
281
+ const newPointsA = [...r.pointsA];
282
+ const newPointsB = [...r.pointsB];
283
+ let finalPtA, finalPtB;
284
+ if (side === 'A') {
285
+ finalPtA = point;
286
+ const b1 = newPointsB[index], b2 = newPointsB[(index + 1) % newPointsB.length];
287
+ finalPtB = { x: (b1.x + b2.x) / 2, y: (b1.y + b2.y) / 2 };
288
+ } else {
289
+ finalPtB = point;
290
+ const a1 = newPointsA[index], a2 = newPointsA[(index + 1) % newPointsA.length];
291
+ finalPtA = { x: (a1.x + a2.x) / 2, y: (a1.y + a2.y) / 2 };
292
+ }
293
+ newPointsA.splice(index + 1, 0, finalPtA);
294
+ newPointsB.splice(index + 1, 0, finalPtB);
295
+ return { ...r, pointsA: newPointsA, pointsB: newPointsB };
296
  }
297
  return r;
298
  }));
 
299
  }
300
 
301
+ if ((key === 'd' || key === 'backspace' || key === 'delete') && hoveredPoint) {
302
  const { regionId, index } = hoveredPoint;
303
  setRegions(prev => prev.map(r => {
304
  if (r.id === regionId && r.pointsA.length > 3) {
305
+ return {
306
+ ...r,
307
+ pointsA: r.pointsA.filter((_, i) => i !== index),
308
+ pointsB: r.pointsB.filter((_, i) => i !== index)
309
+ };
310
  }
311
  return r;
312
  }));
 
314
  }
315
  };
316
 
317
+ const onKeyUp = (e) => {
318
+ if (e.key.toLowerCase() === 'z') setIsZPressed(false);
 
 
319
  };
320
 
321
+ window.addEventListener('keydown', onKeyDown);
322
+ window.addEventListener('keyup', onKeyUp);
 
323
  return () => {
324
+ window.removeEventListener('keydown', onKeyDown);
325
+ window.removeEventListener('keyup', onKeyUp);
 
326
  };
327
  }, [hoveredEdge, hoveredPoint]);
328
 
329
+ // --- Texture Mapping Core ---
330
+ const triangulate = (points) => {
331
+ const indices = points.map((_, i) => i);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  const result = [];
333
+ let count = 0;
334
+ while (indices.length > 3 && count < 500) {
335
+ count++;
336
+ let found = false;
337
+ for (let i = 0; i < indices.length; i++) {
338
+ result.push(indices[(i + indices.length - 1) % indices.length], indices[i], indices[(i + 1) % indices.length]);
339
+ indices.splice(i, 1);
340
+ found = true;
341
+ break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  }
343
+ if (!found) break;
 
 
 
 
344
  }
345
+ if (indices.length === 3) result.push(...indices);
346
  return result;
347
  };
348
 
349
+ const drawMapping = useCallback(() => {
350
  if (!previewMode || !imgA || !imgB || !canvasPreviewRef.current) return;
351
  const canvas = canvasPreviewRef.current;
352
  const ctx = canvas.getContext('2d');
353
  const imageA = new Image();
354
  const imageB = new Image();
355
+ let loaded = 0;
356
+ const onLoad = () => {
357
+ loaded++;
358
+ if (loaded === 2) {
 
 
359
  canvas.width = imageA.width;
360
  canvas.height = imageA.height;
 
361
  ctx.drawImage(imageA, 0, 0);
362
  regions.forEach(region => {
363
  if (region.pointsA.length < 3) return;
364
  const ptsA = region.pointsA.map(p => ({ x: p.x * imageA.width, y: p.y * imageA.height }));
365
  const ptsB = region.pointsB.map(p => ({ x: p.x * imageB.width, y: p.y * imageB.height }));
366
+ const tris = triangulate(ptsB);
367
+ for (let i = 0; i < tris.length; i += 3) {
368
+ 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]]);
369
  }
370
  });
371
  }
372
  };
373
+ imageA.src = imgA; imageB.src = imgB;
374
+ imageA.onload = onLoad; imageB.onload = onLoad;
 
 
 
375
  }, [previewMode, imgA, imgB, regions]);
376
 
377
+ useEffect(() => { drawMapping(); }, [drawMapping]);
378
+
379
+ const drawTriangle = (ctx, img, p0, p1, p2, t0, t1, t2) => {
380
+ ctx.save();
381
+ ctx.beginPath();
382
+ ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath();
383
+ ctx.clip();
384
+ const delta = (t0.x - t2.x) * (t1.y - t2.y) - (t1.x - t2.x) * (t0.y - t2.y);
385
+ if (Math.abs(delta) < 0.001) { ctx.restore(); return; }
386
+ const a = ((p0.x - p2.x) * (t1.y - t2.y) - (p1.x - p2.x) * (t0.y - t2.y)) / delta;
387
+ const b = ((p0.y - p2.y) * (t1.y - t2.y) - (p1.y - p2.y) * (t0.y - t2.y)) / delta;
388
+ const c = ((t0.x - t2.x) * (p1.x - p2.x) - (t1.x - t2.x) * (p0.x - p2.x)) / delta;
389
+ const d = ((t0.x - t2.x) * (p1.y - p2.y) - (t1.x - t2.x) * (p0.y - p2.y)) / delta;
390
+ const e = p2.x - a * t2.x - c * t2.y;
391
+ const f = p2.y - b * t2.x - d * t2.y;
392
+ ctx.setTransform(a, b, c, d, e, f);
393
+ ctx.drawImage(img, 0, 0);
394
+ ctx.restore();
395
  };
396
 
397
+ const renderSVG = (side) => {
398
+ const visibleRegions = (side === 'B' && focusModeB)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  ? regions.filter(r => r.id === selectedRegionId)
400
  : regions;
401
 
 
 
 
 
 
 
 
 
402
  return (
403
+ <svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
 
 
 
 
 
404
  {visibleRegions.map(r => {
405
  const isSelected = r.id === selectedRegionId;
406
+ const points = side === 'A' ? r.pointsA : r.pointsB;
407
+ const polyStr = points.map(p => `${p.x * 100},${p.y * 100}`).join(' ');
408
 
409
  return (
410
+ <g key={r.id}>
411
  <polygon
412
+ points={polyStr}
413
+ className="pointer-events-auto cursor-pointer"
414
+ onMouseDown={() => setSelectedRegionId(r.id)}
415
  style={{
 
416
  fill: r.color,
417
+ fillOpacity: isSelected ? 0.35 : 0.1,
418
+ stroke: isSelected ? '#fff' : r.color,
419
+ strokeWidth: isSelected ? 1 : 0.5,
420
+ vectorEffect: 'non-scaling-stroke'
421
  }}
 
 
422
  />
423
+ {points.map((p, idx) => {
424
+ const isHovered = hoveredPoint?.regionId === r.id && hoveredPoint?.index === idx;
425
+ const isDrag = draggingPoint?.regionId === r.id && draggingPoint?.index === idx && draggingPoint.side === side;
426
+ const baseRadius = isSelected ? 1.0 : 0.5;
427
+ const finalRadius = isHovered || isDrag ? 1.8 : baseRadius;
428
+
429
+ return (
430
+ <circle
431
+ key={idx}
432
+ cx={p.x * 100} cy={p.y * 100}
433
+ r={finalRadius}
434
+ className="pointer-events-auto cursor-move"
435
+ onMouseDown={(e) => handlePointMouseDown(e, side, r.id, idx)}
436
+ style={{
437
+ fill: isSelected ? '#fff' : r.color,
438
+ stroke: (isHovered || isDrag) ? '#00e5ff' : '#000',
439
+ strokeWidth: isSelected ? 0.4 : 0.2,
440
+ vectorEffect: 'non-scaling-stroke'
441
+ }}
442
+ />
443
+ );
444
+ })}
445
+ {isSelected && hoveredEdge && hoveredEdge.side === side && (
446
+ <circle cx={hoveredEdge.point.x * 100} cy={hoveredEdge.point.y * 100} r={1.2}
447
+ style={{ fill: '#fff', stroke: '#00e5ff', strokeWidth: 0.5, vectorEffect: 'non-scaling-stroke', filter: 'drop-shadow(0 0 2px rgba(0,229,255,0.8))' }}
448
+ />
449
+ )}
450
  </g>
451
  );
452
  })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  </svg>
454
  );
455
  };
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  return (
458
+ <div className="flex flex-col h-screen bg-[#080808] text-white font-sans overflow-hidden" onMouseUp={handleMouseUp}>
459
+ <header className="flex items-center justify-between px-6 py-4 bg-[#111] border-b border-white/5 shadow-2xl z-20">
460
+ <div className="flex items-center gap-4">
461
+ <div className="p-2.5 bg-indigo-600 rounded-xl shadow-lg shadow-indigo-500/20"><Target size={22} /></div>
462
+ <div>
463
+ <h1 className="text-lg font-black tracking-tighter leading-none italic">UV WRAPPER</h1>
464
+ <span className="text-[9px] text-indigo-400 font-bold uppercase tracking-[0.3em]">Mapping Engine v2.5</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  </div>
466
+ </div>
467
 
468
+ <div className="flex items-center gap-3">
469
+ <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">
470
+ <Plus size={18} /> Add Region
471
+ </button>
472
+ <div className="h-6 w-px bg-white/10 mx-2" />
473
+ <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">
474
+ <FolderOpen size={16} /> Import JSON
475
+ <input type="file" className="hidden" accept=".json" onChange={handleJsonUpload} />
476
+ </label>
477
+ <button onClick={() => {
478
+ const blob = new Blob([JSON.stringify(regions, null, 2)], { type: 'application/json' });
479
+ const url = URL.createObjectURL(blob);
480
+ const a = document.createElement('a'); a.href = url; a.download = 'uv-config.json'; a.click();
481
+ }} 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">
482
+ <Download size={16} /> Export JSON
483
+ </button>
484
+ <button
485
+ onClick={() => setPreviewMode(!previewMode)}
486
+ 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'}`}
487
+ >
488
+ {previewMode ? <EyeOff size={18} /> : <Eye size={18} />}
489
+ {previewMode ? 'Exit Preview' : 'Live Preview'}
490
  </button>
491
  </div>
492
+ </header>
493
 
494
+ <main className="flex-1 flex overflow-hidden relative">
495
  {!previewMode ? (
496
+ <>
497
+ <div className="flex-1 flex flex-col border-r border-white/5 bg-[#080808]">
498
+ <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">
499
+ <Target size={12} /> Target Canvas (A)
500
+ </div>
501
+ <div className="flex-1 relative flex items-center justify-center p-12 overflow-hidden bg-[radial-gradient(circle_at_center,_#111_0%,_#080808_100%)]">
502
+ {!imgA ? (
503
+ <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">
504
+ <Upload className="text-zinc-700" size={40} />
505
+ <span className="text-zinc-500 font-bold">Upload UV Target</span>
506
+ <input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'A')} />
507
+ </label>
508
+ ) : (
509
+ <div className="relative inline-block shadow-2xl border border-white/5"
510
+ onMouseMove={(e) => handleMouseMove(e, 'A')}
511
+ onMouseLeave={handleMouseLeave}
512
+ >
513
+ <img ref={imgRefA} src={imgA} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
514
+ {renderSVG('A')}
515
+ {isZPressed && <Magnifier imgUrl={imgA} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="A" imgSize={imgSizeA} focusModeB={focusModeB} />}
516
  </div>
517
+ )}
518
  </div>
519
  </div>
520
 
521
+ <div className="flex-1 flex flex-col bg-[#0b0b0b]">
522
+ <div className="p-2 px-4 flex items-center justify-between bg-zinc-900/40">
523
+ <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>
524
+ <button
525
+ onClick={() => setFocusModeB(!focusModeB)}
526
+ 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'}`}
527
+ >
528
+ <MousePointer2 size={12} />
529
+ Focus Mode: {focusModeB ? 'ON' : 'OFF'}
530
+ </button>
531
  </div>
532
+ <div className="flex-1 relative flex items-center justify-center p-12 overflow-hidden bg-[radial-gradient(circle_at_center,_#141414_0%,_#0b0b0b_100%)]">
533
+ {!imgB ? (
534
+ <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">
535
+ <Upload className="text-zinc-700" size={40} />
536
+ <span className="text-zinc-500 font-bold">Upload Source Drawing</span>
537
+ <input type="file" className="hidden" onChange={(e) => handleImageUpload(e, 'B')} />
538
+ </label>
539
+ ) : (
540
+ <div className="relative inline-block shadow-2xl border border-white/5"
541
+ onMouseMove={(e) => handleMouseMove(e, 'B')}
542
+ onMouseLeave={handleMouseLeave}
543
+ >
544
+ <img ref={imgRefB} src={imgB} className="max-w-full max-h-[72vh] block select-none rounded-sm" draggable={false} />
545
+ {renderSVG('B')}
546
+ {isZPressed && <Magnifier imgUrl={imgB} regions={regions} selectedId={selectedRegionId} mousePos={magnifierPos} side="B" imgSize={imgSizeB} focusModeB={focusModeB} />}
547
  </div>
548
+ )}
549
  </div>
550
  </div>
551
+
552
+ {/* Bottom Status Bar */}
553
+ <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">
554
+ <div className="flex items-center gap-5 pr-10 border-r border-white/10">
555
+ <div className="text-[9px] font-black text-zinc-500 uppercase tracking-widest">Selection</div>
556
+ {selectedRegion ? (
557
+ <div className="flex items-center gap-4">
558
+ <div className="w-6 h-6 rounded-full shadow-lg border-2 border-white/20" style={{ backgroundColor: selectedRegion.color }} />
559
+ <span className="text-sm font-black tracking-tight">{selectedRegion.name}</span>
560
+ <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>
561
+ </div>
562
+ ) : (
563
+ <span className="text-sm font-bold text-zinc-700 italic">No Active Selection</span>
564
+ )}
565
+ </div>
566
+
567
+ <div className="flex gap-8 items-center text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em]">
568
+ <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>
569
+ <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>
570
+ <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>
571
+ </div>
572
  </div>
573
+ </>
574
+ ) : (
575
+ <div className="flex-1 flex flex-col items-center justify-center bg-[#050505] p-12 overflow-auto">
576
+ <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">
577
+ <canvas ref={canvasPreviewRef} className="max-w-full max-h-[75vh] block rounded-2xl" />
578
  </div>
579
  </div>
580
  )}
581
+ </main>
582
  </div>
583
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  }