Composable parts

Composable parts are experimental in v0.1. They provide a constrained assembly of simple primitives and features; they are not a full CAD language.

The intended workflow is that an authoring agent (human or AI) writes a composable-part spec to describe intent, and a separate coder agent later implements it (for example as OpenSCAD or CadQuery source). The schema favors validation that catches authoring mistakes early (unknown ids, size mismatches, cycles) and fields that carry explanatory intent (description), over inventing a fully formal CAD language. composable_part has two independent, real-kernel-verified generators (TypeScript only) that don't need the coder-agent handoff to get real feedback: generateBrepJs() (a .brep.ts module) and generateCadQuery() (a plain CadQuery Python module) -- see "composable_part generator" and "composable_part CadQuery generator" in docs/generators.md. The two agree exactly on every example that doesn't use a CadQuery-generator-only-unsupported feature (thread, text -- see the CadQuery section for why). Neither OpenSCAD nor the Python package generate composable_part output.

Every component, feature, and group id must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (a code-safe identifier), since a coder agent is likely to use it directly as a source-code variable or object name.

Component and feature geometry uses a consistent coordinate convention: each component is modeled centered on its own local origin along X and Y, with Z=0 at its base and height/thickness extending in +Z. rotation (optional, degrees) is applied first, as extrinsic (fixed-axis) rotations in X, then Y, then Z order, then position translates the result relative to the part's local origin or, when relation is set, relative to the relation's resolved anchor (see Relations below). Feature position uses the same convention relative to its target.

