git:20261008.13091b2 to git:20261011.e893f51

4 added, 0 removed. Audit A to A.

---
name: kernelcad-features
description: Edge and face features — fillet, chamfer, shell, face refs through operations, holes, cutouts. Use when adding rounded edges, recesses, bolt patterns, or carved profiles to a kernelCAD model.
---
# kernelCAD — edge and face features
## Face refs through operations
Canonical face refs (`{ face: 'top' }`, etc.) work transparently across transforms (`.translate`, `.rotate`, `.scale`, `.reflect`, `.mirror`) and unambiguous booleans (`.subtract`, `.union`, `.intersect`). The kernel walks each face's lineage back to its originating primitive and forward through history.
Two cases produce explicit diagnostics:
- `feature.face-ref.ambiguous-after-split` — an upstream boolean split the named face into multiple children (e.g., a divider cut splits `top` into two halves). Geometry-fallback disambiguation is planned for a future release; current workaround: apply the edge/face feature before the splitting operation, or use a query-based selector.
- `feature.face-ref.removed` — an upstream boolean removed the named face entirely. Reference a different face that still exists in the current shape.
- `feature.hole.no-target-face` — the hole entry face matched, but no body sits along the bore axis to drill into. Pick an entry face on a different body, or verify the target body extends along the bore axis.
- `feature.hole.cut-missing` — a bore centre is off the entry face, the depth is zero, or material is still on the bore axis after the cut. The feature fails; it never ships a part with a missing hole. The message gives the face centre, the u/v axes and the u/v span. See "Hole position frame" below.
- `feature.created-ref.fallback-used` — *warning* (not error). The created-ref resolver fell back to a geometry-snapshot match after the topology lookup lost the face. The downstream feature still resolves. Lock the ref against future edits by naming the upstream feature with `.name()` and addressing it by `<name>.<slot>`.
(The same `feature.face-ref.*` codes apply to both edge features (`fillet`, `chamfer`) and face features (`shell`).)
### Created face refs
Subtractive features (`hole`, `cutout`) write created face refs that downstream
ops can address by `<featureName>.<slot>`:
```typescript
const plate = box(100, 60, 5)
.hole('top', { u: 0, v: 0, diameter: 6, depth: 3, name: 'pilotHole' });
plate.fillet(0.2, { face: 'pilotHole.floor' });
```
Slots written by `hole`: `wall`, `floor`, `wall-back`, `counterbore-wall`,
`counterbore-floor`, `countersink-cone`, `entry-rim`, `floor-rim`,
`wall-back-rim`. Slots written by `cutout`: see `cutoutClassifier`.
When an upstream op rewrites enough topology that the slot-by-name lookup
loses the face, the resolver falls back to a geometry-snapshot match
(centroid + normal + area + surfaceType from the create-time fingerprint).
Successful fallback emits `feature.created-ref.fallback-used` (warning,
not error) — the downstream feature still resolves. Lock the ref against
future edits by naming the upstream feature with `.name()` and addressing
it by `<name>.<slot>`.
Per-primitive canonical face applicability:
- Box: all six (`top` / `bottom` / `left` / `right` / `front` / `back`).
- Cylinder: only `top` and `bottom` (the disc end-caps). Side faces have no canonical name.
- Sphere: none. Sphere with any `{ face }` filter → error.
## Face authoring — emboss & project
Two face-bound features add geometry onto an existing body's face. They author *into* the face's local (u, v) parametric space rather than into the world frame, so the result rides the body through downstream transforms.
Use them whenever a branded consumer product needs raised text, an engraved logo, or a label silhouette: eyewear temples with brand text, bottles with logos, appliance plates with model numbers. Flat-extruded glyphs in world coordinates look like a toy; embossed, engraved, or surface-wrapped text looks like a real product.
### `Shape.embossText({...})`
Raises or recesses text on a target face. Signed `depth` selects fuse vs cut:
```typescript
// Ray-Ban brand text raised 0.4 mm out of the temple's top face
const tempRayBan = box(130, 4, 2)
.embossText({
textContent: 'Ray-Ban',
face: 'top',
size: 2,
depth: 0.4, // > 0 ⇒ emboss out
align: 'center',
anchorU: 0.5, anchorV: 0.5,
});
// CE compliance mark engraved 0.3 mm into the underside
const compliance = tempRayBan.embossText({
textContent: 'CE',
face: 'bottom',
size: 1.2,
depth: -0.3, // < 0 ⇒ engrave in
anchorU: 0.85, anchorV: 0.5,
});
```
| Field | Type | Notes |
|---|---|---|
| `textContent` | `string` | UTF-8, non-empty, non-whitespace. |
| `size` | `Editable<number>` | Glyph cap height in mm. |
| `depth` | `Editable<number>` | Signed mm. Positive = emboss out (fuse); negative = engrave in (cut). Zero rejected. |
| `face` | `FaceSelector \| string` | Canonical name (`'top'` etc.) or face label. |
| `align` | `'left' \| 'center' \| 'right'` | Default `'center'`. |
| `anchorU`, `anchorV` | `Editable<number>` | Face-local UV in `[0, 1]`. `0 = umin / vmin`, `0.5 = centre`, `1 = umax / vmax`. |
| `rotation` | `Editable<number>` | CCW degrees in the face tangent plane. |
| `scaleMode` | `'original' \| 'native' \| 'bounds'` | Default `'original'` (preserves mm size; planar faces only). |
| `fontFamily` | `string?` | Logical name or `.ttf` path. Default Liberation Sans. |
The emboss feature lowers via `replicad.drawText → drawing.sketchOnFace(face, scaleMode) → sketch.extrude(|depth|) → parent.fuse|.cut`. The fuse / cut runs through history-aware booleans (`fuseWithHistory` / `cutWithHistory`) so the newly-created glyph faces carry created-ref labels on the result `historyMap` and can be targeted downstream:
| `depth` sign | Label on planar glyph face | Label on side walls |
|---|---|---|
| `depth > 0` (emboss) | `embossed-text` | `embossed-text-wall` |
| `depth < 0` (engrave) | `engraved-text-floor` | `engraved-text-wall` |
Use these names to chain further features against the embossed/engraved geometry — for example `result.fillet(0.1, { face: 'embossed-text-wall' })` to round the glyph side walls on a raised brand mark.
### `Shape.projectCurve({...})`
Wraps a 2D closed curve onto a 3D face along the face normal. Returns a `Sketch` (face-bound) that composes with `.extrude(d)` to land a raised silhouette, or pair with the parent's `.subtract(...)` for an engraved logo:
```typescript
// Bottle body with a brand silhouette projected and raised on the top face.
// The source curve is expressed as a SketchCommand[] (the same wire format
// emitted by `path().moveTo(...).lineTo(...).close()`).
const mm = (n) => ({ expression: String(n), unit: 'mm', evaluated: n });
const logoCommands = [
{ kind: 'moveTo', x: mm(-8), y: mm(-5) },
{ kind: 'lineTo', x: mm( 8), y: mm(-5) },
{ kind: 'lineTo', x: mm( 8), y: mm( 5) },
{ kind: 'lineTo', x: mm(-8), y: mm( 5) },
{ kind: 'close' },
];
const bottle = cylinder(120, 30);
const raised = bottle
.projectCurve({ source: { kind: 'sketchCommands', commands: logoCommands }, face: 'top' })
.extrude(0.4); // extrude along the face normal — raises the silhouette
const branded = bottle.union(raised);
```
| Field | Type | Notes |
|---|---|---|
| `source` | `ProjectCurveSource` | `{ kind: 'sketchCommands', commands }` or `{ kind: 'drawing', drawingJson }` (drawing-JSON branch is a follow-up). |
| `face` | `FaceSelector \| string` | Canonical / label. |
| `scaleMode` | `'original' \| 'native' \| 'bounds'` | Same as embossText. |
| `asEdge` | `boolean?` | **NOT IMPLEMENTED.** When `true`, the lowerer emits `feature.project-curve.no-intersection`. Use closed-curve projection. |
### Recovery table
| Diagnostic code | What it means | Fix |
|---|---|---|
| `feature.emboss-text.depth-zero` | `depth === 0` rejected at capture. | Pass a non-zero `depth`: positive = emboss, negative = engrave. |
| `feature.emboss-text.face-too-small` | Glyph block exceeded the face bounds at wrap time. | Lower `size`, pick a larger face, or set `scaleMode: 'bounds'`. |
| `feature.emboss-text.boolean-noop` | The emboss/engrave boolean changed nothing — the glyph tool never intersected the body (anchored over a hole, off the face, or the wrong depth sign). | Move the anchor over solid material and check the `depth` sign (positive = emboss out, negative = engrave in). |
| `feature.project-curve.curve-empty` | `source.commands` had zero entries. | Build the path via `path().moveTo(...).lineTo(...).close().build()` so the wire has at least one segment. |
| `feature.project-curve.no-intersection` | Closed-curve case: the curve missed the face. Open-wire case (`asEdge:true`): not implemented. | Closed: clamp curve coords into the face bounds. Open: rewrite as a closed projection. |
| `feature.face.invalid-uv-anchor` | `anchorU` / `anchorV` outside `[0, 1]`. | Clamp the anchor to `[0, 1]`. |
| `sketch.text.empty-content` | `textContent` was empty or whitespace. | Pass a non-empty string with at least one printable glyph. |
## Hole and cutout vocabulary
Three subtractive features ship with hard-coded **created face refs** so chained `.fillet()` / `.shell()` / further `.hole()` / `.cutout()` calls can address the new geometry by name without queries.
```typescript
// Single counterbored bolt hole through a plate
plate.hole('top', {
u: 10, v: 10,
diameter: 6, depth: 'through',
counterbore: { diameter: 11, depth: 4 },
});
// Bolt pattern — 4 holes, one feature record, one editable unit
plate.holes('top', {
positions: [{u: -20, v: -20}, {u: 20, v: -20}, {u: -20, v: 20}, {u: 20, v: 20}],
diameter: 5, depth: 'through',
});
// D-shaped slot via cutout (irregular shape hole() can't express)
plate.cutout(
path().moveTo(-5, 0).lineTo(5, 0).threePointsArc(-5, 0, 0, 10).close(),
{ face: 'top', depth: 6 },
);
```
### Hole position frame
`u`, `v` are mm offsets from the centre of the entry face's outer boundary.
They are NOT world coordinates. Holes already in the face do not move the
origin. Face names and axes follow the part through `.rotate()`.
| Face | u | v |
|---|---|---|
| `top` / `bottom` | +X | +Y |
| `front` / `back` | +X | +Z |
| `left` / `right` | +Y | +Z |
Convert a world point: `u = worldU - centreU`, `v = worldV - centreV`.
```typescript
// Plate spans Z 5..60, so the 'front' centre is at Z 32.5.
// Hole wanted at world X 0, Z 45.5:
plate.hole('front', { u: 0, v: 45.5 - 32.5, diameter: 3.4, depth: 'through' });
```
Every bore of `hole` / `holes` is checked. A centre off the face, a zero depth,
or material left on the bore axis fails the feature with
`feature.hole.cut-missing`.
`cutout` profile (x, y) uses the same origin, and x runs along hole `u`. On
`top`, `front` and `right`, profile y runs along hole `v`. On `bottom`, `back`
and `left`, profile y runs the opposite way (-Y or -Z).
Created refs emitted per feature kind (resolvable via `{ face: '<name>' }`):
| Ref | Emitted when |
|---|---|
| `wall` | always (cylindrical bore wall, or cutout side walls) |
| `floor` | blind only (no `'through'`, no `upToFace`) |
| `wall-back` | through (`'through'` set OR `upToFace` set) |
| `counterbore-wall` | hole/holes with `counterbore: {...}` |
| `counterbore-floor` | hole/holes with `counterbore: {...}` |
| `countersink-cone` | hole/holes with `countersink: {...}` |
Resolution rule when names collide with canonical face names: created refs always win on the result Shape. After `box.hole('top', ...)`, both `'wall'` (the new bore) and `'top'` (the remaining annular planar region of the original top face) resolve. The canonical name survives because the original face wasn't fully consumed.
`holes(...)`'s bare `'wall'` selector is collective sugar — `.fillet(0.2, { face: 'wall' })` rounds every bore lip in one call.
### Countersinks
`countersink: { diameter, angleDeg? }` cuts a real cone (default 90° included
angle): `diameter` is the rim at the entry face, and the cone narrows into the
body until it meets the bore. Size it to the screw head (ISO 10642 M4: head
Ø8.96) and a revolved flat head of that diameter sits flush with zero gap on
the cone. If a hole sub-feature (cone, counterbore, thread groove) cannot be
built, the hole fails with an error diagnostic; it is never cut without it.
### Tapped holes (internal threads)
`thread: { pitch, modeled?, clearance? }` turns a hole into an ISO metric
tapped hole. `diameter` is then the NOMINAL thread size (M6 → 6), and the hole
drills the ISO 68-1 minor diameter `diameter − 1.0825 × pitch`.
```typescript
// M6 × 1 nut thread with 0.05 mm of play; modeled: false keeps the bore only
nutBlank.hole('top', {
u: 0, v: 0, diameter: 6, depth: 'through',
thread: { pitch: 1, modeled: true, clearance: 0.05 },
});
```
| Field | Meaning |
|---|---|
| `pitch` | ISO pitch in mm, `0 < pitch ≤ diameter / 4` (M6 coarse → 1). `Editable<number>`. |
| `modeled` | `true` cuts the 60° helical groove. `false` (default) is a cosmetic thread: only the minor-diameter bore plus the recorded `threadPitch` / `threadClearance` / `threadModeled` params. Pick it for speed; a modeled thread costs seconds per hole. |
| `clearance` | mm of play, `0 ≤ clearance ≤ pitch / 8` (default 0; the cap is 0.125 mm on M6 × 1, 0.156 mm on M8 × 1.25). Grows the bore radius, the crest radius and each flank (normal to the flank) by that amount. Past the cap neighbouring groove turns would merge. `Editable<number>`. |
The thread is right-handed. Its groove centre crosses the face's `u` direction
at the entry face, so a bolt threaded with `sweep(helix(...), { spine: 'helix' })`
mates when the nut's entry face sits a whole number of pitches from the bolt
helix start. A blind modeled thread stops short of the floor and needs
`depth ≥ 2 × pitch`. Check the fit with `verify({ check: 'dfm' })` and a
`dfmSpec({ minClearance })`: the exact distance between bolt and nut equals the
clearance.
**Print tolerance (FDM).** A printed nut usually needs 0.2–0.4 mm of play, more
than the pitch/8 cap. Keep `clearance` at the cap and grow the whole internal
thread instead: a larger nominal `diameter` shifts the bore, crest and both
flanks outward together, and a radial shift δ opens each 60° flank by δ/2. So
add `4 × (play − pitch/8)` to `diameter`: an M6 × 1 nut with 0.3 mm of flank
play is `diameter: 6.7, thread: { pitch: 1, modeled: true, clearance: 0.125 }`.
The error for an over-cap clearance prints these numbers for your pitch.
## Naming features (slice 2)
When two `.hole()` (or `.cutout()`) calls land on the same target, the bare `'wall'` selector resolves to *all* their walls collectively. To address them individually, give each one a `name:` and use `<name>.<ref>`:
```typescript
plate
.hole('top', { u: -20, v: 0, diameter: 5, depth: 'through', name: 'mountFront' })
.hole('top', { u: 20, v: 0, diameter: 5, depth: 'through', name: 'mountBack' })
.fillet(0.4, { face: 'mountFront.wall' }) // only the front bore lip
.fillet(0.8, { face: 'mountBack.wall' }); // only the back bore lip (deeper fillet)
```
Names are the durable interface. Use them when the chain order may change or when the disambiguation matters semantically.
For lazy chains where naming each feature is overhead, the **ordinal fallback** form `<kind><N>.<ref>` works without any opt change:
```typescript
plate
.hole('top', { u: -20, v: 0, diameter: 5, depth: 'through' }) // hole1
.hole('top', { u: 20, v: 0, diameter: 5, depth: 'through' }) // hole2
.fillet(0.4, { face: 'hole1.wall' })
.fillet(0.8, { face: 'hole2.wall' });
```
Ordinals count chain-call order among **unnamed** same-kind features only — named features never consume an ordinal slot. If you insert a new unnamed `.hole()` between two existing unnamed ones, the ordinals shift; for stable references, use `name:`.
The resolver tries lineage matching first (canonical → label → named → ordinal), then falls back to a geometric snapshot match (centroid + normal + area) when topology lookup returns zero hits and a fallback snapshot is available. The snapshot path is implicit — agents don't see it as a separate selector form; it just makes named/ordinal references survive ops that would otherwise lose the topology link. Multi-match snapshot results emit `feature.face-ref.ambiguous-after-split`.
Selector parse rules:
- `<ref>` — collective; matches all faces with that label.
- `<name>.<ref>` — feature name match; resolves to that feature's faces only.
- `<name>[i].<ref>` — indexed access into a batched named feature (forward-compatible; slice-2 minimal collapses to `<name>.<ref>`).
- `<kind><N>.<ref>` — ordinal among unnamed same-kind features.
Names must match `/^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/` and must be unique within a chain. Both rules emit `feature.invalid-args` at script time with hints calling out the violation.
## Query selectors
### Composed-query failure isolation
`kc.q.union(a, b, c)` is **strict by default**: if any sub-query raises (e.g. `query.unknown-label` because `c` references a label that does not exist on this scene), the composed query fails with `query.composition-strict-failure`, a single named wrapper that quotes the inner code for trace. Annotate with `.asLenient()` to allow partial success — failed sub-queries contribute zero entities, the surviving sub-queries are unioned, and execution continues.
```typescript
// Strict — throws query.composition-strict-failure if 'side-bevel' is not on this design.
const filletEdges = kc.q.union(
kc.q.face(kc.q.withLabel('top')),
kc.q.face(kc.q.withLabel('side-bevel')), // may not exist
);
// Lenient — fillets what is present, skips what is not.
const filletEdgesLenient = kc.q.union(
kc.q.face(kc.q.withLabel('top')),
kc.q.face(kc.q.withLabel('side-bevel')),
).asLenient();
```
The same strict/lenient rule applies to `kc.q.intersection(...)` and `kc.q.subtraction(a, b)`.
### Runtime kind checks
`tsc --strict` rejects `kc.fillet({ edges: kc.q.face(...) })` at compile time because `kc.q.face(...)` returns `Query<FaceMarker>` and the `edges` slot expects `Query<EdgeMarker>`. The static check vanishes at JSON-AST / string-DSL boundaries — most notably the `query({ mode: 'evaluate' })` MCP tool's `query` input, where the phantom marker cannot survive `JSON.parse`. Consumers expecting a specific kind fall back to a runtime check that surfaces `query.type-mismatch` when the `Query.target` data field disagrees with the expected kind.
```typescript
// Compile-time: tsc rejects, kc.q.face(...) is Query<FaceMarker>, fillet wants edges.
kc.box(10).fillet({ edges: kc.q.face(kc.q.createdBy('arm')) }); // tsc error
// Runtime (JSON-AST input): the consumer's assertQueryKind(query, 'edge', 'fillet.edges')
// throws query.type-mismatch with a constructor-level repair:
// "use kc.q.edge(...) instead of kc.q.face(...)".
```
Queries built without a kind narrower (`kc.q.createdBy('arm')`, target `'any'`) are always accepted; they narrow downstream by the consumer's evaluator branch.
### Authoring sugar — `q` and `kc.q` inside a `.kcad.ts` script
The constructor namespace is reachable from any `.kcad.ts` source without an explicit `import`. Both forms work:
```typescript
// Top-level form — matches the api spread convention used by box(),
// cylinder(), assembly(), etc.
const lid = q.face(q.withFeatureName('case')).and(q.withLabel('lid'));
// Namespaced form — matches the SKILL.md prose voice and pairs with
// `kc.box(...)`.
const lid2 = kc.q.face(kc.q.withFeatureName('case')).and(kc.q.withLabel('lid'));
```
Both lines reach the identical constructor table and produce structurally equal `Query<FaceMarker>` values.
## Cookbook — Query DSL
Each snippet below ships as a runnable `.kcad.ts` file under `src/agent/skills/kernelcad-features/cookbook/snippets/`. The test `tests/integration/mcp/queryCookbookSmoke.test.ts` evaluates every snippet on every CI run; agents can paste these patterns into a model script and expect them to work end-to-end.
### Q-S1 — Construct and inspect a face Query
Build a `Query<FaceMarker>` lazily; the value carries the AST and the entity-kind narrower without resolving any topology. Use this whenever the same selector is reused across multiple consumers — author once, pass around as a value.
```typescript
const part = box(20, 20, 5, false, { faceLabels: { lid: 'top' } });
const lidQuery = q.face(q.withFeatureName('box1')).and(q.withLabel('lid'));
// Query<T> data fields are all serializable; chainable methods are
// non-enumerable so JSON.stringify round-trips cleanly.
JSON.stringify(lidQuery); // { "_kind": "kc.query", "target": "face", "ast": {...} }
```
See `cookbook/snippets/Q-S1-construct-and-inspect-faces.kcad.ts`.
### Q-S2 — Set algebra on face Queries
`.and(...)`, `.or(...)`, and `.minus(...)` are the three composers. Each returns a fresh `Query<FaceMarker>` whose AST records the operation, so the originals stay reusable.
```typescript
const allFaces = q.face().and(q.withFeatureName('box1'));
const floor = q.face().and(q.withLabel('floor'));
const everythingBut = allFaces.minus(floor); // subtraction
const lidOrFloor = q.face().and(q.withLabel('lid')).or(floor); // union
```
See `cookbook/snippets/Q-S2-set-algebra.kcad.ts`.
### Q-S3 — Lenient composition for optional features
`.asLenient()` flips the Query into partial-success mode: sub-query failures (e.g. an undeclared label) contribute zero entities rather than tripping `query.composition-strict-failure`. Use this when the design family has optional features.
```typescript
const filletEdges = q.union(
q.face(q.withLabel('lid')),
q.face(q.withLabel('side-bevel')), // may not exist on this design
).asLenient();
```
See `cookbook/snippets/Q-S3-lenient-composition.kcad.ts`.
## Verification gates
After applying edge/face features, run before reporting done:
| Gate | Pass criterion |
|------|----------------|
| G-eval | `kernelcad evaluate` exits 0 — no `feature.invalid-args` or `feature.face-ref.*` diagnostics |
| G-face-ref-survives-transform | Apply features BEFORE final transforms when face-ref names matter. After translate/rotate, canonical face refs (top, bottom, etc.) still resolve, but inline queries may not |
| G-no-rim-island | Fillets / chamfers on subtractive boundaries (hole rims, pocket edges) reach a CONTINUOUS edge loop — no broken arcs or unfilleted segments in renders |
| G-shell-thickness-positive | Shell offset `t` is positive (outward) AND less than the smallest local feature radius — otherwise shells inverts or self-intersects |
| G-no-overlap | `kernelcad interference` reports zero overlaps; subtractive features (`hole`, `cutout`) clear their target volume completely |
+ ## Bent tubes
+
+ `tube(name, opts)` and `tubeSpec({ ... })` route bent tubes between ports and check them against bending and clearance rules: load `kernelcad-tubes`.
+
## Related skills
- `kernelcad-authoring` — primitives and sketches that this skill operates on.
- `kernelcad-params` — bind feature dimensions (radius, depth) to editable parameters.
- `kernelcad-nurbs` — for freeform surfaces that fillet/chamfer cannot reach.