Changelog

0.3.1

npm package version:

  • Bump packages/typescript/package.json from 0.3.0 to 0.3.1 (patch). The schema and Python version track stays at 0.2.0 (root package.json / packages/python/pyproject.toml), which is an independent track from the npm package version by design (see docs/hosted-schemas.md).

Release tooling:

  • Fix the manual release workflow to validate the independent npm and schema/Python version tracks instead of requiring all package versions to be identical.
  • Use the committed npm lockfile during release verification and document the currently published npm 0.3.1 / schema and Python 0.2.0 versions.

0.3.0

Schema version:

  • Bump the printspec schema version from 0.1.0 to 0.2.0 (root package.json and packages/python/pyproject.toml, which the two must always match per scripts/check-version-consistency.mjs). This is a meaningful-but-backward-compatible bump, not a breaking one: no schema shape changed as part of it, only the version stamp itself and everything derived from it. v0.1.0 was already tagged and released (git tag v0.1.0); per this project's own documented version policy (docs/hosted-schemas.md, docs/release-process.md), that directory stays published and immutable at /printspec/0.1.0/ -- npm run sync:schemas/npm run build:docs-site only ever write to the current version's directory, verified by re-running the full build and confirming public/printspec/0.1.0/ was untouched (same file mtimes, same $id values) while a new public/printspec/0.2.0/ (schemas, docs site, llms.txt) appeared alongside it. Updated every place that mirrors the schema version: every schema's $id and printspec.schema.json's printspecVersion const; the hardcoded schemaBaseUri/_SCHEMA_BASE_URI fallback constants in both packages; the // Generated by printspec 0.1.0 header emitted by all 5 TypeScript and 2 Python generators (and the 39 committed golden fixture files that snapshot-test against it); the bundle-format VERSION fallback in both packages' bundle exporters; every example/fixture spec's printspecVersion field (~59 files); every hardcoded version literal across the TypeScript/Python/schema test suites; and living docs/README prose (leaving the historical, already-shipped docs/v0.1.0-release-checklist.md and docs/releases/v0.1.0.md untouched, since those correctly describe what actually shipped in v0.1.0). Note: packages/typescript/package.json's npm package version is an independent track from the schema version by design (see docs/hosted-schemas.md); it was bumped separately, 0.2.4 -> 0.3.0, for this release's feature scope (see below), not because it needed to match the schema version numerically. This does surface a pre-existing gap in .github/workflows/release.yml's "verify requested version matches package versions" check, which currently asserts all three (root/typescript/python) are exactly equal -- that assumption was already stale before this bump (npm package was at 0.2.4 while schema was 0.1.0) and remains so now (npm 0.3.0 vs. schema 0.2.0); reconciling that workflow with the documented independent-versioning policy is a separate, not-yet-scoped follow-up.

npm package version:

  • Bump packages/typescript/package.json from 0.2.4 to 0.3.0 (minor, not patch) to reflect this release's scope: a full second composable_part generator (generateCadQuery/generateComposablePartCadQuery) and the new static docs site + llms.txt for AI agents, comparable in scope to the 0.2.0 "Feature release" entry below. packages/python/pyproject.toml tracks the schema version instead (see "Schema version" above), so it is not part of this bump.

