Stable references (topological naming) and history replay
SkillProductivityThis 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Stable references (topological naming) and history replay skill
What this skill tells your AI
The instructions your AI receives, as published by andymai/brepjs in .claude/skills/shaperef-lineage/SKILL.md and read by ahel’s review.
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[]>>inshapeRefTypes.ts) is the spine:origin → role → face hash codes. assignRoles(shape, origin)names faces;updateRoles(roles, origin, evolution)advances those hashes through one operation'sShapeEvolution.- 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 beexact.
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:
updateRolesintentionally does not consumeevolution.generated(see the comment at theupdateRolesdocblock inshapeRefFns.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.confidenceis hardcoded'geometric-fallback'inshapeRefTypes.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.tsheader 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 exceedsNORMAL_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-6tie → 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,
resolveRefrejects anything belowMIN_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)); // *WithEvolution → Result
const advanced = updateRoles(roles, 'box', evolution);
const resolved = resolveRef(ref, advanced, next); // 'face' in resolved ? exact/fallback : broken
*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'.deletedmeans the role table had the role but its tracked successor was removed. - Edge / vertex / derived:
reasonis only'ambiguous' | 'not-found'— neverdeleted. They track adjacent face roles, not their own hash, so a vanished entity isnot-found. This is stated in the type docblocks inshapeRefTypes.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.tsaddXRef, itsXHint,ResolvedXRef(with aconfidence), andBrokenXRef. Decide the failure taxonomy up front: it can reportdeletedonly 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.tswithcreateXRef(capture the hint on the pre-edit shape) andresolveXRef. Follow the established pattern: resolve neighbour roles viaroleLookup.tshelpers (facesForRole,roleOfFace), disambiguate with the hint, apply an ambiguity margin. Return earlynot-foundwhenfacesForRoleyields[]. - Dispatch — in
refResolveFns.tsadd anisXRefstructural guard and a branch inresolveLineageRef, and extend theLineageRefunion (andResolvedEntityif 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 (roleis 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, andsrc/index.ts. Once it's in theLineageRefunion and the guards, it auto-flows throughresolveRefParamsand history replay for free. - Recursion caveat —
resolveRefParamsdescends viaisPlainOptions, which skips arrays it already handled, non-plain-prototype objects, and anything with awrappedkey. 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.tsgated to occt-wasm. Use the divergence-skip mechanics owned by thewriting-testssibling 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 callsresolveRefParamsagainst 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
replayHistoryandreplayFrombefore invoking theOperationFn. - The stored step keeps its raw refs; resolution happens fresh at each replay. A
ShapeRefis plain JSON, so it survivesserializeHistory/deserializeHistory. modifyStep(stepId, newParams)is the parametric-edit loop: it updates the params thenreplayFroms that step.tests/historyRefReplay.test.tsis 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.mdgate a newcreate*/resolve*pair must pass through.kernel-abstraction— kernel-side evolution,*WithHistorymethods, adapters, capability differences.wasm-interop— embind enum.value,Uint32Arrayconversion, and hash-code details underlyingevolutionOps.ts.writing-tests— the kernel-divergence skip pattern for gating a new ref-kind test to occt-wasm.
Signals
- GitHub stars
- 101
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
shaperef-lineage- Source
- github.com/andymai/brepjs