Generators

The generators produce source code only. They do not execute OpenSCAD, CadQuery, brepjs, filesystem exports, subprocesses, or CAD runtimes in tests.

Supported part families are rounded_rectangular_plate, spacer_block, round_spacer, electronics_standoff, cable_comb, cable_clip, wall_mount_bracket, l_bracket, drawer_divider, and project_enclosure_tray for OpenSCAD and CadQuery in both the TypeScript and Python packages, and for brepjs in the TypeScript package only. drill_guide, simple_box, and simple_lid have schemas and examples but no generator support yet in any language.

composable_part has its own dedicated generators for both brepjs and CadQuery (TypeScript only; see "composable_part generator" and "composable_part CadQuery generator" below) but is not supported by OpenSCAD or the Python package. The two composable_part generators are two independent implementations of the same schema, not one generator retargeted -- see "composable_part CadQuery generator" for how much of the position/rotation/pattern/group resolution logic they actually share (via composable.shared.ts) versus how much is generator-specific geometry construction.

The TypeScript and Python generators are independent implementations and are expected to report the same supported result for every one of the 10 families above; tests/python/test_generator_parity.py runs both against every example under examples/part-families/ and fails if they diverge. generateBrepJs is TypeScript-only (brepjs has no Python binding), but tests/typescript/printspec.test.js asserts it agrees with generateOpenScad/generateCadQuery on supported for every one of those 10 families. composable_part is not in listPartFamilies() and isn't covered by that parity check; its brepjs and CadQuery support are each tested separately against examples/composable/.

APIs:

generateOpenScad(spec)
generateCadQuery(spec)
generateBrepJs(spec)
generate_openscad(spec)
generate_cadquery(spec)

Each returns { supported, code, message?, warnings? }. Generators validate first; invalid specs return supported: false and empty code. Generated CadQuery defines a final part variable and intentionally omits export commands. Generated CAD must be reviewed before manufacturing.

brepjs generator

brepjs is a TypeScript BRep CAD library (OpenCascade via WASM). generateBrepJs(spec) emits a .brep.ts module following the brepjs-cad authoring convention: the module imports from 'brepjs' and default-exports a zero-arg function returning a shape (export default () => part;), so it can be run and verified directly with the brep CLI from brepjs-cad without further editing. Like the OpenSCAD/CadQuery generators, @invisra/printspec does not depend on brepjs itself, does not initialize a WASM kernel, and does not execute any geometry — it only emits the source text.

printspec to-brepjs examples/part-families/round-spacer.basic.json --output model.brep.ts

composable_part generator

generateBrepJs(spec) also supports composable_part specs (see docs/composable-parts.md), through a dedicated code path (generateComposablePartBrepJs) that isn't shared with the 10 families above and isn't available via generateOpenScad/generateCadQuery. It resolves every component/feature/group's position, rotation, pattern instances (including a group's own pattern, repeating every member as one unit), and group composition to literal numbers at generation time, and emits real brepjs primitives and boolean operations for each: box/cylinder/shape().fuse()/.cut()/.intersect()/.translate()/.rotate()/.fillet() for box, rounded_box, cylinder, tube, plate, tab, and boss. add/subtract/intersect share one CSG assembly pass: subtract and intersect components are processed together, in their original declaration order relative to each other (not "every subtract, then every intersect"), each targeting every add component declared before it by default or the components named in its own appliesTo, exactly like subtract already did before intersect was added -- see examples/composable/d-shaft-with-intersect-flat.json, real-kernel-verified to match the hand-derived circular-segment volume of a round shaft trimmed to a D-profile flat. This CSG pass is fully kind-agnostic -- every component kind builds its own geometry independently via buildComponentGeometry() regardless of its own operation, so nothing kind-specific was needed to support a newer kind (torus, ellipsoid, swept_profile, and so on) as a subtract/intersect operand, only ever previously exercised as add. Real-kernel-verified for torus (see examples/composable/orifice-plate-with-oring-groove.json, an O-ring groove cut into a flat face, matching a hand-derived volume exactly) and swept_profile (a bent channel cut into a solid block, matching a hand-derived volume closely once correctly positioned -- easy to get wrong the first time, since a profile-based kind's own points/path are always relative to its own local origin, never the target's, exactly like any add component's own placement). rib and wedge are both built as real tapered profiles via wireLoop/line/face/extrude -- a right-triangular gusset (full height at the wall end, tapering to zero at the far end) for rib, real-kernel-verified to match the triangular-prism volume formula exactly. extruded_profile reuses the same wireLoop/line/face/extrude technique for an arbitrary author-supplied polygon footprint: unlike rib/wedge, whose profile lies in a vertical (XZ-like) plane and therefore needs an explicit [0, h, 0]-style direction vector passed to extrude, extruded_profile's footprint already lies in the XY plane, so the plain-number form of extrude(face, height) -- which extrudes along Z -- is correct as-is. Real-kernel testing confirmed the polygon's winding direction (the order points are listed in) doesn't affect correctness for either convex or concave footprints, so points are emitted in author order with no normalization. Because its footprint's bounding box is well-defined, extruded_profile (unlike rib/wedge) has a real derived AABB and participates fully in relation anchoring and the connectivity check, the same as any box-like kind. sphere, torus, and ellipsoid are built with brepjs's sphere()/torus()/ellipsoid(), all of which -- unlike box()/cylinder(), whose native default already places Z=0 at the base -- center at their own origin by default, so the generator shifts each up (by its own radius, minor radius, or Z half-length respectively) to follow the same "Z=0 at base" convention as every other kind. torus's author-facing dimensions (outerDiameter, tubeDiameter -- the measurements one would actually take of a real ring) are derived into brepjs's native (majorRadius, minorRadius) form; semantic validation (componentDimensionErrors()/_component_dimension_errors() in semantic.ts/semantic.py) guarantees tubeDiameter < outerDiameter first, so the derived major radius is always positive. See examples/composable/knob-with-sphere-cap.json, examples/composable/grommet-with-torus-ring.json, and examples/composable/dome-with-ellipsoid-cap.json -- all real-kernel-verified, though ellipsoid's underlying brepjs primitive has a small inherent volume-measurement tolerance (confirmed by measuring it in isolation), unlike sphere/torus, which measure exactly. None of sphere/torus/ellipsoid are currently shell/fillet/chamfer-supported targets (see SHELL_SUPPORTED_FACES/FILLET_SUPPORTED_EDGES below -- none has a flat cap or straight edge for either technique to identify), so targeting one with any of the three produces a warning rather than an attempt. revolved_profile sweeps an arbitrary author-supplied cross-section around Z via brepjs's real revolve(face, options?) operation -- the "sketch and revolve" counterpart to extruded_profile's "sketch and extrude", built with the same wireLoop/line/face technique but with the profile's points in the (radius, z) half-plane (X=radius, Y=0) instead of the XY plane, and revolve() in place of extrude(). Unlike extruded_profile's XY footprint (centered at the component's own local origin, since it has no privileged reference point), radius is never shifted or centered -- the revolve axis (radius=0) is a fixed reference the whole profile is authored against; z is shifted so the profile's own minimum lands at 0, the same convention as every other kind. sweepAngle (degrees, author-facing) converts to the radians revolve()'s {angle} option expects, and is omitted entirely for the default full 360-degree sweep to keep generated code readable (matching applyTransform()'s skip-the-default-case convention elsewhere). Real-kernel-verified for a full sweep (matching a hand-derived conical-frustum volume exactly), a partial sweep (correctly capped with flat faces at each end, producing exactly the expected fraction of the full-sweep volume), and a profile entirely at radius > 0 (a hollow, torus-like ring, matching Pappus's theorem exactly) -- see examples/composable/pulley-with-revolved-profile.json. Like extruded_profile, revolved_profile has a real derived AABB (hx/hy from the profile's max radius, z from its Z span) and participates fully in relation anchoring and the connectivity check, but is excluded from shell/fillet/chamfer support (a revolved shape's lateral surface is generally curved with no flat cap or straight edge for either technique's face/edge-identification method to reliably find, unlike extruded_profile's flat-vertical-edges special case). hole, slot, counterbore, and countersink features are cut with cylinder/box/cone. text features are implemented too -- see below.

