From df40822129df9635b47e6bb80a0e68bac2bf6273 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Thu, 17 Sep 2026 11:03:58 +0530 Subject: [PATCH] fix: Restore UVEditor and implement AI texture editing --- src/components/UVEditor.tsx | 332 ++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) diff --git a/src/components/UVEditor.tsx b/src/components/UVEditor.tsx index 4e3072a..8287413 100644 --- a/src/components/UVEditor.tsx +++ b/src/components/UVEditor.tsx @@ -196,6 +196,338 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje console.error("Canvas render error (likely CORS taint):", e); return ""; } + }, [canvasW, canvasH]); + + useEffect(() => { + setPreviewUrl(renderCanvas(layers, bgTransparent, bgColor)); + }, [layers, bgTransparent, bgColor]); + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + const url = ev.target?.result as string; + const img = new Image(); + img.onload = () => { + const nl: Layer = { id: `l-${Date.now()}`, url, img, x: canvasW / 2, y: canvasH / 2, scaleX: 1, scaleY: 1, rotation: 0, cropL: 0, cropT: 0, cropR: 0, cropB: 0, visible: true }; + setLayers(p => [...p, nl]); + setSelectedId(nl.id); + }; + img.src = url; + }; + reader.readAsDataURL(file); + e.target.value = ''; + }; + + const updateLayer = (id: string, u: Partial) => setLayers(p => p.map(l => l.id === id ? { ...l, ...u } : l)); + const selectedLayer = layers.find(l => l.id === selectedId) ?? null; + + const getSF = () => { + const r = containerRef.current?.getBoundingClientRect(); + return r ? canvasW / r.width : 1; + }; + + const onHandleDown = (e: React.MouseEvent, handle: Handle, layer: Layer) => { + e.stopPropagation(); + setSelectedId(layer.id); + dragRef.current = { handle, startX: e.clientX, startY: e.clientY, layer: { ...layer } }; + e.preventDefault(); + }; + + // Snap helper: snap value to nearest multiple of snapSize + const snap = (v: number, snapSize: number) => Math.round(v / snapSize) * snapSize; + + const onMouseMove = (e: React.MouseEvent) => { + const d = dragRef.current; + if (!d) return; + const sf = getSF(); + const dx = (e.clientX - d.startX) * sf; + const dy = (e.clientY - d.startY) * sf; + const isShift = e.shiftKey; + const SNAP_PX = 16; // grid snap in canvas units + + if (d.handle === 'move') { + let nx = d.layer.x + dx; + let ny = d.layer.y + dy; + // Clamp to canvas bounds with soft margin + nx = Math.max(0, Math.min(canvasW, nx)); + ny = Math.max(0, Math.min(canvasH, ny)); + // Hold Shift to snap to grid + if (isShift) { nx = snap(nx, SNAP_PX); ny = snap(ny, SNAP_PX); } + // Center snapping: snap near canvas center + if (Math.abs(nx - canvasW / 2) < SNAP_PX) nx = canvasW / 2; + if (Math.abs(ny - canvasH / 2) < SNAP_PX) ny = canvasH / 2; + updateLayer(d.layer.id, { x: nx, y: ny }); + } else if (d.handle === 'rotate') { + let rot = d.layer.rotation + dx / 2; + // Snap rotation to 15° increments when Shift held + if (isShift) rot = snap(rot, 15); + updateLayer(d.layer.id, { rotation: rot }); + } else if (d.handle.startsWith('scale')) { + if (isShift) { + // Shift held: stretch X and Y independently + const sx = Math.max(0.05, d.layer.scaleX + dx / 200); + const sy = Math.max(0.05, d.layer.scaleY + dy / 200); + updateLayer(d.layer.id, { scaleX: sx, scaleY: sy }); + } else { + // No shift: uniform scale driven by the larger axis + const delta = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / 200; + const sx = Math.max(0.05, d.layer.scaleX + delta); + const sy = Math.max(0.05, d.layer.scaleY + delta); + updateLayer(d.layer.id, { scaleX: sx, scaleY: sy }); + } + } + }; + + const onMouseUp = () => { dragRef.current = null; }; + + const handleSave = () => { + if (previewUrl) { + const serialized = layers.map(({ img, ...rest }) => rest); + onSave(previewUrl, mappingType, serialized, bgColor, bgTransparent); + } + onClose(); + }; + + const PreviewModel = () => { + const mockObjs = useMemo(() => { + if (!sceneObjects) return []; + return sceneObjects.map(obj => obj.id === targetObject?.id + ? { ...obj, materialProps: { ...obj.materialProps, map: previewUrl, mappingType } } + : obj + ); + }, [sceneObjects, previewUrl, mappingType]); + const cloned = useMemo(() => loadedModel?.clone() ?? null, [loadedModel]); + if (!targetObject) return ; + if (targetObject.type === 'cube') { + const tex = previewUrl ? (() => { const t = new THREE.TextureLoader().load(previewUrl); t.colorSpace = THREE.SRGBColorSpace; t.flipY = false; return t; })() : null; + return ; + } + return ; + }; + + // Bounding box overlay rendered on canvas div + const BoundingBox = ({ layer }: { layer: Layer }) => { + const iw = (layer.img?.width ?? 200) * (1 - layer.cropL - layer.cropR); + const ih = (layer.img?.height ?? 200) * (1 - layer.cropT - layer.cropB); + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return null; + + const w = (iw * layer.scaleX / canvasW) * 100; + const h = (ih * layer.scaleY / canvasH) * 100; + const lx = (layer.x / canvasW) * 100; + const ly = (layer.y / canvasH) * 100; + + const cornerStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({ + position: 'absolute', left: cx, top: cy, width: 10, height: 10, + background: '#ffffff', border: '2px solid #3b82f6', borderRadius: 2, + transform: 'translate(-50%,-50%)', cursor, zIndex: 10, touchAction: 'none' + }); + const edgeStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({ + position: 'absolute', left: cx, top: cy, width: 8, height: 8, + background: '#3b82f6', borderRadius: '50%', + transform: 'translate(-50%,-50%)', cursor, zIndex: 10 + }); + + return ( +
+ {/* Corners - scale */} + {(['NW','NE','SW','SE'] as const).map(c => ( +
onHandleDown(e, `scale${c}` as Handle, layer)} + /> + ))} + {/* Edge midpoints - scale */} + {[ + { id: 'N', cx: '50%', cy: '0%', cur: 'ns-resize' }, + { id: 'S', cx: '50%', cy: '100%', cur: 'ns-resize' }, + { id: 'E', cx: '100%', cy: '50%', cur: 'ew-resize' }, + { id: 'W', cx: '0%', cy: '50%', cur: 'ew-resize' }, + ].map(({ id, cx, cy, cur }) => ( +
onHandleDown(e, `scale${id}` as Handle, layer)} /> + ))} + {/* Rotate handle - top center above box */} +
onHandleDown(e, 'rotate', layer)} title="Rotate" /> + {/* Line from box to rotate handle */} +
+
+ ); + }; + + return ( +
+ {/* Top Bar */} +
+
+ +
+ UV Map +
+
+ + {!bgTransparent && ( +
+ +
+ {/* Left sidebar */} +
+
+ + +
+

Layers

+ {layers.length === 0 && ( +
Upload an image to start
+ )} + {[...layers].reverse().map((layer, i) => ( +
setSelectedId(layer.id)} + className={`flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all ${selectedId === layer.id ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-800 hover:border-zinc-600 bg-zinc-800/20'} ${!layer.visible ? 'opacity-40' : ''}`}> + + Layer {layers.length - i} + + +
+ ))} + + {/* Layer props */} + {selectedLayer && ( +
+

Transform

+
+ {(['x', 'y'] as const).map(ax => ( +
+

{ax}

+ updateLayer(selectedLayer.id, { [ax]: +e.target.value })} + className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" /> +
+ ))} +
+
+ {(['scaleX', 'scaleY'] as const).map(ax => ( +
+

{ax === 'scaleX' ? 'Scl X' : 'Scl Y'}

+ updateLayer(selectedLayer.id, { [ax]: Math.max(0.01, +e.target.value) })} + className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" /> +
+ ))} +
+
+
+ Rotation{Math.round(selectedLayer.rotation)}° +
+ updateLayer(selectedLayer.id, { rotation: +e.target.value })} + className="w-full h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-blue-500" /> +
+ +
+

Crop

+ {(['cropL', 'cropT', 'cropR', 'cropB'] as const).map(s => ( +
+ {s.slice(4)} + updateLayer(selectedLayer.id, { [s]: +e.target.value })} + className="flex-1 h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-orange-500" /> + {Math.round(selectedLayer[s] * 100)}% +
+ ))} + + {/* AI Layer Edit */} +
+

+ AI Texture Edit +

+ + {aiPreviewUrl ? ( +
+

Review AI Generation:

+
+ + +
+ +
+ ) : ( +
+