tscad GitHub

Converting OpenSCAD models

convertScad(source) turns an OpenSCAD .scad file into a tscad model (.tsx). Customizer variables become params, modules become components, and the geometry matches OpenSCAD's.

import { convertScad } from "tscad/src/convert"   // CLI: tscad convert in.scad -o out.tsx

const { tsx, warnings } = convertScad(await Bun.file("box.scad").text(), { name: "Box" })
for (const w of warnings) console.warn(`box.scad:${w.line}: ${w.message}`)

warnings lists everything that couldn't be converted exactly, by .scad line. A source that doesn't parse throws ScadSyntaxError with line and col.

What you get

/* [Box] */
// Outer width
width = 60; // [20:1:200]
part = "both"; // [base, lid, both]
module shell(w, d, h, t) {
  difference() { cube([w, d, h]); translate([t, t, t]) cube([w - 2*t, d - 2*t, h]); }
}
if (part != "lid") shell(width, 40, 25, 2);

becomes

export const params = defineParams({
  width: { type: "int", default: 60, min: 20, max: 200, step: 1, description: "Outer width", group: "Box" },
  part: { type: "choice", default: "both", options: ["base", "lid", "both"], group: "Box" },
})

/** OpenSCAD module shell(w, d, h, t) */
function Shell({ w, d, h, t }: { w: number; d: number; h: number; t: number }) {
  return (
    <difference>
      <box size={[w, d, h]} center={false} />
      <translate by={[t, t, t]}><box size={[w - 2 * t, d - 2 * t, h]} center={false} /></translate>
    </difference>
  )
}

export default function Model(p: ParamValues<typeof params>) {
  const { width, part } = p
  return <>{part !== "lid" ? <Shell w={width} d={40} h={25} t={2} /> : null}</>
}

The output is self-contained: any OpenSCAD semantics it relies on (vector arithmetic, degree trig, ranges, truthiness) are small helper functions copied to the end of the file.

Customizer → defineParams

Variables are treated as params when they are assigned a literal at the top level before the first module/function definition, and are not in the /* [Hidden] */ section. That is the Customizer's own rule.

.scadparam
x = 5; // [0:10]number (int when all values are whole) with min/max
x = 5; // [0:0.5:10]min / step / max
x = 5; // [10]max (min 0)
x = "a"; // [a, b, c]choice
x = 2; // [1:Low, 2:Mid, 3:High]choice with optionLabels
x = true;boolean
s = "hi"; // 8text, maxLength: 8
/* [Section] */group of the variables after it
comment line above (else a plain trailing comment)description

Other top-level variables become consts inside the model, in order. OpenSCAD's "last assignment wins" is kept, with a warning.

Language

Fully converted:

Geometry

OpenSCAD's placement conventions are kept, so shapes land exactly where they did:

.scadtscad
cube(s, center)<box size center={false}> (corner at the origin unless centred)
sphere(r | d)<sphere>
cylinder(h, r | d, r1/r2/d1/d2, center)<cylinder> / <cone> (base on z = 0, center centres z)
cylinder(..., $fn = 6) (any literal $fn < 16)a faceted prism: <extrude><regularPolygon sides> (hex nut traps!), or a ruled loft for a cone
$fn / $fa / $fs otherwiseignored: tscad circles are exact
union / difference / intersectionsame; each operand statement is one solid (loops become a <union>)
translate / rotate(a) / rotate(a, v) / scale / mirror<translate by> / <rotate by | z> / <rotate axis angle> / <scale by> / <mirror axis>
linear_extrude(height, center)<extrude>
rotate_extrude(angle)<revolve>
square(s, center) / circle / polygon(points, paths) / text / offset(r | delta)<rect center={false}> / <circle> / <polygon> (one per path, so nested paths are holes) / <text> / <offset round>
scale(v) circle(r)<ellipse rx ry>
2D hull()<hull>: exact (tangent lines and true arcs)
2D minkowski() { shape; circle(r); }<offset distance={r}> (rounded): exact
minkowski() { cube(s); sphere(r); }<fillet radius={r}><box> grown by r on every side: exact (a box ⊕ a ball is the grown box with every edge filleted r)
minkowski() { cube(s); cylinder(r, h); }<extrude height={z + h}><roundedRect> grown by r: exact
import("logo.svg") / import("plate.dxf", layer=...) (literal file names, 2D)<svg data> / <dxf data>, with a text import of the file added at the top
multmatrix(m)<transform matrix={m}> (4×4 or 3×4, row-major, column-vector)
resize([x, y, z]) (literal sizes) on one cube/squarethe primitive with the new size (0 keeps a dimension)
resize([x, y, z]) on one sphere/circle/cylinder of literal size<scale by> of the computed factors
3D hull() of straight prisms on one base (cylinders and cubes of one height, moved by translate([x, y, 0]), rotate([0, 0, a]) or for loops)<extrude height><hull> of their footprints: exact, e.g. rounded plates from four corner cylinders

2D booleans. A 2D difference / intersection (and a union of several shapes) becomes one exact 2D boolean: <difference2d>, <intersection2d> or <union2d>. It is extruded or revolved once: <extrude><difference2d>…</difference2d></extrude>. Each child statement is one operand, and several shapes from a loop or a module call are united first. 2D booleans inside offset() and hull() are exact too. Plain sibling shapes under an extrusion are still extruded one by one and unioned as solids, which is exact and lets 2D transforms under linear_extrude become 3D transforms. Inside a 2D boolean, transforms are sketch placements (<sketch at rotate mirror>), and 2D scale() there only converts for a circle (to an ellipse). A module that draws 2D gets a $leaf prop, so the caller decides how its shapes become solids. A 2D object where a solid is expected (top level) is extruded 1 mm, with a warning.

Not converted (warnings): other 3D hulls and minkowskis (children unioned), polyhedron, import of anything but .svg/.dxf, surface, projection, other resizes (auto, expressions), linear_extrude twist / scale, offset(chamfer), 2D scale() of non-circles inside a 2D boolean or rotate_extrude, color (set it on the <part>), text fonts (metrics differ, so check the size), search, rands (works but random).

Tests and examples

tests/convert.test.ts covers the parser and codegen. It also checks geometric equivalence: converted snippets and three classic parametric models (tests/fixtures/scad/: a box with lid, a knurled knob with a D-shaft bore, a hex-key holder, a key tag hulled from four corner cylinders, a plate cam from a 2D hull), plus a corpus of realistic models (tests/fixtures/scad/corpus/: an enclosure with screw posts, a spool holder, a cable comb, a gridfinity-style bin) are evaluated by the kernel, and their volumes are compared with closed forms. The converted models are in examples/converted/ next to their .scad sources. A test keeps them in sync with the converter and checks that each evaluates in under 10 s.