Both extruded_profile and revolved_profile (and, by reuse, loft_profile's and swept_profile's own profile points) support a per-point curve (an arc via brepjs's threePointArc(start, through, end), a Bezier via bezier([start, ...controlPoints, end]), or a smooth spline via bsplineApprox([start, ...through, end])) instead of always connecting consecutive points with a straight line() -- a shared buildProfileEdges() builds the edge list for every kind, parameterized only by how each maps a raw authored point to its shifted, formatted coordinates and by how a shifted pair becomes brepjs's 3D point form ([x, y, 0] for extruded_profile, [r, 0, z] for revolved_profile). Both pointsBoundingBox() and revolveProfileExtents() include every curve's through/controlPoints, not just the main vertices (via a shared allProfileCoordinatePairs()), since an arc can bulge (a Bezier control point can sit, or a spline's through points can sit) well outside the polygon the vertices alone would form -- real-kernel-verified for threePointArc (the resulting bulge area matches a hand-derived semicircle-segment area exactly), for bezier (matches the standard quadratic-Bezier area-vs-chord formula, 2/3 of the control triangle's area, exactly), and for bsplineApprox (a curve through an asymmetric set of through points produces a volume well above the straight-line baseline, confirming it genuinely curves through them rather than degenerating to a straight edge -- brepjs's own doc comment calls it an "approximation," and real-kernel testing found the curve does bulge slightly past the given through points rather than landing exactly on them, consistent with that). A curved revolved_profile needs more care than a curved extruded_profile, for any of the three curve types: threePointArc's three points must all lie on one circular arc, so a through point chosen too close to one endpoint relative to a long chord between far-apart vertices can produce a wildly different, unintended arc (looping through the far side of a large circle) -- real-kernel testing hit this directly (a REVOLVE_FAILED kernel error, not just an unintended shape) while authoring examples/composable/flared-hub-with-curved-revolve-profile.json, and the fix was picking a through point near the arc's own natural midpoint relative to its immediate neighbors instead. bsplineApprox and bezier are both real-kernel-verified valid for the same flared-hub-style revolve, but carry the same general "an aggressively bulging revolved cross-section can self-intersect" risk in principle -- always real-kernel-verify a revolved_profile that uses curve, regardless of which type.

Two brepjs operations were explored and found not to work as documented in the currently pinned version (18.118.3): twistExtrude() (helical twist extrusion) and complexExtrude() (extrusion with a linear/s-curve scaling profile) both return ok: true with a seemingly valid solid, but real-kernel testing (measuring volume and bounds at extreme parameter values -- a 720-degree twist over 100mm, a 100:1 taper ratio) found the twist/scaling options have zero effect: the result is bit-for-bit identical to a plain, straight, unscaled extrusion every time. This is a real limitation of the installed package, not a misunderstanding of its API (multiple parameter shapes and extreme values were tried); no composable-part feature is built on either function for exactly this reason. Revisit if a future brepjs version fixes this.

