diff --git a/web/src/lib/shellize.ts b/web/src/lib/shellize.ts index 613cc8f..8a08946 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,78 @@ 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." + ); + // 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)}`, + ); + } + } +} + // 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 +1071,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 +1319,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 +1352,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 +1383,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 +1450,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 +1499,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 ───