/**
* OpenSCAD CLI wrapper — renders, exports, and previews 3D models
*/
import { execFile } from 'child_process';
import { writeFile, readFile, mkdir, access } from 'fs/promises';
import path from 'path';
const WORK_DIR = process.env.SCAD_WORK_DIR || '/tmp/3d-mcp-work';
const OPENSCAD_BIN = process.env.OPENSCAD_BIN || 'openscad';
export interface RenderResult {
success: boolean;
outputPath: string;
format: string;
stderr: string;
durationMs: number;
}
export interface PreviewResult {
success: boolean;
imagePath: string;
imageBase64: string;
stderr: string;
durationMs: number;
}
async function ensureWorkDir(): Promise<void> {
try {
await access(WORK_DIR);
} catch {
await mkdir(WORK_DIR, { recursive: true });
}
}
/**
* Check if OpenSCAD is installed
*/
export async function checkOpenScad(): Promise<{ installed: boolean; version?: string; error?: string }> {
return new Promise((resolve) => {
execFile(OPENSCAD_BIN, ['--version'], (error, _stdout, stderr) => {
if (error) {
resolve({
installed: false,
error: `OpenSCAD not found. Install it:\n Ubuntu/Debian: sudo apt install openscad\n macOS: brew install openscad\n Windows: https://openscad.org/downloads.html`,
});
} else {
const version = stderr.trim() || 'unknown';
resolve({ installed: true, version });
}
});
});
}
/**
* Save SCAD code to a file
*/
export async function saveScadFile(code: string, filename?: string): Promise<string> {
await ensureWorkDir();
const name = filename || `model-${Date.now()}.scad`;
const filePath = path.join(WORK_DIR, name);
await writeFile(filePath, code, 'utf-8');
return filePath;
}
/**
* Export to STL, OBJ, 3MF, AMF, OFF, DXF, SVG, CSG
*/
export async function exportModel(
scadCode: string,
format: 'stl' | 'obj' | '3mf' | 'amf' | 'off' | 'dxf' | 'svg' | 'csg' = 'stl',
filename?: string
): Promise<RenderResult> {
await ensureWorkDir();
const start = Date.now();
const scadPath = await saveScadFile(scadCode);
const outName = filename || `export-${Date.now()}.${format}`;
const outputPath = path.join(WORK_DIR, outName);
return new Promise((resolve) => {
execFile(
OPENSCAD_BIN,
['-o', outputPath, scadPath],
{ timeout: 120_000 },
(error, _stdout, stderr) => {
resolve({
success: !error,
outputPath,
format,
stderr: stderr?.trim() || (error?.message ?? ''),
durationMs: Date.now() - start,
});
}
);
});
}
/**
* Render a PNG preview of the model
*/
export async function renderPreview(
scadCode: string,
options: {
width?: number;
height?: number;
camera?: string; // "translateX,Y,Z,rotX,Y,Z,dist" or "eyeX,Y,Z,centerX,Y,Z"
colorScheme?: string;
projection?: 'perspective' | 'orthogonal';
} = {}
): Promise<PreviewResult> {
await ensureWorkDir();
const start = Date.now();
const scadPath = await saveScadFile(scadCode);
const imgName = `preview-${Date.now()}.png`;
const imagePath = path.join(WORK_DIR, imgName);
const {
width = 800,
height = 600,
camera,
colorScheme = 'Tomorrow Night',
projection = 'perspective',
} = options;
const args = [
'-o', imagePath,
'--imgsize', `${width},${height}`,
'--colorscheme', colorScheme,
'--projection', projection[0], // 'p' or 'o'
];
if (camera) {
args.push('--camera', camera);
}
// Use --render for full render (not just preview)
args.push('--render');
args.push(scadPath);
return new Promise((resolve) => {
execFile(
OPENSCAD_BIN,
args,
{ timeout: 120_000 },
async (error, _stdout, stderr) => {
let imageBase64 = '';
if (!error) {
try {
const buf = await readFile(imagePath);
imageBase64 = buf.toString('base64');
} catch {
// image file might not exist
}
}
resolve({
success: !error,
imagePath,
imageBase64,
stderr: stderr?.trim() || (error?.message ?? ''),
durationMs: Date.now() - start,
});
}
);
});
}
/**
* Get model info (bounding box, etc.) by rendering to CSG and parsing
*/
export async function getModelInfo(scadCode: string): Promise<{
success: boolean;
vertices?: number;
stderr: string;
}> {
const result = await exportModel(scadCode, 'stl', `info-${Date.now()}.stl`);
return {
success: result.success,
stderr: result.stderr,
};
}