loft_profile blends 2 or more cross-sectional profiles via brepjs's real loft(wires: Wire[], options?) operation, confirmed (by checking shapeType/isValidSolid directly) to already return a capped, valid Solid with no separate end-face-capping step needed, unlike some other CAD kernels' raw loft primitives. Each profile section builds its own closed wireLoop with the same buildProfileEdges() used by extruded_profile/revolved_profile (so curved segments work in a loft profile too), and each section is centered on its own local XY origin independently -- deliberately not on one shared bounding box across all sections -- so concentric stacking (a smaller round profile centered inside a larger square one, for instance) is the default rather than something the author has to correct for. Every section's z is shifted uniformly (by the same amount, derived from the lowest section's own z) so the whole component's base still lands at Z=0, matching every other kind's convention. brepjs's loft() itself handles mismatched vertex counts between sections (a 4-point square lofting into a 6-point hexagon, for example) by corresponding vertices automatically, so loft_profile's sections are not required to share a point count. aabbExtents() derives loft_profile's AABB from the widest hx/hy across all sections and the full Z span, so it participates in relation anchoring and the connectivity check like extruded_profile/revolved_profile, but is excluded from the single-field hole/slot bounds check and from shell/fillet/chamfer support for the same reason as ellipsoid/revolved_profile (no single footprint number, no flat cap or straight edge to reliably target). Real-kernel-verified for a basic square-to-round transition (a classic HVAC duct adapter combining loft_profile with curved arc segments -- see examples/composable/square-to-round-duct-adapter.json) -- valid Solid, plausible volume; unlike the other kinds added this session, a square-to-round loft has no simple closed-form volume formula to check exactly against, so validity and plausibility (not an exact hand-derived match) is the appropriate verification bar here.

swept_profile sweeps an arbitrary closed cross-section along a straight-line-segment 3D path via brepjs's real sweep(profileWire, spineWire, options?) operation -- the profile builds its own closed wireLoop via the same shared buildProfileEdges() every other profile-based kind uses (so curved profile segments work here too), while the path builds a plain (open) wire() from line() edges between consecutive, already-shifted path points (path[0] becomes the local origin, so every other point is emitted relative to it). Real-kernel exploration surfaced two load-bearing findings neither obvious from brepjs's own API docs nor guessable without testing: first, sweep() does not auto-orient the profile wire to the spine's own tangent direction at all -- a profile authored in the XY plane (this generator's fixed convention for every 2D profile) swept along a spine whose first segment isn't parallel to Z produced invalid, zero-volume geometry in every configuration tried (default options, frenet: true, forceProfileSpineOthogonality: true, every transitionMode), so swept_profile's path is required (checked by componentDimensionErrors()/_component_dimension_errors() in semantic.ts/semantic.py, not just documented) to have its first segment run parallel to Z, matching the profile's own fixed orientation -- every later segment, however, was real-kernel-confirmed to bend in any direction at all, not just within a single plane. Second, unlike every other profile-based kind, the profile wire's own literal coordinates matter: real-kernel testing (comparing getBounds() of two otherwise-identical sweeps, one built from a profile centered at the origin and one built from the same shape offset to sit at (100, 100)) confirmed brepjs does not recenter or snap the profile onto the spine's start point, so swept_profile's profile is deliberately not centered on its own bounding box the way extruded_profile's is -- a profile point at (0, 0) sits exactly on the path's own centerline. transitionMode: "round" is always passed: real-kernel testing found the default (no config at all) and "right" (a sharp miter) both produced invalid geometry at a direction change, while "round" produced valid geometry with a volume close to (but, from the rounded transition, slightly less than) the naive sum of each segment's own straight-prism volume -- and, confirmed separately, "round" doesn't change a single-segment straight sweep's exact volume either, so it's always safe to pass regardless of how many bends the path actually has. aabbExtents() returns null for swept_profile, the same as rib/wedge -- a bent path spanning an essentially arbitrary direction has no clean, symmetric footprint to derive an AABB from -- so it's excluded from relation anchoring (falling back to the target's own origin, the same rib/wedge already fall back to), the connectivity check, hole/slot bounds-checking, clearance constraints (added to NO_AABB_KINDS/_NO_AABB_KINDS alongside rib/wedge), and shell/fillet/chamfer support. See examples/composable/gooseneck-cable-guide.json (a wall-mounted cable guide arm bending twice), real-kernel-verified valid with a plausible volume -- like loft_profile, a swept path has no simple closed-form volume to check exactly against.