Supported component kinds include box, rounded_box, cylinder, tube, plate, tab, boss, rib, wedge, extruded_profile, sphere, torus, ellipsoid, revolved_profile, loft_profile, and swept_profile. Component operations are add, subtract, and intersect, and any component kind may use any operation -- a torus cutting an O-ring groove into a flat face, or an ellipsoid clipping a domed pocket, are just as valid as the more obvious box/cylinder cutters (real-kernel-verified for torus, ellipsoid, and swept_profile -- see examples/composable/orifice-plate-with-oring-groove.json). A subtract/intersect component still needs its own explicit position/relation to land in the right place relative to whatever it's cutting, the same as any add component -- its own points/profile/path are always relative to its own local origin (component kinds each define their own, see below), never automatically aligned to the target it happens to be cutting. Each component's dimensions object is schema-validated against a kind-specific shape (for example plate requires exactly length, width, and thickness; boss requires exactly diameter and height; sphere requires exactly diameter; torus requires exactly outerDiameter and tubeDiameter; ellipsoid requires exactly lengthX, lengthY, and lengthZ), so a misspelled or wrong-kind dimension key fails validation instead of silently producing an underspecified component. Like every other kind, sphere/torus/ellipsoid/revolved_profile/loft_profile all sit with their base -- their lowest point, not their center -- at Z=0 on their own local origin, so they compose with on_top_of/attached_to_face/relations exactly like a box or cylinder would (swept_profile also has Z=0 at its base -- its path's own first point -- but see its own paragraph below for how it differs from every other kind). sphere and torus participate fully in hole/slot bounds-checking (their footprint and depth bounds are diameter for sphere, and outerDiameter/tubeDiameter respectively for torus); ellipsoid, revolved_profile, and loft_profile are all excluded from that check, the same as rib/wedge/extruded_profile, since none has a single "footprint" number to check a hole/slot's size against (an ellipsoid's lengthX/lengthY can differ; a revolved or lofted profile's footprint is derived from its own points, the same reasoning extruded_profile already follows). torus's tubeDiameter must be less than its outerDiameter (so its ring has a positive major radius) -- checked at validation time, since an inverted torus (like an inverted tube, whose innerDiameter must likewise be less than its outerDiameter) produces a real-kernel-confirmed zero-volume, degenerate solid rather than an error you'd see coming. None of sphere/torus/ellipsoid/revolved_profile/loft_profile are currently supported shell/fillet/chamfer targets -- see "composable_part generator" in docs/generators.md.

loft_profile blends between 2 or more cross-sectional profiles at different heights along Z -- a square-to-round adapter, a tapered housing, or any other transitional shape none of the other component kinds approximate directly. dimensions is {profiles}, where profiles is an array of at least 2 {points, z} sections: points is the same extruded_profile-style array of {x, y} vertices (including optional per-point curve), and z is that section's own height along the component's Z axis, in whatever coordinate space is convenient (the generator shifts every section's z uniformly so the lowest section lands at the component's own Z=0). Sections don't need matching vertex counts -- brepjs's underlying loft algorithm corresponds vertices between mismatched profiles (for example a 4-point square to a 6-point hexagon) automatically -- and each section is centered on its own local origin independently, so cross-sections stack concentrically by default rather than needing to share one combined bounding box. See examples/composable/square-to-round-duct-adapter.json for a worked example (a classic HVAC-style transition, whose round profile is itself built from 4 arcs via curve), real-kernel-verified valid with a plausible volume -- unlike every other new 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 right bar here.

swept_profile sweeps an arbitrary closed cross-section along a 3D path -- a cable channel, a handle, a bent standoff, or any other shape that follows a path none of the other component kinds can approximate directly. dimensions is {profile, path}: profile is the same extruded_profile-style array of {x, y} vertices (including optional per-point curve), and path is an array of at least 2 {x, y, z} points connected by straight lines (curved path segments aren't supported yet). Unlike every other profile-based kind, profile is not centered on its own bounding box -- a profile point at {x: 0, y: 0} sits exactly on the path's own centerline, so author it directly in the path's local frame (offset it deliberately for an off-center channel). path[0] is the component's own local origin (Z=0), the same base convention as every other kind, and the path's first two points must differ only in z -- the first segment must run parallel to the Z axis. This is a real, checked (not just documented) constraint, not an arbitrary restriction: real-kernel testing found brepjs's underlying sweep() does not automatically reorient the cross-section to match the spine's own tangent direction, so since the profile is always authored flat in the XY plane, the path's very first segment has to already be parallel to that plane's normal (Z) for the sweep to produce valid geometry at all -- every subsequent segment, however, may bend in any direction (not just within a single plane), since brepjs itself handles the direction change from there. swept_profile has no derived AABB (like rib/wedge) -- a bent path has no clean, symmetric footprint -- so it's excluded from relation anchoring (a relation may still target it, but anchors at its own local origin rather than a computed surface point), hole/slot bounds-checking, the connectivity check, clearance constraints, and shell/fillet/chamfer support. See examples/composable/gooseneck-cable-guide.json for a worked example (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.

revolved_profile is the "sketch and revolve" counterpart to extruded_profile's "sketch and extrude": dimensions is {points, sweepAngle?}, where points is an array of at least 3 {radius, z} vertices (radius must be >= 0; z may be any convenient value -- the generator shifts the whole profile so its minimum z lands at the component's own Z=0) tracing the cross-section's perimeter in the (radius, z) half-plane, not the XY plane extruded_profile uses -- implicitly closed back to the first point, and revolved around the Z axis to form a solid of revolution (a pulley, a custom shaft, a flange, a bushing, a knob -- any rotationally-symmetric shape none of the other component kinds approximate directly). A profile that includes radius: 0 at some point produces a solid revolve with no center hole; a profile entirely at radius > 0 produces a hollow ring instead (real-kernel-verified against Pappus's theorem for a torus-like quadrilateral profile). sweepAngle (degrees, default 360) revolves through less than a full turn for an open, partial solid of revolution (for example a quarter-round channel) -- real-kernel-verified to be correctly capped with flat faces at each end, not left open. See examples/composable/pulley-with-revolved-profile.json for a worked example, real-kernel-verified to match its hand-derived conical-frustum volume exactly. Like extruded_profile, any point may set an optional curve ({type: "arc", through: {radius, z}}, {type: "bezier", controlPoints: [{radius, z}, ...]}, or {type: "spline", through: [{radius, z}, ...]}) to make the edge to the next point a curve instead of a straight line -- see examples/composable/flared-hub-with-curved-revolve-profile.json (a hub that flares via an arc instead of a sharp step), real-kernel-verified to match its hand-derived volume exactly. Curved revolve profiles need more care than curved extruded_profile footprints, for any curve type, not just arc: because the profile sweeps a full circle around Z, an aggressively-chosen through/control point (rather than one near the curve's own natural midpoint) can produce a profile that real-kernel testing confirmed fails outright at brepjs's revolve() step (REVOLVE_FAILED), not just an unintended shape -- always real-kernel-verify a revolved_profile that uses curve.

extruded_profile is the escape hatch for a footprint none of the other kinds can approximate (an L-shaped bracket, a custom brace, any polygon cross-section): dimensions is {points, height}, where points is an array of {x, y} vertices (at least 3) tracing the footprint's perimeter in either winding direction -- the polygon comes out the same either way, so there's nothing to get backwards -- implicitly closed back to the first point, extruded along Z by height. Points may be given in whatever coordinate space is convenient; like every other kind, the generator centers the footprint's bounding box at the component's own local origin, so the same list of points always produces the same shape. Unlike rib/wedge, extruded_profile has a well-defined axis-aligned bounding box (derived from points), so relations, hole/slot placement, and the connectivity check all work against it the same as any box-like kind.

Any point may set an optional curve to make the edge to the next point (in points' own order) an arc, a Bezier curve, or a smooth spline instead of a straight line: {type: "arc", through: {x, y}} (a circular arc through this point, the given through point, and the next point, via brepjs's threePointArc), {type: "bezier", controlPoints: [{x, y}, ...]} (a Bezier curve through this point, the control point(s) in order, and the next point, via brepjs's bezier), or {type: "spline", through: [{x, y}, ...]} (a smooth B-spline through this point, the given through point(s) in order, and the next point, via brepjs's bsplineApprox; real-kernel-verified to genuinely curve through the given points, not degenerate to a straight edge). Prefer spline over arc when curving smoothly through more than one intermediate point (an arc's three points must lie on one circle, which a multi-point curve generally can't satisfy), and over bezier when a smooth, organic curve is wanted rather than one pulled toward a control point that the curve doesn't actually pass through. The centering bounding box accounts for a curve's through/controlPoints too, not just the main vertices, 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. Choosing a good through/control point matters more than it might seem for arc specifically: threePointArc's three points must all lie on one circular arc, so a through point picked too close to one endpoint relative to a long chord between two far-apart vertices can produce a wildly different, unintended arc (looping around through the far side of a large circle) instead of a small local bulge -- real-kernel testing hit this directly while authoring examples/composable/flared-hub-with-curved-revolve-profile.json. Prefer a through point near the arc's own natural midpoint relative to its two immediate neighbors (see examples/composable/rounded-corner-bracket-with-arc-profile.json, whose through point is deliberately the fillet arc's exact 45-degree midpoint), and real-kernel-verify any profile that uses curve.

A subtract component cuts from every add component listed before it by default; an intersect component trims every add component listed before it down to just its overlap with the intersect component's own shape, the classic 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 box, instead of authoring a bespoke extruded_profile). Both share identical scoping: to target specific components instead of everything added so far, set appliesTo to a list of component ids; semantic validation checks that every appliesTo id refers to a known component and rejects a component that references itself. appliesTo has no effect on add components. A subtract or intersect component whose targets don't resolve to any live add shape (an appliesTo id that's itself subtract/intersect-only, or -- with no appliesTo -- a component declared before any add component, so "every add declared earlier" is empty) produces a warning instead of silently doing nothing. subtract and intersect components interleave according to their actual declaration order relative to each other (and to every other component), the same as appliesTo's own default resolves against declaration order -- an intersect declared before a subtract targeting the same component trims it first, so the subtract's cut applies to the already-trimmed result, not the original shape; declaring them in the other order reverses which one sees the other's effect. See examples/composable/d-shaft-with-intersect-flat.json for a worked example.

A subtract or intersect component's geometry is literal, unmarginalized, and exactly as authored -- unlike a hole/slot feature's depth: "through", which deliberately uses an oversized cutter to guarantee it fully penetrates its target regardless of that target's exact thickness (see below), neither has an equivalent convenience. A cylinder authored with height: 20 occupies exactly [0, 20] along its own local Z axis before being placed like any other component (Z=0 at its own base, extending in +Z) -- it does not automatically center or extend itself to guarantee it spans a target's full depth. Positioning one to look like a clean through-cut (for example via relation: {type: "centered_on", target: someTarget}) is easy to get wrong: centered_on's anchor is the target's centroid, but the cutter's own Z=0 is still its base, not its center, so the cutter ends up straddling the anchor asymmetrically (spanning upward from it) rather than symmetrically around it, and may only partially penetrate the target without any validation error to catch it -- semantic validation's footprint/depth bounds checks are hole/slot/shell/fillet/chamfer-specific and don't apply to a bare subtract/intersect component. For intersect specifically, size and position the trimming shape generously in every dimension except the one doing the actual clipping (see examples/composable/d-shaft-with-intersect-flat.json, where the trimming box is taller and wider than the shaft in every direction except the one Y face that actually flattens it) -- an intersect shape that's accidentally too small in an unintended dimension trims material you meant to keep, with no validation error to catch it either. Prefer a hole/slot feature over a hand-authored subtract component whenever a guaranteed full-depth cut is the intent; reach for subtract/intersect when the cut genuinely needs to be an arbitrary shape, a deliberately partial-depth pocket, or (for intersect) a clip to a flat or custom footprint.

Supported feature kinds include hole, slot, counterbore, countersink, fillet, chamfer, text, shell, and thread. Each feature's parameters object is schema-validated against a kind-specific shape (for example hole requires diameter and accepts an optional depth, axis, countersink, and counterbore). A feature's target may reference either a component id or another feature id (for a stacked feature, such as a counterbore on top of a hole); semantic validation checks that target refers to a known component or feature id and rejects a feature that targets itself. Both components and features accept an optional description string for explaining intent (for example why a hole exists or what a component is for) to whoever or whatever implements the part next.

When a hole or slot feature targets a component directly (not another feature) and uses the default z axis, semantic validation also sanity-checks its size against the target's dimensions: the hole diameter (or the larger of the slot's length/width) must not exceed the target's in-plane dimension (width for box-like kinds, diameter/outerDiameter for cylindrical kinds), and a numeric depth must not exceed the target's height/thickness. This check is skipped for non-default axes and for kinds without a clean footprint/depth split (rib, wedge), where it would otherwise be ambiguous.

shell hollows out its target: parameters is {thickness, openFaces}, both required -- openFaces (at least one entry, using the same top/bottom/front/back/left/right/side convention as Relation.face) lists which faces of the target's bounding box to remove, since the underlying operation has no "fully sealed, no opening" mode. Semantic validation sanity-checks thickness against the target's dimensions the same way hole/slot checks size (must be less than half of the target's smallest relevant dimension, skipped for kinds without a clean footprint/depth split). Which openFaces actually produce correct geometry depends on the target's kind -- top/bottom are reliable everywhere a footprint/depth split exists; front/back/left/right are reliable for box/plate/tab but not rounded_box (its flat walls are adjacent to the corner fillets); side isn't reliable for cylinder/boss; and tube/extruded_profile aren't supported as shell targets at all. See "composable_part generator" in docs/generators.md for exactly why, and the exact support matrix. An unsupported (kind, face) combination is dropped with a warning by the brepjs generator rather than silently producing wrong geometry. A target with multiple pattern instances already fused into one shape can't be shelled at all (warned, not silently skipped) -- a real kernel limitation (the underlying .shell() operation throws on a compound of disjoint solids, regardless of how the faces to remove were identified), unlike fillet/chamfer below, which fully support a patterned target. Unlike a cut-based feature, shell replaces its target's shape in place, so declaring it before or after another feature on the same target changes the result -- see examples/composable/open-top-enclosure-shell.json.

fillet and chamfer apply to a subset of the target's edges: parameters is {radius, edges} for fillet and {distance, edges} for chamfer, both fields required. edges is one of "vertical" (the target's edges parallel to its own local Z axis), "top"/"bottom" (the perimeter edges of the target's top/bottom face), or "all" (every edge on the target -- a full 3D round-over, like a soap bar). Semantic validation sanity-checks radius/distance the same way shell checks thickness (less than half of the target's smallest relevant dimension). Which (kind, edges) combinations actually work depends on the target's geometry -- see "composable_part generator" in docs/generators.md for the exact support matrix and why each restriction exists (a plain cylinder has no straight edges for "vertical" to find, for example; "all" is restricted to box/plate/tab only, not the full "vertical"+"top"+"bottom" set, since a curved kind's extra seam edge and rounded_box's own already-baked-in fillet both carry real, documented risk). An unsupported combination is dropped with a warning rather than guessed at. Unlike shell, fillet/chamfer fully support a target with multiple pattern instances already fused into one shape (its own pattern, or membership in a patterned transforming group): "vertical" and "all" need no special handling (both match every qualifying edge across the whole shape, or compound of disjoint instances, automatically), and "top"/"bottom" replicates its face lookup across every actual instance, combining all of them into one call so every instance gets filleted/chamfered, not just one. Like shell, fillet/chamfer replace their target's shape in place, so declaration order matters -- and, notably, stacking both on the same target is order-sensitive in a way that isn't just about which cut happens first: real-kernel testing found chamfering a target's vertical edges after filleting its top edges fails outright at the kernel level, once the fillet has shortened those vertical edges, while chamfering first (so the fillet is the last operation, seeing full-length edges) works cleanly. See examples/composable/rounded-top-chamfered-lid.json for a worked example that intentionally uses the working order.

text embosses (raises) or engraves (recesses) a string onto its target: parameters is {content, depth, fontUrl} required, plus optional size (defaults to 10mm) and mode ("emboss" default, or "engrave"). Unlike shell/fillet/chamfer, text is positioned via the ordinary position/relation pipeline shared with hole/slot (typically relation: {type: "attached_to_face", face: "top"} to sit it on a target's top surface), so it works against any target kind, with no support-matrix restriction. brepjs has no bundled default font, so fontUrl is required and must be a real, fetchable http(s):// URL or a data: URI with the font bytes inlined -- a local file path or file:// URL will not work (Node's built-in fetch(), which loadFont() uses directly, errors on it outright; semantic validation rejects this specifically). Semantic validation also checks engrave mode's depth against the target's own depth dimension (an engraved recess deeper than the target itself would cut all the way through). Verifying a text-using spec's generated geometry against a real kernel requires network access to fetch the font, unlike every other feature. See examples/composable/id-tag-with-embossed-and-engraved-text.json for a worked example combining both modes.

thread builds a real helical screw-thread ridge via brepjs's thread() operation, either fused onto a target's outer surface (mode: "external", the default -- a printed screw, bolt, or threaded post) or cut from a target's inner bore (mode: "internal" -- a printed nut or threaded insert). parameters requires pitch (axial distance per full turn) and height (total thread length along the target's axis, starting at the feature's own resolved position, Z=0 relative to the target like every other feature); optional depth (radial thread height, defaults to 0.6 x pitch), toothHalfWidth (axial half-width of the tooth at the root, defaults to 0.42 x pitch), crest (a flat-crest half-width for a trapezoidal/Acme/square-thread profile instead of a sharp V; must be less than toothHalfWidth), sectionsPerTurn (loft resolution per turn, default 20), and lefthand. 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 diameter (a cylinder/boss, external only), outerDiameter (a tube, external), or innerDiameter (a tube, internal), or, for a thread stacked on a hole feature (internal only, the same stacking convention counterbore/countersink already use), the hole's own diameter -- so the thread always sits exactly flush with the surface it belongs to, with no way to author a mismatched radius by mistake. Semantic validation 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) 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's emboss/engrave, no overlap-margin trick is 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 something this generator trims; harmless in practice (real-kernel-verified valid geometry either way), just worth knowing before assuming a thread feature's height is an exact, hard boundary. See examples/composable/threaded-post-and-nut.json for a worked example combining both modes: an external thread on a boss and an internal thread cut into a tube's own bore.

A feature's cut is scoped to its target (and only its target), not the whole assembled part — a "through" hole/slot, in particular, uses a deliberately oversized cutter to guarantee it fully penetrates its target regardless of that target's exact thickness, so an unscoped cut would otherwise be free to bleed into any other component that happens to sit along its axis, silently drilling something the feature never named. target resolves to the actual cut scope as follows: a component id cuts just that component; a group id cuts every one of the group's current memberIds, using the same cutter geometry for each (useful for one hole that's meant to pass through several stacked, grouped parts at once, like a bracket bolted to a base plate); a feature id (a stacked feature, like a counterbore on top of a hole) resolves recursively through that feature's own target down to the underlying component(s). If a target doesn't resolve to any component currently present as an add shape (for example it names a subtract-only component), the cut is skipped and a warning is produced rather than silently doing nothing.

Constraints

constraints is an optional array of declarative assertions. There are two types today, dimension and clearance, with different scopes and different validation-time guarantees.

dimension constraints are checked at validation time against the part's already-authored numbers -- not a solver: every value referenced must already be concretely specified elsewhere in the spec (a component's dimensions, a feature's parameters). This is the same principle every built-in bounds check in this schema already follows (a hole's diameter against its target's width, a shell's thickness against half the target's smallest dimension, and so on), just made author-expressible for relationships the schema doesn't hardcode -- for example "this boss must fit inside that hole with at least 0.2mm of radial clearance," a relationship between two arbitrary components the schema has no built-in opinion about. Shape: {type: "dimension", left, operator, right, margin?, id?, description?}. left/right are each either a literal number or a DimensionRef ({ref, key}, where ref is a component or feature id and key is a property name within that node's dimensions (component) or parameters (feature) object). operator is one of <, <=, >, >=, ==, !=. margin (default 0) is added to the right-hand value before comparing, so left >= right with margin: 0.2 means "left must exceed right by at least 0.2, not just be greater" -- the natural way to express a clearance or fit requirement. id is optional and only used to label the constraint in validation error messages (falling back to its index in the array, #0, #1, ...); constraints aren't targetable by anything else, unlike component/feature/group ids.

Semantic validation resolves both dimension operands (rejecting a ref that doesn't exist, or a key that's missing or non-numeric on the referenced node) and, if both resolve, evaluates left OPERATOR (right + margin), reporting a validation error if it doesn't hold. This deliberately only resolves dimension/parameter values, not positions, patterns, or relations. See examples/composable/clearance-fit-boss-and-cap.json for a worked example -- a genuinely spatial-sounding relationship ("this bore must clear that boss radially") that's still expressible as a plain dimension comparison between two diameters, since it never actually needs either component's position.

clearance constraints are for the relationship dimension constraints can't express: a minimum gap between two components' actual resolved positions, for example "the latch arm must clear the housing wall by 0.3mm to flex freely." Shape: {type: "clearance", a, b, minDistance, id?, description?}, where a/b are component ids (not features or groups) and minDistance (in millimeters, >= 0) is the required gap; 0 means the two components must not overlap but may touch. Checking whether a clearance constraint actually holds needs each component's fully resolved world position -- relations, rotation, group/pattern composition -- which only the TypeScript brepjs generator can compute (the same reason the connectivity check below is generator-only, not semantic validation). Semantic validation (shared by both languages) therefore only checks that a/b reference real, distinct components with a well-defined bounding box (not rib/wedge, which have none -- see "Component kinds" above); a spec with a clearance constraint whose gap is actually violated still validates cleanly (valid: true), and the violation is reported as a generator warning, not a validation error, and only by generateBrepJs -- there is no Python-side equivalent, since the Python package has no composable_part generator at all. Like the connectivity check, the gap is approximated via each component's (every pattern instance's) axis-aligned bounding box, not real boolean geometry, so it can miss a real gap between two components whose boxes still overlap, but never wrongly clear a genuine violation between the boxes themselves. See examples/composable/latch-arm-with-clearance-constraint.json for a worked example, real-kernel-verified to match its hand-derived combined volume exactly.

Relations

Supported relation types are absolute, on_top_of, attached_to_face, centered_on, aligned_with, offset_from, and mirrored_from. target is required for every type except absolute, and attached_to_face additionally requires face (one of top, bottom, front, back, left, right, side). target may be a component id, a feature id, or a group id.

position (on the component, feature, or group itself) is the only offset mechanism: there is no separate offset field on the relation. Each relation type resolves to an anchor point, and position is then added on top of that anchor — for absolute (or no relation at all), the anchor is the origin, so position is an absolute placement.

Anchor points are defined against the target's axis-aligned bounding box (its footprint and height/thickness), not its visual surface — this keeps the rule uniform across component kinds, including cylindrical ones, rather than needing kind-specific curved-surface math:

  • on_top_of: anchor is the center of the target's top face (target's X/Y center, Z = target's base + height/thickness).
  • attached_to_face: anchor is the center of the named face of the target's bounding box — top is the same as on_top_of; bottom is the X/Y center at the target's base Z; front/back are the Y-min/Y-max face centers; left/right are the X-min/X-max face centers. side is only meaningful for cylindrical kinds (cylinder, boss, tube) and anchors at the +X point on the circumference at mid-height — for anything else around the circumference, rotate the child explicitly with rotation.
  • centered_on: anchor is the target's bounding-box centroid (X/Y center, Z = target's base + height/thickness ÷ 2).
  • aligned_with: anchor shares the target's X/Y center (the same centerline) but leaves Z unconstrained (Z = target's base; override with position.z). Use this when two components share an axis but sit at different heights, for example a shaft aligned with a bearing bore.
  • offset_from: anchor is the target's own local origin, with no automatic footprint/face reasoning — position must express the full offset. This is the relation to reach for when none of the smarter anchors apply.
  • mirrored_from: not a positional anchor. It mirrors the target's own resolved position across the plane perpendicular to mirrorAxis (default x, meaning the YZ plane — negate X) through the part's origin. position is still added on top of the mirrored point if set. Rotation is not automatically mirrored; set rotation explicitly on the mirrored component/feature/group if its orientation also needs to change.

A relation's anchor position correctly follows a rotated target — whether rotated directly via its own rotation, or by belonging to a transforming group with its own rotation — so on_top_of a component that's been tipped on its side, for example, anchors to where that component's top face actually ended up, not where it would be unrotated. But by default this only affects the anchor point; it does not rotate the dependent component or feature itself to match the target's new orientation (the same principle as mirrored_from's rotation note, above, generalized to every relation type). A component resting on_top_of a rotated target will, by default, sit with its base at the correct point in space but keep growing along its own (typically world) Z axis, not the target's rotated face normal.

Set relation.inheritRotation: true (component and feature relations only, not group relations) to opt into matching the target's orientation instead of hand-copying and re-deriving the same rotation value onto every dependent down a chain: the dependent's own rotation (if it has one) is composed on top of, not instead of, the target's fully-resolved rotation (the target's own rotation, further composed with its transforming group's rotation if grouped, and, transitively, with whatever rotation the target itself inherited if it also set inheritRotation). A hole/slot feature with inheritRotation: true gets its cutting axis tipped along with a rotated target too, so it drills perpendicular to a tipped surface instead of straight down in world Z — see examples/composable/angled-sensor-mount-with-inherited-rotation.json for a worked example combining both. inheritRotation is not applied automatically for mirrored_from: the target's rotation is copied verbatim, not mirrored, matching how mirrorAxis only mirrors position — combine with an explicit rotation if the mirrored orientation also needs adjusting.

rib and wedge targets don't have a well-defined bounding-box footprint (see the dimension shapes above), so on_top_of/attached_to_face/centered_on/aligned_with against them fall back to the target's own origin — position must express the full offset manually, the same as offset_from. The same fallback applies when the target is a group, since a group's own footprint is only as well-defined as its members' combined extent.

A relation may not target a patterned component or feature (one with pattern set) unless it also sets targetInstance to anchor to one specific instance instead (see "Patterns" below) -- the anchor rules above otherwise assume a single instance, and a pattern has no one instance to anchor to by default. Without targetInstance, this is a hard validation error, not a fallback, since there's no position a patterned target could reasonably resolve to; targetInstance is itself rejected when target is a patterned group rather than a component/feature (a different anchor-resolution shape, out of scope for this mechanism -- see "Patterns"). CSG relationships (appliesTo, a feature's own target) are unaffected by any of this — the shape of a patterned entry (the union of its instances) is well-defined regardless of anchor-point ambiguity, so cutting from or drilling into a patterned component works normally, and always affects every instance (there is no way to scope a cut to just one).

Semantic validation checks relation.target for every relation type on components, features, and groups; rejects a relation that targets its own component/feature/group or a patterned target; and detects relation cycles of any length (for example A on_top_of B and B on_top_of A, or longer chains). A feature's bare target (used as both an implicit position anchor when no relation is set, and as the CSG cut scope described above) is included in that same cycle check even without an explicit relation, so two features that target each other are rejected here rather than causing unbounded recursion wherever something tries to resolve them. Semantic validation does not resolve the anchor math above — that's the interpreter's job (the coder agent, or a future generator).

Patterns

Components, features, and groups all accept an optional pattern (rectangular, linear, or radial repetition, shared with the part-family holes/slots convention via common.schema.json). All instances share the single id on the component/feature/group; appliesTo and a feature's own target (CSG scope) always mean the whole patterned entry (the union of every instance) and have no way to select just one. A relation, however, can anchor to one specific instance instead of the pattern's center via relation.targetInstance (a zero-based index): the classic case is a different counterbore on just one corner of a bolt pattern, without needing to give the counterbore its own matching pattern (which would put one on every corner) or hand-author that one corner as a wholly separate, non-patterned hole+counterbore pair. targetInstance is only valid when target is itself a patterned component or feature (not a group -- see below) and must be less than that pattern's total instance count (countX × countY for rectangular, count for linear/radial); semantic validation checks both, in exact parity across languages, without needing any position resolution (instance count is pure arithmetic on the pattern's own authored numbers). Resolving the actual anchor point for a specific instance does need position resolution, so -- like the geometric side of a clearance constraint -- that part only happens in the TypeScript brepjs generator; see examples/composable/corner-counterbore-with-target-instance.json for a worked example, real-kernel-verified to match its hand-derived volume exactly. A group's own pattern is out of scope for targetInstance in this version: it repeats every member of the group together, as one rigid unit, and addressing "instance N" of that composite unit would need a different anchor-resolution shape than a single component/feature's own pattern uses -- see Groups below for how a feature already "moves with" a patterned group's members without needing this mechanism at all.