Feature:

  • Add a static docs site rendered from the repository's own Markdown, published alongside the existing schema site under public/printspec/<version>/docs/ (new scripts/build-docs-site.mjs, wired into npm run build:schema-site as npm run build:docs-site). Renders all 18 docs/*.md files, docs/releases/v0.1.0.md, and README.md/CHANGELOG.md/CONTRIBUTING.md/CODE_OF_CONDUCT.md/SECURITY.md (24 pages total) to HTML via marked (a new, build-time-only root devDependency with zero transitive deps -- never bundled into @invisra/printspec's published dist/schemas/README.md), rewrites relative *.md cross-links to the flat <slug>.html layout the rendered pages actually live in, assigns stable slugified ids to every heading for deep linking, and groups everything into a categorized docs index (Start here / Reference / Project / Repository). Also publishes llms.txt -- verbatim, not HTML-rendered, since it's meant to be fetched as plain text per the llms.txt convention -- at both the site root (public/llms.txt) and the versioned docs dir (public/printspec/<version>/llms.txt), with a matching text/plain Vercel header rule. Extracted the schema site's previously-inline HTML page chrome (page()/siteHeader()/brand assets/theme toggle/shared styles) and project constants (PROJECT_NAME/SCHEMA_VERSION/etc.) out of scripts/sync-schemas.mjs into two new shared modules, scripts/lib/site-template.mjs and scripts/lib/project-info.mjs, so the schema site and docs site render as one consistent site instead of two independently-styled ones; added "Docs" and "llms.txt" links to the shared site nav and a "Documentation" card to the printspec project index page. Extended tests/schema-site.test.mjs with docs-site-specific checks: every rendered doc page carries the same brand/theme markers as the rest of the site, no unrewritten relative .md links or broken same-directory .html links survive rendering, and llms.txt is published byte-identical to its repository source at both locations.

  • Add a second composable_part generator: generateCadQuery(spec, options?) (TypeScript only), emitting plain CadQuery Python source via a new generateComposablePartCadQuery (packages/typescript/src/generators/cadquery.composable.ts), alongside the existing generateBrepJs. Supports every component kind, curve type, feature (except thread/text, deliberately deferred -- see below), pattern, relation, group, constraint, and the options.isolate introspection option that generateComposablePartBrepJs already has -- all of it, not a reduced subset. Extracted the entire position/rotation/pattern/group resolution engine (Vec3 math, aabbExtents()/localAnchor(), buildResolver(), patternOffsets(), the connectivity/clearance checks) out of brepjs.composable.ts into a new shared, generator-agnostic module, composable.shared.ts -- this logic only ever computed plain numbers and warning strings, never brepjs-specific code, so both generators now import one implementation instead of risking two silently diverging over time; brepjs.composable.ts itself was refactored to use it with zero output changes (verified: identical generated code for every existing example, zero snapshot diffs). Every CadQuery API used was checked against a real, installed cadquery (2.8.0) before being trusted, the same discipline already established for brepjs, and surfaced several real, non-obvious findings: cq.Solid.makeSphere(radius) alone defaults to a hemisphere, not a full sphere (real-kernel-verified: exactly half the expected volume until angleDegrees1=-90 is passed); there is no makeEllipsoid (built instead as a unit sphere non-uniformly scaled via .transformGeometry() with a diagonal cq.Matrix, real-kernel-verified valid with the same small tolerance brepjs's own ellipsoid() already has); shelling is Solid.hollow(faces, thickness), not a same-named .shell() (an unrelated face-selector method -- calling it produces a confusing TopoDS::Face type-mismatch, not a clear error), and its sign convention is the opposite of brepjs's (hollow() shells outward on a positive thickness, so printspec's "inward" convention needs the value negated); cq.Solid.sweep()'s default transitionMode is invalid at a bend just like brepjs's, but (unusually) CadQuery's own "right" transition was found valid where brepjs's wasn't -- "round" is still used unconditionally in both, for output consistency between the two generators, not just because it's the only safe choice here. Cross-generator agreement was checked directly: every examples/composable/ spec not using thread/text produces the exact same volume through both generators' real kernels (both ultimately build on the same OCCT). Added scripts/cadquery-verify/verify.mjs (a standalone real-kernel check against every composable example, mirroring scripts/brepjs-verify/verify.mjs's role) and tests/typescript/cadquery-composable.test.js (code-shape tests, matching the existing part-family CadQuery tests' own style, added to npm test). thread (no CadQuery helical-thread primitive exists; a hand-built tooth-profile helix sweep is deliberately deferred) and text (CadQuery's text() needs a local font file/system font name, not brepjs's fetchable fontUrl -- a genuinely different architecture, not a direct port) are dropped with a clear warning rather than attempted; both remain brepjs-only for now.

  • Confirm (real-kernel-verified, no code changes needed) that every component kind added this session works as a subtract/intersect operand, not just add -- the CSG assembly pass was already fully kind-agnostic, but this had never been explicitly exercised for torus/ellipsoid/revolved_profile/loft_profile/swept_profile. Verified for torus (an O-ring groove cut into a flat face: plate minus bore minus exactly half the torus's own volume, since the ring's center sits exactly on the plate's top surface -- matches exactly) and swept_profile (a bent channel cut into a solid block -- matches closely once correctly positioned; an authoring mistake made once while testing this: a profile-based kind's own points/path are always relative to its own local origin, not the target it's cutting, so it still needs its own explicit position/relation like any add component). Added examples/composable/orifice-plate-with-oring-groove.json and a dedicated torus/ellipsoid subtract/intersect test.

  • Add "all" as a fourth edges value for fillet/chamfer features, alongside "vertical"/"top"/"bottom": selects every edge on the target -- a full 3D round-over, like a soap bar -- via brepjs's real edgeFinder().findAll(currentVar), needing no face lookup or per-instance replication (it already walks the whole shape, including every instance of a patterned target, in one call). Deliberately restricted to box/plate/tab only, not the full "vertical"+"top"+"bottom" support set: real-kernel testing confirmed "all" produces an exact, hand-verifiable Minkowski-sum-style volume for a plain box (matching a hand-derived formula -- core box shrunk by the radius, plus edge quarter-cylinders, plus corner octant-spheres -- exactly, and matching 3x that same formula exactly for a 3-instance patterned box), but found a plain cylinder/boss has an extra parametric "seam" edge (edgeFinder().findAll() returns 3 edges, not the 2 circular rims one would expect) with no real-kernel confirmation of what filleting it actually produces, and rounded_box already bakes its own vertical-edge fillet into its own construction, risking the same real fillet-after-fillet fragility already documented for stacking fillet+chamfer on one target. Added examples/composable/fully-rounded-electronics-case.json (a small enclosure body with every edge rounded, plus a vent hole through the rounded top), real-kernel-verified to match its hand-derived volume closely (a small, expected discrepancy from the idealized formula's assumption that fillets never interact near an intersecting hole). While investigating this, also confirmed patterned components and patterned thread features compose correctly together as a real integration check (4 patterned threaded standoffs on a plate, real-kernel-verified as one valid, fully welded solid with a plausible volume) -- no bugs found, but worth recording as a deliberate cross-feature stress test given the number of independent capabilities added this session.

  • Add generateBrepJs(spec, { isolate: id }): instead of fusing every add component into one final part, the emitted module's export default points at just one named component or feature's own already-resolved shape (its own placement, patterns, and any features already applied to it), for inspecting a single piece's own volume/bounds/validity via scripts/brepjs-verify/inspect.mjs --isolate <id> or an MCP-style run_program tool -- without hand-deriving combined-shape math or authoring a separate spec that isolates it by hand, both real friction hit repeatedly while developing and verifying every composable-part kind added this session. id may be a component id, a feature id whose kind builds a standalone shape (hole/slot/counterbore/countersink/text/thread), or a group id (fuses that group's own live members' shapes -- not the group's transform wrapper, which has no shape beyond its members'); shell/fillet/chamfer have no shape of their own to isolate (they modify their target in place), and an unknown id or a non-composable_part spec are both rejected with a clear message rather than silently falling back to the whole part. Implemented by capturing each feature's own placed cut/emboss shape into a new featureShapesById map (alongside the existing per-component shapesById) and swapping only the generator's final line; every other line and warning is identical to the non-isolated call. Real-kernel-verified: isolating a thread feature from examples/composable/threaded-post-and-nut.json produces just the helical ridge alone (confirmed via its own bounds -- radially out to root radius + depth, axially overhanging [0, height] at both ends exactly as documented). While investigating this, also found brepjs-cad's bundled brep-mcp MCP server fails every run_program call outright in at least one environment (a bug in its own transport layer, not in printspec's generated code); inspect.mjs's direct brep verify CLI call is unaffected and remains the reliable fallback -- see docs/generators.md.

  • Add spline as a third profile curve segment type, alongside arc/bezier: {type: "spline", through: [{x, y}, ...]} (or {radius, z} points for revolved_profile) makes the edge to the next point a smooth B-spline through this point, the given through point(s) in order, and the next point, via brepjs's real bsplineApprox([start, ...through, end]) -- shares the same buildProfileEdges()/allProfileCoordinatePairs() infrastructure arc/bezier already use (so it works on extruded_profile, revolved_profile, and, by reuse, loft_profile's and swept_profile's own profile points, with no extra plumbing needed). Real-kernel-verified to genuinely curve through the given points rather than degenerating to a straight edge (confirmed with an asymmetric through-point set whose bulge produces a volume unmistakably above the straight-line baseline -- a symmetric set was tried first and turned out to be an ambiguous test case, since a straight-line degenerate and a real curve happen to enclose the same net area for a perfectly symmetric bulge). While investigating further extrusion-family operations, also real-kernel-tested twistExtrude() (helical twist) and complexExtrude() (linear/s-curve taper): both return ok: true with a seemingly valid solid, but even at extreme parameter values (a 720-degree twist, a 100:1 taper ratio) the resulting volume and bounds are bit-for-bit identical to a plain, straight, unscaled extrusion -- the twist/taper options have no effect in the currently pinned brepjs version, a real limitation of the installed package rather than a usage mistake, so neither is used by any composable-part feature; see docs/generators.md for the record of this in case a future brepjs version fixes it.

  • Add swept_profile as a new composable-part component kind: dimensions: {profile, path}, where profile is an extruded_profile-style array of {x, y} vertices (including curve support) and path is an array of 2 or more {x, y, z} points connected by straight lines, swept via brepjs's real sweep(profileWire, spineWire, options?) operation -- unlocking cable channels, handles, bent standoffs, and any other shape that follows a path none of the other component kinds approximate directly. Real-kernel exploration surfaced two load-bearing, non-obvious findings: sweep() does not auto-orient the profile to the spine's own tangent (a profile authored in the XY plane swept along a spine not starting parallel to Z produced invalid geometry in every configuration tried), so the path's first segment is required -- checked at validation time, not just documented -- to run parallel to Z, matching the profile's fixed orientation (every later segment may still bend in any direction); and the profile's own literal coordinates matter (brepjs does not recenter or snap it to the spine's start), so unlike every other profile-based kind, profile is deliberately not centered on its own bounding box -- a point at (0, 0) sits exactly on the path's own centerline. transitionMode: "round" is always passed: real-kernel testing found the default (no options) and "right" (sharp miter) both produce invalid geometry at a bend, while "round" is valid (with a volume close to, but slightly less than, the naive sum of each segment's own straight-prism volume) and doesn't change a single-segment straight sweep's exact volume either. Has no derived AABB (like rib/wedge, added alongside them to NO_AABB_KINDS/_NO_AABB_KINDS) -- a bent path has no clean, symmetric footprint -- so it's excluded from relation anchoring (falls back to the target's own origin), the connectivity check, hole/slot bounds-checking, clearance constraints, and shell/fillet/chamfer support. Added 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.

  • Add thread as a new composable-part feature kind: a real helical screw-thread ridge built via brepjs's thread({radius, pitch, height, depth?, toothHalfWidth?, crest?, sectionsPerTurn?, lefthand?, inward?}) operation, either fused onto a target's outer surface (mode: "external", the default -- a printed screw/bolt/threaded post) or cut from a target's inner bore (mode: "internal" -- a printed nut/threaded insert). Unlike every other feature, thread takes no size parameter of its own for the ridge's radius: it's always derived directly from the target's own dimensions (a cylinder/boss's diameter, a tube's outerDiameter/innerDiameter, or a stacked hole feature's own diameter for an internal thread -- the same stacking convention counterbore/countersink already use), so the thread always sits exactly flush with the surface it belongs to, with no way to author a mismatched radius. "external" mode is fused, not cut, the same as text's "emboss" mode (both are now recognized by one featureAddsMaterial() helper, renamed from the text-only isEmbossFeature()). Semantic validation (threadFeatureErrors()/_thread_feature_errors() in semantic.ts/semantic.py) rejects a target/mode combination with no matching surface (external targeting a hole feature, internal targeting a solid cylinder/boss with no bore of its own), rejects crest >= toothHalfWidth (leaves no sloped tooth flank, per brepjs's own thread() docs), and checks height against the target's own axial dimension (or a stacked hole's own depth). Real-kernel testing found a rod/bore built at exactly the same radius as the thread's own root radius fuses/cuts cleanly with no gap -- unlike text, no overlap-margin trick was needed here. It 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 rather than a generator bug -- harmless in practice (still real-kernel-verified valid), documented rather than worked around. Added examples/composable/threaded-post-and-nut.json (a boss with an external thread next to a tube with an internal thread cut into its own bore), real-kernel-verified valid -- like ellipsoid/loft_profile, a helical thread has no simple closed-form volume to check exactly against, so validity plus plausibility is the appropriate bar.

  • Add loft_profile as a new composable-part component kind: dimensions: {profiles}, where profiles is an array of 2 or more {points, z} cross-sections, blended via brepjs's real loft(wires: Wire[], options?) operation -- unlocking square-to-round adapters, tapered housings, and any other transitional shape none of the other component kinds approximate directly. Each section's points is the same extruded_profile-style vertex array (including the curved-segment curve support added alongside this), built into a closed wireLoop via the same shared buildProfileEdges(); confirmed brepjs's loft() already returns a capped, valid Solid directly with no separate end-capping step needed. Each section is centered on its own local XY origin independently -- deliberately not on one shared bounding box across sections -- so concentric stacking is the default; every section's z is shifted uniformly so the whole component's base still lands at the component's own Z=0, matching every other kind's convention. brepjs's loft() handles mismatched vertex counts between sections (a 4-point square lofting into a 6-point hexagon, for instance) by corresponding vertices automatically, so sections aren't required to share a point count. Has a real derived AABB (the widest hx/hy across all sections, the full Z span for height) and so participates fully in relation anchoring and the connectivity check, the same as extruded_profile/revolved_profile; excluded (like ellipsoid/revolved_profile) from hole/slot footprint/depth bounds-checking and from shell/fillet/chamfer support, since it has no single footprint number and no flat cap or straight edge for either technique to reliably find. Added examples/composable/square-to-round-duct-adapter.json (a classic HVAC-style transition adapter, its round profile itself built from 4 quarter-circle arcs via curve, combining both new capabilities), real-kernel-verified as a valid solid with a plausible volume -- unlike every other kind added this session, a square-to-round loft has no simple closed-form volume formula to check exactly against, so validity and dimensional plausibility (not an exact hand-derived match) is the appropriate bar here.

  • Add curved segments to extruded_profile and revolved_profile: any point in points may set an optional curve ({type: "arc", through} or {type: "bezier", controlPoints: [...]}) to make the edge to the next point an arc (brepjs's threePointArc(start, through, end)) or a Bezier curve (brepjs's bezier([start, ...controlPoints, end])) instead of a straight line, for either kind's own coordinate convention ({x, y} for extruded_profile, {radius, z} for revolved_profile). Both kinds share one buildProfileEdges() implementation (parameterized only by the coordinate shift and 3D-point-string convention each already had), and both pointsBoundingBox()/revolveProfileExtents() now include every curve's through/controlPoints in the bounding box (via a shared allProfileCoordinatePairs()), not just the main vertices -- an arc bulge or Bezier control point can extend well beyond the polygon the vertices alone would form, which would otherwise silently produce a too-small footprint for centering, relation anchoring, and the connectivity check. Real-kernel-verified for both curve types: threePointArc's bulge area matches a hand-derived semicircle-segment area exactly, and bezier's matches the standard quadratic-Bezier area-vs-chord formula (2/3 of the control triangle's area) exactly. Also caught and documented a real authoring hazard specific to revolved_profile: an aggressively-chosen through/control point (rather than one near the curve's own natural midpoint relative to its immediate neighbors) can produce a profile that fails outright at brepjs's revolve() step (REVOLVE_FAILED) instead of merely an unintended shape, confirmed while authoring one of the two new worked examples below and fixed by choosing a more localized through point. Added examples/composable/rounded-corner-bracket-with-arc-profile.json (a bracket footprint with one corner rounded via curve instead of a separate fillet feature) and examples/composable/flared-hub-with-curved-revolve-profile.json (a hub that flares via an arc instead of a sharp step), both real-kernel-verified to match their hand-derived volumes exactly.

  • Add revolved_profile as a new composable-part component kind: dimensions: {points, sweepAngle?}, where points is an array of at least 3 {radius, z} vertices (radius >= 0, enforced at the schema level) tracing a cross-section in the (radius, z) half-plane, revolved around the Z axis via brepjs's real revolve(face, options?) operation to form a solid of revolution -- the "sketch and revolve" counterpart to the existing extruded_profile's "sketch and extrude", unlocking pulleys, custom shafts, flanges, bushings, and knobs that none of the other component kinds approximate directly. Built with the same wireLoop/line/face technique already used for extruded_profile/rib/wedge, with points in the (radius, z) half-plane instead of the XY plane. 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 the component's own Z=0, the same convention as every other kind. sweepAngle (degrees, default 360) revolves through less than a full turn for an open, partial solid of revolution. Real-kernel-verified for three distinct cases: a full 360-degree sweep (matching a hand-derived conical-frustum volume exactly, 1832.60 mm³), a 90-degree partial sweep (correctly capped with flat faces at each end, producing exactly 1/4 of the full-sweep volume, confirming it isn't left open), and a profile entirely at radius > 0 -- a hollow, torus-like ring with no center hole in the revolve axis itself -- matching Pappus's centroid theorem exactly (1357.17 mm³ for a rhombus cross-section). Has a real derived AABB (max radius for the footprint, Z span for the height) and so participates fully in relation anchoring and the connectivity check, the same as extruded_profile; excluded (like sphere/torus/ellipsoid) from hole/slot footprint/depth bounds-checking and from shell/fillet/chamfer support, since a revolved shape's lateral surface is generally curved with no flat cap or straight edge for either technique to reliably find. Added examples/composable/pulley-with-revolved-profile.json (a V-groove pulley on a base plate, with an axle hole through it), real-kernel-verified to match its hand-derived combined volume exactly.

  • Add relation.targetInstance (a zero-based integer index) so a relation can anchor to one specific instance of a patterned component or feature target instead of only its pattern center -- previously a hard restriction with no escape hatch ("a relation may not target a patterned component or feature"), forcing an author who needed one different treatment on a single pattern instance (the docs' own motivating example: "a different counterbore on one corner hole") to either hand-duplicate that instance as a wholly separate, non-patterned component/feature, or accept the feature applying only at the pattern's center (the existing "no pattern of its own" warning's scenario, now suppressed when targetInstance is set explicitly, since that's the deliberate, addressed version of the same choice). Resolving an instance's actual anchor position needs position resolution (the instance's own patternOffsets() offset, added to whatever local anchor the relation type already produces, then rotated into world space by the existing worldRotatePoint() in one call -- rotation is linear, so this is equivalent to and reuses the exact same code path as every other anchor computation, including mirrored_from and a grouped/rotated/inherited-rotation target), so -- like a clearance constraint's geometric side below -- only the TypeScript brepjs generator can actually place it. Semantic validation (shared by both languages) handles everything checkable without position resolution: rejects targetInstance on an unpatterned target, on a patterned group target (a different anchor-resolution shape, out of scope for this mechanism), and out-of-range indices, via a new patternInstanceCount()/_pattern_instance_count() helper that computes a pattern's total instance count from its own authored numbers (countX × countY for rectangular, count for linear/radial) with no position resolution needed at all. Added examples/composable/corner-counterbore-with-target-instance.json (a counterbore on just one corner of a 2x2 bolt-hole pattern), real-kernel-verified to match its hand-derived volume exactly.

  • Add clearance as a second composable-part constraint type, alongside dimension: {type: "clearance", a, b, minDistance, id?, description?} asserts a minimum gap in millimeters between two components' (a/b, both component ids) fully resolved world positions -- the genuinely spatial relationship dimension constraints deliberately can't express (checking those only ever compares already-authored numbers, never positions). Split the same way the connectivity check already is between the two languages: semantic validation (clearanceConstraintErrors()/_clearance_constraint_errors() in semantic.ts/semantic.py, shared by both) only checks that a/b reference real, distinct components with a well-defined bounding box (not rib/wedge, which have none), since resolving an actual position needs relations/rotation/group/pattern composition, which only the TypeScript brepjs generator can compute; a spec whose clearance constraint is actually violated still validates cleanly, and the violation is reported as a generateBrepJs warning (checkClearanceConstraints(), reusing the exact per-instance-AABB infrastructure -- instanceAabbsOf() -- already built for the connectivity check, via a new aabbDistance() helper: the standard per-axis-gap Euclidean distance between two boxes) -- there is no Python-side equivalent, since the Python package has no composable_part generator at all. Approximated via bounding boxes, not real boolean geometry, the same tradeoff the connectivity check already makes. Added examples/composable/latch-arm-with-clearance-constraint.json (a fixed wall and a latch arm asserted to clear it by 0.3mm, positioned with a 0.4mm actual gap), real-kernel-verified to match the hand-derived combined volume of the two components exactly.

  • Add torus (dimensions: {outerDiameter, tubeDiameter}) and ellipsoid (dimensions: {lengthX, lengthY, lengthZ}) as two more new composable-part component kinds, built with brepjs's torus()/ellipsoid() -- both, like sphere(), center at their own origin by default rather than basing at Z=0, so the generator shifts each up (by the minor radius, or by the Z half-length, respectively) to follow the same convention as every other kind. torus's dimensions are author-facing measurements of a real ring (overall outer diameter and tube cross-section diameter) rather than the major/minor radii brepjs's own torus() expects, derived at generation time; a new componentDimensionErrors()/_component_dimension_errors() semantic check (shared with the tube fix below) rejects tubeDiameter >= outerDiameter, which would otherwise leave no positive major radius. torus participates fully in hole/slot footprint/depth bounds-checking (FOOTPRINT_DIM/DEPTH_DIM, mapped to outerDiameter/tubeDiameter); ellipsoid is deliberately excluded from that check, the same as rib/wedge, since its lengthX/lengthY can differ and it has no single "footprint" number to check against. Neither is added to SHELL_SUPPORTED_FACES/FILLET_SUPPORTED_EDGES (no flat cap or straight edge for either technique to find), so both produce the existing "not supported" warning rather than an unverified attempt. Added examples/composable/grommet-with-torus-ring.json and examples/composable/dome-with-ellipsoid-cap.json, real-kernel-verified -- torus matches its hand-derived combined volume exactly (6043.57 mm³ for a plate + a 7mm-major/3mm-minor-radius ring); ellipsoid's underlying brepjs primitive turned out to have a small inherent volume-measurement tolerance (confirmed by measuring it in isolation: 3140.22 mm³ measured vs. 3141.59 mm³ from the pure mathematical formula, a real kernel-primitive characteristic rather than anything wrong in this project's own position/generation math, which combines exactly with the base plate's volume either way).

  • Add sphere as a new composable-part component kind (dimensions: {diameter}), built with brepjs's sphere(). Unlike box()/cylinder(), whose native brepjs default already places Z=0 at the base, sphere() centers at its own origin by default -- the generator shifts it up by its own radius first so it follows the same "Z=0 at base, extending in +Z" convention as every other kind, and so composes correctly with on_top_of/attached_to_face/every other relation type with no special-casing needed elsewhere. Participates fully in relation anchoring, hole/slot footprint/depth bounds-checking (FOOTPRINT_DIM/DEPTH_DIM in semantic.ts/semantic.py, both mapped to diameter, the sphere's only dimension), and the connectivity check, the same as any box-like kind, since it has a well-defined AABB (unlike rib/wedge). Deliberately not added to SHELL_SUPPORTED_FACES/FILLET_SUPPORTED_EDGES: a sphere has no flat cap or straight edge for either technique's face/edge-identification method to find, so targeting one with shell/fillet/chamfer produces the existing "not supported" warning rather than an unverified attempt. Added examples/composable/knob-with-sphere-cap.json (a cylindrical stem topped with a sphere, the classic rounded-knob shape), real-kernel-verified to match the hand-derived combined cylinder+sphere volume exactly.

  • Add intersect as a third composable-part component operation, alongside add/subtract: an intersect component trims every targeted add component down to just its overlap with the intersect component's own shape (via brepjs's .intersect()), the natural way to clip a primitive to a flat or a custom footprint that none of the other component kinds model directly -- for example a round shaft trimmed to a D-profile flat with a plain box, instead of authoring a bespoke extruded_profile polygon by hand. Shares subtract's exact appliesTo/declaration-order/warning semantics rather than introducing a parallel set of rules: both are processed together in one pass over components, in their original declaration order relative to each other (not "every subtract, then every intersect"), so an intersect declared before a subtract targeting the same component trims it first and the subtract's cut sees the already-trimmed result, matching how appliesTo's own default ("every add declared before this component's own index") already depends on declaration order; a component whose targets don't resolve to any live add shape produces the same warning subtract already had, worded for whichever operation it is. No semantic-validation changes were needed at all -- appliesTo/target-existence/self-reference checks were already fully operation-agnostic. Added examples/composable/d-shaft-with-intersect-flat.json, real-kernel-verified to match the hand-derived circular-segment volume of the trimmed shaft exactly (1347.15 mm³, vs. a naive full-cylinder volume of 1570.8 mm³).

  • Warn when a stacked feature (a counterbore/countersink on a hole, for example) targets a patterned component or feature but has no pattern of its own. A component's or feature's own pattern doesn't propagate through a stacked feature (already documented in "Patterns"), so the stacked feature silently anchors to the pattern's single center point and applies only once -- for a counterbore stacked on a 3-instance patterned hole, that means 2 of the 3 holes silently end up plain, uncounterbored through-holes, with no other signal anything unusual happened. This is valid and occasionally intentional (a one-off feature on just the "representative" instance), but easy to author by accident, and was caught doing exactly that while dry-running an authoring loop. Targeting a member of a patterned group is deliberately excluded from this warning, since that case already auto-propagates correctly with no pattern of its own needed (also already documented, and unaffected by this change).