shell (hollow-out) is implemented, despite being a face-based sibling of the same edge-tracking problem fillet/chamfer hit: a target's face, unlike an edge, can be identified robustly after CSG by a single point at zero .atDistance() from that face's known local center (the same attached_to_face anchor math a relation uses), with no dependency on fusion having preserved anything about the original topology. The generator emits faceFinder().atDistance(0, [x, y, z]).findAll(currentShape) for each requested openFaces entry and passes the combined array to .shell(faces, thickness). Real-kernel testing (scripts/brepjs-verify) found this only works reliably for a subset of (kind, face) combinations, so the generator restricts to exactly that subset and drops anything else with a warning rather than emitting code that silently produces wrong geometry: top/bottom work for box, rounded_box, plate, tab, cylinder, and boss; front/back/left/right additionally work for box/plate/tab but not rounded_box -- its flat side walls are topologically adjacent to the vertical-edge fillets, and shelling with one of them removed completes without error but silently returns the original, unshelled volume (no exception to catch and warn on, which is exactly why this had to be verified against the real kernel rather than assumed to work by analogy with plain box). side (the curved lateral face of cylinder/boss) throws a kernel-level SHELL_FAILED exception outright and is excluded. tube and extruded_profile aren't supported at all: a tube's top/bottom faces are annuli that don't cover the local origin the point-based technique relies on, and an extruded_profile's polygon may be concave with its bounding-box center landing outside the solid (commonly exactly on an L-shape's reflex corner, ambiguous between three faces at once); rib/wedge have no footprint/depth model to derive a face-center point from in the first place. openFaces is required with at least one entry, since brepjs's shell() has no "fully sealed" mode -- it throws if given an empty face array. A target with multiple pattern instances already fused into one shape (its own pattern, or membership in a patterned transforming group) is also rejected with a warning: real-kernel testing confirmed .shell() throws on a compound of disjoint solids, so there is no single call that shells every instance at once the way a cut's cutter can be replicated and unioned first. Unlike the cut-based features above, shell replaces its target's current shape in place rather than subtracting from it, so declaration order matters: a hole/slot feature declared after a shell feature on the same target cuts through the already-hollowed result (and its thin wall), while one declared before is hollowed along with the rest of the material -- see examples/composable/open-top-enclosure-shell.json.

fillet and chamfer are implemented too, as of the same edge-tracking-workaround family as shell: parameters.edges ("vertical", "top", "bottom", or "all") selects the target's edges to fillet/chamfer. "vertical" emits a self-contained .fillet((e) => e.inDirection([x, y, z]), radius)/.chamfer(...) call, where [x, y, z] is the target's own local Z axis rotated into world space by its own rotation and, if grouped, its transforming group's rotation -- a bare 'Z' string would be wrong for any rotated target (it names world Z, not the target's local Z); real-kernel testing confirmed this exact failure ("No edges found for fillet") and confirmed the rotated-vector form fixes it. "top"/"bottom" reuse shell's exact faceFinder().atDistance(0, point) face-identification technique, then brepjs's edgesOfFace(face) to get that face's boundary edges, and pass the result directly to .fillet()/.chamfer() (both accept a plain Edge[], not just a finder callback). Real-kernel testing established which (kind, edges) combinations actually work, reusing shell's reliability findings where they carry over: "vertical" works for box/plate/tab (identical boxy geometry), rounded_box (its vertical seams between flat faces and the corner fillets -- not the corners themselves, which no longer exist as sharp edges), and extruded_profile (one straight vertical edge per polygon vertex, always -- "vertical" is a pure direction filter with no dependency on the bounding-box-center-point technique that excludes extruded_profile from "top"/"bottom"); cylinder/boss/tube have no straight edges at all, and real-kernel testing confirmed .inDirection('Z') there throws rather than silently matching nothing. "top"/"bottom" work for exactly SHELL_SUPPORTED_FACES's top/bottom set (box, rounded_box, plate, tab, cylinder, boss) -- unlike shell's front/back/left/right restriction, rounded_box's top/bottom fillet/chamfer was real-kernel-verified to work cleanly, since the top/bottom caps aren't adjacent to the corner fillets the way the side walls are. tube/extruded_profile are excluded from "top"/"bottom" for the same face-identification reasons as shell; rib/wedge have no footprint/depth model to derive a face-center point from. Unlike shell, a target with multiple pattern instances fused into one shape is fully supported, not rejected: real-kernel testing confirmed .fillet()/.chamfer() (unlike .shell()) tolerate a disjoint-solid compound just fine. "vertical" needs no special handling at all (.inDirection() matches every qualifying edge across the whole compound automatically, regardless of instance count). "top"/"bottom" replicates the face lookup once per (ownPatternOffset, groupPatternOffset) combination -- targetPatternInstanceOffsets() resolves both a target's own pattern and its transforming group's pattern independently, defaulting either to [ZERO] when it doesn't apply, so the cartesian product is just one lookup for the ordinary unpatterned case -- and combines every instance's edgesOfFace() result into one array via spread, so a single .fillet()/.chamfer() call covers every actual instance. Real-kernel-verified against a 3-instance linear pattern (12 combined edges, correct volume matching 3× the single-instance result) and a 3-instance group pattern; also confirmed the bug this fixes is real, not hypothetical -- an unreplicated single-point lookup on the same 3-instance compound only ever fillets the one instance it happens to find, silently leaving the other two sharp-edged. "all" selects every edge on the target via brepjs's real edgeFinder().findAll(currentVar), needing no face lookup or per-instance replication at all -- findAll() with no filter already walks the entire shape (including every instance of a patterned/compound target) in one call. Deliberately restricted to box/plate/tab (FILLET_SUPPORTED_EDGES's "all" set is a strict subset of its "vertical"/"top"/"bottom" set, not the union): real-kernel testing confirmed it produces an exact, hand-verifiable Minkowski-sum-style full 3D round-over for a plain box (measured volume matched a hand-derived formula -- core box shrunk by the radius on each axis, plus edge quarter-cylinders, plus corner octant-spheres -- exactly, both in isolation and through the full generator pipeline via examples/composable/fully-rounded-electronics-case.json), and a 3-instance patterned box (volume matching 3x the single-instance formula exactly, confirming no special per-instance handling was needed). cylinder/boss are excluded: real-kernel testing found edgeFinder().findAll() on a plain cylinder returns 3 edges, not the 2 circular rims a human would expect (an extra parametric "seam" edge along the curved lateral surface), and there's no real-kernel confirmation of what filleting that seam edge actually looks like -- a .fillet() call there returned a valid solid, but with no hand-derivable formula to check it against, unlike the box case. rounded_box is excluded too, for an unrelated reason: it already bakes its own vertical-edge fillet into its own construction (see the "rounded_box" case in buildComponentGeometry()), so stacking "all" on top of that risks the same real, documented fillet-after-fillet fragility the chamfer/fillet stacking-order gotcha below already demonstrates. One real-kernel-discovered gotcha worth calling out: stacking a bounded fillet and a bounded chamfer on the same target is order-sensitive -- filleting a target's top edges and then chamfering its (now-shortened, truncated-by-the-fillet) vertical edges failed outright at the kernel level (chamferWithHistory: operation failed), while the reverse order (chamfer first, so the fillet is the last operation and sees full-length, unmodified vertical edges) worked cleanly and matched the hand-expected volume exactly. This isn't something the generator can detect or fix automatically; author defensively (chamfer before fillet when combining both on one target) and real-kernel-verify any spec that stacks them -- see examples/composable/rounded-top-chamfered-lid.json.

