MAO commited on
Commit
87f540c
·
1 Parent(s): 3639647

Upload project files for Hugging Face Space deployment

Browse files
Files changed (8) hide show
  1. Dockerfile +15 -0
  2. index.html +14 -0
  3. package-lock.json +0 -0
  4. package.json +27 -0
  5. src/App.jsx +599 -0
  6. src/index.css +0 -0
  7. src/main.jsx +10 -0
  8. vite.config.js +7 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json package-lock.json ./
6
+ RUN npm ci
7
+
8
+ COPY . .
9
+ RUN npm run build
10
+
11
+ # Hugging Face Spaces 預設使用 7860 port
12
+ EXPOSE 7860
13
+
14
+ # 使用 npx serve 啟動靜態檔案伺服器
15
+ CMD ["npx", "serve", "-s", "dist", "-l", "7860"]
index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <title>Texture Mapper</title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.jsx"></script>
13
+ </body>
14
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "texturemapper",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^18.2.0",
14
+ "react-dom": "^18.2.0",
15
+ "lucide-react": "^0.344.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.2.43",
19
+ "@types/react-dom": "^18.2.17",
20
+ "@vitejs/plugin-react": "^4.2.1",
21
+ "eslint": "^8.55.0",
22
+ "eslint-plugin-react": "^7.33.2",
23
+ "eslint-plugin-react-hooks": "^4.6.0",
24
+ "eslint-plugin-react-refresh": "^0.4.5",
25
+ "vite": "^4.5.8"
26
+ }
27
+ }
src/App.jsx ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { Upload, Plus, Eye, EyeOff, Trash2, Save, FolderOpen, Download, Search } from 'lucide-react';
3
+
4
+ // --- 數學與幾何輔助函數 ---
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
+ // --- 主應用程序 ---
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 功能相關狀態
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; // 縮放倍率
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
+
57
+ const handleJsonUpload = (e) => {
58
+ const file = e.target.files[0];
59
+ if (!file) return;
60
+ const reader = new FileReader();
61
+ reader.onload = (event) => {
62
+ try {
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 格式錯誤", err);
70
+ }
71
+ };
72
+ reader.readAsText(file);
73
+ e.target.value = '';
74
+ };
75
+
76
+ const addRegion = () => {
77
+ if (!imgA || !imgB) return;
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 }]
84
+ };
85
+ setRegions([...regions, newRegion]);
86
+ setSelectedRegionId(newId);
87
+ };
88
+
89
+ const deleteRegion = (id) => {
90
+ setRegions(regions.filter(r => r.id !== id));
91
+ if (selectedRegionId === id) setSelectedRegionId(null);
92
+ };
93
+
94
+ const handlePointMouseDown = (e, side, regionId, index) => {
95
+ e.stopPropagation();
96
+ e.preventDefault();
97
+ setSelectedRegionId(regionId);
98
+ setDraggingPoint({ regionId, index, side });
99
+ };
100
+
101
+ const handleMouseMove = (e, side) => {
102
+ const imgEl = side === 'A' ? imgRefA.current : imgRefB.current;
103
+ if (!imgEl) return;
104
+
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
+ }));
124
+ return;
125
+ }
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
+ }));
206
+ setHoveredPoint(null);
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("下載錯誤:", err);
344
+ }
345
+ };
346
+
347
+ const renderOverlay = (side, isZoomView = false) => {
348
+ const pointsKey = side === 'A' ? 'pointsA' : 'pointsB';
349
+
350
+ // Side B 的過濾邏輯:勾選 hideUnselected 時只顯示選中區域
351
+ const visibleRegions = (side === 'B' && hideUnselected && selectedRegionId)
352
+ ? regions.filter(r => r.id === selectedRegionId)
353
+ : regions;
354
+
355
+ const baseStrokeWidth = 1.5;
356
+ const selectedStrokeWidth = 3;
357
+ const baseCircleRadius = 0.5;
358
+
359
+ // 縮放視圖中保持線條比例
360
+ const currentStrokeW = isZoomView ? (baseStrokeWidth / zoomFactor) : baseStrokeWidth;
361
+ const currentSelectedStrokeW = isZoomView ? (selectedStrokeWidth / zoomFactor) : selectedStrokeWidth;
362
+ const currentCircleRadius = isZoomView ? (baseCircleRadius / zoomFactor) : baseCircleRadius;
363
+
364
+ return (
365
+ <svg
366
+ className="absolute top-0 left-0 w-full h-full pointer-events-none"
367
+ style={{ zIndex: 10, overflow: 'visible' }}
368
+ viewBox="0 0 100 100"
369
+ preserveAspectRatio="none"
370
+ >
371
+ {visibleRegions.map(r => {
372
+ const isSelected = r.id === selectedRegionId;
373
+ const points = r[pointsKey];
374
+ const pointsStr = points.map(p => `${p.x * 100},${p.y * 100}`).join(' ');
375
+
376
+ return (
377
+ <g key={`poly-group-${r.id}`}>
378
+ <polygon
379
+ points={pointsStr}
380
+ style={{
381
+ vectorEffect: 'non-scaling-stroke',
382
+ fill: r.color,
383
+ fillOpacity: isSelected ? 0.4 : 0.1,
384
+ stroke: isSelected ? '#00f2ff' : r.color,
385
+ strokeWidth: isSelected ? currentSelectedStrokeW : currentStrokeW,
386
+ strokeDasharray: isSelected ? "none" : (isZoomView ? `${0.5 / zoomFactor}, ${0.5 / zoomFactor}` : "2, 2")
387
+ }}
388
+ className={isZoomView ? "" : "pointer-events-auto cursor-pointer"}
389
+ onMouseDown={isZoomView ? undefined : (e) => { e.stopPropagation(); setSelectedRegionId(r.id); }}
390
+ />
391
+ </g>
392
+ );
393
+ })}
394
+
395
+ {visibleRegions.map(reg => {
396
+ const isRegSelected = reg.id === selectedRegionId;
397
+ return reg[pointsKey].map((p, idx) => {
398
+ let strokeColor = isRegSelected ? '#fff200' : 'rgba(0, 0, 0, 0.4)';
399
+ let fillColor = reg.color;
400
+ let radius = isRegSelected ? (currentCircleRadius * 1.5) : currentCircleRadius;
401
+ let pointStrokeW = isZoomView ? (0.5 / zoomFactor) : (isRegSelected ? 1 : 0.5);
402
+
403
+ const isDragging = !isZoomView && draggingPoint && draggingPoint.regionId === reg.id && draggingPoint.index === idx;
404
+ const isHovering = !isZoomView && hoveredPoint && hoveredPoint.regionId === reg.id && hoveredPoint.index === idx;
405
+
406
+ if (isDragging || isHovering) {
407
+ strokeColor = isDragging ? '#ff0000' : '#0066ff';
408
+ fillColor = 'white';
409
+ radius = currentCircleRadius * 2;
410
+ pointStrokeW = isZoomView ? (1 / zoomFactor) : 2;
411
+ }
412
+
413
+ return (
414
+ <circle
415
+ key={`node-${reg.id}-${idx}`}
416
+ cx={p.x * 100} cy={p.y * 100} r={radius}
417
+ style={{
418
+ vectorEffect: 'non-scaling-stroke',
419
+ fill: fillColor,
420
+ fillOpacity: (isRegSelected || isHovering) ? 1 : 0.8,
421
+ stroke: strokeColor,
422
+ strokeWidth: pointStrokeW,
423
+ cursor: isZoomView ? 'default' : 'move',
424
+ pointerEvents: isZoomView ? 'none' : 'auto'
425
+ }}
426
+ onMouseDown={isZoomView ? undefined : (e) => handlePointMouseDown(e, side, reg.id, idx)}
427
+ />
428
+ );
429
+ });
430
+ })}
431
+ </svg>
432
+ );
433
+ };
434
+
435
+ const renderMagnifier = (side, imageSrc, rect) => {
436
+ if (!isZooming || !mousePos.relX || !rect) return null;
437
+
438
+ const viewSize = 240; // 放大鏡圓圈的大小
439
+
440
+ // 計算邏輯修正:
441
+ // 放大鏡中心點應該對應滑鼠目前的相對位置 (relX, relY)
442
+ // 放大後的內容左上角,相對於放大鏡中心的偏移量:
443
+ // offsetX = (放大鏡半徑) - (滑鼠相對位置 * 圖片目前的寬度 * 縮放倍率)
444
+ const centerX = viewSize / 2;
445
+ const centerY = viewSize / 2;
446
+
447
+ const translateX = centerX - (mousePos.relX * rect.width * zoomFactor);
448
+ const translateY = centerY - (mousePos.relY * rect.height * zoomFactor);
449
+
450
+ return (
451
+ <div
452
+ className="absolute pointer-events-none border-2 border-blue-500 shadow-2xl rounded-full overflow-hidden bg-black z-[100]"
453
+ style={{
454
+ left: `${mousePos.relX * 100}%`,
455
+ top: `${mousePos.relY * 100}%`,
456
+ width: `${viewSize}px`,
457
+ height: `${viewSize}px`,
458
+ transform: 'translate(-50%, -115%)' // 讓放大鏡浮在滑鼠上方
459
+ }}
460
+ >
461
+ <div
462
+ className="absolute origin-top-left" // 固定從左上角開始繪製內容,不使用 flex 置中
463
+ style={{
464
+ width: rect.width,
465
+ height: rect.height,
466
+ transform: `translate(${translateX}px, ${translateY}px) scale(${zoomFactor})`,
467
+ }}
468
+ >
469
+ <div className="relative w-full h-full">
470
+ <img src={imageSrc} className="block w-full h-full object-contain" draggable={false} />
471
+ {renderOverlay(side, true)}
472
+ </div>
473
+ </div>
474
+
475
+ {/* 精準導航準星 */}
476
+ <div className="absolute top-1/2 left-0 w-full h-px bg-blue-400/50"></div>
477
+ <div className="absolute left-1/2 top-0 w-px h-full bg-blue-400/50"></div>
478
+ {/* 內陰影效果 */}
479
+ <div className="absolute inset-0 rounded-full border-[4px] border-black/20 pointer-events-none shadow-inner"></div>
480
+ </div>
481
+ );
482
+ };
483
+
484
+ return (
485
+ <div className="flex flex-col h-screen bg-gray-900 text-gray-100 font-sans select-none">
486
+ <div className="flex items-center justify-between p-4 bg-gray-800 border-b border-gray-700 shadow-md z-50">
487
+ <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>
488
+ <div className="flex items-center space-x-2 text-[10px] text-gray-500 mr-auto ml-6 font-medium">
489
+ <span className="bg-gray-700/50 border border-gray-600 px-1.5 py-0.5 rounded text-gray-300">A</span> 新增點
490
+ <span className="bg-gray-700/50 border border-gray-600 px-1.5 py-0.5 rounded text-gray-300 ml-1">D</span> 刪除點
491
+ <span className="bg-gray-700/50 border border-gray-600 px-1.5 py-0.5 rounded text-gray-300 ml-1">Z</span> 長按放大
492
+ </div>
493
+ <div className="flex items-center space-x-3">
494
+ <div className="flex space-x-2">
495
+ <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">
496
+ <Upload size={14} /> <span>圖片 A</span>
497
+ <input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, setImgA)} />
498
+ </label>
499
+ <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">
500
+ <Upload size={14} /> <span>圖片 B</span>
501
+ <input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, setImgB)} />
502
+ </label>
503
+ </div>
504
+ <div className="w-px h-6 bg-gray-700 mx-1"></div>
505
+ <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} /> 新增區域</button>
506
+ <div className="flex space-x-2">
507
+ <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">
508
+ <FolderOpen size={14} /> <span>匯入 JSON</span>
509
+ <input type="file" accept=".json" className="hidden" onChange={handleJsonUpload} />
510
+ </label>
511
+ <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} /> 匯出 JSON</button>
512
+ </div>
513
+ {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} /> 刪除</button>}
514
+ <div className="w-px h-6 bg-gray-700 mx-1"></div>
515
+ <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'}`}>
516
+ {previewMode ? <EyeOff size={14} /> : <Eye size={14} />} {previewMode ? '返回編輯' : '預覽結果'}
517
+ </button>
518
+ </div>
519
+ </div>
520
+
521
+ <div className="flex-1 flex overflow-hidden relative">
522
+ {!previewMode ? (
523
+ <div className="flex w-full h-full">
524
+ <div className="flex-1 flex flex-col border-r border-gray-800 bg-gray-950 p-6 relative">
525
+ <div className="mb-3 text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 text-center">目標畫布 (Side A)</div>
526
+ <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)}>
527
+ {imgA ? (
528
+ <div className="relative" style={{ width: 'fit-content', height: 'fit-content', maxWidth: '100%', maxHeight: '100%' }}>
529
+ <img ref={imgRefA} src={imgA} className="block w-auto h-auto max-w-full max-h-full shadow-lg" draggable={false} />
530
+ {renderOverlay('A')}
531
+ {renderMagnifier('A', imgA, mousePos.rect)}
532
+ </div>
533
+ ) : <div className="text-gray-700 text-sm italic">請上傳圖片 A</div>}
534
+ </div>
535
+ </div>
536
+ <div className="flex-1 flex flex-col bg-gray-950 p-6 relative">
537
+ <div className="mb-3 text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 text-center flex items-center justify-center gap-4">
538
+ 紋理來源 (Side B)
539
+ <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">
540
+ <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)} />
541
+ <span className="text-[10px] font-medium">僅顯示選中區域</span>
542
+ </label>
543
+ </div>
544
+ <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)}>
545
+ {imgB ? (
546
+ <div className="relative" style={{ width: 'fit-content', height: 'fit-content', maxWidth: '100%', maxHeight: '100%' }}>
547
+ <img ref={imgRefB} src={imgB} className="block w-auto h-auto max-w-full max-h-full shadow-lg" draggable={false} />
548
+ {renderOverlay('B')}
549
+ {renderMagnifier('B', imgB, mousePos.rect)}
550
+ </div>
551
+ ) : <div className="text-gray-700 text-sm italic">請上傳圖片 B</div>}
552
+ </div>
553
+ </div>
554
+ </div>
555
+ ) : (
556
+ <div className="absolute inset-0 z-20 bg-gray-950 flex flex-col items-center justify-center p-8 overflow-auto">
557
+ <div className="mb-6 flex gap-4">
558
+ <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">
559
+ <Download size={18} /> 下載結果 (.png)
560
+ </button>
561
+ <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">
562
+ 返回編輯
563
+ </button>
564
+ </div>
565
+ <div className="relative shadow-2xl rounded-2xl overflow-hidden border border-gray-800 bg-black max-w-full max-h-[80vh]">
566
+ <canvas ref={canvasPreviewRef} className="block max-w-full max-h-[75vh] mx-auto" />
567
+ </div>
568
+ </div>
569
+ )}
570
+ </div>
571
+ </div>
572
+ );
573
+ }
574
+
575
+ function textureMapTriangle(ctx, img, p0, p1, p2, t0, t1, t2) {
576
+ ctx.save();
577
+ const midX = (p0.x + p1.x + p2.x) / 3, midY = (p0.y + p1.y + p2.y) / 3;
578
+ const expand = (p) => {
579
+ const dx = p.x - midX, dy = p.y - midY;
580
+ const mag = Math.sqrt(dx * dx + dy * dy) || 1;
581
+ return { x: p.x + (dx / mag) * 0.5, y: p.y + (dy / mag) * 0.5 };
582
+ };
583
+ const ep0 = expand(p0), ep1 = expand(p1), ep2 = expand(p2);
584
+ ctx.beginPath();
585
+ ctx.moveTo(ep0.x, ep0.y); ctx.lineTo(ep1.x, ep1.y); ctx.lineTo(ep2.x, ep2.y);
586
+ ctx.closePath(); ctx.clip();
587
+ const x0 = t0.x, y0 = t0.y, x1 = t1.x, y1 = t1.y, x2 = t2.x, y2 = t2.y;
588
+ const u0 = p0.x, v0 = p0.y, u1 = p1.x, v1 = p1.y, u2 = p2.x, v2 = p2.y;
589
+ const delta = (x0 - x2) * (y1 - y2) - (x1 - x2) * (y0 - y2);
590
+ if (Math.abs(delta) < 0.0001) { ctx.restore(); return; }
591
+ const a = ((u0 - u2) * (y1 - y2) - (u1 - u2) * (y0 - y2)) / delta;
592
+ const b = ((v0 - v2) * (y1 - y2) - (v1 - v2) * (y0 - v2)) / delta;
593
+ const c = ((x0 - x2) * (u1 - u2) - (x1 - x2) * (u0 - u2)) / delta;
594
+ const d = ((x0 - x2) * (v1 - v2) - (x1 - x2) * (v0 - v2)) / delta;
595
+ const e = u2 - a * x2 - c * y2, f = v2 - b * x2 - d * y2;
596
+ ctx.setTransform(a, b, c, d, e, f);
597
+ ctx.drawImage(img, 0, 0, img.width, img.height);
598
+ ctx.restore();
599
+ }
src/index.css ADDED
File without changes
src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })