From 09e927ab6663b903066f30db9e5855c22137f315 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:22:54 +0000 Subject: [PATCH 1/3] 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/3] 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)}`, + ); + } } } From 792f3494a9b2ff438a64292afb08b30dc6baf6dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:46:51 +0000 Subject: [PATCH 3/3] Cover the tie-refusal path in the browser, and fix the empty-surface message The tie failure modes KOF-203 added are exercised only by tests/test_shellize_mpc.mjs, a bun/node test. Coverage in this repo comes from the Istanbul-instrumented app the Playwright suite drives (.nycrc includes src/** only; tests/coverage.ts harvests the page and the worker), so nothing in those node tests reaches the coverage report: every drop path in tieCouplings, all of tieCouplingProblem and the worker's reportTieCouplings read as uncovered code. tests/tie-refusal.spec.ts drives the same four failures through the worker entry point the Solve button uses, one test per mode. A tie that couples nothing is reported before the engine is called, so none of them runs a solve. Writing them turned up a wrong sentence: the no-pool-nodes case said "picked surface B has a node in the solved model", which names the side that is fine and sends the user to the wrong surface. It now says "has no node", and both the new spec and the node test pin that wording. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mk3GeGsp3YqEQVJ7oeRy4r --- web/src/lib/shellize.ts | 4 +- web/tests/test_shellize_mpc.mjs | 6 + web/tests/tie-refusal.spec.ts | 279 ++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 web/tests/tie-refusal.spec.ts diff --git a/web/src/lib/shellize.ts b/web/src/lib/shellize.ts index 8a08946..484f76c 100644 --- a/web/src/lib/shellize.ts +++ b/web/src/lib/shellize.ts @@ -979,8 +979,8 @@ export function tieCouplingProblem( 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 " + + `${head} — ${drop.side === "both" ? "neither picked surface has a node" : `picked surface ${drop.side} has no 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": diff --git a/web/tests/test_shellize_mpc.mjs b/web/tests/test_shellize_mpc.mjs index 732e82c..1abcce9 100644 --- a/web/tests/test_shellize_mpc.mjs +++ b/web/tests/test_shellize_mpc.mjs @@ -458,6 +458,12 @@ check( orphan.tieReports[0].drop.side === "B" && (tieCouplingProblem(orphan.tieReports[0]) ?? "").includes( 'Tie "Stale pick"', + ) && + // The side that has nothing is the side the sentence must say is empty — + // "surface B has a node in the solved model" would send the user to the + // one surface that is fine. + (tieCouplingProblem(orphan.tieReports[0]) ?? "").includes( + "picked surface B has no node in the solved model", ), JSON.stringify(orphan.tieReports), ); diff --git a/web/tests/tie-refusal.spec.ts b/web/tests/tie-refusal.spec.ts new file mode 100644 index 0000000..9d68e44 --- /dev/null +++ b/web/tests/tie-refusal.spec.ts @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2026 Michael Kofler +// SPDX-License-Identifier: AGPL-3.0-or-later + +// A tie connection that couples nothing must stop the coupled solve and say why +// (KOF-203). The four ways a tie can fail each have a different fix, so each +// carries its own sentence — and the refusal has to reach the user, which means +// crossing the worker boundary the way the Solve button does. +// +// The builder-side unit checks live in tests/test_shellize_mpc.mjs; these pin +// the worker path: handleMixedSolve reports every tie BEFORE it solves, so a +// declared connection that produced no load path is refused rather than solved +// as a split assembly returning a plausible-looking but wrong shape. + +import { test, expect } from "./coverage"; +import type { Page } from "@playwright/test"; + +const STEP = 5; // element edge +const SIDE = 10; // cube side, two elements across +const GAP = 1; // clearance between the two facing cubes + +interface Node { + id: number; + x: number; + y: number; + z: number; +} +interface Element { + id: number; + type: string; + nodeIds: number[]; + propertyId: number; +} +interface TieGroup { + name: string; + facesA: { nodeIds: number[] }[]; + facesB: { nodeIds: number[] }[]; + extent: "full" | "region"; + searchDistance: number; +} +interface SolvePayload { + nodes: Node[]; + elements: Element[]; + materials: unknown[]; + properties: unknown[]; + constraints: unknown[]; + loads: unknown[]; + surfaceLoads: unknown[]; + tieGroups: TieGroup[]; +} + +// Splitting a hex cell into 6 tets (Kuhn), the same decomposition the builder +// unit tests use — it keeps every tet edge on the 5 mm grid, so the median edge +// the coupling search derives its distances from is exactly STEP. +const KUHN = [ + [0, 1, 3, 7], + [0, 3, 2, 7], + [0, 2, 6, 7], + [0, 6, 4, 7], + [0, 4, 5, 7], + [0, 5, 1, 7], +]; + +// Cubes of CTETRA elements at the given x offsets, one body (property) each, +// plus two CTRIA3 facets capping the first cube. The shells are what routes the +// solve to the mixed shell/solid path — they share the cube's own nodes, so they +// are welded to it and play no part in the tie under test. +function assembly(xOffsets: number[]) { + const nodes: Node[] = []; + const elements: Element[] = []; + const index = new Map(); + const at = (x: number, y: number, z: number): number => { + const key = `${x},${y},${z}`; + let id = index.get(key); + if (id === undefined) { + id = nodes.length; + index.set(key, id); + nodes.push({ id, x, y, z }); + } + return id; + }; + + xOffsets.forEach((x0, body) => { + for (let i = 0; i < 2; i++) + for (let j = 0; j < 2; j++) + for (let k = 0; k < 2; k++) { + const corner = [ + at(x0 + i * STEP, j * STEP, k * STEP), + at(x0 + (i + 1) * STEP, j * STEP, k * STEP), + at(x0 + i * STEP, (j + 1) * STEP, k * STEP), + at(x0 + (i + 1) * STEP, (j + 1) * STEP, k * STEP), + at(x0 + i * STEP, j * STEP, (k + 1) * STEP), + at(x0 + (i + 1) * STEP, j * STEP, (k + 1) * STEP), + at(x0 + i * STEP, (j + 1) * STEP, (k + 1) * STEP), + at(x0 + (i + 1) * STEP, (j + 1) * STEP, (k + 1) * STEP), + ]; + for (const t of KUHN) + elements.push({ + id: elements.length, + type: "CTETRA", + nodeIds: [corner[t[0]], corner[t[1]], corner[t[2]], corner[t[3]]], + propertyId: body + 1, + }); + } + }); + + const cap = [ + at(xOffsets[0], 0, SIDE), + at(xOffsets[0] + SIDE, 0, SIDE), + at(xOffsets[0] + SIDE, SIDE, SIDE), + at(xOffsets[0], SIDE, SIDE), + ]; + elements.push( + { + id: elements.length, + type: "CTRIA3", + nodeIds: [cap[0], cap[1], cap[2]], + propertyId: 99, + }, + { + id: elements.length + 1, + type: "CTRIA3", + nodeIds: [cap[0], cap[2], cap[3]], + propertyId: 99, + }, + ); + + // Every node of the plane x = value: one face of one cube, a 3x3 grid. + const faceAt = (value: number): number[] => + nodes.filter((n) => Math.abs(n.x - value) < 1e-9).map((n) => n.id); + + return { nodes, elements, faceAt }; +} + +function payloadFor( + model: ReturnType, + tie: TieGroup, + extraNodes: Node[] = [], +): SolvePayload { + return { + nodes: [...model.nodes, ...extraNodes], + elements: model.elements, + materials: [ + { + id: 1, + name: "Steel", + young: 210000, + poisson: 0.3, + density: 7.85e-9, + color: "#8899aa", + }, + ], + properties: [ + { id: 1, materialId: 1 }, + { id: 2, materialId: 1 }, + { id: 3, materialId: 1 }, + { id: 99, materialId: 1, thickness: 2 }, + ], + constraints: [], + loads: [], + surfaceLoads: [], + tieGroups: [tie], + }; +} + +// Send the payload through the same worker entry point the Solve button uses, +// and return the refusal. A tie that couples nothing is reported before the +// engine is ever called, so these never run a solve. +async function solveError(page: Page, payload: SolvePayload): Promise { + await page.goto("/app/"); + await page.waitForFunction(() => + Boolean((window as unknown as { __kofem?: unknown }).__kofem), + ); + return page.evaluate(async (sent) => { + try { + await ( + window as unknown as { + __kofem: { + sendToWorker(name: string, payload: object): Promise; + }; + } + ).__kofem.sendToWorker("solve", sent); + return ""; + } catch (err) { + return (err as Error).message; + } + }, payload); +} + +test("a tie whose search distance is shorter than the clearance is refused", async ({ + page, +}) => { + const model = assembly([0, SIDE + GAP]); + const message = await solveError( + page, + payloadFor(model, { + name: "Pin to eye", + facesA: [{ nodeIds: model.faceAt(SIDE) }], + facesB: [{ nodeIds: model.faceAt(SIDE + GAP) }], + extent: "region", + searchDistance: 0.5 * GAP, + }), + ); + + // The clearance and the distance the user set are both named, because the fix + // is to raise one above the other. + expect(message).toContain('Tie "Pin to eye" coupled no nodes'); + expect(message).toContain(`no closer than ${GAP.toFixed(4)} mm`); + expect(message).toContain(`${(0.5 * GAP).toFixed(4)} mm search distance`); +}); + +test("a tie between surfaces the search never reaches is refused", async ({ + page, +}) => { + // A third cube 2 m away — past the seven doublings of the widening search, so + // the two surfaces never see each other at all. + const model = assembly([0, SIDE + GAP, 2000]); + const message = await solveError( + page, + payloadFor(model, { + name: "Distant body", + facesA: [{ nodeIds: model.faceAt(0) }], + facesB: [{ nodeIds: model.faceAt(2000 + SIDE) }], + extent: "full", + searchDistance: 0, + }), + ); + + expect(message).toContain('Tie "Distant body" coupled no nodes'); + expect(message).toContain("apart"); + expect(message).toContain("re-pick them"); +}); + +test("a tie onto a surface with no node in the solved model is refused", async ({ + page, +}) => { + // A pick that survived a re-mesh: the node is still named by the connection + // but belongs to no element, so it reaches the solve as nothing at all. + const model = assembly([0, SIDE + GAP]); + const orphan = { id: 100000, x: SIDE + 0.5 * GAP, y: 0, z: 0 }; + const message = await solveError( + page, + payloadFor( + model, + { + name: "Stale pick", + facesA: [{ nodeIds: model.faceAt(SIDE) }], + facesB: [{ nodeIds: [orphan.id] }], + extent: "full", + searchDistance: 0, + }, + [orphan], + ), + ); + + expect(message).toContain('Tie "Stale pick" coupled no nodes'); + expect(message).toContain("picked surface B has no node in the solved model"); +}); + +test("a tie onto a surface too sparse to distribute onto is refused", async ({ + page, +}) => { + // The two cubes' OUTWARD faces: a whole assembly apart, so each reference + // finds only the one node directly opposite — short of the three partners a + // distributing coupling needs. + const model = assembly([0, SIDE + GAP]); + const message = await solveError( + page, + payloadFor(model, { + name: "Wrong faces", + facesA: [{ nodeIds: model.faceAt(0) }], + facesB: [{ nodeIds: model.faceAt(2 * SIDE + GAP) }], + extent: "full", + searchDistance: 0, + }), + ); + + expect(message).toContain('Tie "Wrong faces" coupled no nodes'); + expect(message).toContain("three partners a distributing coupling needs"); +});