text (emboss/engrave) is architecturally unlike shell/fillet/chamfer: it's positioned via the ordinary relation/position pipeline shared with hole/slot (no bespoke face/edge lookup needed -- it works against any target kind, including rib/wedge), but its content depends on a font brepjs does not bundle. loadFont(fontUrl, familyKey) fetches an actual font file at runtime; real-kernel testing confirmed Node's built-in fetch() cannot resolve a local file:// path (it errors outright), so parameters.fontUrl must be a real http(s):// URL or a data: URI with the font bytes inlined -- there is no offline-safe default, which is why fontUrl is schema-required. Every unique fontUrl across a spec's text features is loaded exactly once via a top-level unwrap(await loadFont(url, "font_N")) statement emitted before any other geometry-building code, regardless of where in features the text feature(s) using it are declared -- top-level await is valid at an ES module's top level and real-kernel/runtime-verified to correctly delay a plain dynamic import() of the generated module until it resolves, so export default () => shape stays a synchronous factory with no calling-convention change for consumers. (An earlier version of this code wrote await unwrap(loadFont(...)) -- awaiting the result of unwrap(), not the promise loadFont() returns, so unwrap() received a pending Promise instead of a Result and always failed with a confusing "Called unwrap() on an Err: undefined". Real-kernel testing against brep verify caught this immediately; the fix is strictly unwrap(await loadFont(...)).) sketchText(content, {...}).extrude(depth) returns a raw shape directly (unlike the free extrude() function used by rib/wedge/extruded_profile, which returns a Result needing unwrap()) -- real-kernel-verified. "emboss" (the default) fuses that extrusion onto the target instead of cutting, unlike every other feature kind except a thread feature in its own "external" mode (see below) -- both are recognized by the same featureAddsMaterial() helper in the main features loop. Both modes extrude TEXT_OVERLAP_MARGIN (0.2mm) taller than requested and shift the result so it genuinely overlaps the target's surface, not just touches it -- the same generous-overlap idea already used by counterbore/countersink/hole/slot's oversized cutters, extended to fuse (not just cut): real-kernel testing found a flush-touching (zero-overlap) emboss fuse left multi-glyph text as invalid geometry outright (allBodiesValid: false, "Solid failed BRepCheck validation"), not merely an unmerged-but-valid compound; the embedded-root fix turns this into valid geometry, though multi-glyph text may still report as several touching (not topologically welded into one) solids -- expected and fine for printing, not a bug. fontUrl is checked at generation time for well-formed-ness and scheme (http:/https:/data: only, explicitly rejecting file:) rather than relying on the schema's "format": "uri", since that keyword is a silent no-op in the Python validator without the optional rfc3987 package. engrave mode's depth is additionally checked against the target's own depth dimension.

thread builds a real helical screw-thread ridge via brepjs's thread({radius, pitch, height, depth?, toothHalfWidth?, crest?, sectionsPerTurn?, lefthand?, inward?}) operation -- like text, it's positioned via the ordinary relation/position pipeline, with the ridge's own base at Z=0 (the feature's resolved position) growing in +Z by height, the same own-origin convention as every other kind. Unlike every other feature, thread takes no size parameter of its own for the ridge's radius: resolveThreadRadius() derives it directly from the target's own dimensions -- a cylinder/boss's diameter, a tube's outerDiameter (external mode) or innerDiameter (internal mode), or a stacked hole feature's own diameter (internal mode only, the same stacking convention counterbore/countersink already use) -- since semantic validation (threadFeatureErrors() in semantic.ts/semantic.py) already guarantees the target/mode combination is valid before generation runs. "external" mode (the default) fuses the ridge onto the target, exactly like text's "emboss" mode (featureAddsMaterial() recognizes both); "internal" mode cuts it, passing brepjs's own inward: true option so the tooth points toward the axis, correct for cutting a groove into a bore wall rather than adding one to an outer surface. Real-kernel testing found this needs none of text's TEXT_OVERLAP_MARGIN overlap-margin trick: a rod or bore built at exactly the same radius as the thread's own root radius fuses/cuts cleanly with no gap, so the ridge is emitted flush, unmodified. Real-kernel testing also found the ridge extends a small amount (a few percent of its own volume) past both ends of its nominal [0, height] range -- an inherent characteristic of thread()'s own lofted tooth-section construction, not something this generator trims or works around, and harmless in practice (still real-kernel-verified valid either way). See examples/composable/threaded-post-and-nut.json, real-kernel-verified valid (a helical thread ridge has no simple closed-form volume to check exactly against, the same "validity plus plausibility" bar ellipsoid/loft_profile already established).

constraints never appear in generated code, but are handled differently by kind. A dimension constraint is a validation-time-only assertion checked by validateSemantic/validate_semantic; generateComposablePartBrepJs doesn't read or emit anything for it. A clearance constraint ({a, b, minDistance}) is the opposite split: semantic validation only checks that a/b reference real, geometrically well-defined components (not whether the gap actually holds), while generateComposablePartBrepJs is the only place that can evaluate it, since it needs each component's fully resolved world position -- reusing the exact same per-instance-AABB infrastructure (instanceAabbsOf()) already built for the connectivity check above, via a new aabbDistance() helper (the standard per-axis-gap Euclidean distance between two boxes, 0 if they touch or overlap on every axis) and checkClearanceConstraints(). A violated clearance constraint is reported as a warning, not a hard failure (supported stays true), the same severity level as the connectivity check, so the rest of the part is still inspectable. See "Constraints" in docs/composable-parts.md.

A relation or feature target that resolves to a component belonging to a transforming group (a group with its own position/rotation/relation) anchors to that component's final, fully-transformed world position — not its pre-group authoring-space position — so a feature cut lands on the actual geometry even after the group moves it. See "Groups" in docs/composable-parts.md.

