Spaces:
Sleeping
Sleeping
| import { spawn } from 'child_process'; | |
| import path from 'path'; | |
| import fs from 'fs'; | |
| import { NodeIO } from '@gltf-transform/core'; | |
| import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; | |
| import { weld, dedup } from '@gltf-transform/functions'; | |
| import draco3d from 'draco3dgltf'; | |
| import sharp from 'sharp'; | |
| const USE_WINE = process.env.USE_WINE === 'true'; | |
| const CONVERTER_BIN_NAME = USE_WINE ? 'skp_converter.exe' : 'skp_converter'; | |
| const CONVERTER_DIR = path.join(process.cwd(), 'services/processor/bin'); | |
| const CONVERTER_BIN = path.join(CONVERTER_DIR, CONVERTER_BIN_NAME); | |
| export interface ConversionResult { | |
| vertices: number; | |
| faces: number; | |
| textured_faces: number; | |
| materials: number; | |
| textures: number; | |
| texture_width: number; | |
| texture_height: number; | |
| texture_bytes: number; | |
| estimated_geometry_bytes: number; | |
| skp_overhead_bytes?: number; | |
| file_size_bytes: number; | |
| logs?: string; | |
| } | |
| export async function convertGlbToSkp(inputPath: string, outputPath: string, options: { textureFormat?: 'jpeg' | 'png' } = {}): Promise<ConversionResult> { | |
| // CRITICAL FIX: Always append suffix instead of replacing extension | |
| // The multer upload path has no .glb extension, so .replace() did nothing | |
| // which caused the original file to be overwritten with stripped content! | |
| const processedGlbPath = `${inputPath}_processed.glb`; | |
| // Log original file size for debugging | |
| const originalStats = fs.statSync(inputPath); | |
| console.log(`[DEBUG] Original GLB size: ${originalStats.size} bytes`); | |
| try { | |
| console.log('Preprocessing GLB for optimal SketchUp conversion...'); | |
| const io = new NodeIO() | |
| .registerExtensions(ALL_EXTENSIONS) | |
| .registerDependencies({ | |
| 'draco3d.decoder': await draco3d.createDecoderModule(), | |
| 'draco3d.encoder': await draco3d.createEncoderModule(), // Required even for disposal/write | |
| }); | |
| // Read document - Draco is decoded automatically here into accessors | |
| const document = await io.read(inputPath); | |
| const root = document.getRoot(); | |
| // Check for Draco/Sparse usage before cleanup | |
| let dracoFound = false; | |
| for (const ext of root.listExtensions()) { | |
| if (ext.getName().includes('draco')) dracoFound = true; | |
| } | |
| let sparseConverted = 0; | |
| let totalVerts = 0; | |
| // 1. Force Sparse -> Dense | |
| // Draco often decodes to sparse accessors. C++ tinygltf loader needs dense accessors with bufferViews. | |
| for (const acc of root.listAccessors()) { | |
| if (acc.getSparse()) { | |
| acc.setSparse(false); | |
| sparseConverted++; | |
| } | |
| } | |
| // Log stats | |
| for (const mesh of root.listMeshes()) { | |
| for (const prim of mesh.listPrimitives()) { | |
| const pos = prim.getAttribute('POSITION'); | |
| if (pos) totalVerts += pos.getCount(); | |
| } | |
| } | |
| console.log(`[DEBUG] Document Stats: ${root.listMeshes().length} meshes, ${totalVerts} vertices`); | |
| console.log(`[DEBUG] Draco extension detected: ${dracoFound}. Converted ${sparseConverted} sparse accessors to dense.`); | |
| // 2. Remove problematic extensions (Draco, WebP) | |
| // CRITICAL: Must remove these BEFORE writing or they'll be re-encoded | |
| const extensions = root.listExtensions(); | |
| console.log(`[DEBUG] Extensions found: ${extensions.map(e => e.extensionName).join(', ') || 'none'}`); | |
| for (const ext of extensions) { | |
| const extName = ext.extensionName; // Use extensionName, not getName() | |
| // Remove Draco, WebP, and other non-standard extensions | |
| if (extName.toLowerCase().includes('draco') || | |
| extName.includes('webp') || | |
| extName === 'EXT_texture_webp' || | |
| extName === 'KHR_draco_mesh_compression') { | |
| console.log(` Disposing extension: ${extName}`); | |
| ext.dispose(); | |
| } | |
| } | |
| // ======= CRITICAL FIX FOR DRACO: Force accessor buffer assignment ======= | |
| // When Draco is decoded, accessors may still have bufferView = -1 | |
| // We MUST assign each accessor to a valid buffer so gltf-transform writes proper | |
| // bufferView references when serializing. This is the KEY fix for accessor=-1. | |
| const buffers = root.listBuffers(); | |
| let buffer = buffers.length > 0 ? buffers[0] : document.createBuffer(); | |
| let accessorsFixed = 0; | |
| for (const acc of root.listAccessors()) { | |
| // Force the accessor to use our buffer - this makes gltf-transform | |
| // generate proper bufferView references when writing | |
| acc.setBuffer(buffer); | |
| accessorsFixed++; | |
| } | |
| console.log(`[DEBUG] Assigned ${accessorsFixed} accessors to buffer`); | |
| // 4. Apply dedup() FIRST - this forces accessor data to be materialized into buffers | |
| // This is CRITICAL: dedup() breaks the Draco "lazy" reference and creates real buffer data | |
| await document.transform(dedup()); | |
| console.log(`[DEBUG] Applied dedup() transform`); | |
| // 5. Apply weld() transform - consolidates vertices and generates proper index buffer | |
| // This is important for Draco-decoded data to work properly in SketchUp | |
| await document.transform(weld()); | |
| console.log(`[DEBUG] Applied weld() transform`); | |
| // Optional: Remove Skins/Animations | |
| for (const skin of root.listSkins()) skin.dispose(); | |
| for (const anim of root.listAnimations()) anim.dispose(); | |
| // ======= CRITICAL: Convert WebP textures to PNG/JPEG ======= | |
| // stb_image (used by tinygltf in C++) does NOT support WebP | |
| // We MUST convert all textures to PNG or JPEG for the converter to work | |
| const textureFormat = options?.textureFormat || 'jpeg'; | |
| const textures = document.getRoot().listTextures(); | |
| if (textures.length > 0) { | |
| console.log(` - Converting ${textures.length} textures to ${textureFormat.toUpperCase()} (stb_image compatible)...`); | |
| let textureCount = 0; | |
| for (const texture of textures) { | |
| const image = texture.getImage(); | |
| if (!image) { | |
| console.log(` [Texture ${textureCount}] No image data, skipping`); | |
| textureCount++; | |
| continue; | |
| } | |
| const mimeType = texture.getMimeType(); | |
| console.log(` [Texture ${textureCount}] Original format: ${mimeType} (${image.length} bytes)`); | |
| try { | |
| // Skip conversion if already PNG or JPEG (stb_image compatible) | |
| if (mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/jpg') { | |
| console.log(` -> Already ${mimeType}, skipping conversion`); | |
| texture.setURI(''); // Ensure embedded | |
| textureCount++; | |
| continue; | |
| } | |
| // Use sharp to convert WebP/other formats to the requested format | |
| let convertedBuffer: Buffer; | |
| const sharpInstance = sharp(image); | |
| if (textureFormat === 'png') { | |
| convertedBuffer = await sharpInstance.png().toBuffer(); | |
| texture.setMimeType('image/png'); | |
| console.log(` -> Converted to PNG`); | |
| } else { | |
| // Default: JPEG | |
| convertedBuffer = await sharpInstance.jpeg({ quality: 85 }).toBuffer(); | |
| texture.setMimeType('image/jpeg'); | |
| console.log(` -> Converted to JPEG`); | |
| } | |
| texture.setImage(convertedBuffer); | |
| texture.setURI(''); // Ensure embedded | |
| console.log(` -> Final size: ${convertedBuffer.length} bytes`); | |
| } catch (sharpErr) { | |
| console.error(` [Texture ${textureCount}] Sharp conversion failed:`, sharpErr); | |
| } | |
| textureCount++; | |
| } | |
| console.log(` - Processed ${textureCount} textures`); | |
| } else { | |
| console.log(' - No textures to process'); | |
| } | |
| // Write processed GLB using a CLEAN NodeIO (without Draco encoder) | |
| // CRITICAL: If we use the same 'io' that has ALL_EXTENSIONS registered, | |
| // gltf-transform will RE-ENCODE the mesh with Draco during write! | |
| const cleanIO = new NodeIO(); // No extensions registered = no Draco re-encoding | |
| await cleanIO.write(processedGlbPath, document); | |
| console.log(`[DEBUG] Written with CLEAN NodeIO (no Draco encoder)`); | |
| const stats = fs.statSync(processedGlbPath); | |
| console.log(`[DEBUG] Processed GLB saved to ${processedGlbPath} (Size: ${stats.size} bytes)`); | |
| // CRITICAL: Validate the processed file isn't corrupt | |
| if (stats.size < 1000 && originalStats.size > 10000) { | |
| throw new Error(`GLB preprocessing failed: output (${stats.size} bytes) is much smaller than input (${originalStats.size} bytes). Geometry may have been lost.`); | |
| } | |
| if (!fs.existsSync(CONVERTER_BIN)) throw new Error(`Converter binary not found at ${CONVERTER_BIN}`); | |
| console.log(`Running converter: ${CONVERTER_BIN}`); | |
| return await new Promise<ConversionResult>((resolve, reject) => { | |
| const command = USE_WINE ? 'wine' : CONVERTER_BIN; | |
| const args = USE_WINE ? [CONVERTER_BIN, processedGlbPath, outputPath] : [processedGlbPath, outputPath]; | |
| // Should verify bin exists before spawning | |
| if (!fs.existsSync(CONVERTER_BIN)) { | |
| reject(new Error(`Converter binary not found at ${CONVERTER_BIN}`)); | |
| return; | |
| } | |
| console.log(`Spawning: ${command} ${args.join(' ')}`); | |
| const child = spawn(command, args, { | |
| cwd: CONVERTER_DIR // Run in bin dir so DLLs are found | |
| }); | |
| let stdout = ''; | |
| let stderr = ''; | |
| child.stdout.on('data', (data) => { | |
| const dataStr = data.toString(); | |
| stdout += dataStr; | |
| console.log(dataStr); | |
| }); | |
| child.stderr.on('data', (data) => { | |
| const dataStr = data.toString(); | |
| stderr += dataStr; | |
| }); | |
| child.on('close', (code, signal) => { | |
| // Clean up processed file | |
| if (fs.existsSync(processedGlbPath)) { | |
| try { fs.unlinkSync(processedGlbPath); } catch (e) { } | |
| } | |
| if (code === 0) { | |
| try { | |
| const jsonMatch = stdout.match(/\{[\s\S]*\}/); | |
| let result: any = {}; | |
| if (jsonMatch) { | |
| try { result = JSON.parse(jsonMatch[0]); } catch (e) { } | |
| } | |
| let size = 0; | |
| try { size = fs.statSync(outputPath).size; } catch (e) { } | |
| resolve({ | |
| vertices: result.vertices || 0, | |
| faces: result.faces || 0, | |
| textured_faces: result.textured_faces || 0, | |
| materials: result.materials || 0, | |
| textures: result.textures || 0, | |
| texture_width: result.texture_width || 0, | |
| texture_height: result.texture_height || 0, | |
| texture_bytes: result.texture_bytes || 0, | |
| estimated_geometry_bytes: result.estimated_geometry_bytes || 0, | |
| skp_overhead_bytes: result.skp_overhead_bytes || 0, | |
| file_size_bytes: size, | |
| logs: stdout + "\n[STDERR]:\n" + stderr | |
| }); | |
| } catch (err) { | |
| resolve({ | |
| vertices: 0, faces: 0, textured_faces: 0, materials: 0, textures: 0, | |
| texture_width: 0, texture_height: 0, texture_bytes: 0, | |
| estimated_geometry_bytes: 0, file_size_bytes: 0, | |
| logs: stdout + "\n[STDERR]:\n" + stderr + "\n[JS Error]: " + (err as any).message | |
| }); | |
| } | |
| } else { | |
| reject(new Error(`Converter failed with code ${code}. Logs:\n${stdout}\nstderr:\n${stderr}`)); | |
| } | |
| }); | |
| child.on('error', (err) => reject(new Error(`Failed to start converter process: ${err.message}`))); | |
| }); | |
| } catch (err) { | |
| if (fs.existsSync(processedGlbPath)) try { fs.unlinkSync(processedGlbPath); } catch (e) { } | |
| throw err; | |
| } | |
| } | |