Fix:

  • Fix three TypeScript/Python semantic-validation parity gaps found by an explicit audit (semantic.ts vs semantic.py), all in already-shared checks rather than anything composable-part-specific: (1) textFontUrlErrors/_text_font_url_errors -- Python's urlparse("http://") happily returns scheme="http" with an empty host, while JS's new URL("http://") throws outright, so an empty-host fontUrl like "http://" previously passed Python validation but failed TypeScript's; fixed by explicitly rejecting an empty host for http/https schemes in Python (data: URIs, which legitimately have no host, are unaffected). (2) The top-level hardware quantity check used isinstance(quantity, int) in Python vs Number.isInteger(quantity) in TypeScript -- since JSON Schema's "type": "integer" (and JS's own numbers, which have no separate int/float) both accept a whole-valued float like 5.0, but json.loads parses that into a Python float, isinstance(5.0, int) is False and Python wrongly rejected an otherwise-valid spec; fixed with a new _is_integer_number() helper mirroring Number.isInteger()'s actual semantics (accepts a whole-valued float, rejects bool the same way JS rejects a boolean). (3) _bounded_dimension_error's error message reformatted its bound operand to drop a JS-style trailing .0 but not the value operand on the same line, so a feature parameter authored as 4.0 printed "(4.0 >= 3)" in Python vs "(4 >= 3)" in TypeScript; fixed by extracting a shared _js_number_str() helper and applying it to both operands (and to the analogous _feature_fit_errors messages, the same bug's sibling case in the same file region). No corresponding TypeScript changes were needed for any of these -- JS's own number/URL semantics already matched the intended behavior; only Python's approximation of them was wrong.
  • Fix composable-part semantic validation (semantic.ts/semantic.py) to reject an inverted tube (innerDiameter >= outerDiameter): previously unvalidated, despite TubeDimensions' own schema description already documenting the constraint, so it silently produced a real-kernel-confirmed zero-volume, degenerate solid ("shape has no geometry") with no warning at all. Added a new componentDimensionErrors()/_component_dimension_errors() helper for exactly this class of check (a dimension's documented relationship to a sibling dimension, which a bare "type": "number" schema property can't express on its own) -- also used by the new torus kind's analogous tubeDiameter >= outerDiameter check below, so both share one code path in each language rather than duplicating the pattern.
  • Fix the composable-part brepjs generator's approximate connectivity check to actually consider a component's own pattern (as opposed to its transforming group's pattern, already handled): previously it only ever computed one bounding box per component, using its pattern's center position with no pattern offset applied at all, so a component whose own instances are spread far apart from everything else (for example a linear pattern with a large spacing) was silently missed -- the center point happened to sit correctly on top of its target, so the check saw exactly one (trivially "touching") box and never noticed the actual instances were nowhere near it. A first attempt fixed this by widening that one box into an envelope spanning every instance, but real-kernel testing (well, real-math testing) of an adversarial case caught a deeper problem with the envelope approach itself: patternOffsets()'s per-instance formula is always symmetric about its anchor, so a combined envelope spanning two widely-spaced instances always reaches back through the anchor point -- which, for an on_top_of-style relation, sits exactly on the target -- wrongly making the whole envelope "touch" it regardless of how far apart the real instances are. Fixed properly by comparing every actual instance of one component against every actual instance of another (the cartesian product of each component's own pattern offsets and its transforming group's pattern offsets), rather than either a single center point or one combined envelope; two components are considered connected if any instance of one touches any instance of the other. Confirmed against the existing group-pattern connectivity test (unchanged behavior there, since a group's own pattern was already handled) and a new regression test for a component's own pattern specifically.
  • Fix validatePrintSpec/validate_printspec (and validatePartFamilySpec/validate_part_family_spec) to narrow schema validation down to the one part type an input actually names, instead of running it through the full oneOf over all 13 part-family types plus composable_part. part's schema (and part-family.schema.json's own schema) is a oneOf discriminated entirely by type, but neither validator library treats it that way by default: for an otherwise-recognizable part with one real mistake somewhere in it, ajv (TypeScript) reports a separate failure for every one of the 14 branches that don't match -- dozens of irrelevant "doesn't match spacer_block"/"doesn't match cable_clip"/etc. errors burying the one relevant one -- while jsonschema (Python) collapses the whole oneOf into a single, even less useful "is not valid under any of the given schemas" message with no detail at all. Caught while dry-running the authoring loop an agent would actually use end to end: authoring a new composable_part spec with one deliberate mistake (a position missing y/z) and calling validatePrintSpec/validate_printspec -- the standard, README-documented, CLI-default entry point for validating any spec -- produced a 50-plus-line wall of noise in TypeScript and a single content-free message in Python, in both cases burying or discarding the two lines that actually mattered. Fixed by building (once, cached per type value) a variant of the top-level schema with part's oneOf replaced by a direct $ref to just the one schema file matching the input's own part.type (every part-family schema and composable-part.schema.json already declare a literal properties.type.const, so this needed no schema changes, just a lookup table built from schemas already loaded) -- every other check (printspecVersion/units consts, the top-level part/project requirement, and so on) still applies unchanged, since only part's own schema is swapped. Falls back to the original, full-oneOf validator (and its noisier output) only when type doesn't match any known schema at all, since there's no single branch to narrow to in that case. validateComposablePartSpec/validate_composable_part_spec were already unaffected (they validate directly against composable-part.schema.json, with no oneOf-over-types to begin with) and now agree exactly with the narrowed validatePrintSpec/validate_printspec output, modulo the /part path prefix.