relation.targetInstance (anchoring to one specific instance of a patterned component/feature target, instead of its pattern center) is resolved the same way every other local-frame offset is: the instance's own patternOffsets() offset is added to whatever local anchor the relation type already produces (localAnchor()'s on_top_of/attached_to_face/etc. offset, or nothing for offset_from), and the combined vector is rotated into world space by worldRotatePoint() in one call -- rotation is linear, so rotating the sum gives the same result as rotating each term separately and adding, which is what makes reusing the exact same anchor-resolution path (rather than a parallel one) correct. mirrored_from gets the same treatment: it mirrors the target's instance-specific world position (base position plus the rotated instance offset), not just its pattern-center position. Semantic validation guarantees targetInstance is only set on a component/feature target (not a group) and is in range, so the generator doesn't re-check either.

relation.inheritRotation: true (component and feature relations only) composes the target's fully-resolved rotation onto the dependent's own placement -- including transitively, if the target itself inherited rotation from its own relation target -- so it stays flush with (or, for a feature, drills perpendicular to) a rotated target instead of remaining axis-aligned at the correct anchor point but the wrong orientation. See "Relations" in docs/composable-parts.md.

The generator also warns (without failing) if the add components don't appear to form a single connected part, using an approximate bounding-box touch/overlap check (rib/wedge/swept_profile are excluded, since they have no clean footprint/depth split to bound) — no real geometry kernel involved, just the same position-resolution math already used for everything else. This is deliberately approximate (a real per-pair boolean interference check would need the actual kernel, which this source-only generator never runs) but catches the most likely authoring mistake for a spec composed without ever rendering it: a component positioned with a gap from the rest of the part.

generateBrepJs(spec, { isolate: id }) emits a module identical to the plain (no-options) call in every way except the final line: instead of fusing every add component into one final part, export default points at just the one named component or feature's own already-resolved shape (its own placement, patterns, and any features already applied to it — everything up to but not including the final cross-component fuse). id may be a component id, a feature id whose kind builds a standalone cut/emboss shape of its own (hole/slot/counterbore/countersink/text/thread), or a group id (fuses that group's own live members' shapes -- the same subset a feature targeting the group would cut, not the group's own transform wrapper, since a group has no shape beyond its members'); shell/fillet/chamfer have no standalone shape (they modify their target's shape in place) and return {supported: false} with a clear message if isolated, as does an unknown id or a non-composable_part spec. This exists for exactly the debugging workflow "Real-kernel inspection" below describes: checking one specific piece's own volume/bounds/validity (was this thread ridge actually built where I think? does this hole's own bounds look right?) without hand-deriving combined-shape math or authoring a separate spec that isolates it by hand -- both real friction this project hit repeatedly while developing and verifying new component/feature kinds. inspect.mjs (below) exposes it via --isolate <id>.

Real-kernel inspection for an authoring agent (opt-in)

For a precise, ground-truth check that goes beyond the approximate warning above — an actual solid-count-based manifold/connectivity check, volume/bounds/center-of-mass measurements, manufacturability checks (minimum radius, bore detection), and multi-angle rendered PNGs with dimensions burned in — printspec's generated brepjs source is directly compatible with brepjs-cad's verify substrate, the same tool it exposes to MCP-capable agents via its bundled brep-mcp server. No format bridging needed: a .brep.ts file is exactly what generateBrepJs() already emits.

cd scripts/brepjs-verify && npm install   # once; installs brepjs, occt-wasm, and brepjs-cad
cd ../..
node scripts/brepjs-verify/inspect.mjs examples/composable/wedge-ramp-with-countersunk-mount.json
node scripts/brepjs-verify/inspect.mjs <spec.json> --snapshot ./shots   # also renders multi-angle PNGs
node scripts/brepjs-verify/inspect.mjs <spec.json> --isolate <componentOrFeatureId>   # inspect one piece alone

inspect.mjs generates brepjs source for the given spec (the whole part, or -- with --isolate -- just one named component/feature's own shape, via generateBrepJs's {isolate} option above), writes it to a temp .brep.ts, and runs brep verify against it (from the local brepjs-cad install above), printing the JSON report. Like verify.mjs, this needs Node ≥24 and is not part of npm test/CI or a dependency of the package itself.

For live use during authoring (not just one-off inspection), brepjs-cad's brep-mcp stdio MCP server exposes a run_program({code, timeoutMs?}) tool that takes .brep.ts source directly and returns the same verification report as JSON, sandboxed with a timeout and memory cap — a closed build → verify loop in a single tool call. To register it for Claude Code (adjust the node path to a Node ≥24 binary; the rest of this repo's default tooling runs on Node 20, so brep-mcp needs an explicit path or a separate nvm use 24 shell, not whatever node is first on PATH):

claude mcp add -s local brep -- /path/to/node-24/bin/node scripts/brepjs-verify/node_modules/brepjs-cad/dist/mcp/server.js

-s local keeps this personal to your machine (not committed) since the Node ≥24 binary path isn't portable across setups; there's no project-scoped (-s project, committed .mcp.json) registration for the same reason. --snapshot/rendered images need brepjs-cad's optional Puppeteer/Chrome dependency; if npm install above skipped its install script (via allow-scripts or similar), the JSON report still works without it and brep verify --snapshot degrades with a clear message. Note: the brep-mcp server wrapper has been found, in at least one environment, to fail every run_program call outright (The URL must be of scheme file, thrown even for the simplest possible program) despite reporting a healthy connection -- a bug in brepjs-cad's own MCP transport layer, not in printspec's generated code or inspect.mjs (the underlying brep verify CLI, which inspect.mjs calls directly, is unaffected and was used throughout this project's own development). If run_program misbehaves, fall back to inspect.mjs above, which exercises the identical brep verify substrate through a path confirmed to work.

Every fixture under examples/composable/ has a corresponding snapshot under tests/fixtures/generated/brepjs/composable/, and a test actually imports and runs each generated module against a committed, dependency-free stub brepjs implementation (tests/fixtures/brepjs-stub/brepjs.mjs) -- catching a syntactically broken or API-misusing generated module that a snapshot comparison alone would miss. The stub only checks call shapes, not real geometry.

Real-kernel verification (opt-in)

