diff --git a/src/components/ModelViewer.tsx b/src/components/ModelViewer.tsx index 73dc635..ebcda10 100644 --- a/src/components/ModelViewer.tsx +++ b/src/components/ModelViewer.tsx @@ -7,6 +7,8 @@ import { useTexturePipeline } from "../hooks/useTexturePipeline"; interface ModelViewerProps { object: THREE.Object3D | null; sceneObjects: any[]; + bakeRequest?: { id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', timestamp: number } | null; + onBakeComplete?: (dataUrl: string) => void; } interface MeshMaterialManagerProps { @@ -211,7 +213,9 @@ const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: T return createPortal(fillNode, originalMesh); }; -export const ModelViewer = forwardRef(({ object, sceneObjects }, ref) => { +import { reprojectUVs, bakeTextureToNewUVs } from '../lib/bakingUtils'; + +export const ModelViewer = forwardRef(({ object, sceneObjects, bakeRequest, onBakeComplete }, ref) => { const internalRef = useRef(null); const groupRef = (ref as any) || internalRef; const textureCache = useRef>(new Map()); @@ -241,6 +245,43 @@ export const ModelViewer = forwardRef(({ object, }; }, []); + const { gl } = useThree(); + + // Handle Baking Request + useEffect(() => { + if (bakeRequest && onBakeComplete && meshCache.size > 0) { + const sceneObj = sceneObjects.find(obj => obj.id === bakeRequest.id); + if (!sceneObj) return; + + // Find the primary mesh for this object (we assume the first mesh for now) + let targetMesh: THREE.Mesh | null = null; + for (const mesh of meshCache.values()) { + targetMesh = mesh; + break; + } + + if (targetMesh && sceneObj.materialProps?.map) { + const tex = textureCache.current.get(sceneObj.materialProps.map); + if (tex) { + // Keep old UVs + const oldUVArray = targetMesh.geometry.attributes.uv.array.slice() as Float32Array; + + // Apply new UV mapping (mutates geometry.attributes.uv) + reprojectUVs(targetMesh.geometry, bakeRequest.type); + + // Bake + try { + const dataUrl = bakeTextureToNewUVs(targetMesh, oldUVArray, tex, gl, 1024); + onBakeComplete(dataUrl); + } catch (e) { + console.error("Baking failed", e); + onBakeComplete(sceneObj.materialProps.map); // Fallback to original + } + } + } + } + }, [bakeRequest, meshCache, sceneObjects, gl, onBakeComplete]); + // Main transform update for the core model useEffect(() => { if (object && sceneObjects) { diff --git a/src/lib/bakingUtils.ts b/src/lib/bakingUtils.ts new file mode 100644 index 0000000..88e7d08 --- /dev/null +++ b/src/lib/bakingUtils.ts @@ -0,0 +1,203 @@ +import * as THREE from 'three'; + +/** + * Calculates new UVs for a geometry based on the chosen projection mapping. + * Supported types: 'planar', 'cylinder', 'sphere', 'box' + */ +export function reprojectUVs(geometry: THREE.BufferGeometry, mappingType: 'planar' | 'cylinder' | 'sphere' | 'box') { + if (!geometry.attributes.position) return; + + geometry.computeBoundingBox(); + const bbox = geometry.boundingBox!; + const size = new THREE.Vector3(); + bbox.getSize(size); + const center = new THREE.Vector3(); + bbox.getCenter(center); + + const posAttr = geometry.attributes.position; + const uvArray = new Float32Array(posAttr.count * 2); + + for (let i = 0; i < posAttr.count; i++) { + const x = posAttr.getX(i); + const y = posAttr.getY(i); + const z = posAttr.getZ(i); + + // Normalize coordinates around center, scaled by size + const nx = size.x === 0 ? 0.5 : (x - bbox.min.x) / size.x; + const ny = size.y === 0 ? 0.5 : (y - bbox.min.y) / size.y; + const nz = size.z === 0 ? 0.5 : (z - bbox.min.z) / size.z; + + let u = 0; + let v = 0; + + if (mappingType === 'planar') { + // Front planar projection + u = nx; + v = ny; + } else if (mappingType === 'cylinder') { + // Cylindrical projection + const centeredX = x - center.x; + const centeredZ = z - center.z; + // atan2 returns -PI to PI. We want 0 to 1 around the cylinder. + let angle = Math.atan2(centeredZ, centeredX); + u = (angle + Math.PI) / (2 * Math.PI); + v = ny; + } else if (mappingType === 'sphere') { + // Spherical projection + const centeredX = x - center.x; + const centeredY = y - center.y; + const centeredZ = z - center.z; + const radius = Math.sqrt(centeredX * centeredX + centeredY * centeredY + centeredZ * centeredZ); + + const phi = Math.acos(centeredY / radius); // 0 to PI + let theta = Math.atan2(centeredZ, centeredX); // -PI to PI + + u = (theta + Math.PI) / (2 * Math.PI); + v = 1.0 - (phi / Math.PI); + } else if (mappingType === 'box') { + // Simple box projection (pick dominant axis based on normal) + // This requires normals + let normX = 0, normY = 0, normZ = 1; + if (geometry.attributes.normal) { + normX = geometry.attributes.normal.getX(i); + normY = geometry.attributes.normal.getY(i); + normZ = geometry.attributes.normal.getZ(i); + } + const ax = Math.abs(normX), ay = Math.abs(normY), az = Math.abs(normZ); + if (ax > ay && ax > az) { + u = nz; v = ny; + } else if (ay > ax && ay > az) { + u = nx; v = nz; + } else { + u = nx; v = ny; + } + } + + // Clamp UVs to avoid edge artifacts + u = Math.max(0.001, Math.min(0.999, u)); + v = Math.max(0.001, Math.min(0.999, v)); + + uvArray[i * 2] = u; + uvArray[i * 2 + 1] = v; + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2)); + geometry.attributes.uv.needsUpdate = true; +} + +/** + * Bakes the old texture applied to the old UVs onto a new texture using the new UVs. + * We do this by rendering the mesh to a WebGLRenderTarget using a custom shader. + * @param mesh The mesh with the new UVs applied to geometry.attributes.uv + * @param oldUVArray The Float32Array of the original UVs + * @param oldTexture The original texture map + * @param renderer The active WebGLRenderer + * @param resolution The resolution of the baked texture + * @returns A data URL of the baked texture + */ +export function bakeTextureToNewUVs( + mesh: THREE.Mesh, + oldUVArray: Float32Array, + oldTexture: THREE.Texture, + renderer: THREE.WebGLRenderer, + resolution = 1024 +): string { + // Store original geometry attributes + const geometry = mesh.geometry.clone(); + + // Create a secondary UV set for the old UVs + geometry.setAttribute('uv2', new THREE.BufferAttribute(oldUVArray, 2)); + + // Custom Shader: + // Position is derived from the new UVs (to flatten the mesh into the 2D canvas) + // Color is sampled from the old texture using the old UVs (uv2) + const bakeMaterial = new THREE.ShaderMaterial({ + uniforms: { + tDiffuse: { value: oldTexture } + }, + vertexShader: ` + attribute vec2 uv2; + varying vec2 vOldUV; + void main() { + vOldUV = uv2; + // The new UVs (uv) range from 0 to 1. + // We map them to clip space: -1 to 1. + // Also flip Y so the texture is right-side up. + gl_Position = vec4(uv.x * 2.0 - 1.0, (uv.y * 2.0 - 1.0), 0.0, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tDiffuse; + varying vec2 vOldUV; + void main() { + gl_FragColor = texture2D(tDiffuse, vOldUV); + } + `, + side: THREE.DoubleSide + }); + + const bakeMesh = new THREE.Mesh(geometry, bakeMaterial); + + const scene = new THREE.Scene(); + scene.add(bakeMesh); + + // Orthographic camera covering exactly -1 to 1 clip space + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1); + + // Render target for the bake + const renderTarget = new THREE.WebGLRenderTarget(resolution, resolution, { + format: THREE.RGBAFormat, + type: THREE.UnsignedByteType, + colorSpace: THREE.SRGBColorSpace, + magFilter: THREE.LinearFilter, + minFilter: THREE.LinearFilter + }); + + const originalRenderTarget = renderer.getRenderTarget(); + const originalClearColor = renderer.getClearColor(new THREE.Color()); + const originalClearAlpha = renderer.getClearAlpha(); + + // Render + renderer.setRenderTarget(renderTarget); + renderer.setClearColor(0x000000, 0); // Transparent background + renderer.clear(); + renderer.render(scene, camera); + + // Read pixels from render target + const pixels = new Uint8Array(resolution * resolution * 4); + renderer.readRenderTargetPixels(renderTarget, 0, 0, resolution, resolution, pixels); + + // Restore renderer state + renderer.setRenderTarget(originalRenderTarget); + renderer.setClearColor(originalClearColor, originalClearAlpha); + + // Flip Y when copying to canvas because readRenderTargetPixels reads bottom-up + const canvas = document.createElement('canvas'); + canvas.width = resolution; + canvas.height = resolution; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("Could not get 2d context"); + + const imgData = ctx.createImageData(resolution, resolution); + // Manual flip Y + for (let y = 0; y < resolution; y++) { + for (let x = 0; x < resolution; x++) { + const srcIndex = ((resolution - 1 - y) * resolution + x) * 4; + const destIndex = (y * resolution + x) * 4; + imgData.data[destIndex] = pixels[srcIndex]; + imgData.data[destIndex + 1] = pixels[srcIndex + 1]; + imgData.data[destIndex + 2] = pixels[srcIndex + 2]; + imgData.data[destIndex + 3] = pixels[srcIndex + 3]; + } + } + + ctx.putImageData(imgData, 0, 0); + + // Clean up + renderTarget.dispose(); + geometry.dispose(); + bakeMaterial.dispose(); + + // Return base64 JPEG to match sizes + return canvas.toDataURL('image/jpeg', 0.85); +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 4f7a99a..2a24835 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -638,6 +638,11 @@ export default function Editor() { } | null>(null); const [exportDialogName, setExportDialogName] = useState(''); const [isRendering, setIsRendering] = useState(false); + const [rendering, setRendering] = useState(false); + const [renderProgress, setRenderProgress] = useState<{ step: string; progress: number } | null>(null); + const [isRenderComplete, setIsRenderComplete] = useState(false); + const [bakedDataUrl, setBakedDataUrl] = useState(null); + const [bakeRequest, setBakeRequest] = useState<{ id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', timestamp: number } | null>(null); const [importStatus, setImportStatus] = useState(null); const [importError, setImportError] = useState(null); const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null); @@ -2528,7 +2533,18 @@ export default function Editor() { return null; })} - + { + if (bakeRequest) { + updateObjectMaterial(bakeRequest.id, { map: dataUrl, mappingType: 'uv' }); + } + setBakeRequest(null); + }} + /> {(!isRendering || !renderRequest?.includeAlpha) ? ( customHdri ? ( @@ -4667,8 +4683,24 @@ export default function Editor() { + + {selectedObject.materialProps?.mappingType && selectedObject.materialProps.mappingType !== 'uv' && selectedObject.materialProps.mappingType !== 'triplanar' && ( + + )} {/* UV Offset */}