The resolved position/relation anchor is the center of the pattern, not its first instance:

  • Rectangular (countX, countY, spacingX, spacingY): instance (i, j) for i in 0..countX-1, j in 0..countY-1 is offset by ((i - (countX-1)/2) * spacingX, (j - (countY-1)/2) * spacingY) from the anchor. This centers the grid on the anchor regardless of whether the counts are odd or even, which is what a symmetric bolt pattern needs.
  • Linear (count, spacing, axis): instance i for i in 0..count-1 is offset by (i - (count-1)/2) * spacing along axis from the anchor.
  • Radial (count, radius, startAngle, sweepAngle): instances sit on a circle of radius centered at the anchor. Angle 0 is the +X direction, increasing counterclockwise. If sweepAngle is given, instances are spaced evenly across it inclusive of both endpoints (so count: 2 puts one instance at startAngle and one at startAngle + sweepAngle); if omitted, instances are spaced evenly around a full 360° circle (dividing by count, not count - 1, so there's no duplicate instance at the seam).

A component's or feature's own pattern doesn't propagate through a relation or a stacked feature: since a patterned entity's resolved anchor is its single center point (not each instance), a feature that targets a patterned component or feature (for example a counterbore stacked on a patterned hole) anchors to that one center point too, not to every instance. To stack a feature on every instance of a pattern, give the stacked feature the same pattern explicitly — it doesn't inherit its target's.

A group's pattern is the one exception to that rule: a feature targeting a member of a patterned group does repeat automatically across every instance, without needing its own pattern — the same "moves with it implicitly" behavior a feature already gets from an unpatterned group's position/rotation (see Groups below). This asymmetry is intentional: a component/feature's own pattern is about repeating that one entity's geometry, which a dependent feature has no inherent reason to mirror, while a group's pattern repeats an entire sub-assembly (components and, implicitly, their features) as a single logical unit, which a feature belongs to by targeting one of its members.

Groups

groups is an optional array of named collections of components: {id, description?, memberIds, position?, rotation?, relation?, pattern?}. memberIds must reference existing component ids — groups are not nestable (a memberIds entry can't be another group's id), and a component may belong to more than one group. Features are not group members directly; they stay associated with their target component and move with it implicitly.

A group's position/rotation compose with, rather than replace, each member's own individually-authored position/rotation: author each member as if it were positioned within the group's own local space, then the group's transform (rotation first, then position, same order as everywhere else) carries the whole set into the part. This is the main reason to use a group — author a sub-assembly's internal layout once, then move or mirrored_from-mirror it as a unit instead of hand-duplicating and re-deriving every member's coordinates. See examples/composable/cable-tie-anchor-strip.json for a worked example: two identical single-component "feet" are each authored at their own local origin, then one group positions the left foot and a second group mirrors it into the right foot via relation: {type: "mirrored_from", target: <first group>}.

A group's id can be used as a relation.target by other components, features, or groups — see the fallback note under Relations above for how anchors resolve against a group target. A group's own relation.target may not be one of its own memberIds: that member's position would be re-computed twice (once on its own, then again as the anchor the group's transform is built from and then re-applies to it), which is confusing to reason about and essentially never the intended result — target a different component instead. Semantic validation checks group id uniqueness, that every memberIds entry exists, and includes group relations in the same target-existence and cycle-detection checks as components and features — plus, since a grouped component's final position depends on its group's own transform (see below), a component that belongs to a transforming group has an implicit dependency edge on that group for cycle-detection purposes too, even though memberIds isn't itself a relation.