scripts/brepjs-verify/verify.mjs runs every brepjs-supported example (all of examples/composable/, plus every brepjs-supported family under examples/part-families/) against a real brepjs + occt-wasm OpenCascade WASM kernel, and checks each produces a valid, non-degenerate solid with a positive volume. This is stronger than the stub above -- it caught a real bug (wedge's profile extruding along the wrong axis, producing a silently zero-volume solid) that the stub, snapshot tests, and full test suite all missed.

It's deliberately not part of npm test or CI: it needs Node ≥24 (brepjs's real kernel requires it, while the rest of this repo targets Node 20) and downloads a real WASM kernel, neither of which @invisra/printspec depends on. Run it by hand after touching brepjs.core.ts or brepjs.composable.ts:

nvm install 24 && nvm use 24   # or any other way to get Node >=24
npm run build
npm run verify:brepjs-real-kernel

The first run installs brepjs/occt-wasm into scripts/brepjs-verify/node_modules (gitignored; package-lock.json there is committed for reproducibility). The script prints each example's measured volume, which is also useful for eyeballing an unexpected size change after a generator edit, not just pass/fail.

composable_part CadQuery generator

generateCadQuery(spec, options?) also supports composable_part specs, through a dedicated code path (generateComposablePartCadQuery, packages/typescript/src/generators/cadquery.composable.ts) that mirrors generateComposablePartBrepJs's own design closely -- same components/features/relations/patterns/groups/constraints, same options.isolate support -- but emits plain CadQuery Python source instead of brepjs TypeScript source. The two generators share one module, composable.shared.ts, for every piece of position/rotation/pattern/group resolution logic (Vec3 math, aabbExtents()/localAnchor(), buildResolver()'s resolveOwn/worldPointForGroupInstance/worldRotatePoint, patternOffsets(), and the connectivity/clearance checks) -- this logic computes plain numbers and warning strings, not brepjs- or CadQuery-specific code, and is by far the most intricate and bug-prone part of either generator, so both TypeScript-emitting-brepjs and TypeScript-emitting-Python import the exact same implementation rather than maintaining two copies that could silently drift apart. Only the actual shape-construction code (building geometry per component kind, curve segments, feature cuts, fillet/chamfer/shell face-and-edge identification, and the final per-line code assembly) is generator-specific.

Every CadQuery call used was checked against a real, installed cadquery package (2.8.0) before being trusted, matching this project's established discipline for brepjs -- several real, non-obvious findings came out of that:

  • cq.Solid.makeBox()/makeCylinder()/makeCone() already place Z=0 at the base, exactly like brepjs's own primitives -- no shift needed, same centeredBox()-style XY-centering translate as brepjs's box/plate/tab/rounded_box.
  • cq.Solid.makeSphere(radius) alone defaults to a hemisphere (angleDegrees1=0, angleDegrees2=90), not a full sphere -- real-kernel-verified (measured volume was exactly half the full-sphere formula before passing angleDegrees1=-90, and matched it exactly after). Every sphere component explicitly passes angleDegrees1=-90, angleDegrees2=90; getting this wrong would have silently halved every sphere's volume with no error.
  • cq.Solid.makeTorus(majorRadius, minorRadius) centers at its own origin by default, same as brepjs's torus() -- same up-shift-by-minor-radius needed.
  • There is no makeEllipsoid. ellipsoid is built as a unit full sphere non-uniformly scaled via .transformGeometry(cq.Matrix([[rx,0,0,0],[0,ry,0,0],[0,0,rz,0]])) (a diagonal scaling matrix) -- real-kernel-verified to produce a valid ellipsoid solid, with the same small inherent volume-measurement tolerance brepjs's own ellipsoid() primitive already has (confirmed by measuring both in isolation against the pure mathematical formula).
  • cq.Solid.extrudeLinear(wire, [], vecNormal)/.revolve(wire, [], angleDegrees, axisStart, axisEnd)/.makeLoft([wires])/.sweep(wire, [], path, transitionMode=...) all take a Wire directly -- unlike brepjs, no separate face() step is needed at all, and extrudeLinear's explicit direction vector argument means rib/wedge's vertical XZ-plane profile needs no special-casing the way brepjs's extrude() (which only extrudes along Z given a plain number) does.
  • cq.Solid.sweep(..., transitionMode=...) real-kernel-verified to match brepjs's own findings almost exactly: the default ("transformed") produces invalid, degenerate geometry at a bend (confirmed both by isValid() and by an unexpectedly low volume), and "round" is valid with the same volume brepjs's own "round" sweep produces for an identical bent path (both wrap the same underlying OCCT, so this cross-generator exact agreement is expected, not a coincidence, once both are correct). Surprisingly, CadQuery's own "right" (sharp miter) was found valid here (unlike brepjs's, where it was invalid) -- but "round" is used unconditionally regardless, for output consistency between the two generators (the same spec produces the same shape either way), not just because it's the only safe option in this one.
  • Boolean ops (.fuse()/.cut()/.intersect()), .translate(), and .rotate() are plain instance methods directly on the accumulated Shape/Solid/Compound -- no shape()-style wrapper needed the way brepjs's fluent API requires (real-kernel-verified that these methods remain available after a boolean op even though the result downcasts to a generic Compound, not a Solid). .rotate(axisStartPoint, axisEndPoint, angleDegrees) real-kernel-verified to rotate in the same direction (about the given axis, right-hand rule) as this project's own rotateExtrinsic() math already assumes, so no sign flip was needed versus brepjs's .rotate(deg, {axis}).
  • .fillet(radius, edgeList) and .chamfer(distance, None, edgeList) real-kernel-verified to match brepjs's own hand-derived volumes exactly (a Minkowski-sum-style full box round-over, a vertical-only fillet, a chamfer) for identical inputs -- argument order is reversed from brepjs's .fillet(edges, radius).
  • Shelling is shape.hollow(faceList, thickness), not a same-named .shell() (that's an unrelated shell-selector, not the hollow-out operation -- calling it produced a confusing TopoDS::Face type-mismatch error, not a clear "wrong method" error, until traced back through CadQuery's own Workplane.shell() source to find the real Solid.hollow() underneath). Its sign convention is also the opposite of brepjs's: a positive thickness shells outward, so printspec's own "thickness measured inward" schema convention needs the negated value passed to hollow(), real-kernel-verified to produce the exact expected wall thickness once negated.
  • shape.edges() (no selector) returns every edge, matching brepjs's edgeFinder().findAll() for "all"; shape.edges(cq.ParallelDirSelector(cq.Vector(x, y, z))) matches a direction-vector edge filter for "vertical" (including a rotated target -- real-kernel-verified against the same rotated-box hand formula brepjs's own "vertical" fillet used, an exact match); face.Edges() matches edgesOfFace() for "top"/"bottom".
  • shape.faces(selector_string) (for example ">Z") returns a single Face directly, not a list -- iterating it with list(...) (the natural first instinct, and what edges() needs) silently iterates its wires instead and produces nonsense (Area() of 0), a real trap found by checking types at each step rather than assuming symmetry with edges()'s own list-returning behavior.
  • shape.faces(cq.NearestToPointSelector(point)) matches brepjs's faceFinder().atDistance(0, point).findUnique() face-identification technique exactly, including for a rotated target (real-kernel-verified: found the correct, full-area top face of a 30-degree-rotated box using the same world-rotated anchor point localAnchor()/worldPointForGroupInstance() already compute).
  • rounded_box's own baked-in vertical-edge fillet needs the un-filleted box's own edges after it's already built, but buildComponentGeometry() must return one reusable expression (inline, patterned, or further transformed) -- Python's walrus operator ((_rb := <box expression>).fillet(radius, list(_rb.edges(...)))) keeps this to one expression while referencing the box twice, real-kernel-verified safe to reuse the same _rb name even when two rounded_box instances (from a pattern, for example) appear together in one combined, fused expression.

