diff --git a/web/src/lib/shellize.ts b/web/src/lib/shellize.ts index 613cc8f..484f76c 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 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": + 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..1abcce9 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,125 @@ 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"', + ) && + // 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), + ); + + // 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 ─── 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"); +});