diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 34e9c9b..6ed64bd 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -214,6 +214,203 @@ const FixedBackplate = ({ url }: { url: string }) => { return null; }; +const ShadowFloor = ({ bounds, opacity, rotation, softness }: { bounds: any, opacity: number, rotation: number, softness: number }) => { + const alphaTexture = useMemo(() => { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext('2d'); + if (!context) return null; + + // Softness affects the inner radius and the falloff + // Higher softness = more gradual fade + const innerRadius = 0; + const outerRadius = 128; + const gradient = context.createRadialGradient(128, 128, innerRadius, 128, 128, outerRadius); + + // At softness 0, it's a sharper circle (but still a gradient) + // At softness 25, it's an extremely soft fade + const midPoint = Math.max(0.001, 0.5 * Math.exp(-(softness || 0.5) * 0.2)); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(midPoint, 'white'); + gradient.addColorStop(1, 'black'); + + context.fillStyle = gradient; + context.fillRect(0, 0, 256, 256); + return new THREE.CanvasTexture(canvas); + }, [softness]); + + if (!bounds || !alphaTexture) return null; + + const planeSize = (bounds.maxDim || 5) * 5; + const posY = bounds.minY !== undefined ? bounds.minY : -0.5; + + return ( + + + void }) => ( + +); + +const ToolbarButton = ({ icon: Icon, label, active }: { icon: any, label?: string, active?: boolean }) => ( + +); + +const MaterialCard = ({ color, name, active, onClick, roughness = 0.5, metalness = 0, transmission = 0 }: { + color: string, + name: string, + active?: boolean, + onClick?: () => void, + roughness?: number, + metalness?: number, + transmission?: number +}) => ( + +); + +interface MaterialProps { + color: string; + roughness: number; + metalness: number; + clearcoat: number; + transmission: number; + thickness: number; + ior: number; + sheen: number; + opacity: number; + specularIntensity: number; + emissive?: string; + emissiveIntensity?: number; + attenuationDistance?: number; + attenuationColor?: string; + map?: string | null; + normalMap?: string | null; + specularMap?: string | null; + alphaMap?: string | null; + roughnessMap?: string | null; + metalnessMap?: string | null; + uvScale?: number; + uvTiling?: [number, number]; + uvOffset?: [number, number]; + uvRotation?: number; + mappingType?: 'uv' | 'planar' | 'box' | 'cylinder' | 'sphere'; + uvLayers?: any[]; + uvBackground?: string; + uvTransparent?: boolean; +} + +interface AdditionalLight { + id: string; + type: 'point' | 'spot'; + position: [number, number, number]; + intensity: number; + color: string; + distance: number; + decay: number; + angle?: number; + penumbra?: number; + castShadow: boolean; +} + +interface TransformProps { + position: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; +} + +interface SceneObject { + id: string; + name: string; + type: 'cube' | 'model' | 'group' | 'mesh'; + parentId: string | null; + visible: boolean; + materialProps?: MaterialProps; + transformProps?: TransformProps; + fillProps?: { + enabled: boolean; + type: 'solid' | 'liquid'; + height: number; + color: string; + }; +} + +const SceneSetup = () => { + const { gl } = useThree(); + useEffect(() => { + gl.shadowMap.enabled = true; + gl.shadowMap.type = THREE.PCFShadowMap; + gl.localClippingEnabled = true; + }, [gl]); + return null; +}; + +const FixedBackplate = ({ url }: { url: string }) => { + const texture = useTexture(url); + const { scene } = useThree(); + + useEffect(() => { + if (texture) { + texture.colorSpace = THREE.SRGBColorSpace; + const originalBackground = scene.background; + scene.background = texture; + return () => { + scene.background = originalBackground; + }; + } + }, [scene, texture]); + + return null; +}; + const ShadowFloor = ({ bounds, opacity, rotation, softness }: { bounds: any, opacity: number, rotation: number, softness: number }) => { const alphaTexture = useMemo(() => { const canvas = document.createElement('canvas'); @@ -264,6 +461,72 @@ const ShadowFloor = ({ bounds, opacity, rotation, softness }: { bounds: any, opa ); }; +export const extractTextureUrl = (texture: any) => { + if (!texture) return undefined; + const img = texture.image || (texture.source && texture.source.data); + if (!img) return undefined; + if (typeof img.src === 'string' && img.src.length > 0) return img.src; + if (typeof img.currentSrc === 'string' && img.currentSrc.length > 0) return img.currentSrc; + + // Fallback: draw ImageBitmap or HTMLImageElement to canvas to extract data URL + try { + const MAX = 1024; + let scale = 1; + if (img.width > MAX || img.height > MAX) { + scale = MAX / Math.max(img.width, img.height); + } + const cw = Math.round((img.width || 1024) * scale); + const ch = Math.round((img.height || 1024) * scale); + + const canvas = document.createElement('canvas'); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0, cw, ch); + return canvas.toDataURL('image/webp', 0.8); + } + } catch (e) { + console.error('Error extracting texture URL:', e); + } + return undefined; +}; + +export const extractMaterialProps = (material: any): Partial => { + if (!material) return {}; + const mProps: Partial = {}; + + if (material.color) mProps.color = `#${material.color.getHexString()}`; + if (material.roughness !== undefined) mProps.roughness = material.roughness; + if (material.metalness !== undefined) mProps.metalness = material.metalness; + if (material.opacity !== undefined) mProps.opacity = material.opacity; + + // Standard material props + const m = material as any; + if (m.transmission !== undefined) mProps.transmission = m.transmission; + if (m.ior !== undefined) mProps.ior = m.ior; + if (m.thickness !== undefined) mProps.thickness = m.thickness; + if (m.clearcoat !== undefined) mProps.clearcoat = m.clearcoat; + if (m.sheen !== undefined) mProps.sheen = m.sheen; + if (m.specularIntensity !== undefined) mProps.specularIntensity = m.specularIntensity; + + // Extract textures + if (m.map) mProps.map = extractTextureUrl(m.map); + if (m.normalMap) mProps.normalMap = extractTextureUrl(m.normalMap); + if (m.roughnessMap) mProps.roughnessMap = extractTextureUrl(m.roughnessMap); + if (m.metalnessMap) mProps.metalnessMap = extractTextureUrl(m.metalnessMap); + if (m.alphaMap) mProps.alphaMap = extractTextureUrl(m.alphaMap); + + // Extract UV properties from the main map if available + if (m.map) { + if (m.map.repeat) mProps.uvTiling = [m.map.repeat.x, m.map.repeat.y]; + if (m.map.offset) mProps.uvOffset = [m.map.offset.x, m.map.offset.y]; + if (m.map.rotation !== undefined) mProps.uvRotation = m.map.rotation * (180 / Math.PI); + } + + return mProps; +}; + export default function Editor() { const { projectId } = useParams(); const navigate = useNavigate(); @@ -505,6 +768,8 @@ export default function Editor() { normalMap: undefined, specularMap: undefined, alphaMap: undefined, + roughnessMap: undefined, + metalnessMap: undefined, uvScale: 1, uvTiling: [1, 1], uvOffset: [0, 0], @@ -995,733 +1260,6 @@ export default function Editor() { if (data.modelData && data.modelData.data) { setModelData(data.modelData); reImportModel(data.modelData); - } - } catch (err) { - console.error("Failed to load scene", err); - } - }; - reader.readAsText(file); - setOpenMenu(null); - // Clear input so same file can be loaded again - event.target.value = ''; - }; - - const reImportModel = async (modelInfo: { name: string, data: string, extension: string }) => { - setIsLoading(true); - setImportStatus("Restoring model..."); - try { - const url = modelInfo.data; - - const manager = new THREE.LoadingManager(); - let object: THREE.Object3D | null = null; - const extension = modelInfo.extension || 'glb'; - - if (extension === 'glb' || extension === 'gltf') { - const loader = new GLTFLoader(manager); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); - loader.setDRACOLoader(dracoLoader); - const gltf = await loader.loadAsync(url); - object = gltf.scene; - dracoLoader.dispose(); - } else if (extension === 'fbx') { - const loader = new FBXLoader(manager); - object = await loader.loadAsync(url); - } else if (extension === 'obj') { - const loader = new OBJLoader(manager); - object = await loader.loadAsync(url); - } else if (['usdz', 'usd', 'usda', 'usdc'].includes(extension)) { - const loader = new USDLoader(manager); - object = await loader.loadAsync(url); - } - - if (object) { - object.visible = true; - // Setup shadows - object.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - child.receiveShadow = true; - child.frustumCulled = false; // Prevent flickering on large models - } - }); - - setSceneObjects(prev => { - // If the scene already has objects, don't overwrite them - if (prev.length > 0) return prev; - - // Build default hierarchy for fresh imported models (like from SparkPlug) - const mainModelId = `model-${Date.now()}`; - const builtObjects: SceneObject[] = [ - { - id: mainModelId, - name: modelInfo.name || 'Imported Model', - type: 'model', - parentId: null, - visible: true, - transformProps: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } - } - ]; - - let mIdx = 0; - object!.traverse((child) => { - if (child instanceof THREE.Mesh) { - const meshId = `mesh-${mIdx++}`; - const material = Array.isArray(child.material) ? child.material[0] : child.material; - const mProps = { ...globalMaterialProps }; - - if (material) { - if (material.color) mProps.color = `#${material.color.getHexString()}`; - if (material.roughness !== undefined) mProps.roughness = material.roughness; - if (material.metalness !== undefined) mProps.metalness = material.metalness; - if (material.opacity !== undefined) mProps.opacity = material.opacity; - } - - builtObjects.push({ - id: meshId, - name: child.name || `Mesh ${meshId.slice(0, 4)}`, - type: 'mesh', - parentId: mainModelId, - visible: true, - materialProps: mProps - }); - } - }); - return builtObjects; - }); - - setLoadedModel(object); - setBlobURLs(prev => [...prev, url]); - } - setIsLoading(false); - } catch (error) { - console.error('Error re-importing model:', error); - setIsLoading(false); - } - }; - - const handleTextureUpload = (event: React.ChangeEvent, type: 'map' | 'normalMap' | 'specularMap' | 'alphaMap' = 'map') => { - const file = event.target.files?.[0]; - if (!file || !selectedObject) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const textureUrl = e.target?.result as string; - updateObjectMaterial(selectedObject.id, { [type]: textureUrl }); - }; - reader.readAsDataURL(file); - }; - - const handleHdriUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const hdriUrl = e.target?.result as string; - setCustomHdri(hdriUrl); - setEnvPreset(null); // Clear preset when custom HDRI is used - }; - reader.readAsDataURL(file); - }; - - const removeTexture = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { map: null }); - if (textureInputRef.current) textureInputRef.current.value = ""; - } - }; - - const removeNormalMap = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { normalMap: null }); - if (normalInputRef.current) normalInputRef.current.value = ""; - } - }; - - const removeSpecularMap = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { specularMap: null }); - if (specularInputRef.current) specularInputRef.current.value = ""; - } - }; - - const removeAlphaMap = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { alphaMap: null }); - if (alphaInputRef.current) alphaInputRef.current.value = ""; - } - }; - - const saveCameraPreset = (name: string) => { - if (!orbitControlsRef.current) return; - const controls = orbitControlsRef.current; - if (!controls.object || !controls.target) return; - - // Get current camera position and target - const p = controls.object.position; - const t = controls.target; - const position = [p.x || 0, p.y || 0, p.z || 0]; - const target = [t.x || 0, t.y || 0, t.z || 0]; - - const newPreset = { - id: Math.random().toString(36).substr(2, 9), - name, - fov: cameraProps.fov, - zoom: cameraProps.zoom, - autoRotate: cameraProps.autoRotate, - orthographic: cameraProps.orthographic, - cameraMode: cameraProps.cameraMode, - spherical: { ...cameraProps.spherical }, - position, - target - }; - - setCameraPresets(prev => [...prev, newPreset]); - }; - - const loadCameraPreset = (preset: any) => { - if (!orbitControlsRef.current) return; - const controls = orbitControlsRef.current; - - setCameraProps(prev => ({ - ...prev, - fov: preset.fov, - zoom: preset.zoom, - autoRotate: preset.autoRotate || false, - orthographic: preset.orthographic || false, - cameraMode: preset.cameraMode || "absolute", - spherical: preset.spherical || { distance: 6.326, azimuth: -91.475, inclination: -0.393, twist: 0 }, - target: preset.target || [0, 0, 0], - position: preset.position || [3, 2, 5], - pivot: preset.target || [0, 0, 0], - walkthroughMode: false, - groundGrid: false, - depthOfField: { enabled: false, focusDistance: 0.01, focalLength: 0.02, bokehScale: 2 } - })); - - // We need to set the camera position and the controls target - const p = preset.position; - const t = preset.target; - controls.object.position.set(p[0], p[1], p[2]); - controls.target.set(t[0], t[1], t[2]); - controls.update(); - }; - - // Keyboard Shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't intercept if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - // Prevent default for common shortcuts - if (e.ctrlKey || e.metaKey) { - switch (e.key.toLowerCase()) { - case 'n': - e.preventDefault(); - newScene(); - break; - case 's': - e.preventDefault(); - openSaveDialog(); - break; - case 'o': - e.preventDefault(); - sceneInputRef.current?.click(); - break; - case 'i': - e.preventDefault(); - fileInputRef.current?.click(); - break; - case 'e': - e.preventDefault(); - setExportRequest(true); - break; - case 'z': - e.preventDefault(); - if (e.shiftKey) { - redo(); - } else { - undo(); - } - break; - case 'y': - e.preventDefault(); - redo(); - break; - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, []); - - const RenderManager = () => { - const { gl, scene, camera } = useThree(); - - useEffect(() => { - if (!renderRequest) return; - const { width, height, includeAlpha, format, name } = renderRequest; - - setIsRendering(true); - - // Use an offscreen render target so we get exactly the requested resolution - // without resizing the visible canvas (which can produce multiple download events) - const renderFrame = () => { - // Save original state - const originalBackground = scene.background; - const originalToneMapping = gl.toneMapping; - const originalToneMappingExposure = gl.toneMappingExposure; - - // Create offscreen render target at exact requested size - const rt = new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: includeAlpha ? THREE.RGBAFormat : THREE.RGBFormat, - colorSpace: THREE.SRGBColorSpace, - }); - - if (includeAlpha) { - scene.background = null; - } - - // Render scene into the offscreen target - gl.setRenderTarget(rt); - gl.render(scene, camera); - gl.setRenderTarget(null); - - // Read pixels from the render target - const pixels = new Uint8Array(width * height * 4); - gl.readRenderTargetPixels(rt, 0, 0, width, height, pixels); - - // Flip vertically (WebGL reads bottom-to-top) - const flipped = new Uint8Array(width * height * 4); - for (let row = 0; row < height; row++) { - const src = (height - 1 - row) * width * 4; - const dst = row * width * 4; - flipped.set(pixels.subarray(src, src + width * 4), dst); - } - - // Draw into a 2D canvas and export - const offscreen = document.createElement('canvas'); - offscreen.width = width; - offscreen.height = height; - const ctx = offscreen.getContext('2d')!; - const imageData = ctx.createImageData(width, height); - imageData.data.set(flipped); - ctx.putImageData(imageData, 0, 0); - - const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png'; - const dataUrl = offscreen.toDataURL(mimeType, 0.95); - const link = document.createElement('a'); - link.download = `${name}.${format}`; - link.href = dataUrl; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Restore - rt.dispose(); - scene.background = originalBackground; - gl.toneMapping = originalToneMapping; - gl.toneMappingExposure = originalToneMappingExposure; - - setRenderRequest(null); - setShowRenderModal(false); - setIsRendering(false); - }; - - // Wait for React to hide UI elements before capturing - setTimeout(renderFrame, 150); - }, [renderRequest, gl, scene, camera]); - - return null; - }; - - const ExportManager = () => { - const { scene } = useThree(); - - useEffect(() => { - if (exportRequest) { - if (exportFormat === 'glb') { - const exporter = new GLTFExporter(); - const exportScene = scene.clone(); - - // Collect things to remove from exportScene - const toRemove: THREE.Object3D[] = []; - exportScene.traverse((child: any) => { - if ( - child instanceof THREE.Camera || - child instanceof THREE.Light || - child instanceof THREE.GridHelper || - child instanceof THREE.PlaneHelper || - child.type === 'GridHelper' || - child.type === 'DirectionalLightHelper' || - child.name === '__background__' || - child.type === 'TransformControls' || - (child.name && child.name.includes('TransformControls')) || - (child.name && child.name.includes('Grid')) || - // Exclude Floor/Shadow plane - (child instanceof THREE.Mesh && child.geometry && child.geometry.type === 'PlaneGeometry') || - // Exclude TransformControls gizmo meshes - (child.parent && child.parent.type === 'TransformControls') - ) { - toRemove.push(child); - } - }); - - toRemove.forEach(c => c.removeFromParent()); - - exporter.parse( - exportScene, - (result) => { - const blob = new Blob([result as ArrayBuffer], { type: 'application/octet-stream' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - const fname = (exportFileName || 'model').replace(/\.glb$/i, ''); - link.download = `${fname}.glb`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - setExportRequest(false); - }, - (error) => { - console.error('GLB export error:', error); - setExportRequest(false); - }, - { binary: true, trs: true, onlyVisible: true, truncateDrawRange: true } - ); - } else if (exportFormat === 'usdz') { - const exporter = new USDZExporter(); - const exportScene = scene.clone(); - const toRemove: THREE.Object3D[] = []; - exportScene.traverse((child: any) => { - if ( - child instanceof THREE.Camera || - child instanceof THREE.Light || - child instanceof THREE.GridHelper || - child instanceof THREE.PlaneHelper || - child.type === 'GridHelper' || - child.type === 'DirectionalLightHelper' || - child.name === '__background__' || - child.type === 'TransformControls' || - (child.name && child.name.includes('TransformControls')) || - (child.name && child.name.includes('Grid')) || - (child instanceof THREE.Mesh && child.geometry && child.geometry.type === 'PlaneGeometry') || - (child.parent && child.parent.type === 'TransformControls') - ) { - toRemove.push(child); - } - }); - toRemove.forEach(c => c.removeFromParent()); - - exporter.parse( - exportScene, - (result) => { - const blob = new Blob([result], { type: 'model/vnd.usdz+zip' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - const fname = (exportFileName || 'model').replace(/\.usdz$/i, ''); - link.download = `${fname}.usdz`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - setExportRequest(false); - }, - (error) => { - console.error('USDZ export error:', error); - setExportRequest(false); - } - ); - } - } - }, [exportRequest, exportFormat, scene]); - - return null; - }; - - const loadBackplate = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (e) => { - const result = e.target?.result; - if (typeof result === 'string') { - setBackplateImage(result); - setBackgroundType("image"); - } - }; - reader.readAsDataURL(file); - }; - - const handleImport = async (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - - setIsLoading(true); - setImportProgress(0); - setImportStatus("Preparing files..."); - setImportError(null); - - const fileMap: { [key: string]: string } = {}; - const currentBlobURLs: string[] = []; - let rootFile: { name: string, url: string } | null = null; - - try { - const processFile = (name: string, data: Uint8Array | Blob) => { - const blob = data instanceof Blob ? data : new Blob([data]); - const url = URL.createObjectURL(blob); - // Store both full name and just the filename for better matching - fileMap[name] = url; - const fileName = name.split('/').pop() || name; - fileMap[fileName] = url; - currentBlobURLs.push(url); - - const ext = name.split('.').pop()?.toLowerCase(); - if (!rootFile && ['glb', 'gltf', 'fbx', 'obj', 'usdz', 'usd', 'usda', 'usdc'].includes(ext || '')) { - rootFile = { name, url }; - } - }; - - // Handle Zip or Multiple Files - if (files.length === 1 && files[0].name.endsWith('.zip')) { - setImportStatus("Unzipping archive..."); - const buffer = await files[0].arrayBuffer(); - const unzipped = fflate.unzipSync(new Uint8Array(buffer)); - for (const name in unzipped) { - processFile(name, unzipped[name]); - } - } else { - for (let i = 0; i < files.length; i++) { - const file = files[i]; - processFile(file.name, file); - } - } - - if (!rootFile) { - throw new Error("No supported 3D model file found. Please select a .glb, .gltf, .fbx, .obj, or .usdz file."); - } - - setImportStatus("Parsing 3D data..."); - const extension = rootFile.name.split('.').pop()?.toLowerCase(); - - const manager = new THREE.LoadingManager(); - manager.setURLModifier((url) => { - const fileName = url.split('/').pop() || url; - // Try to find the file in our map - if (fileMap[fileName]) return fileMap[fileName]; - if (fileMap[url]) return fileMap[url]; - - // Handle cases where the path might be slightly different - const decodedUrl = decodeURIComponent(url); - const decodedFileName = decodedUrl.split('/').pop() || decodedUrl; - if (fileMap[decodedFileName]) return fileMap[decodedFileName]; - - return url; - }); - - let object: THREE.Object3D | null = null; - - if (extension === 'glb' || extension === 'gltf') { - const loader = new GLTFLoader(manager); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); - loader.setDRACOLoader(dracoLoader); - const gltf = await loader.loadAsync(rootFile.url); - object = gltf.scene; - // Dispose draco loader after use to avoid memory issues - dracoLoader.dispose(); - } else if (extension === 'fbx') { - const loader = new FBXLoader(manager); - object = await loader.loadAsync(rootFile.url); - } else if (extension === 'obj') { - const loader = new OBJLoader(manager); - // Look for companion .mtl file - const mtlFile = Object.keys(fileMap).find(name => name.toLowerCase().endsWith('.mtl')); - if (mtlFile) { - const mtlLoader = new MTLLoader(manager); - try { - const materials = await mtlLoader.loadAsync(fileMap[mtlFile]); - materials.preload(); - loader.setMaterials(materials); - } catch (err) { - console.warn("Failed to load MTL file:", err); - } - } - object = await loader.loadAsync(rootFile.url); - } else if (['usdz', 'usd', 'usda', 'usdc'].includes(extension || '')) { - const loader = new USDLoader(manager); - object = await loader.loadAsync(rootFile.url); - } - - if (object) { - // Save model data for scene persistence - const modelBlob = await fetch(rootFile.url).then(r => r.blob()); - setImportStatus("Uploading to cloud storage... 0%"); - const cloudUrl = await uploadAssetToStorage(modelBlob, 'assets', `models/${user?.id || 'guest'}_${Date.now()}_${rootFile.name}`, (prog) => { - setImportStatus(`Uploading to cloud storage... ${prog}%`); - }); - setModelData({ name: rootFile.name, data: cloudUrl, extension: extension || '' }); - - setImportStatus("Optimizing geometry..."); - // 1. Calculate model bounds - const boundingBox = new THREE.Box3().setFromObject(object); - const center = new THREE.Vector3(); - boundingBox.getCenter(center); - const size = new THREE.Vector3(); - boundingBox.getSize(size); - const maxDim = Math.max(size.x || 0, size.y || 0, size.z || 0) || 1; - const radius = maxDim / 2; - const minY = boundingBox.min.y; - - setModelBounds({ center, size, maxDim, minY, radius }); - - // 2. Adjust Directional Light Shadow Camera - if (directionalLightRef.current) { - const light = directionalLightRef.current; - light.shadow.camera.left = -maxDim; - light.shadow.camera.right = maxDim; - light.shadow.camera.top = maxDim; - light.shadow.camera.bottom = -maxDim; - light.shadow.camera.near = 0.1; - light.shadow.camera.far = maxDim * 5; - light.shadow.camera.updateProjectionMatrix(); - light.shadow.mapSize.width = 2048; - light.shadow.mapSize.height = 2048; - } - - // 3. Auto-framing Camera - let cameraZDistance = 0; - if (cameraRef.current) { - const camera = cameraRef.current; - const fovRadians = camera.fov * (Math.PI / 180); - cameraZDistance = radius / Math.sin(fovRadians / 2); - const padding = 1.5; - cameraZDistance *= padding; - - camera.position.set( - center.x, - center.y + (maxDim / 4), - center.z + cameraZDistance - ); - camera.near = radius / 100; - camera.far = radius * 100; - camera.updateProjectionMatrix(); - camera.lookAt(center); - - setCameraProps(prev => ({ - ...prev, - position: [center.x, center.y + (maxDim / 4), center.z + cameraZDistance] - })); - } - - // 4. Update OrbitControls - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - if (center) controls.target.copy(center); - controls.minDistance = radius / 2; - controls.maxDistance = (cameraZDistance || radius * 10) * 5; - controls.update(); - } - - const mainModelId = `model-${Date.now()}`; - const newSceneObjects: SceneObject[] = [ - { - id: mainModelId, - name: rootFile.name, - type: 'model', - parentId: null, - visible: true, - transformProps: { - position: [0, 0, 0], - rotation: [0, 0, 0], - scale: [1, 1, 1] - } - } - ]; - - object.visible = true; - let mIdx = 0; - object.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - child.receiveShadow = true; - child.frustumCulled = false; - const meshId = `mesh-${mIdx}`; - mIdx++; - - // Extract mesh material properties if available - const material = Array.isArray(child.material) ? child.material[0] : child.material; - const mProps = { ...globalMaterialProps }; - - const extractTextureUrl = (texture: any) => { - if (!texture) return undefined; - const img = texture.image || (texture.source && texture.source.data); - if (!img) return undefined; - if (typeof img.src === 'string' && img.src.length > 0) return img.src; - if (typeof img.currentSrc === 'string' && img.currentSrc.length > 0) return img.currentSrc; - - // Fallback: draw ImageBitmap or HTMLImageElement to canvas to extract data URL - try { - const MAX = 1024; - let scale = 1; - if (img.width > MAX || img.height > MAX) { - scale = MAX / Math.max(img.width, img.height); - } - const cw = Math.round((img.width || 1024) * scale); - const ch = Math.round((img.height || 1024) * scale); - - const canvas = document.createElement('canvas'); - canvas.width = cw; - canvas.height = ch; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.drawImage(img, 0, 0, cw, ch); - return canvas.toDataURL('image/webp', 0.8); - } - } catch (e) { - console.error('Error extracting texture URL:', e); - } - return undefined; - }; - - if (material) { - if (material.color) mProps.color = `#${material.color.getHexString()}`; - if (material.roughness !== undefined) mProps.roughness = material.roughness; - if (material.metalness !== undefined) mProps.metalness = material.metalness; - if (material.opacity !== undefined) mProps.opacity = material.opacity; - - // Standard material props - const m = material as any; - if (m.transmission !== undefined) mProps.transmission = m.transmission; - if (m.ior !== undefined) mProps.ior = m.ior; - if (m.thickness !== undefined) mProps.thickness = m.thickness; - if (m.clearcoat !== undefined) mProps.clearcoat = m.clearcoat; - if (m.sheen !== undefined) mProps.sheen = m.sheen; - if (m.specularIntensity !== undefined) mProps.specularIntensity = m.specularIntensity; - - // Extract textures - if (m.map) mProps.map = extractTextureUrl(m.map); - if (m.normalMap) mProps.normalMap = extractTextureUrl(m.normalMap); - if (m.roughnessMap) mProps.roughnessMap = extractTextureUrl(m.roughnessMap); - if (m.metalnessMap) mProps.metalnessMap = extractTextureUrl(m.metalnessMap); - if (m.alphaMap) mProps.alphaMap = extractTextureUrl(m.alphaMap); - - // Extract UV properties from the main map if available - if (m.map) { - if (m.map.repeat) mProps.uvTiling = [m.map.repeat.x, m.map.repeat.y]; - if (m.map.offset) mProps.uvOffset = [m.map.offset.x, m.map.offset.y]; - if (m.map.rotation !== undefined) mProps.uvRotation = m.map.rotation * (180 / Math.PI); - } - } - - newSceneObjects.push({ - id: meshId, - name: child.name || `Mesh ${meshId.slice(0, 4)}`, - type: 'mesh', parentId: mainModelId, visible: true, transformProps: {