From 09e927ab6663b903066f30db9e5855c22137f315 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:22:54 +0000 Subject: [PATCH 1/2] Refuse tie connections that couple nothing in a coupled solve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tie connection is a modelling statement: the user picks two surfaces and says they are joined. The coupled path took that statement and, in four different situations, produced no coupling at all and said nothing — the solve ran on a split assembly and returned a plausible-looking but structurally wrong deformed shape. The all-solid weld path already refuses this ("Tie ... connected no nodes"). The coupled path now reports the same way. tieCouplings returns a report per connection instead of `continue`-ing past it, naming which of the four ways it failed, since each has a different fix: no-pool-nodes a picked surface has no node in the solved model out-of-reach the surfaces never saw each other beyond-search-distance they meet, but outside the tie's own distance too-few-partners in range, but no reference found three partners The builders stay pure and only report; solver.worker turns a report into the refusal, and logs what every connection that did couple contributed (references, partner slots, measured gap) — the coupled path had no such line before. Two surfaces that SHARE their nodes are already rigidly joined through the common pool DOFs, so they report as connected, not dropped. The shipped crane-hook-shell example is unaffected: its pin/hook tie couples 58 references onto 662 partner slots across a 0.3497 mm clearance. This is the same silent-drop failure class as KOF-190/#381, in the code that replaced the autoDetectSolidCouplings master-body heuristic KOF-203 was originally filed against. Fixes KOF-203 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HcvBKZJpUiFF21sLyKMPVW --- web/src/lib/shellize.ts | 165 ++++++++++++++++++++++++++----- web/src/workers/solver.worker.ts | 26 +++++ web/tests/test_shellize_mpc.mjs | 137 +++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 22 deletions(-) diff --git a/web/src/lib/shellize.ts b/web/src/lib/shellize.ts index 613cc8f..a1045fc 100644 --- a/web/src/lib/shellize.ts +++ b/web/src/lib/shellize.ts @@ -705,6 +705,7 @@ export interface CoupledModel { solidPool: Map; // original vertex index → pool index (solid) shellPool: number[]; // local shell index → pool index refPool: Map; // reference-point vertex index → pool index + tieReports: TieCouplingReport[]; // what each tie connection contributed } // Boundary of a tet mesh: the faces used by exactly one element, as a flat @@ -939,6 +940,69 @@ export interface TieSurfaces { maxSeparation: number; } +// Why a tie connection ended up coupling nothing. The all-solid weld path already +// refuses a connection that joins nothing (solver.worker: "connected no nodes"), +// because an assembly that stays split solves to a plausible-looking but +// structurally wrong shape — the couplings are the load path. The coupled path +// used to drop the same connection silently (KOF-203); it now says which of the +// four ways it failed, since each has a different fix. +export type TieCouplingDrop = + // One or both picked surfaces contributed no node to the solved pool. + | { kind: "no-pool-nodes"; side: "A" | "B" | "both" } + // The two surfaces never saw each other, even after widening the search. + | { kind: "out-of-reach"; searched: number } + // They do meet, but no closer than the connection's own search distance. + | { kind: "beyond-search-distance"; gap: number; reach: number } + // In range, but no reference found the three partners an RBE3 needs. + | { kind: "too-few-partners"; refs: number; radius: number }; + +// What one tie connection actually contributed to the coupled model. +export interface TieCouplingReport { + name: string; + nCoupled: number; // reference nodes that got a distributing coupling + nPartners: number; // partner slots those references distribute onto + nShared: number; // nodes the two surfaces already have in common + gap: number; // measured closest approach, 0 when never measured + drop?: TieCouplingDrop; +} + +// The sentence for a connection that coupled nothing, or undefined when it did +// couple. Nodes the two surfaces SHARE are already rigidly joined through the +// common pool DOFs, so a connection that only shares is connected, not dropped. +export function tieCouplingProblem( + report: TieCouplingReport, +): string | undefined { + if (report.nCoupled > 0 || report.nShared > 0 || !report.drop) + return undefined; + const head = `Tie "${report.name}" coupled no nodes`; + const drop = report.drop; + switch (drop.kind) { + case "no-pool-nodes": + return ( + `${head} — ${drop.side === "both" ? "neither picked surface has" : `picked surface ${drop.side} has`} ` + + "a node in the solved model. Re-pick its surfaces after remeshing, or " + + "mark the body Solid if the tie lands on a wall that was idealised as shell." + ); + case "out-of-reach": + return ( + `${head} — its two surfaces are more than ${drop.searched.toFixed(4)} mm apart. ` + + "They are not the surfaces that touch; re-pick them." + ); + case "beyond-search-distance": + return ( + `${head} — its surfaces come no closer than ${drop.gap.toFixed(4)} mm, ` + + `beyond its ${drop.reach.toFixed(4)} mm search distance. Increase the ` + + "distance, or couple the full surface." + ); + case "too-few-partners": + return ( + `${head} — none of its ${drop.refs} in-range reference node(s) found the ` + + `three partners a distributing coupling needs within ${drop.radius.toFixed(4)} mm. ` + + "Refine the mesh on the other surface, or pick more of it." + ); + } +} + // Distance from each ref node to its nearest master node, for the refs that have // one within `reach`. Grid cells are `reach` wide, so the 27-cell scan sees every // candidate in range. @@ -998,58 +1062,112 @@ function nearestMasterDistances( // A "within distance" connection additionally keeps only the references within // its search distance of the other surface, which is what limits the tie to the // part of the surface that actually touches. +// +// Every connection gets a report, whether or not it coupled: a connection the +// user declared and that produced nothing is a missing load path, and the caller +// refuses it rather than solving a split assembly (KOF-203). function tieCouplings( ppt: (i: number) => [number, number, number], - ties: { name: string; masters: number[]; refs: number[]; reach: number }[], + ties: PoolTie[], medEdge: number, maxCoupledNodes: number, -): CouplingSet { +): { coupling: CouplingSet; reports: TieCouplingReport[] } { let all: CouplingSet = { ref: [], offsets: [0], solid: [] }; + const reports: TieCouplingReport[] = []; for (const tie of ties) { // Nodes the two surfaces share are already rigidly joined through the common // pool DOFs, and a coupling reference must not also be a partner. - const shared = new Set(tie.masters.filter((pi) => tie.refs.includes(pi))); + const refSet = new Set(tie.refs); + const shared = new Set(tie.masters.filter((pi) => refSet.has(pi))); const masters = tie.masters.filter((pi) => !shared.has(pi)); const refs = tie.refs.filter((pi) => !shared.has(pi)); - if (masters.length === 0 || refs.length === 0) continue; + const base: TieCouplingReport = { + name: tie.name, + nCoupled: 0, + nPartners: 0, + nShared: shared.size, + gap: 0, + }; + const dropped = (drop: TieCouplingDrop, gap = 0): void => { + reports.push({ ...base, gap, drop }); + }; + if (masters.length === 0 || refs.length === 0) { + dropped({ + kind: "no-pool-nodes", + side: masters.length === 0 ? (refs.length === 0 ? "both" : "A") : "B", + }); + continue; + } let distances = new Map(); + let searched = 0; for ( let reach = Math.max(2 * medEdge, 1e-9), doublings = 0; doublings <= 6 && distances.size === 0; reach *= 2, doublings++ - ) + ) { + searched = reach; distances = nearestMasterDistances(ppt, masters, refs, reach); - if (distances.size === 0) continue; // the surfaces never reach each other + } + if (distances.size === 0) { + dropped({ kind: "out-of-reach", searched }); + continue; + } const gap = Math.min(...distances.values()); const kept = [...distances] .filter(([, distance]) => distance <= tie.reach) .map(([pi]) => pi); - if (kept.length === 0) continue; - - all = concatCouplings( - all, - autoDetectCouplings( - ppt, - masters, - kept, - partnerSearchRadius(medEdge, gap), - maxCoupledNodes, - ), + if (kept.length === 0) { + dropped({ kind: "beyond-search-distance", gap, reach: tie.reach }, gap); + continue; + } + + const radius = partnerSearchRadius(medEdge, gap); + const one = autoDetectCouplings( + ppt, + masters, + kept, + radius, + maxCoupledNodes, ); + if (one.ref.length === 0) { + dropped({ kind: "too-few-partners", refs: kept.length, radius }, gap); + continue; + } + all = concatCouplings(all, one); + reports.push({ + ...base, + gap, + nCoupled: one.ref.length, + nPartners: one.solid.length, + }); } - return { ref: all.ref, offsets: all.offsets, solid: all.solid }; + return { + coupling: { ref: all.ref, offsets: all.offsets, solid: all.solid }, + reports, + }; +} + +// One tie connection mapped onto pool nodes: surface A becomes the coupling +// partners, surface B the references. +interface PoolTie { + name: string; + masters: number[]; + refs: number[]; + reach: number; } // Map a connection's two picked surfaces from store vertex indices onto pool // nodes. A vertex with no pool node is skipped: on the auto-shell path the thin // walls a picked surface covered were replaced by mid-surface shell nodes, which -// the seam coupling already ties. +// the seam coupling already ties. A surface left with NO pool node is reported as +// a dropped tie rather than skipped silently — the seam ties that shell back to +// its own retained solid, not to the body on the other side of this connection. function tiesToPool( ties: TieSurfaces[], poolOfVertex: (vi: number) => number | undefined, -): { name: string; masters: number[]; refs: number[]; reach: number }[] { +): PoolTie[] { const toPool = (vertices: number[]): number[] => { const out: number[] = []; for (const vi of vertices) { @@ -1192,7 +1310,7 @@ export function buildCoupledModel( // The tie connections first — their reference nodes become coupling-dependent, // so the shell↔solid seam detection must not also target them (a target DOF // must be independent). - const solidCoupling = tieCouplings( + const { coupling: solidCoupling, reports: tieReports } = tieCouplings( ppt, tiesToPool(ties, (vi) => solidPool.get(vi)), medEdge, @@ -1225,6 +1343,7 @@ export function buildCoupledModel( solidPool, shellPool, refPool, + tieReports, // The shell↔solid seam is continuous material (a thin wall idealised as shell, // tied back to its retained solid) — not a tie connection between parts, and // never something the user declares — so it uses the relaxed MPC coupling @@ -1255,6 +1374,7 @@ export interface ExplicitCoupledModel { coupling: CouplingSet; poolOfVertex: Map; // store vertex index → pool index shellPoolIndex: Set; // pool indices carrying shell stiffness + tieReports: TieCouplingReport[]; // what each tie connection contributed } export function buildExplicitCoupledModel( @@ -1321,7 +1441,7 @@ export function buildExplicitCoupledModel( const medEdge = medianTetEdge(ppt, tets); // Gapped solid↔solid interfaces (a pin in a hole) tied across the clearance by // the model's tie connections — the same couplings the auto-shell path builds. - const solidCoupling = tieCouplings( + const { coupling: solidCoupling, reports: tieReports } = tieCouplings( ppt, tiesToPool(ties, (vi) => poolOfVertex.get(vi)), medEdge, @@ -1370,6 +1490,7 @@ export function buildExplicitCoupledModel( ), poolOfVertex, shellPoolIndex, + tieReports, }; } diff --git a/web/src/workers/solver.worker.ts b/web/src/workers/solver.worker.ts index 6cef904..9c0149a 100644 --- a/web/src/workers/solver.worker.ts +++ b/web/src/workers/solver.worker.ts @@ -23,6 +23,8 @@ import { buildCoupledModel, buildExplicitCoupledModel, type TieSurfaces, + tieCouplingProblem, + type TieCouplingReport, dropCouplingsOnFixedNodes, shellNodeLocator, isShellPoolIndex, @@ -1387,6 +1389,26 @@ function mapCoupledVonMises( // Returns the coupled displacement/von-Mises result, or null when no body is // marked Shell (→ the caller runs the all-solid path). `shellBodyIds` is the // per-body Shell choice (property ids); an empty set means every body is solid. +// Say what each tie connection contributed to a coupled model, and refuse one +// that contributed nothing. A declared connection that couples no node and shares +// none leaves the assembly split: the solve still runs and returns a +// plausible-looking but structurally wrong shape. This is the same refusal the +// all-solid weld path makes on its own reports (KOF-203) — a connection is a +// modelling statement, so failing to honour it is an error, not a silent skip. +function reportTieCouplings(id: number, reports: TieCouplingReport[]): void { + for (const report of reports) { + const problem = tieCouplingProblem(report); + if (problem) throw new Error(problem); + self.postMessage({ + id, + log: + report.nCoupled === 0 + ? `Tie "${report.name}": already joined through ${report.nShared} shared node(s)` + : `Tie "${report.name}": ${report.nCoupled} distributing coupling(s) onto ${report.nPartners} partner node(s) across a ${report.gap.toFixed(4)} mm gap${report.nShared > 0 ? `, ${report.nShared} node(s) already shared` : ""}`, + }); + } +} + function tryCoupledSolve( payload: SolvePayload, shellBodyIds: Set, @@ -1442,6 +1464,9 @@ function tryCoupledSolve( vid(nodeId, "coupling reference point"), ), }); + // Before the bail below: a dropped tie must not be hidden by falling through to + // the pure-shell path, which would solve a different model entirely. + reportTieCouplings(0, model.tieReports); if (model.coupling.ref.length === 0 && couplings.length === 0) return null; // shell doesn't couple to the solid const nearestShell = shellNodeLocator(model); @@ -2009,6 +2034,7 @@ function handleMixedSolve(id: number, payload: SolvePayload) { ), }, ); + reportTieCouplings(id, model.tieReports); const poolOf = (nodeId: number): number => { const pi = model.poolOfVertex.get(vid(nodeId, "coupled bc/load")); diff --git a/web/tests/test_shellize_mpc.mjs b/web/tests/test_shellize_mpc.mjs index 3137f71..732e82c 100644 --- a/web/tests/test_shellize_mpc.mjs +++ b/web/tests/test_shellize_mpc.mjs @@ -25,6 +25,7 @@ import { dropCouplingsOnFixedNodes, extractThinWallShells, shellWallTets, + tieCouplingProblem, } from "../src/lib/shellize.ts"; let failures = 0; @@ -356,6 +357,29 @@ check( tied.coupling.mpc.every((flag) => flag === 0), ); + check( + "a tie that coupled reports what it contributed", + tied.tieReports.length === 1 && + tied.tieReports[0].name === "Tie1" && + tied.tieReports[0].nCoupled === surfaceB.length && + tied.tieReports[0].nPartners > 0 && + Math.abs(tied.tieReports[0].gap - GAP) < 1e-9 && + tied.tieReports[0].drop === undefined, + JSON.stringify(tied.tieReports), + ); + check( + "a tie that coupled is not a problem", + tieCouplingProblem(tied.tieReports[0]) === undefined, + ); + + // ── A declared tie that couples nothing is reported, never dropped silently ── + // + // Every way tieCouplings can fail to produce a coupling must name the tie and + // say which way it failed (KOF-203). The builders stay pure — they report; the + // worker turns a report into the refusal, exactly as the all-solid weld path + // does. Solving on regardless leaves the assembly split and returns a + // plausible-looking but structurally wrong shape. + // A "within distance" connection shorter than the clearance reaches nothing. const tooShort = buildExplicitCoupledModel(verts, solidTets, [], [], { ties: [ @@ -372,6 +396,119 @@ check( tooShort.coupling.ref.length === 0, `got ${tooShort.coupling.ref.length} couplings`, ); + check( + "...and says the surfaces are beyond the search distance", + tooShort.tieReports.length === 1 && + tooShort.tieReports[0].drop?.kind === "beyond-search-distance" && + Math.abs(tooShort.tieReports[0].drop.gap - GAP) < 1e-9 && + tooShort.tieReports[0].drop.reach === 0.5 * GAP, + JSON.stringify(tooShort.tieReports), + ); + check( + "...as a problem naming the tie and both distances", + (tieCouplingProblem(tooShort.tieReports[0]) ?? "").includes('Tie "Tie1"') && + (tieCouplingProblem(tooShort.tieReports[0]) ?? "").includes( + "search distance", + ), + tieCouplingProblem(tooShort.tieReports[0]), + ); + + // The outward-facing faces of the two bodies: a whole body apart, so they come + // into nominal range but no reference finds the three partners an RBE3 needs + // (only the one node directly opposite is within the radius). + const backToBack = buildExplicitCoupledModel(verts, solidTets, [], [], { + ties: [ + { + name: "Wrong faces", + verticesA: faceAt(0), + verticesB: faceAt(10 + GAP + 10), + maxSeparation: Infinity, + }, + ], + }); + check( + "surfaces too sparse to distribute onto are reported, not dropped", + backToBack.coupling.ref.length === 0 && + backToBack.tieReports.length === 1 && + backToBack.tieReports[0].drop?.kind === "too-few-partners" && + (tieCouplingProblem(backToBack.tieReports[0]) ?? "").includes( + 'Tie "Wrong faces"', + ), + JSON.stringify(backToBack.tieReports), + ); + + // A surface whose nodes are in no element of the solved model — the auto-shell + // case where the picked wall was idealised away, and the re-pick-after-remesh + // case — leaves that side with no pool node at all. + const orphan = buildExplicitCoupledModel(verts, solidTets, [], [], { + ties: [ + { + name: "Stale pick", + verticesA: surfaceA, + verticesB: [verts.length / 3 + 5], + maxSeparation: Infinity, + }, + ], + }); + check( + "a surface with no node in the solved model is reported, not skipped", + orphan.coupling.ref.length === 0 && + orphan.tieReports.length === 1 && + orphan.tieReports[0].drop?.kind === "no-pool-nodes" && + orphan.tieReports[0].drop.side === "B" && + (tieCouplingProblem(orphan.tieReports[0]) ?? "").includes( + 'Tie "Stale pick"', + ), + JSON.stringify(orphan.tieReports), + ); + + // Two surfaces that are the SAME nodes are already rigidly joined through the + // shared pool DOFs. That is a connected tie, so it must NOT be reported as a + // problem even though it produces no coupling. + const shared = buildExplicitCoupledModel(verts, solidTets, [], [], { + ties: [ + { + name: "Coincident", + verticesA: surfaceA, + verticesB: surfaceA, + maxSeparation: Infinity, + }, + ], + }); + check( + "a tie whose surfaces share their nodes is joined, not a problem", + shared.tieReports.length === 1 && + shared.tieReports[0].nShared === surfaceA.length && + shared.tieReports[0].nCoupled === 0 && + tieCouplingProblem(shared.tieReports[0]) === undefined, + JSON.stringify(shared.tieReports), + ); + + // Surfaces that never see each other at all: a third body far enough away that + // the search gives up before reaching it. Added last — `cube` appends to the + // shared vertex array, and the models above were built from it. + const farTets = [...solidTets, ...cube(2000)]; + const farApart = buildExplicitCoupledModel(verts, farTets, [], [], { + ties: [ + { + name: "Distant body", + verticesA: faceAt(0), + verticesB: faceAt(2010), + maxSeparation: Infinity, + }, + ], + }); + check( + "surfaces the search never reaches are reported out of reach", + farApart.coupling.ref.length === 0 && + farApart.tieReports.length === 1 && + farApart.tieReports[0].drop?.kind === "out-of-reach" && + farApart.tieReports[0].drop.searched > 0 && + (tieCouplingProblem(farApart.tieReports[0]) ?? "").includes( + 'Tie "Distant body"', + ), + JSON.stringify(farApart.tieReports), + ); } // ── extractThinWallShells / wall split is thickness-driven, not body-driven ─── From 45ec72f16cdec122868e5c34f144d3e4603ba499 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:50:00 +0000 Subject: [PATCH 2/2] Throw on an unhandled tie drop kind instead of falling off the switch The exhaustive switch in tieCouplingProblem had no default, so control flow could implicitly reach the end of the function and return undefined - reading as 'this tie is fine' for a tie that coupled nothing. Adds the default the rest of the codebase uses (analysisFile.ts vtkCellType): a never binding so a new TieCouplingDrop kind without a message is a compile error, and a throw naming the drop if one ever arrives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HcvBKZJpUiFF21sLyKMPVW --- web/src/lib/shellize.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/lib/shellize.ts b/web/src/lib/shellize.ts index a1045fc..8a08946 100644 --- a/web/src/lib/shellize.ts +++ b/web/src/lib/shellize.ts @@ -1000,6 +1000,15 @@ export function tieCouplingProblem( `three partners a distributing coupling needs within ${drop.radius.toFixed(4)} mm. ` + "Refine the mesh on the other surface, or pick more of it." ); + // TieCouplingDrop is a closed union, so this is unreachable. The `never` + // binding turns adding a drop kind without a message into a compile error, + // rather than a connection that coupled nothing and reports no reason. + default: { + const unhandled: never = drop; + throw new Error( + `Cannot say why tie "${report.name}" coupled nothing: unhandled drop ${JSON.stringify(unhandled)}`, + ); + } } }