Feature:

  • Give composable-part constraints a real schema shape, semantic-validation implementation (in TS/Python parity), and worked example -- previously a complete no-op placeholder ({"type": "array", "items": {"type": "object"}}, no validation, no generator support, documented as "reserved for a future release"). Scoped deliberately as validation-only, not a solver, matching the project's existing "emit concrete numbers... favors validation that catches authoring mistakes early... over inventing a fully formal CAD language" philosophy: a dimension constraint ({type: "dimension", left, operator, right, margin?, id?, description?}) asserts a numeric relationship between two already-authored values -- each of left/right is either a literal number or a DimensionRef ({ref, key}, resolving key in a referenced component's dimensions or feature's parameters) -- and is checked once every value is already concretely specified elsewhere in the spec, generalizing the same kind of check the schema's built-in bounds already do (a hole's diameter against its target's width, a shell's thickness against half the target's smallest dimension) into something an author can express for relationships the schema has no built-in opinion about, for example "this boss must fit inside that hole with at least 0.2mm of radial clearance." margin (default 0) is added to the right-hand value before comparing, the natural way to express a clearance/fit requirement (left >= right + margin) without needing a general arithmetic expression language. Deliberately does not support spatial/positional constraints (for example "these two components must not overlap"): checking those would need the same position-resolution engine generateComposablePartBrepJs uses, which only exists in the TypeScript generator, not in semantic validation (shared by both languages) or in the Python package at all -- a dimension constraint only ever reads a dimensions/parameters object directly, staying entirely within semantic validation's existing scope. generateComposablePartBrepJs never reads or emits anything for constraints at all (it's a validation-time-only assertion). Added examples/composable/clearance-fit-boss-and-cap.json (a boss and a separately-printed cap plate whose bore must clear the boss by a margin-expressed radial clearance), real-kernel-verified valid and manifold.

