kernelcad-features · diff
git:20260914.f68a377 to git:20260928.02af8f8
29 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`. The OCCT binding is available; the lowering is not. 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). Grows the bore radius, the crest radius and each flank (normal to the flank) by that amount. `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.
## 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 |
## 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.