| 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 |
| }; |
| } |
|
|