Feature:

  • Support filleting/chamfering a target with multiple pattern instances fused into one shape (its own pattern, or membership in a patterned transforming group) -- previously rejected outright with a warning, the same restriction shell has. Real-kernel testing found .fillet()/.chamfer() (unlike .shell(), which throws on a compound of disjoint solids regardless of how the faces to remove were identified) tolerate a disjoint-solid compound just fine. "vertical" needed no code change at all: .inDirection() already matches every qualifying edge across a whole compound automatically, regardless of instance count. "top"/"bottom" now replicates its face lookup across the cartesian product of a target's own pattern offsets and its transforming group's pattern offsets (a new targetPatternInstanceOffsets() helper; worldPointForGroupInstance() gained an optional ownPatternOffset parameter, applied before a component's own rotation, generalizing its existing groupOffset parameter, which applies before the group's rotation), combining every instance's edgesOfFace() result into one array via spread so a single .fillet()/.chamfer() call covers every actual instance. Confirmed via real-kernel testing that this was a real bug, not just a missing feature: an unreplicated single-point lookup on a 3-instance compound only ever fillets the one instance it happens to find, silently leaving the other two sharp-edged -- exactly the kind of thing an agent composing a patterned bracket that also wants rounded corners would hit. shell keeps its existing rejection, since it's a genuine kernel limitation with no equivalent workaround.

