Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 152 additions & 22 deletions web/src/lib/shellize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ export interface CoupledModel {
solidPool: Map<number, number>; // original vertex index → pool index (solid)
shellPool: number[]; // local shell index → pool index
refPool: Map<number, number>; // 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
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected to return a value at the end of function 'tieCouplingProblem'


Any code paths that do not have explicit returns will return undefined. It is recommended to replace any implicit dead-ends that return undefined with a return null statement.

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)}`,
);
}
}
Comment on lines +979 to +1012

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected a default case


Some code conventions require that all switch statements have a default case, even if the default case is empty. The idea is to always explicitly state what the default behavior should be so that it's clear that the developer didn't forget to handle the default fallback case.

}

// 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.
Expand Down Expand Up @@ -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<number, number>();
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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1255,6 +1383,7 @@ export interface ExplicitCoupledModel {
coupling: CouplingSet;
poolOfVertex: Map<number, number>; // store vertex index → pool index
shellPoolIndex: Set<number>; // pool indices carrying shell stiffness
tieReports: TieCouplingReport[]; // what each tie connection contributed
}

export function buildExplicitCoupledModel(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1370,6 +1499,7 @@ export function buildExplicitCoupledModel(
),
poolOfVertex,
shellPoolIndex,
tieReports,
};
}

Expand Down
26 changes: 26 additions & 0 deletions web/src/workers/solver.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
buildCoupledModel,
buildExplicitCoupledModel,
type TieSurfaces,
tieCouplingProblem,
type TieCouplingReport,
dropCouplingsOnFixedNodes,
shellNodeLocator,
isShellPoolIndex,
Expand Down Expand Up @@ -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<number>,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down
Loading
Loading