A relation or feature target that resolves to a component belonging to a transforming group anchors to that component's final, fully-transformed world position — the group's position/rotation applied on top of the member's own — not its pre-group authoring-space position. This is also why a feature "moves with" its target component when that component is grouped (see below): a feature with no explicit relation implicitly anchors to its target's position, and that resolution already uses the target's final world position. Targeting a group directly and targeting one of its members are both valid and can differ: the group's own resolved point (its own position/relation, before any per-member offset) versus that specific member's own final location.

A group's pattern (rectangular, linear, or radial, the same shape used by component/feature patterns — see Patterns below) repeats the whole group — every member, as one rigid unit — instead of moving it once: the motivating case is a multi-component cluster (say, a standoff and its own subtract-cutter mounting hole) authored once and repeated at several positions, instead of hand-duplicating and re-deriving each cluster's components at every instance's coordinates by hand. Per-instance offsets are computed in the group's own local frame, before the group's own rotation is applied — the same convention a component's own pattern already uses — so the instances rotate together with the group. A feature that targets a member of a patterned group isn't a group member itself, but still "moves with it implicitly" the same way it does for an unpatterned group: it repeats its own cut across the same pattern as its target, landing on every actual instance rather than only the pattern's center point. See examples/composable/quad-standoff-mounting-plate.json for a worked example: one standoff, authored once with a hole feature targeting it, is repeated at all four corners of a plate by a single group pattern.