Feature:

  • Implement text (emboss/engrave) as a composable-part feature -- the last of the previously warned-and-skipped feature kinds, and architecturally unlike shell/fillet/chamfer: it's positioned via the ordinary relation/position pipeline shared with hole/slot (works against any target kind, no bespoke face/edge lookup), but depends on a font brepjs doesn't bundle. parameters.fontUrl (schema-required, since there's no safe offline default) is fetched at runtime via loadFont(); real-kernel testing confirmed Node's built-in fetch() can't resolve a local file:// path, so fontUrl must be a real http(s):// URL or a data: URI with the font bytes inlined -- checked at generation time for well-formed-ness and an allowed scheme (http:/https:/data:, explicitly rejecting file:) rather than relying on the schema's "format": "uri", since that keyword turned out to be a silent no-op in the Python validator without the optional rfc3987 package (a pre-existing gap supplierReference.url already worked around with its own manual check; fontUrl's check is now fully self-contained in both languages for the same reason). Every unique fontUrl across a spec is loaded exactly once via a top-level unwrap(await loadFont(url, "font_N")) statement emitted before any other geometry-building code -- 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. Caught and fixed a real bug during this work: an initial await unwrap(loadFont(...)) awaited the result of unwrap(), not the promise loadFont() returns, so unwrap() received a pending Promise and always failed with a confusing "Called unwrap() on an Err: undefined" -- real-kernel testing against brep verify caught this immediately; fixed to unwrap(await loadFont(...)). "emboss" (default) fuses the extruded text onto the target instead of cutting, unlike every other feature kind. Both modes extrude 0.2mm taller than requested and shift the result so it genuinely overlaps the target's surface rather than merely touching it -- the same generous-overlap idea already used by counterbore/countersink/hole/slot's oversized cutters, extended to fuse: real-kernel testing found a flush-touching emboss fuse left multi-glyph text as invalid geometry outright (not merely an unmerged-but-valid compound), which the embedded-root fix resolves. engrave mode's depth is semantically checked against the target's own depth dimension (in TS/Python parity). Added examples/composable/id-tag-with-embossed-and-engraved-text.json (both modes on the same target's top face, using a live Google Fonts URL), real-kernel-verified valid and manifold -- the first example in this project whose real-kernel verification requires network access, an inherent characteristic of this feature rather than a generator limitation.

Feature:

  • Implement fillet and chamfer as composable-part features (previously warned-and-skipped, same as text still is), scoped to a bounded subset of the target's edges rather than attempting every edge on the shape: parameters.edges is one of "vertical" (the target's edges parallel to its own local Z axis), "top", or "bottom" (the perimeter edges of the target's top/bottom face), alongside the existing radius/distance, both now required. "vertical" emits a self-contained .fillet((e) => e.inDirection([x, y, z]), radius) -- the target's own local Z axis rotated into world space by its own rotation and, if grouped, its transforming group's rotation, since a bare 'Z' string would name world Z rather than the target's local Z and (real-kernel-verified) throw "No edges found for fillet" for any rotated target. "top"/"bottom" reuse shell's exact faceFinder().atDistance(0, point) face-identification technique (see below), then brepjs's edgesOfFace(face) to get that face's boundary edges, passed directly to .fillet()/.chamfer() (both accept a plain Edge[], not just a finder callback) -- this is what actually closes the "edge-tracking gap" that previously blocked these two features entirely: a face survives CSG identifiably (by a zero-distance point), and its edges can then be enumerated directly, sidestepping the need to track a specific edge's identity through fusion at all. Real-kernel testing established a support matrix per (kind, edges) combination, reusing shell's SHELL_SUPPORTED_FACES findings for "top"/"bottom" (box/rounded_box/plate/tab/cylinder/boss -- notably including rounded_box, whose top/bottom caps aren't adjacent to the corner fillets the way its side walls are, unlike shell's front/back/left/right restriction there) and adding a new matrix for "vertical" (box/plate/tab/rounded_box/extruded_profile -- the last because "vertical" is a pure direction filter independent of the bounding-box-center-point technique that excludes extruded_profile from "top"/"bottom"; cylinder/boss/tube have no straight edges at all, confirmed to throw rather than silently match nothing). An unsupported combination is dropped with a warning, and a target with multiple pattern instances fused into one shape is rejected with a warning, both for the same reasons as shell. One real-kernel-discovered gotcha specific to these two: stacking a bounded fillet and chamfer on the same target is order-sensitive beyond the usual "later feature sees the earlier one's result" -- filleting a target's top edges then chamfering its (now fillet-shortened) vertical edges fails outright at the kernel level (chamferWithHistory: operation failed), while chamfering first and filleting last works cleanly; this isn't something the generator can detect or fix, so it's documented as an authoring caveat rather than guarded against. Added a matching semantic bounds check (semantic.ts/semantic.py, in exact parity, refactored out of the existing shell thickness check into a shared boundedDimensionError/_bounded_dimension_error helper): radius/distance must be less than half of the target's smallest relevant dimension. Added examples/composable/rounded-top-chamfered-lid.json (a lid plate with both features stacked in the required working order), real-kernel-verified valid and manifold.

Feature:

  • Add a new composable-part feature kind, shell, to hollow out a target component -- the first of the fillet/chamfer/text-adjacent "whole-shape modifier" feature kinds to actually be implemented, rather than warned-and-skipped. parameters is {thickness, openFaces}, both required (openFaces needs at least one entry, since brepjs's shell() has no fully-sealed mode -- it throws NO_FACES on an empty array). Implemented via faceFinder().atDistance(0, [x, y, z]).findAll(currentShape) per requested face -- a point exactly on a face has distance 0 from it and (real-kernel-verified) from no other face, so a face's known local center (the same attached_to_face anchor math a relation already uses) reliably identifies it after any prior boolean ops, sidestepping the edge-tracking gap that still blocks fillet/chamfer (edges, unlike faces, have no equally robust after-fusion identifier). Real-kernel testing (scripts/brepjs-verify) found several plausible (kind, face) combinations don't actually work, so the generator restricts to exactly the subset verified to produce correct geometry and drops the rest with a warning: top/bottom work for box, rounded_box, plate, tab, cylinder, boss; front/back/left/right additionally work for box/plate/tab but not rounded_box, whose flat walls sit adjacent to the corner fillets -- shelling one of those faces completes without error but silently returns the original, unshelled volume, a failure mode with no exception to catch, which is exactly why this needed real-kernel volume verification rather than code-review confidence; side (the curved lateral face of cylinder/boss) throws a kernel-level SHELL_FAILED exception outright; tube (whose top/bottom faces are annuli not covering the local origin the point-based technique relies on) and extruded_profile (whose bounding-box center may sit outside a concave polygon, commonly exactly on an L-shape's reflex corner) aren't supported as shell targets at all. 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, since real-kernel testing confirmed shell() throws on a compound of disjoint solids. Unlike a cut-based feature, shell replaces its target's shapesById entry in place rather than subtracting from it, so declaration order relative to other features on the same target changes the result (a later feature cuts into the hollowed shape; an earlier one is hollowed along with the rest). Added a matching semantic bounds check (semantic.ts/semantic.py, in exact parity): thickness must be less than half of the target's smallest relevant dimension, reusing the existing FOOTPRINT_DIM/DEPTH_DIM maps. Added examples/composable/open-top-enclosure-shell.json (an open-top tray with a cable pass-through hole cut through the already-hollowed wall), real-kernel-verified to match its hand-derived volume exactly (13518.903 mm³: an open-top shell's 13632 mm³ minus the through-hole's two 2mm-wall plugs).

Feature:

  • Add a new composable-part component kind, extruded_profile: an arbitrary author-supplied polygon footprint (dimensions: {points, height}, points an array of at least 3 {x, y} vertices tracing the perimeter in either winding direction, implicitly closed back to the first point) extruded along Z -- the escape hatch for a cross-section none of the other 9 kinds can approximate (an L-shaped bracket, a custom brace, and so on). Built with the same wireLoop/line/face/extrude technique already used for rib/wedge, but since its profile lies in the XY plane (unlike rib/wedge's vertical-plane profiles), the plain-number form of extrude(face, height) is correct as-is with no explicit direction vector needed. Real-kernel testing confirmed polygon winding direction doesn't affect correctness for convex or concave footprints alike, so points are emitted in author order with no normalization step. Unlike rib/wedge, extruded_profile has a well-defined derived axis-aligned bounding box (from points' min/max, centered at the component's local origin the same as every other kind, shared via a new pointsBoundingBox() helper), so it fully participates in relation anchoring and the connectivity check like any box-like kind; it's still excluded from the hole/slot footprint/depth bounds-check (semantic.ts/semantic.py), same as rib/wedge, since that check needs a single string-keyed dimension rather than a derived box. Reuses the existing (previously unused) common.schema.json#/$defs/Point2D definition. Added examples/composable/l-shaped-bracket-custom-profile.json, an L-shaped mounting bracket with a hole in each arm, real-kernel-verified to match its hand-derived volume exactly. Also corrected a stale claim in the generator's module doc comment that no brepjs text primitive was confirmed to exist -- sketchText/drawText/loadFont/compoundSketchExtrude are all confirmed present in brepjs; text (like fillet/chamfer) remains unimplemented in this generator purely for scope, not because brepjs lacks the primitive.

Feature:

  • Add a composable_part -> brepjs source generator (TypeScript only, via generateBrepJs), covering all 9 component kinds (box, rounded_box, cylinder, tube, plate, tab, boss, rib approximated as a box, and wedge built as a real tapered profile via wireLoop/line/face/extrude), relation-based anchoring, pattern expansion (rectangular/linear/radial), appliesTo-scoped add/subtract CSG assembly, and hole/slot/counterbore/countersink feature cuts. fillet, chamfer, and text features are not implemented and produce a warning instead of being silently dropped, as does a stray rib taper. Added examples/composable/wedge-ramp-with-countersunk-mount.json to exercise the wedge and countersink code paths, and snapshot fixtures under tests/fixtures/generated/brepjs/composable/ for all 6 composable examples.

Feature:

  • Add an opt-in real-kernel verification script (scripts/brepjs-verify/verify.mjs, npm run verify:brepjs-real-kernel) that runs every brepjs-supported example against a real brepjs + occt-wasm OpenCascade WASM kernel and checks each produces a valid, non-degenerate solid with a positive volume -- turning the one-off manual verification that caught the wedge bug below into a repeatable check. Deliberately not part of npm test/CI (needs Node >=24 and downloads a real WASM kernel, neither a dependency of the package itself); self-installs its own isolated brepjs/occt-wasm in scripts/brepjs-verify/node_modules on first run.
  • Add a connectivity warning to the composable-part brepjs generator: if the add components don't appear to form a single connected part, it now warns instead of silently generating a disconnected shape. Checked via an approximate bounding-box touch/overlap test (rib/wedge excluded, no clean footprint/depth split to bound) using the exact same position-resolution math the generator already uses for everything else -- no real geometry kernel needed, so this is always available, not just when running the opt-in real-kernel script above. Investigated using the real kernel first: .fuse() always reports its result as a compound regardless of whether the pieces actually touch or even overlap, so shape-type introspection after fusing turned out not to be a usable signal for this at all -- the bounding-box approach checks connectivity before fusing instead.
  • Add scripts/brepjs-verify/inspect.mjs (npm run inspect:composable-part), a real-kernel inspection tool for a single spec built on brepjs-cad's verify substrate rather than the simpler pass/fail check in verify.mjs: a real solid-count-based manifold/connectivity check (more precise than the approximate bounding-box warning above), volume/bounds/center-of-mass measurements, manufacturability checks (minimum radius, bore detection), and, with --snapshot, multi-angle rendered PNGs with dimensions burned in. No format bridging was needed -- generateBrepJs()'s output is already exactly the .brep.ts module brepjs-cad's tooling expects. The same verify substrate is available live during authoring via brepjs-cad's bundled brep-mcp stdio MCP server (run_program({code, timeoutMs?}), sandboxed with a timeout/memory cap); see "Real-kernel inspection for an authoring agent" in docs/generators.md for how to register it (personal/local scope only -- it needs a Node >=24 binary path that isn't portable across machines, so there's no committed project-scoped registration).
  • Add a pattern field to composable-part groups (schema, semantic validation in both languages, and the brepjs generator): a group's pattern repeats the whole group -- every member, as one rigid unit -- instead of a single component or feature. The motivating case is a multi-component cluster (for example a standoff and its own mounting hole) authored once and repeated at several positions, instead of hand-duplicating and re-deriving every cluster's components at each instance's coordinates by hand -- exactly the kind of repetitive coordinate arithmetic an agent composing a spec by hand is prone to getting subtly inconsistent across instances. A patterned group is, like a patterned component or feature, rejected as a relation.target (no single instance to anchor to) and counts as "transforming" for the existing at-most-one-transforming-group-per-member rule. The approximate bounding-box connectivity warning (above) now unions every pattern instance's bounding box for a patterned group's members, instead of only the group's own (pattern-center) position, so it can't silently miss a real gap between instances. A feature targeting a member of a patterned group now also repeats its own cut across that group's pattern -- including transitively through a stacked feature -- the same "moves with its target implicitly" behavior a feature already had for an unpatterned group's position/rotation; this was caught and fixed as part of building group patterns (a feature previously would have cut only once, at the pattern's center point, not once per actual target instance). Added examples/composable/quad-standoff-mounting-plate.json as a worked example, both real-kernel-verified and hand-calculation-verified exactly.
  • Build a real tapered gusset for the composable-part brepjs generator's rib kind, instead of the plain rectangular box it was approximated as (with a warning) since the schema has no taper angle. Uses the same wireLoop/line/face/extrude technique already proven for wedge: a right-triangular prism, full height at the wall end and tapering to zero at the far end -- the classic structural-reinforcement shape. Real-kernel-verified to match the triangular-prism volume formula (0.5 * length * height * thickness) exactly.
  • Add relation.inheritRotation: true (schema, semantic validation, and the brepjs generator; component and feature relations only, not group relations) so a component or feature can opt into matching a rotated relation target's orientation, instead of remaining axis-aligned at the correct anchor position but the wrong orientation -- a relation's anchor point already correctly follows a rotated target (see the transforming-group-rotation fix above), but by default nothing composes the dependent's own placement with that rotation, so every dependent down a chain of rotated components previously needed the exact same rotation value hand-copied and re-derived onto it, an easy place for a small transcription mistake to compound. Implemented as an additional rotation stage emitted before the dependent's own translate (mirroring the existing own-rotation-then-group-rotation composition for grouped components), not by trying to store a composed rotation as a flattened value, for the same "not generally representable as one Euler triple" reason documented for group rotation. A hole/slot feature with inheritRotation: true gets its cutting axis tipped to match too, so it drills perpendicular to a rotated target's surface instead of straight down in world Z. Recursive/transitive: if the target itself inherited rotation from its relation target, that composes in too -- this was caught as a real bug while building the worked example below (a naive single-level implementation silently used zero rotation for a target one level removed from the actual rotated ancestor, landing at the right position but the wrong orientation) and fixed before shipping. Both the position-only and position-plus-rotation cases are real-kernel-verified to match hand-derived volumes exactly. Also fixed a stale schema description on mirrorAxis claiming it mirrors the target's rotation too, which the code never actually did (only position, matching the already-documented mirrored_from behavior). Added examples/composable/angled-sensor-mount-with-inherited-rotation.json as a worked example combining component- and feature-level inheritRotation, chained transitively.

Fix:

  • Fix a bug in the new composable-part brepjs generator (caught while adding the group example above to its own test coverage): a relation or feature target resolving to a component that belongs to a transforming group (a group with its own position/rotation/relation) previously anchored to that component's pre-group position instead of its final, group-transformed position -- so a feature cut on a grouped component (for example the two tie-down posts in cable-tie-anchor-strip.json) landed at the same wrong, un-transformed location for every group member instead of following each member to its actual place. Extend composable-part relation-cycle detection (semantic.ts/semantic.py) to treat a grouped component's dependency on its transforming group as a graph edge too, since the fix above makes that dependency real; this required generalizing the cycle finder from a one-edge-per-node functional graph to a general directed-graph DFS, since a component can now have both its own relation edge and this new implicit group edge.
  • Fix the composable-part brepjs generator's wedge geometry, caught by executing every generated fixture against a real brepjs + occt-wasm kernel (not just a syntax-shape stub): extrude(face, height) extrudes along the Z axis when height is a plain number, but the wedge's triangular profile is built lying in the XZ plane, so the emitted code was producing a degenerate, zero-volume solid instead of the intended tapered wedge (confirmed both ways: 0 mm³ before the fix, exactly the hand-calculated 6750 mm³ after, for a 30x30x15 test wedge). Fixed by passing an explicit [0, width, 0] direction vector instead of a bare number. Add a committed, dependency-free stub brepjs implementation (tests/fixtures/brepjs-stub/brepjs.mjs) and a test that actually imports and runs every composable-part example's generated module against it, plus the same execution check extended to all 10 core part-family brepjs outputs (previously only checked via string-snapshot comparison, which can't catch a generated module that's syntactically broken or misuses the brepjs call shape -- exactly how the wedge bug slipped past the existing snapshot test). The other 10 families' brepjs geometry was additionally spot-checked by hand against the real kernel's measured volumes (l_bracket, wall_mount_bracket, project_enclosure_tray, electronics_standoff) and matched exactly; no other bugs found.
  • Fix the composable-part brepjs generator to rotate a relation's local anchor offset (for example on_top_of's [0, 0, height]) by the target's transforming group rotation, not just the target's own rotation, when the target belongs to a group with its own rotation. Previously a component anchoring on_top_of/attached_to_face/centered_on/aligned_with a grouped-and-rotated component would compute the anchor point using only that component's pre-group orientation, landing on the wrong face entirely once the group's rotation was applied. Verified both symbolically (hand-derived point rotation) and against the real kernel. This only affects the anchor position; a relation still doesn't automatically rotate the dependent component to match a rotated target's orientation (the pre-existing mirrored_from caveat, now documented as applying to every relation type in docs/composable-parts.md) -- set rotation explicitly on the dependent if it needs to sit flush against the rotated surface.
  • Fix the composable-part brepjs generator to scope a feature's cut (hole/slot/counterbore/countersink) to its target component(s) only, instead of cutting the whole fused assembly. Previously any other component sitting along a feature's cut axis could get an unintended hole silently drilled through it too, since "through" cuts use a deliberately oversized cutter with no awareness of which component was actually named -- confirmed both with a synthetic reproduction (real-kernel volume matched the "both parts holed" prediction exactly, not the intended "only the named target" one) and, more importantly, found active in 3 of this project's own 6 composable-part examples (cable-tie-anchor-strip.json, small-bracket-with-ribs.json, vented-sensor-mount-with-standoffs.json all measure a higher, correct volume after the fix, having previously lost extra unintended material). target now resolves to its actual CSG scope: a component id cuts just that component; a group id cuts every current member (the same cutter reused across each, confirmed safe against the real kernel); a feature id (a stacked feature, like a counterbore on a hole) resolves recursively through that feature's own target. A feature whose target doesn't resolve to any live add component now produces a warning instead of silently doing nothing.
  • Fix a related crash: a feature's bare target (used as an implicit position anchor, and now also the CSG cut scope, even without an explicit relation) wasn't included in composable-part cycle detection (semantic.ts/semantic.py), so two features that target each other with no relation passed validation cleanly but crashed the brepjs generator with a stack overflow. Now caught as a standard "relation cycle detected" validation error instead.
  • Fix the composable-part brepjs generator to actually expand a feature's own pattern (for example a rectangular bolt pattern of holes) into a fused union of cutter instances, instead of silently cutting only a single instance at the pattern's center and dropping every other instance -- despite pattern being schema-valid and documented for features, and despite the same expansion already working correctly for component patterns. Produced no warning and no validation error, so a spec asking for 4 holes could validate cleanly and generate exactly 1. Confirmed via real-kernel volume measurement for both a rectangular and a radial pattern (both matched their hand-calculated "all instances actually cut" volume exactly). This had never been exercised end to end: none of the 6 composable-part examples used a feature pattern, including, ironically, adapter-plate-with-two-hole-patterns.json, which -- despite its name -- used no pattern at all (a leftover from before patterns existed as a schema concept). Rewrote that example to actually use two independent feature patterns (a rectangular bolt pattern and a linear row of vent holes), matching its name.
  • Fix the composable-part brepjs generator's relation.inheritRotation support: the position side of an anchor computation (a relation's local offset like on_top_of's [0, 0, height], a fillet/chamfer's "vertical" edge direction, and the face-lookup point shared by shell/fillet/chamfer's "top"/"bottom" selectors and the connectivity-check bounding box) only ever rotated by a target's own authored rotation field plus its transforming group's rotation -- never by rotation the target itself picked up via its own relation.inheritRotation. The rotation-composition fix shipped alongside inheritRotation above (recursive applyInheritedRotation) only ever applied to a node's own emitted geometry, not to any of these position/direction lookups against that node as someone else's target, so a target whose only rotation came from inheriting it (no rotation field of its own -- the entire point of the feature) still had its dependents anchored as if it were sitting unrotated. Caught by hand-deriving examples/composable/angled-sensor-mount-with-inherited-rotation.json's expected geometry rather than only checking hand-derived volume (which this bug doesn't change, since the through-hole cutter is oversized enough to still fully penetrate the pad regardless): mount_hole's centered_on: pad anchor was landing at pad's un-rotated local [0, 0, 1.5] offset from pad's already-correctly-placed origin, instead of that offset correctly rotated by pad's true (inherited, 20-degree) tilt -- about 0.51mm laterally and 0.09mm in depth off from pad's actual center, for the numbers in that example. Fixed by adding a numeric-point twin of applyInheritedRotation() (rotateByOwnAndInherited()/updated worldRotatePoint()) and threading it through all three affected call sites (relation-anchor resolution, buildBoundedEdgesExpr's "vertical" direction, and worldPointForGroupInstance); re-verified by hand that the corrected anchor ([0, -2.565, 11.048] in world space, replacing the previous [0, -2.052, 11.138]) now matches the 20-degree-rotated offset exactly. Updated the example's snapshot fixture accordingly; no other composable example changed output, since angled-sensor-mount-with-inherited-rotation.json is the only one combining inheritRotation with a downstream anchor/edge/face lookup against the inheriting node.
  • Fix a parity gap between semantic.ts and semantic.py in hardware[].supplierReferences[].url validation: the TypeScript check (new URL(...)) accepted any well-formed URL regardless of scheme (so ftp://... or mailto:... validated), while the Python check was a bare str.startswith(("http://", "https://")) string-prefix test (so a malformed value like http:// with no host still validated, while ftp://example.com/x didn't) -- the same spec could validate in one language's package and fail in the other. Both now parse the URL and require an http/https scheme with a non-empty host and an explicit :// separator, so the two implementations agree on every input checked.
  • Fix the same silent-no-op gap as the feature target fix above, but on the sibling subtract-component path: a subtract component whose appliesTo names only components that are themselves subtract-only (never an "add" shape), or -- with no appliesTo -- one declared before any add component (so the default "every add declared earlier" is empty), previously computed its cutter geometry but never applied it to anything, with no warning. Now produces component <id> (subtract) does not apply to any "add" component; the cut was not applied, matching the feature-side message.
  • Fix Python OpenSCAD/CadQuery generators to support cable_comb, cable_clip, wall_mount_bracket, l_bracket, drawer_divider, and project_enclosure_tray, matching existing TypeScript generator coverage. Previously these 6 of the 10 advertised alpha families silently returned "unsupported" from the Python package only.
  • Add a cross-language parity test (tests/python/test_generator_parity.py) that runs the Python generators and the built TypeScript CLI against every example and fails if generator support diverges between languages.
  • Fix stale generator-family documentation in docs/generators.md, docs/getting-started.md, docs/roadmap.md, docs/part-families.md, and docs/validation.md.
  • Tighten composable-part.schema.json: component dimensions and feature parameters are now validated against a kind-specific shape instead of an arbitrary {string: positive number} bag, so misspelled or wrong-kind keys fail validation instead of silently passing through unused. Added a shared Relation definition (used by both components and features) that requires target for every relation type except absolute and requires target+face for attached_to_face, with face now a closed enum instead of a free string. Added optional rotation (degrees, X/Y/Z) to components. No changes to previously-valid example fixtures under examples/composable/.
  • Replace auto-generated placeholder property descriptions (for example "X parameter.", "One of parameter.") with real descriptions across schemas/common.schema.json and several part-family schemas.
  • Extend composable-part semantic validation (semantic.ts/semantic.py) to check relation.target existence for every relation type, not just mirrored_from/attached_to_face/on_top_of; check it on features as well as components; reject a relation or feature target that references its own id; and allow a feature target to reference another feature id (for a stacked feature such as a counterbore on top of a hole), not just a component id.
  • Detect composable-part relation cycles of any length (for example A on_top_of B and B on_top_of A, or longer chains) and report them as relation cycle detected: a -> b -> a.
  • Add an optional description string to composable-part features (components already had one), for carrying design intent (for example "M3 clearance hole for the standoff screw") to whoever implements the part next.
  • Add an optional appliesTo (component id list) to composable-part components, so a subtract component can scope its cut to specific components instead of defaulting to everything added so far. Semantic validation checks that every appliesTo id refers to a known component and rejects self-reference.
  • Add composable-part semantic bounds checks: a hole/slot feature targeting a component directly on the default z axis now has its size (diameter, or the larger of a slot's length/width) checked against the target's in-plane dimension, and its numeric depth checked against the target's height/thickness. Skipped for non-default axes and for kinds without a clean footprint/depth split (rib, wedge).
  • Require composable-part component/feature id to match a code-safe identifier pattern (^[a-zA-Z_][a-zA-Z0-9_]*$), since a coder agent implementing the part is likely to use it as a source-code variable or object name.
  • Document the composable-part coordinate convention explicitly (component geometry centered on its local origin along X/Y with Z=0 at its base; rotation applied as extrinsic X-then-Y-then-Z before position), matching the convention already used by the preview generators.
  • Add examples/composable/vented-sensor-mount-with-standoffs.json, a worked example demonstrating rotation, appliesTo, a mirrored_from relation, a stacked feature, and description on both components and features.

Feature:

  • Add a generateBrepJs(spec) source generator (TypeScript only) for brepjs, covering the same 10 part families as the OpenSCAD/CadQuery generators. Like those generators it only emits source text (no WASM kernel, no execution); the emitted .brep.ts module follows the brepjs-cad authoring convention (export default () => shape) so it can be run and verified directly with the brep CLI. Wired into the CLI (to-brepjs, and --brepjs on bundle, opt-in since it's newer than the other two generators) and into the bundle API (includeBrepJs, also opt-in).

Fix:

  • Fix l_bracket's OpenSCAD, CadQuery, and brepjs generators (all 5 implementations: TypeScript ×3, Python ×2) to read the schema's actual parameters.holes/parameters.slots arrays instead of the nonexistent parameters.holeDiameter/parameters.holesPerLeg, which additionalProperties: false silently rejected, making the old hole-drilling code unreachable dead code for any schema-valid spec. axis: "z" cuts through leg A; axis: "x" cuts through leg B (reusing a hole/slot's x as the position along leg B's length and y as the lateral position, matching leg A's convention); axis: "y" isn't supported and is reported as a warning instead of silently misplaced. Also added the existing rib.enabled field to the generic "not implemented" warning check (previously silently ignored, like chamfer/fillet used to be before that check existed) and aligned CadQuery's leg placement with OpenSCAD/brepjs's simpler corner-based convention. Added examples/part-families/l-bracket.holes-and-slots.json and cross-generator tests covering both leg axes and the unsupported-axis warning.
  • Fix cable_clip's OpenSCAD generators (TypeScript and Python) to read the schema's actual parameters.mountingHoles array instead of the nonexistent parameters.mountHoleDiameter, which made the optional mounting-hole cut unreachable dead code (same bug class as l_bracket, above). Also added mountingHoles support to CadQuery (both languages) and brepjs, which previously didn't attempt mounting holes at all. Added examples/part-families/cable-clip.with-mount-hole.json and cross-generator tests.
  • Extend the "not implemented" warning check to every family and to cornerRadius, not just chamfer/fillet/rib. 8 of the 9 families whose schema defines cornerRadius (spacer_block, round_spacer, electronics_standoff, cable_comb, cable_clip, wall_mount_bracket, l_bracket, project_enclosure_tray) silently dropped it with no warning; only rounded_rectangular_plate actually implements it and is excluded from the new check. Separately, most families (all except round_spacer and l_bracket) hardcoded warnings: [] and so never surfaced the existing chamfer/fillet warnings either, even though several of their schemas define those fields — every family now routes through the shared warnings helper. Added a cross-generator test that sets cornerRadius/chamfer per family (based on what each family's schema actually declares) and asserts the right warning fires everywhere except where the feature is genuinely implemented.

Composable-part:

  • Precisely define the anchor point for every relation type against the target's axis-aligned bounding box (on_top_of, attached_to_face per named face, centered_on, aligned_with, offset_from), rather than leaving them defined only by name. rib/wedge/group targets fall back to the target's own origin, same as offset_from.
  • Remove Relation.offset (redundant with the component/feature's own position, and ambiguous about how the two would stack) and add Relation.mirrorAxis (x/y/z, default x) so mirrored_from has a defined, adjustable mirror plane instead of an undocumented implicit one. No example fixture used offset, so this is a clean removal.
  • Document pattern (rectangular/linear/radial repetition, previously present in the schema with zero documentation): all instances share one id and are not individually targetable; the resolved position/relation anchor is the pattern's center, not its first instance; exact per-instance offset formulas for all three pattern shapes are now specified in docs/composable-parts.md.
  • Add groups: a new optional top-level array letting components be organized, positioned, rotated, or mirrored as a named unit without hand-duplicating each member's coordinates. A group's position/rotation compose with each member's own; groups are not nestable; a group id can be used as a relation.target by other components/features/groups. Semantic validation checks group id uniqueness, memberIds existence, and includes group relations in the existing target-existence and cycle-detection checks. Added examples/composable/cable-tie-anchor-strip.json demonstrating group position and a group-level mirrored_from, and extended vented-sensor-mount-with-standoffs.json with a simple organizational group.
  • Fix a gap introduced by adding groups: component/feature/group ids were only checked for duplicates within their own category, but relation.target/appliesTo/feature target resolution treats all three as one combined namespace — so a component and a group sharing an id (for example both named "thing") validated cleanly even though any target referencing "thing" would be ambiguous. Added a cross-category duplicate check (id used by more than one component/feature/group: ...) in both languages.
  • Fix three remaining ambiguities found while assessing whether the relation/pattern/group model resolves consistently and deterministically (a prerequisite for building a generator on top of it): (1) a relation could target a patterned component/feature, which has no single instance to anchor to — now a hard validation error, while appliesTo/feature target against a patterned component (a CSG relationship, not a positional anchor) remain valid; (2) a group's relation.target could be one of its own memberIds, which would re-position that member using a transform anchored on its own pre-group position — now rejected; (3) a component could belong to more than one transforming group (a group with its own position/rotation/relation), which is ambiguous about which transform applies or how they'd compose — now rejected, while belonging to any number of purely organizational (transform-free) groups remains fine. Also documented, for the first time, that CSG operations always resolve against each component's fully-composed world-space position regardless of grouping.

0.2.0

Feature release:

  • Add browser-safe renderer-neutral preview scene generator.
  • Add optional Three.js preview adapter.
  • Add static in-browser PrintSpec validator for schemas.invisra.ai.
  • Expand supported practical part families to at least 10.
  • Add examples for all supported alpha families.
  • Keep Three.js optional and out of the main browser entrypoint.
  • Improve browser-safety and package-content checks.
  • Keep schema version at 0.1.0; added alpha family schemas are backward-compatible additions.
  • Preview geometry is visual/non-authoritative and generated source should be reviewed before manufacturing.

0.1.5

Patch release:

  • Make @invisra/printspec/browser fully browser-safe.
  • Add browser-safe generator entrypoints.
  • Ensure browser bundle creation does not import Node schema loading.
  • Add import graph smoke test to prevent node:fs from entering the browser export.
  • No schema changes.

0.1.4

Patch release:

  • Declare ajv-formats as a runtime dependency for Node and browser entrypoints.
  • Fix downstream bundling in Next/Vercel when importing @invisra/printspec/browser.
  • No schema changes.

0.1.3

Patch release:

  • Add browser-safe entrypoint at @invisra/printspec/browser.
  • Embed bundled schemas for browser validation and form metadata.
  • Keep Node entrypoint and CLI unchanged.
  • Add package-content and smoke tests for the browser entrypoint.
  • No schema changes.

0.1.2

Patch release:

  • Add browser-safe entrypoint at @invisra/printspec/browser.
  • Embed bundled schemas for browser validation.
  • Keep Node entrypoint and CLI unchanged.
  • No schema changes.

0.1.1 - npm packaging fix

Patch release:

  • Fix npm package contents.
  • Include built dist/ artifacts in the published npm package.
  • Add package-content checks to prevent missing build artifacts.
  • No schema changes. Schema $id values and printspecVersion remain 0.1.0.

0.1.0 - Experimental release candidate

  • Added package-local JSON Schemas for TypeScript and Python offline validation.
  • Added hosted static schema output under public/printspec/0.1.0/.
  • Added validators, BOM helpers, CLI commands, and starter OpenSCAD/CadQuery generators.
  • Supported starter generator families: rounded_rectangular_plate, spacer_block, round_spacer, and electronics_standoff.
  • Added release-readiness checks for version consistency, npm package smoke tests, and Python wheel smoke tests.

Version immutability warning

Once v0.1.0 is released, do not casually mutate /printspec/0.1.0/ schema contents. Publish /printspec/0.1.1/ or /printspec/0.2.0/ for schema changes. Package patches may happen, but schema $id paths need careful handling.

This release is experimental. Generated CAD source should be reviewed before printing or use, and publishing to npm/PyPI remains manual until explicitly configured.

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 export

  • Added deterministic project and part source bundle export in TypeScript and Python.
  • Added directory writers, Python zip writer, CLI bundle commands, bundle manifests, README generation, and optional experimental PartCAD stubs.