arabago96's picture
Fix Draco compression: dedup transform + clean NodeIO writer
f5e6a99
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;
}
}