| name | shaperef-lineage |
| description | This skill should be used when debugging or extending stable references (topological naming) and history replay in brepjs — when a task says "ShapeRef resolves to the wrong face/edge", "ref won't resolve / returns not-found / BrokenRef", "resolveRef returns ambiguous", "add a new reference kind / ref type", "fillet face has no stable hash", "generated evolution is empty on occt-wasm", "selection lost after editing an upstream parameter", "history replay re-targets the wrong entity", "role table / assignRoles / updateRoles", "lineage ref", "topological naming", "resolveRefParams", "edit-after-reference", or when editing files under src/topology/shapeRef/ or src/operations/historyFns.ts. |
Stable references (topological naming) and history replay
Stable references name a face, edge, or vertex by its stable adjacent-neighbour roles instead of a kernel hash, so a selection survives the edits that re-hash the model. The full concept — name-by-neighbours, the four ref kinds, exact vs geometric-fallback confidence, the failure taxonomy, and per-kernel support — is owned by apps/docs/concepts/stable-references.md. Read that page first for the "why"; this skill owns only extension (adding a ref kind) and debugging (a ref that resolves wrong, to nothing, or to the wrong entity after a replay).
The public surface is the brepjs/shapeRef subpath (package.json exports), re-exported from src/shapeRef.ts, src/topology/shapeRef/index.ts, and src/index.ts. Implementation lives under src/topology/shapeRef/.
Mental model in six lines
- The role table (
RoleTable = ReadonlyMap<origin, ReadonlyMap<role, readonly number[]>> in shapeRefTypes.ts) is the spine: origin → role → face hash codes.
assignRoles(shape, origin) names faces; updateRoles(roles, origin, evolution) advances those hashes through one operation's ShapeEvolution.
- Face refs ride hashes through evolution — they can resolve
exact.
- Edge / vertex / derived refs ride their neighbouring face roles, never their own hash — so they sidestep generated hashes entirely.
exact = resolved via a tracked hash in the role table; geometric-fallback = resolved via the captured geometric hint (normal, centroid, area).
- Derived (fillet/chamfer) faces are always geometric — confidence is hardcoded
geometric-fallback, they can never be exact.
The occt-wasm generated / fillet gotcha (read this first)
This is the single most important fact in the area, and the cause of most "why won't my fillet face resolve" confusion.
On the OCCT kernels, evolution.generated hashes are never live — they refer to an intermediate shape, not the final result — and fillet/chamfer evolution is empty. Consequences, all deliberate:
updateRoles intentionally does not consume evolution.generated (see the comment at the updateRoles docblock in shapeRefFns.ts: "verified: 0 live generated hashes across cut/fuse on occt-wasm"). Naming a generated face via the role table produces a role that never resolves.
- Derived faces are re-found geometrically, never by hash — see the file header of
derivedFaceRefFns.ts.
ResolvedDerivedFaceRef.confidence is hardcoded 'geometric-fallback' in shapeRefTypes.ts.
- Derived-face replay tests are gated to occt-wasm only (not the whole OCCT family) — on OpenCascade.js filleting one edge splits the +Z face into a divergent topology (
tests/shapeRefDerivedReplay.test.ts header comment).
How the geometric workaround actually resolves a derived face (resolveDerivedFaceRef in derivedFaceRefFns.ts):
- Re-derive each of the two bridged faces via the role table, else via
facesByNormal (faces whose normal dotted with the captured normal exceeds NORMAL_MATCH = 0.99).
betweenFaces = faces adjacent to both bridged faces (via cached edge→face adjacency).
- Keep only faces whose normal has a positive component (
> BLEND_THRESHOLD = 0.1) along both bridged normals — this rejects the orthogonal flanking faces that are also adjacent to both.
- One survivor → resolved; several → nearest to the captured
edgeMidpoint (HINT_MARGIN = 1e-6 tie → ambiguous); none → broken.
Symptom → cause → fix
| Symptom | Likely cause | Fix |
|---|
Edge/vertex ref returns not-found | Role table stale — facesForRole returned [] because the tracked hash no longer matches any live face | updateRoles was skipped across an intervening edit; propagate the role table through every operation's ShapeEvolution. Confirm ref.origin matches the string passed to assignRoles. This is the #1 cause. |
| Face ref resolves to the wrong face on geometric fallback | A scorer reject or a weak hint | Check defaultScorer rejects (below); the true face may be scoring -Infinity. Supply a custom FaceScorer if the geometry is unusual. |
resolveRef returns ambiguous | Two candidates tie within a margin, or a duplicated/symmetric feature | Inspect broken.candidates. Thresholds: AMBIGUITY_THRESHOLD = 0.1 (face scoring), HINT_MARGIN = 1e-6 (edge/vertex/derived hint tiebreak). |
Face ref returns deleted; edge/vertex returns not-found for the same vanished feature | Different taxonomies by kind (below) | deleted is expected-skip for a replay engine; edge/vertex/derived can never report deleted (they don't track their own hash) so a vanished entity surfaces as not-found. |
| Non-primitive origin loses stability on rebuild | Only box/cylinder/cone/sphere get semantic roles; everything else falls back to positional opType:face_N | Positional names are stable only if getFaces iteration order is stable across the rebuild. Prefer a primitive origin, or maintain a role table across edits instead of relying on resolveRefIn. |
| Fillet/chamfer face never resolves via the role table | The generated-hash reality above | Use a DerivedFaceRef (geometric), not a face ref. It's inherently geometric-fallback. |
| Replay re-targets the wrong entity | The step has multiple inputs, or a ref points at a non-3D input | resolveStepParams only auto-resolves single-input 3D steps; multi-input refs stay raw. See history section below. |
defaultScorer reject thresholds (scoring.ts)
A debugger hitting a wrong/absent match usually tripped one of these hard rejects:
- Surface-type set but mismatched →
-Infinity (hard reject).
- Normal set and
dot < 0.707 → -Infinity.
- Centroid set and
distSq > 100 (more than 10 units away) → -Infinity; otherwise penalty -distSq/100.
- Area penalty only when
|log(hintArea / faceArea)| > 1.0.
- After scoring,
resolveRef rejects anything below MIN_SCORE = 0.5 → not-found.
Reproduce the role-table path minimally
Mirror tests/shapeRefEditReplay.test.ts — build the table, advance it, resolve:
const roles = new Map([['box', assignRoles(box, 'box')]]);
const ref = createRef('box', 'box:top', topFace);
const { evolution, shape: next } = unwrap(fuseWithEvolution(box, other));
const advanced = updateRoles(roles, 'box', evolution);
const resolved = resolveRef(ref, advanced, next);
*WithEvolution wrappers (fuseWithEvolution, filletWithEvolution, …) are the layer-2 source of the ShapeEvolution fed to updateRoles (src/topology/evolutionFns.ts, exported from src/index.ts). Each returns a Result that must be unwrapped or checked before use — mirror tests/shapeRefEditReplay.test.ts, which does const { shape, evolution } = unwrap(fuseResult);.
Failure taxonomy by ref kind
- Face ref
BrokenRef.reason: 'deleted' | 'ambiguous' | 'not-found'. deleted means the role table had the role but its tracked successor was removed.
- Edge / vertex / derived:
reason is only 'ambiguous' | 'not-found' — never deleted. They track adjacent face roles, not their own hash, so a vanished entity is not-found. This is stated in the type docblocks in shapeRefTypes.ts.
- All broken results carry
candidates? — the tied entities to inspect.
Adding a new reference kind
The four existing kinds (face, edge, vertex, derived) share one contract. To add a fifth, work through this checklist against the real files. Follow the export-surface mechanics in the adding-operations sibling skill — a new create*/resolve* pair must flow through the same six export surfaces plus the brepjs/shapeRef subpath.
- Types — in
shapeRefTypes.ts add XRef, its XHint, ResolvedXRef (with a confidence), and BrokenXRef. Decide the failure taxonomy up front: it can report deleted only if it tracks its own face hash; if it names itself by neighbour roles (the recommended pattern), its reason is 'ambiguous' | 'not-found' like edge/vertex.
- Functions — new
xRefFns.ts with createXRef (capture the hint on the pre-edit shape) and resolveXRef. Follow the established pattern: resolve neighbour roles via roleLookup.ts helpers (facesForRole, roleOfFace), disambiguate with the hint, apply an ambiguity margin. Return early not-found when facesForRole yields [].
- Dispatch — in
refResolveFns.ts add an isXRef structural guard and a branch in resolveLineageRef, and extend the LineageRef union (and ResolvedEntity if it resolves to a new entity type). Guard ordering is load-bearing: guards discriminate structurally, most-specific first. Current order is derived (betweenRoles + op) → edge (faceRoles.length === 2) → vertex (faceRoles.length >= 3) → face (role is a string). Place a new guard so it can't be shadowed by a looser one.
- Barrels — export from
src/topology/shapeRef/index.ts, src/shapeRef.ts, and src/index.ts. Once it's in the LineageRef union and the guards, it auto-flows through resolveRefParams and history replay for free.
- Recursion caveat —
resolveRefParams descends via isPlainOptions, which skips arrays it already handled, non-plain-prototype objects, and anything with a wrapped key. Keep the new ref a plain serializable object (like the others); a class instance would be silently skipped by the walker.
- Test — add
tests/shapeRefXReplay.test.ts gated to occt-wasm. Use the divergence-skip mechanics owned by the writing-tests sibling skill (currentKernelId, shouldSkipSuite, describe.skipIf).
How history replay resolves refs
Replay lets a stored selection survive an upstream parameter edit. The engine is src/operations/historyFns.ts (ModelHistory / OperationStep are pure data; OperationRegistry maps op name → OperationFn).
resolveStepParams(params, inputs) resolves refs only for single-input, 3D steps — it calls resolveRefParams against the sole input. Multi-input, ref-free, or non-3D steps are left raw: with several inputs the target input is ambiguous, and resolving against the wrong input would silently return a wrong-shape entity.
- Called from both
replayHistory and replayFrom before invoking the OperationFn.
- The stored step keeps its raw refs; resolution happens fresh at each replay. A
ShapeRef is plain JSON, so it survives serializeHistory/deserializeHistory.
modifyStep(stepId, newParams) is the parametric-edit loop: it updates the params then replayFroms that step.
tests/historyRefReplay.test.ts is the canonical worked example (re-targets an edge after a box-height edit; multi-input leaves the ref raw).
Replay errors are Result.Err with codes REPLAY_UNKNOWN_OP / REPLAY_MISSING_INPUT / REPLAY_STEP_FAILED / REPLAY_STEP_NOT_FOUND / MODIFY_STEP_NOT_FOUND.
Where evolution comes from
ShapeEvolution = { modified, generated, deleted } (src/kernel/types.ts) is built in src/kernel/occt/evolutionOps.ts and produced by the *WithHistory kernel methods in src/kernel/occt/historyOps.ts (wired via makeHistoryOps). Fillet/chamfer go through BRepFilletAPI_MakeFillet/MakeChamfer; the plumbing populates generated, but on occt-wasm those Generated() results don't map to final-shape faces — the empty/never-live reality above. For anything kernel-side (adding a *WithHistory method, adapter capability differences), defer to the kernel-abstraction sibling skill; for the embind enum/hash-code details underlying evolutionOps.ts, defer to wasm-interop.
Worked examples in the test suite
tests/shapeRefEditReplay.test.ts (modified-face tracking + split-fragment disambiguation), tests/shapeRefDerivedReplay.test.ts (fillet/chamfer normal-blend, occt-wasm-gated), tests/shapeRefEdgeReplay.test.ts, tests/shapeRefVertexReplay.test.ts, tests/historyRefReplay.test.ts, and the unit/integration pair tests/shapeRef.test.ts + tests/shapeRefIntegration.test.ts.
Sibling skills
adding-operations — the six export surfaces + function-lookup.md gate a new create*/resolve* pair must pass through.
kernel-abstraction — kernel-side evolution, *WithHistory methods, adapters, capability differences.
wasm-interop — embind enum .value, Uint32Array conversion, and hash-code details underlying evolutionOps.ts.
writing-tests — the kernel-divergence skip pattern for gating a new ref-kind test to occt-wasm.