vcad-render / server.mjs
campedersen's picture
Initial deploy - SVG rendering with WASM kernel
2b3c4a6 verified
#!/usr/bin/env node
/**
* Render API - HTTP server that renders Compact IR to SVG
* POST /render with body: { ir: "<compact ir>" }
* Returns: SVG image
*/
import { createServer } from 'node:http';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 7860;
// Load WASM kernel
let kernel = null;
async function loadKernel() {
if (kernel) return kernel;
const wasmModule = await import('./kernel-wasm/vcad_kernel_wasm.js');
const possiblePaths = [
path.join(__dirname, 'node_modules', '@vcad', 'kernel-wasm', 'vcad_kernel_wasm_bg.wasm'),
path.join(__dirname, 'kernel-wasm', 'vcad_kernel_wasm_bg.wasm'),
];
let wasmBuffer = null;
for (const wasmPath of possiblePaths) {
if (fs.existsSync(wasmPath)) {
wasmBuffer = fs.readFileSync(wasmPath);
console.log(`WASM loaded from: ${wasmPath}`);
break;
}
}
if (!wasmBuffer) {
throw new Error('Could not find WASM file');
}
wasmModule.initSync({ module: wasmBuffer });
kernel = wasmModule;
console.log('WASM kernel initialized');
return kernel;
}
// 3D to 2D projection
function project(x, y, z, angle = 45, elevation = 25) {
const radAngle = (angle * Math.PI) / 180;
const radElev = (elevation * Math.PI) / 180;
const x1 = x * Math.cos(radAngle) - z * Math.sin(radAngle);
const z1 = x * Math.sin(radAngle) + z * Math.cos(radAngle);
const y1 = y * Math.cos(radElev) - z1 * Math.sin(radElev);
const z2 = y * Math.sin(radElev) + z1 * Math.cos(radElev);
return { x: x1, y: y1, z: z2 };
}
// Render mesh to SVG
function renderMeshToSVG(mesh, width = 600, height = 600) {
const { positions, indices } = mesh;
if (!positions || positions.length === 0) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect fill="#1a1a1a" width="100%" height="100%"/><text x="50%" y="50%" fill="white" text-anchor="middle">Empty mesh</text></svg>`;
}
// Find bounding box
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const p = project(positions[i], positions[i + 1], positions[i + 2]);
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
// Scale to fit
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
const scale = Math.min((width - 100) / rangeX, (height - 100) / rangeY);
const offsetX = width / 2 - ((minX + maxX) / 2) * scale;
const offsetY = height / 2 + ((minY + maxY) / 2) * scale;
// Collect triangles with depth
const triangles = [];
for (let i = 0; i < indices.length; i += 3) {
const i0 = indices[i], i1 = indices[i + 1], i2 = indices[i + 2];
const v0 = project(positions[i0 * 3], positions[i0 * 3 + 1], positions[i0 * 3 + 2]);
const v1 = project(positions[i1 * 3], positions[i1 * 3 + 1], positions[i1 * 3 + 2]);
const v2 = project(positions[i2 * 3], positions[i2 * 3 + 1], positions[i2 * 3 + 2]);
// Calculate normal for lighting
const nx = (v1.y - v0.y) * (v2.z - v0.z) - (v1.z - v0.z) * (v2.y - v0.y);
const ny = (v1.z - v0.z) * (v2.x - v0.x) - (v1.x - v0.x) * (v2.z - v0.z);
const nz = (v1.x - v0.x) * (v2.y - v0.y) - (v1.y - v0.y) * (v2.x - v0.x);
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
// Simple lighting
const light = Math.max(0.3, (nx / len * 0.3 + ny / len * 0.5 + nz / len * 0.8));
const avgZ = (v0.z + v1.z + v2.z) / 3;
triangles.push({
points: [
{ x: v0.x * scale + offsetX, y: -v0.y * scale + offsetY },
{ x: v1.x * scale + offsetX, y: -v1.y * scale + offsetY },
{ x: v2.x * scale + offsetX, y: -v2.y * scale + offsetY },
],
z: avgZ,
light,
});
}
// Sort by depth (painter's algorithm)
triangles.sort((a, b) => a.z - b.z);
// Build SVG
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">\n`;
svg += `<rect fill="#1a1a1a" width="100%" height="100%"/>\n`;
svg += `<g stroke="rgba(255,255,255,0.3)" stroke-width="0.5">\n`;
for (const tri of triangles) {
const g = Math.floor(200 * tri.light);
const b = Math.floor(220 * tri.light);
const r = Math.floor(g * 0.4);
const fill = `rgb(${r},${g},${Math.floor(b * 0.5)})`;
const pts = tri.points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
svg += `<polygon points="${pts}" fill="${fill}"/>\n`;
}
svg += `</g>\n</svg>`;
return svg;
}
async function renderIR(compactIR) {
const k = await loadKernel();
const solid = k.evaluateCompactIR(compactIR);
if (solid.isEmpty()) {
throw new Error('Solid is empty');
}
const mesh = solid.getMesh(32);
return renderMeshToSVG(mesh);
}
// HTTP server
const server = createServer(async (req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>
<html>
<head><title>vcad Render API</title></head>
<body style="background:#1a1a1a;color:white;font-family:sans-serif;padding:2rem">
<h1>vcad Render API</h1>
<p>POST /render with {"ir": "C 50 30 10"} to render Compact IR to SVG</p>
<p><a href="/health" style="color:#4ade80">/health</a> - Health check</p>
</body>
</html>`);
return;
}
if (req.method === 'POST' && req.url === '/render') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { ir } = JSON.parse(body);
if (!ir) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing ir field' }));
return;
}
const svg = await renderIR(ir);
res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
res.end(svg);
} catch (err) {
console.error('Render error:', err.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
// Start server
await loadKernel();
server.listen(PORT, () => {
console.log(`Render API listening on port ${PORT}`);
});