A component may only be a member of more than one group if at most one of those groups actually defines position, rotation, relation, or pattern — the rest must be purely organizational (no transform of their own, like the standoffs tag in vented-sensor-mount-with-standoffs.json). Two or more transforming groups both claiming the same member would be ambiguous (which transform applies, or do they compose, and in what order?), so semantic validation rejects it rather than picking an arbitrary answer. A patterned group, like a patterned component or feature, has no single instance to anchor to, so it may not be used as a relation.target (see Patterns and the note under Relations above) — CSG (appliesTo, a feature's target) is unaffected, since cutting from or drilling into a patterned group's members works normally regardless of anchor-point ambiguity.

CSG operations (add/subtract, including appliesTo) always operate on each component's fully resolved position — its own position/relation, with any single transforming group's position/rotation applied on top. There's exactly one coordinate frame ("world space") that every component, regardless of grouping, resolves into before any boolean operation happens; a subtract component's appliesTo targets are cut in that same shared frame, whether or not either component belongs to a group.

Worked examples

  • examples/composable/vented-sensor-mount-with-standoffs.json uses rotation, appliesTo, a mirrored_from relation, a stacked feature (a counterbore targeting a hole feature), description on both components and features, and a simple organizational groups entry with no transform of its own.
  • examples/composable/cable-tie-anchor-strip.json uses group-level position and a group-level mirrored_from relation to place a two-post sub-assembly without hand-duplicating mirrored coordinates; its two hole features (one per post) demonstrate a feature anchoring correctly to a grouped component's final world position, not its pre-group position.
  • examples/composable/wedge-ramp-with-countersunk-mount.json uses a wedge component and a stacked countersink feature on top of a hole feature, both implemented by the brepjs generator (see docs/generators.md).
  • examples/composable/adapter-plate-with-two-hole-patterns.json uses two independent pattern-repeated features on the same target: a rectangular 2x2 corner bolt pattern and a linear row of ventilation holes.
  • examples/composable/quad-standoff-mounting-plate.json uses a group-level pattern to repeat a standoff (and, implicitly, its hole feature) at all four corners of a plate, authored once instead of hand-duplicated four times.
  • examples/composable/angled-sensor-mount-with-inherited-rotation.json uses relation.inheritRotation on both a component (a mounting pad that sits flush on a support tipped 20 degrees) and a feature (a hole drilled perpendicular through that pad), chained transitively through two levels.
  • examples/composable/l-shaped-bracket-custom-profile.json uses extruded_profile for an L-shaped cross-section, with a hole feature safely positioned in each arm.
  • examples/composable/open-top-enclosure-shell.json uses shell to hollow a box into an open-top tray, then a hole feature declared afterward to cut a cable pass-through through the resulting thin wall -- demonstrating that a feature's position in the features array, not just its target, affects the result.
  • examples/composable/rounded-top-chamfered-lid.json uses bounded chamfer (edges: "vertical") and fillet (edges: "top") on the same target, in the specific order real-kernel testing found necessary for the pair to stack successfully.
  • examples/composable/id-tag-with-embossed-and-engraved-text.json uses text in both emboss and engrave modes on the same target's top face, with fontUrl pointing at a live font URL.
  • examples/composable/d-shaft-with-intersect-flat.json uses an intersect component to trim a round shaft to a D-profile flat, real-kernel-verified to match the hand-derived circular-segment volume exactly.
  • examples/composable/knob-with-sphere-cap.json uses a sphere component resting on top of a cylindrical stem via an ordinary on_top_of relation, real-kernel-verified to match the hand-derived combined cylinder+sphere volume exactly.
  • examples/composable/grommet-with-torus-ring.json uses a torus component resting on top of a plate, real-kernel-verified to match the hand-derived combined plate+torus volume exactly.
  • examples/composable/dome-with-ellipsoid-cap.json uses an ellipsoid component resting on top of a plate, real-kernel-verified against the same generator-side position math as every other kind (brepjs's own ellipsoid() primitive has a small inherent volume-measurement tolerance, confirmed by measuring it in isolation, so this example's total is a close but not bit-exact match to the pure mathematical ellipsoid volume formula -- unlike sphere/torus, whose underlying brepjs primitives measure exactly).
  • examples/composable/latch-arm-with-clearance-constraint.json uses a clearance constraint to assert a minimum 0.3mm gap between a fixed wall and a latch arm positioned 0.4mm away, real-kernel-verified to match the hand-derived combined volume of the two (non-touching, non-overlapping) components exactly.
  • examples/composable/corner-counterbore-with-target-instance.json uses relation.targetInstance to stack a counterbore on just one corner of a 2x2 bolt-hole pattern, real-kernel-verified to match its hand-derived volume exactly.
  • examples/composable/pulley-with-revolved-profile.json uses a revolved_profile component (a V-groove pulley cross-section revolved around Z) with a hole feature through it, real-kernel-verified to match its hand-derived conical-frustum volume exactly.
  • examples/composable/rounded-corner-bracket-with-arc-profile.json uses a curved (arc) point on an extruded_profile to author a rounded corner directly in the footprint, real-kernel-verified to match its hand-derived area-minus-fillet volume exactly.
  • examples/composable/flared-hub-with-curved-revolve-profile.json uses a curved (arc) point on a revolved_profile to flare a hub smoothly instead of stepping it, real-kernel-verified to match its hand-derived volume exactly.
  • examples/composable/square-to-round-duct-adapter.json uses loft_profile to blend a square flange into a round duct, whose round profile is itself built from 4 curve arcs, real-kernel-verified valid with a plausible volume.
  • examples/composable/threaded-post-and-nut.json uses thread in both modes: an external thread fused onto a boss's outer surface, and an internal thread cut into a tube's own inner bore, both real-kernel-verified valid.
  • examples/composable/gooseneck-cable-guide.json uses swept_profile to sweep a square channel along a bent 3D path (up, over, and partway back down), real-kernel-verified valid with a plausible volume.
  • examples/composable/fully-rounded-electronics-case.json uses a bounded fillet feature's edges: "all" to round over every edge of a box (not just its vertical corners), real-kernel-verified to match a hand-derived Minkowski-sum volume formula exactly.
  • examples/composable/orifice-plate-with-oring-groove.json uses a torus component with operation: "subtract" to cut an O-ring groove into a flat face, real-kernel-verified to match a hand-derived volume (plate minus bore minus exactly half the torus's own volume, since the ring's center sits exactly on the plate's top surface) exactly.