thread and text features are not implemented by this generator, dropped with a clear warning (the same pattern as an unsupported (kind, edges)/(kind, face) combination elsewhere) rather than silently ignored or guessed at: CadQuery has no built-in helical thread primitive analogous to brepjs's thread() (replicating it would need a hand-built tooth-profile helix sweep -- deliberately deferred, not attempted); CadQuery's Workplane.text() needs a system font name or a local font file path, not brepjs's fetchable fontUrl -- a genuinely different architecture, not a direct port.

Cross-generator agreement was checked directly: every example under examples/composable/ was run through both generators' real kernels, and every one whose features are fully supported by both produces the exact same volume (down to floating-point noise) -- for example orifice-plate-with-oring-groove.json (a torus subtract), square-to-round-duct-adapter.json (loft_profile combined with curve arcs), and gooseneck-cable-guide.json (a bent swept_profile) all matched exactly. The two examples using thread/text (threaded-post-and-nut.json, id-tag-with-embossed-and-engraved-text.json) still generate and produce a valid solid via CadQuery, just a smaller one (missing the thread ridge or text extrusion), each with the two expected warnings.

Verify by hand (needs a real cadquery install -- this repo's own .venv already has it; pip install cadquery elsewhere):

npm run build
npm run verify:cadquery-real-kernel

tests/typescript/cadquery-composable.test.js covers the same generator's code shape (schema validation, string/regex assertions) as part of npm test, matching the existing part-family CadQuery tests' own style -- it does not execute the generated Python, since cadquery is not a dependency of @invisra/printspec and can't be assumed present in CI.

v0.2.0 release-candidate usage

Install and test from a checkout:

npm install
npm run sync:schemas
npm run build
npm test
python -m pip install --upgrade pip
python -m pip install -e "packages/python[test]"
pytest
python -m pip install build
python -m build packages/python

Both packages provide a printspec CLI. The Python CLI is available after the editable install; the TypeScript CLI can be run from packages/typescript/dist/cli.js after npm run build:

printspec validate examples/part-families/rounded-rectangular-plate.basic.json
printspec to-openscad examples/part-families/round-spacer.basic.json --output model.scad
printspec to-cadquery examples/part-families/electronics-standoff.m3.json --output model.py
printspec bom examples/projects/simple-enclosure-project.json --format markdown
node packages/typescript/dist/cli.js validate examples/part-families/rounded-rectangular-plate.basic.json
node packages/typescript/dist/cli.js to-brepjs examples/part-families/round-spacer.basic.json --output model.brep.ts

Generators currently emit source code only and do not require or execute OpenSCAD, CadQuery, brepjs, FreeCAD, or any CAD runtime. See the supported family list above; OpenSCAD/CadQuery support is the same for both the TypeScript and Python packages, and brepjs support (TypeScript only) matches it exactly. Validation runs before generation. Valid but unsupported optional generator features, such as spacer chamfers or fillets, produce stable warnings rather than being silently ignored. BOM helpers format local spec hardware data only; printspec does not scrape suppliers or create carts. The v0.2.0 line is experimental and intended for review before manufacturing.

Browser/editor form metadata

Printspec schemas now include x-printspec-* browser-editor metadata for parameter ordering, grouping, units, controls, steps, priorities, examples, and documentation-only warnings. See docs/form-metadata.md for the convention plus TypeScript, Python, and CLI usage. This metadata helps tools render forms; it is not a web app and does not execute CAD.

Bundle generation

Bundle export reuses the OpenSCAD and CadQuery source generators when they support a part family; both are included by default. The brepjs generator is available too but is opt-in (includeBrepJs: true in the TypeScript API, --brepjs on the CLI bundle command), since it's TypeScript-only and newer than the other two. Unsupported generators are recorded as warnings in the bundle README and manifest; no CAD runtime is executed and no STL, STEP, or 3MF files are produced.