cadforge-cadquery-openenv / src /scadRenderer.js
sanjuhs's picture
Upload folder using huggingface_hub
6de1b61 verified
import * as THREE from "three";
import { CSG } from "three-csg-ts";
class ScadParser {
constructor(source) {
this.tokens = tokenize(source);
this.index = 0;
}
parseProgram(stopAtBrace = false) {
const nodes = [];
while (!this.done()) {
if (stopAtBrace && this.peek()?.value === "}") break;
if (this.peek()?.value === ";") {
this.index += 1;
continue;
}
nodes.push(this.parseStatement());
}
return nodes;
}
parseStatement() {
const node = this.parseCall();
const next = this.peek();
if (next?.value === "{") {
this.consume("{");
node.children = this.parseProgram(true);
this.consume("}");
} else if (isChildTakingCall(node.name) && next?.type === "identifier") {
node.children = [this.parseStatement()];
}
if (this.peek()?.value === ";") this.index += 1;
return node;
}
parseCall() {
const name = this.consumeType("identifier").value;
this.consume("(");
const args = [];
const named = {};
while (!this.done() && this.peek()?.value !== ")") {
if (this.peek()?.type === "identifier" && this.peek(1)?.value === "=") {
const key = this.consumeType("identifier").value;
this.consume("=");
named[key] = this.parseValue();
} else if (this.peek()?.value === "$" && this.peek(1)?.type === "identifier" && this.peek(2)?.value === "=") {
this.consume("$");
const key = `$${this.consumeType("identifier").value}`;
this.consume("=");
named[key] = this.parseValue();
} else {
args.push(this.parseValue());
}
if (this.peek()?.value === ",") this.index += 1;
}
this.consume(")");
return { name, args, named, children: [] };
}
parseValue() {
const token = this.peek();
if (!token) throw new Error("Unexpected end of SCAD input.");
if (token.value === "[") {
this.consume("[");
const values = [];
while (!this.done() && this.peek()?.value !== "]") {
values.push(this.parseValue());
if (this.peek()?.value === ",") this.index += 1;
}
this.consume("]");
return values;
}
if (token.type === "number") {
this.index += 1;
return Number(token.value);
}
if (token.type === "identifier") {
this.index += 1;
if (token.value === "true") return true;
if (token.value === "false") return false;
return token.value;
}
throw new Error(`Unexpected token "${token.value}" while parsing value.`);
}
consume(value) {
const token = this.peek();
if (token?.value !== value) throw new Error(`Expected "${value}" but found "${token?.value || "end of input"}".`);
this.index += 1;
return token;
}
consumeType(type) {
const token = this.peek();
if (token?.type !== type) throw new Error(`Expected ${type} but found "${token?.value || "end of input"}".`);
this.index += 1;
return token;
}
peek(offset = 0) {
return this.tokens[this.index + offset];
}
done() {
return this.index >= this.tokens.length;
}
}
function tokenize(source) {
const stripped = String(source)
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
const tokens = [];
const pattern = /\s*(-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_]*|\$|[()[\]{},;=])/gy;
let index = 0;
while (index < stripped.length) {
pattern.lastIndex = index;
const match = pattern.exec(stripped);
if (!match) {
if (/\s/.test(stripped[index])) {
index += 1;
continue;
}
throw new Error(`Unsupported SCAD syntax near "${stripped.slice(index, index + 24)}".`);
}
const value = match[1];
tokens.push({ value, type: /^-?\d/.test(value) ? "number" : /^[A-Za-z_]/.test(value) ? "identifier" : "symbol" });
index = pattern.lastIndex;
}
return tokens;
}
function isChildTakingCall(name) {
return ["translate", "rotate", "scale", "color", "union", "difference", "intersection"].includes(name);
}
function vector(value, fallback, length = 3) {
const source = Array.isArray(value) ? value : [value];
return Array.from({ length }, (_, index) => Number(source[index] ?? fallback[index] ?? 0));
}
function firstNumber(node, keys, fallback) {
for (const key of keys) {
if (Number.isFinite(Number(node.named[key]))) return Number(node.named[key]);
}
for (const arg of node.args) {
if (Number.isFinite(Number(arg))) return Number(arg);
}
return fallback;
}
function nodeChildren(node) {
if (!node.children?.length) throw new Error(`${node.name} requires child geometry.`);
return node.children;
}
function prepareMesh(mesh) {
mesh.updateMatrixWorld(true);
mesh.updateMatrix();
return mesh;
}
function evaluateBoolean(children, operation, material) {
if (operation === "union" && children.length > 24) {
throw new Error(`Renderer safety limit: union has ${children.length} children. Keep generated SCAD to 24 or fewer union children so the browser CSG step cannot hang.`);
}
let result = prepareMesh(evaluateNode(children[0], material));
for (const child of children.slice(1)) {
const next = prepareMesh(evaluateNode(child, material));
if (operation === "union") result = CSG.union(result, next);
if (operation === "difference") result = CSG.subtract(result, next);
if (operation === "intersection") result = CSG.intersect(result, next);
result.material = material;
}
result.geometry.computeVertexNormals();
return result;
}
function evaluateNode(node, material) {
if (node.name === "cube") {
const size = vector(node.named.size ?? node.args[0] ?? 1, [1, 1, 1]);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(Math.max(size[0], 0.01), Math.max(size[1], 0.01), Math.max(size[2], 0.01)), material);
if (node.named.center !== true) mesh.position.set(size[0] / 2, size[1] / 2, size[2] / 2);
return mesh;
}
if (node.name === "sphere") {
const radius = Math.max(firstNumber(node, ["r"], Number(node.named.d || 2) / 2 || 1), 0.01);
const segments = Math.min(Math.max(Number(node.named.$fn || 16), 8), 24);
return new THREE.Mesh(new THREE.SphereGeometry(radius, segments, 12), material);
}
if (node.name === "cylinder") {
const height = Math.max(Number(node.named.h ?? node.args[0] ?? 1), 0.01);
const radius = firstNumber(node, ["r"], Number(node.named.d || 2) / 2 || 1);
const r1Raw = node.named.r1 ?? (node.named.d1 === undefined ? undefined : Number(node.named.d1) / 2) ?? radius;
const r2Raw = node.named.r2 ?? (node.named.d2 === undefined ? undefined : Number(node.named.d2) / 2) ?? radius;
const r1 = Math.max(Number(r1Raw), 0.01);
const r2 = Math.max(Number(r2Raw), 0.01);
const segments = Math.min(Math.max(Number(node.named.$fn || 16), 8), 24);
const geometry = new THREE.CylinderGeometry(r2, r1, height, segments);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(geometry, material);
if (node.named.center !== true) mesh.position.z = height / 2;
return mesh;
}
if (node.name === "union") {
return evaluateBoolean(nodeChildren(node), "union", material);
}
if (node.name === "difference") {
return evaluateBoolean(nodeChildren(node), "difference", material);
}
if (node.name === "intersection") {
return evaluateBoolean(nodeChildren(node), "intersection", material);
}
if (node.name === "translate") {
const mesh = evaluateNode(nodeChildren(node)[0], material);
const [x, y, z] = vector(node.args[0] ?? node.named.v, [0, 0, 0]);
mesh.position.add(new THREE.Vector3(x, y, z));
return mesh;
}
if (node.name === "rotate") {
const mesh = evaluateNode(nodeChildren(node)[0], material);
const [x, y, z] = vector(node.args[0] ?? node.named.a, [0, 0, 0]);
mesh.rotation.x += THREE.MathUtils.degToRad(x);
mesh.rotation.y += THREE.MathUtils.degToRad(y);
mesh.rotation.z += THREE.MathUtils.degToRad(z);
return mesh;
}
if (node.name === "scale") {
const mesh = evaluateNode(nodeChildren(node)[0], material);
const [x, y, z] = vector(node.args[0] ?? node.named.v, [1, 1, 1]);
mesh.scale.multiply(new THREE.Vector3(x || 1, y || 1, z || 1));
return mesh;
}
if (node.name === "color") {
return evaluateNode(nodeChildren(node)[0], material);
}
throw new Error(`Unsupported OpenSCAD call "${node.name}". Supported subset: cube, sphere, cylinder, translate, rotate, scale, union, difference, intersection.`);
}
export function renderScadToGroup(source, material) {
const parser = new ScadParser(source);
const nodes = parser.parseProgram();
if (!nodes.length) throw new Error("SCAD input did not contain any renderable geometry.");
const complexity = countRenderableNodes(nodes);
if (complexity.primitives > 24) {
throw new Error(`Renderer safety limit: ${complexity.primitives} primitives. Keep generated SCAD to 24 or fewer primitives.`);
}
const mesh = nodes.length === 1 ? evaluateNode(nodes[0], material) : evaluateBoolean(nodes, "union", material);
mesh.geometry.computeBoundingBox();
mesh.geometry.computeBoundingSphere();
const topology = analyzeMeshTopology(mesh.geometry);
const box = mesh.geometry.boundingBox;
const dimensions = new THREE.Vector3();
box.getSize(dimensions);
const group = new THREE.Group();
group.add(mesh);
return {
group,
stats: {
root_nodes: nodes.length,
triangles: mesh.geometry.index ? mesh.geometry.index.count / 3 : mesh.geometry.attributes.position.count / 3,
bounding_box: {
min: [box.min.x, box.min.y, box.min.z],
max: [box.max.x, box.max.y, box.max.z]
},
dimensions: {
x: dimensions.x,
y: dimensions.y,
z: dimensions.z
},
connected_components: topology.connected_components,
floating_parts: Math.max(0, topology.connected_components - 1),
boundary_edges: topology.boundary_edges,
non_manifold_edges: topology.non_manifold_edges,
watertight: topology.boundary_edges === 0 && topology.non_manifold_edges === 0,
supported_subset: "cube/sphere/cylinder + translate/rotate/scale + union/difference/intersection"
}
};
}
function countRenderableNodes(nodes) {
let total = 0;
let primitives = 0;
const stack = [...nodes];
while (stack.length) {
const node = stack.pop();
total += 1;
if (["cube", "sphere", "cylinder"].includes(node.name)) primitives += 1;
for (const child of node.children || []) stack.push(child);
}
return { total, primitives };
}
function analyzeMeshTopology(geometry) {
const position = geometry.attributes.position;
const index = geometry.index;
const faces = [];
const vertexKeyToId = new Map();
function vertexId(rawIndex) {
const x = position.getX(rawIndex).toFixed(5);
const y = position.getY(rawIndex).toFixed(5);
const z = position.getZ(rawIndex).toFixed(5);
const key = `${x},${y},${z}`;
if (!vertexKeyToId.has(key)) vertexKeyToId.set(key, vertexKeyToId.size);
return vertexKeyToId.get(key);
}
const triCount = index ? index.count / 3 : position.count / 3;
for (let tri = 0; tri < triCount; tri += 1) {
const a = index ? index.getX(tri * 3) : tri * 3;
const b = index ? index.getX(tri * 3 + 1) : tri * 3 + 1;
const c = index ? index.getX(tri * 3 + 2) : tri * 3 + 2;
faces.push([vertexId(a), vertexId(b), vertexId(c)]);
}
const edgeCounts = new Map();
const adjacency = Array.from({ length: vertexKeyToId.size }, () => new Set());
for (const [a, b, c] of faces) {
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
const key = u < v ? `${u}:${v}` : `${v}:${u}`;
edgeCounts.set(key, (edgeCounts.get(key) || 0) + 1);
adjacency[u].add(v);
adjacency[v].add(u);
}
}
let boundaryEdges = 0;
let nonManifoldEdges = 0;
for (const count of edgeCounts.values()) {
if (count === 1) boundaryEdges += 1;
if (count > 2) nonManifoldEdges += 1;
}
const visited = new Set();
let connectedComponents = 0;
for (let start = 0; start < adjacency.length; start += 1) {
if (visited.has(start)) continue;
connectedComponents += 1;
const stack = [start];
visited.add(start);
while (stack.length) {
const current = stack.pop();
for (const next of adjacency[current]) {
if (!visited.has(next)) {
visited.add(next);
stack.push(next);
}
}
}
}
return {
connected_components: connectedComponents,
boundary_edges: boundaryEdges,
non_manifold_edges: nonManifoldEdges
};
}