forked from 1b-agent/fieldcraft-upstream
solver: a node in several ties gets one constraint; ties both ways are an error (#389)
A node on the secondary surfaces of two ties (a chip's underside and its sides, a cut face and an outer face) got one constraint per tie. When the main nodes of one follow the other's main surface, the second constraint is nearly the first again, and eliminating it divided by round-off: the Pi 5 heat model put 21.9 W more in than its 8 W. Such a node now follows the main faces of all its ties together (balanced to 5e-11 of the power), a node following a node that follows it back (a tie each way on one interface) is an error naming both ties, and a tie row that still nearly repeats the constraints before it is an error naming them. mesh.cells ties one way by a fixed order of the cells (hex main to tet) and makes one tie per tied cell, tie_<cell>, replacing the ties an earlier run made, so its own ties never hold a node twice. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
755576de8a
commit
c6eeac35c1
4 files changed
+421
-79
No files matched your search
+190
-45
@@ -14,9 +14,11 @@
|
||||
//! `partition:P:a` and `partition:P:b`. Where the element faces of the two sides match node for
|
||||
//! node (the same divisions and kind on both sides, e.g. a mesh and its mirror image) the
|
||||
//! coincident nodes are merged and the mesh is conforming there. Elsewhere — a hex cell next to a
|
||||
//! tet cell, or two cells meshed with different divisions — the two surfaces are tied: a set on
|
||||
//! each side's partition faces and a tie between them (`interaction.tie`; dual mortar
|
||||
//! constraints in the implicit analyses, so a constant stress crosses the interface exactly).
|
||||
//! tet cell, or two cells meshed with different divisions — the two surfaces are tied
|
||||
//! (`interaction.tie`; dual mortar constraints in the implicit analyses, so a constant stress
|
||||
//! crosses the interface exactly). Each tie goes one way, by a fixed order of the cells (hex cells
|
||||
//! main to tet cells), and each cell's tied cut faces make one tie, so no node is a secondary
|
||||
//! node of two of them and no interface is tied both ways: either would over-constrain it (#389).
|
||||
//! The tie is the transition between hex and tet cells; pyramid elements are not used, because
|
||||
//! they need their own element formulation in every solver and deck, while the tie works for any
|
||||
//! pair of meshes and keeps each cell's mesh as its method made it. The reply lists every
|
||||
@@ -40,7 +42,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const CELLS: CommandSpec = CommandSpec {
|
||||
id: "mesh.cells",
|
||||
label: "Partitioned Mesh",
|
||||
params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, a set on each side and a tie between them are made (`tie_<partition>_<k>`, sets `<tie>_main` and `<tie>_secondary`: the side with fewer nodes is the main surface), replacing ties of those names from an earlier run. The cells of one design body that may sweep (auto, hex, swept, extrude, revolve) are swept together first: one cross-section and one set of layers for all of them, so they share their nodes across every cut, T-junctions included, with no edge seeds (`swept_together` names the body; `not_swept_together` says why not, and those cells are meshed one by one). Reports each cell's method (and why auto fell back to tet), its `quality` (volume, smallest and mean scaled Jacobian, largest edge ratio), and each interface: `merged` with the nodes merged or shared, or `tied`"#,
|
||||
params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, they are tied: each cell's tied cut faces make one tie, `tie_<cell>` (`/` as `_`; sets `<tie>_main`, the facing cells' cut faces, and `<tie>_secondary`, the cell's own), hex cells main to tet cells and otherwise by name, so a node is a secondary node of one such tie only; the ties an earlier run made on these cells are replaced. The cells of one design body that may sweep (auto, hex, swept, extrude, revolve) are swept together first: one cross-section and one set of layers for all of them, so they share their nodes across every cut, T-junctions included, with no edge seeds (`swept_together` names the body; `not_swept_together` says why not, and those cells are meshed one by one). Reports each cell's method (and why auto fell back to tet), its `quality` (volume, smallest and mean scaled Jacobian, largest edge ratio), and each interface: `merged` with the nodes merged or shared, or `tied`"#,
|
||||
form: Some(super::forms::CELLS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
@@ -292,7 +294,12 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
info
|
||||
})
|
||||
.collect();
|
||||
interfaces.extend(join(model, &names, COINCIDENT * diagonal)?);
|
||||
let tet: BTreeSet<String> = report
|
||||
.iter()
|
||||
.filter(|r| r.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("tet")))
|
||||
.filter_map(|r| r.get("body").and_then(Value::as_str).map(String::from))
|
||||
.collect();
|
||||
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
Ok(cleared.report(s, json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size})))
|
||||
}
|
||||
@@ -355,17 +362,27 @@ fn swept_interfaces(design: &fieldcraft_geometry::Design, inputs: &[Input], cm:
|
||||
}
|
||||
}
|
||||
}
|
||||
// The partitions whose cut faces a cell has.
|
||||
let cuts = |c: u32| -> BTreeSet<String> {
|
||||
// The partitions whose cut faces a cell has, with the side: two cells meet across a
|
||||
// partition they are on opposite sides of.
|
||||
let cuts = |c: u32| -> BTreeSet<(String, String)> {
|
||||
let name = inputs.get(c as usize).map_or("", |b| b.name.as_str());
|
||||
let faces = design.body(name).map(|b| b.faces().iter().map(|f| f.name.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
faces.iter().filter_map(|f| f.strip_prefix("partition:")?.rsplit_once(':').map(|(p, _)| p.to_string())).collect()
|
||||
faces
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let (p, side) = f.strip_prefix("partition:")?.rsplit_once(':')?;
|
||||
let side = side.split_once('#').map_or(side, |(s, _)| s);
|
||||
Some((p.to_string(), side.to_string()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
pairs
|
||||
.into_iter()
|
||||
.map(|((a, b), nodes)| {
|
||||
let (na, nb) = (inputs.get(a as usize).map_or("", |x| x.name.as_str()), inputs.get(b as usize).map_or("", |x| x.name.as_str()));
|
||||
let partition = cuts(a).intersection(&cuts(b)).next().cloned().unwrap_or_default();
|
||||
let theirs = cuts(b);
|
||||
let partition =
|
||||
cuts(a).into_iter().find(|(p, side)| theirs.iter().any(|(q, other)| q == p && other != side)).map(|(p, _)| p).unwrap_or_default();
|
||||
json!({"partition": partition, "cells": [na, nb], "joined": "merged", "nodes": nodes.len(), "swept_together": true})
|
||||
})
|
||||
.collect()
|
||||
@@ -374,13 +391,15 @@ fn swept_interfaces(design: &fieldcraft_geometry::Design, inputs: &[Input], cm:
|
||||
/// A cell's mesh in numbers: its volume, smallest and mean scaled Jacobian and largest edge
|
||||
/// ratio.
|
||||
fn cell_quality(model: &Model, body: &str) -> Value {
|
||||
let (mut volume, mut min, mut sum, mut n, mut ratio) = (0.0, f64::INFINITY, 0.0, 0usize, 0.0f64);
|
||||
let (mut volume, mut min, mut sum, mut n, mut ratio) = (0.0, f64::INFINITY, 0.0, 0usize, None::<f64>);
|
||||
for label in model.body_elements(body) {
|
||||
let Some(e) = model.element(label) else { continue };
|
||||
let xyz: Vec<[f64; 3]> = e.nodes.iter().filter_map(|&l| model.node(l).map(|x| x.xyz)).collect();
|
||||
let Ok(q) = fieldcraft_mesh::quality::element_quality(e.kind, &xyz) else { continue };
|
||||
volume += q.size;
|
||||
ratio = ratio.max(q.get(fieldcraft_mesh::quality::Metric::EdgeRatio).unwrap_or(0.0));
|
||||
if let Some(r) = q.get(fieldcraft_mesh::quality::Metric::EdgeRatio) {
|
||||
ratio = Some(ratio.map_or(r, |x| x.max(r)));
|
||||
}
|
||||
if let Some(sj) = q.get(fieldcraft_mesh::quality::Metric::ScaledJacobian) {
|
||||
min = min.min(sj);
|
||||
sum += sj;
|
||||
@@ -452,54 +471,114 @@ fn sides(model: &Model, cells: &[String]) -> BTreeMap<String, [Vec<Side>; 2]> {
|
||||
|
||||
/// Join the cells across every partition: merge where the two sides match node for node, tie
|
||||
/// where they do not. Returns the interfaces.
|
||||
fn join(model: &mut Model, cells: &[String], tol: f64) -> Result<Vec<Value>> {
|
||||
///
|
||||
/// Ties go one way between any two cells, by a fixed order of the cells (hex before tet, then by
|
||||
/// name; the earlier one is the main side), so no two ties hold one interface both ways, and
|
||||
/// each cell's tied cut faces make one tie, `tie_<cell>` (its secondary surface; the main one is
|
||||
/// the facing cells' cut faces), so a node where two of them meet is a secondary node once.
|
||||
/// The ties of an earlier run on these cells are replaced.
|
||||
fn join(model: &mut Model, cells: &[String], tet: &BTreeSet<String>, tol: f64) -> Result<Vec<Value>> {
|
||||
remove_cut_ties(model, cells)?;
|
||||
let rank = |cell: &str| (tet.contains(cell), cell.to_string());
|
||||
let mut out = Vec::new();
|
||||
// Per secondary cell: its cut faces and the facing cells' ones, as (body, face).
|
||||
let mut tied: BTreeMap<String, (BTreeSet<(String, String)>, BTreeSet<(String, String)>)> = BTreeMap::new();
|
||||
let mut tied_pairs: Vec<Value> = Vec::new();
|
||||
for (partition, [a_sides, b_sides]) in sides(model, cells) {
|
||||
let mut k = 0;
|
||||
for a in &a_sides {
|
||||
for b in &b_sides {
|
||||
let overlap = (0..3).all(|i| a.lo[i] <= b.hi[i] + tol && b.lo[i] <= a.hi[i] + tol);
|
||||
if !overlap {
|
||||
// Faces on one plane overlap when their boxes share an area, not just an edge.
|
||||
let shared = (0..3).filter(|&i| a.lo[i].max(b.lo[i]) < a.hi[i].min(b.hi[i]) - tol).count();
|
||||
let touching = (0..3).all(|i| a.lo[i] <= b.hi[i] + tol && b.lo[i] <= a.hi[i] + tol);
|
||||
if !(touching && shared >= 2) {
|
||||
continue;
|
||||
}
|
||||
k += 1;
|
||||
let matched = matching(model, a, b, tol);
|
||||
if let Some(map) = matched {
|
||||
if let Some(map) = matching(model, a, b, tol) {
|
||||
let count = map.len();
|
||||
model.merge_nodes(&map)?;
|
||||
out.push(json!({"partition": partition, "cells": [a.body, b.body], "joined": "merged", "nodes": count}));
|
||||
} else {
|
||||
let name = format!("tie_{partition}_{k}");
|
||||
let (main, secondary) = if a.nodes.len() <= b.nodes.len() { (a, b) } else { (b, a) };
|
||||
let set = |side: &Side| -> Result<Set> { Ok(Set::of_geometry(vec![GeometryRef::face(side.body.as_str(), side.face.as_str())?])) };
|
||||
let (main_set, secondary_set) = (format!("{name}_main"), format!("{name}_secondary"));
|
||||
for (n, side) in [(&main_set, main), (&secondary_set, secondary)] {
|
||||
if model.set(n).is_some() {
|
||||
model.replace_set(n, set(side)?)?;
|
||||
} else {
|
||||
model.add_set(n, set(side)?)?;
|
||||
}
|
||||
}
|
||||
if model.tie(&name).is_some() {
|
||||
model.remove_tie(&name)?;
|
||||
}
|
||||
model.add_tie(&name, Tie::new(&main_set, &secondary_set))?;
|
||||
out.push(json!({
|
||||
"partition": partition,
|
||||
"cells": [a.body, b.body],
|
||||
"joined": "tied",
|
||||
"tie": name,
|
||||
"main": main.body,
|
||||
"secondary": secondary.body,
|
||||
"faces": [a.faces.len(), b.faces.len()],
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let (main, secondary) = if rank(&a.body) <= rank(&b.body) { (a, b) } else { (b, a) };
|
||||
let entry = tied.entry(secondary.body.clone()).or_default();
|
||||
entry.0.insert((secondary.body.clone(), secondary.face.clone()));
|
||||
entry.1.insert((main.body.clone(), main.face.clone()));
|
||||
tied_pairs.push(json!({
|
||||
"partition": partition,
|
||||
"cells": [a.body, b.body],
|
||||
"joined": "tied",
|
||||
"main": main.body,
|
||||
"secondary": secondary.body,
|
||||
"faces": [a.faces.len(), b.faces.len()],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (cell, (own, facing)) in tied {
|
||||
let base = tie_name(&cell);
|
||||
let mut name = base.clone();
|
||||
let mut k = 1;
|
||||
while model.tie(&name).is_some() || model.set(&format!("{name}_main")).is_some() || model.set(&format!("{name}_secondary")).is_some() {
|
||||
k += 1;
|
||||
name = format!("{base}_{k}");
|
||||
}
|
||||
let set = |faces: &BTreeSet<(String, String)>| -> Result<Set> {
|
||||
Ok(Set::of_geometry(faces.iter().map(|(b, f)| GeometryRef::face(b.as_str(), f.as_str())).collect::<fieldcraft_model::Result<_>>()?))
|
||||
};
|
||||
let (main_set, secondary_set) = (format!("{name}_main"), format!("{name}_secondary"));
|
||||
model.add_set(&main_set, set(&facing)?)?;
|
||||
model.add_set(&secondary_set, set(&own)?)?;
|
||||
model.add_tie(&name, Tie::new(&main_set, &secondary_set))?;
|
||||
names.insert(cell, name);
|
||||
}
|
||||
for mut pair in tied_pairs {
|
||||
let name = pair.get("secondary").and_then(Value::as_str).and_then(|c| names.get(c)).cloned();
|
||||
if let (Some(o), Some(name)) = (pair.as_object_mut(), name) {
|
||||
o.insert("tie".into(), json!(name));
|
||||
}
|
||||
out.push(pair);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The name of the tie that holds `cell`'s tied cut faces.
|
||||
fn tie_name(cell: &str) -> String {
|
||||
let name = format!("tie_{cell}");
|
||||
fieldcraft_model::suggest_name(&name).unwrap_or(name)
|
||||
}
|
||||
|
||||
/// Remove the ties an earlier run of mesh.cells made on `cells` (and their two sets): ties
|
||||
/// between sets `<tie>_main` and `<tie>_secondary` that hold only cut faces of these cells.
|
||||
fn remove_cut_ties(model: &mut Model, cells: &[String]) -> Result<()> {
|
||||
let cut_faces_of_cells = |set: &str| {
|
||||
model.set(set).is_some_and(|s| {
|
||||
!s.geometry.is_empty()
|
||||
&& s.nodes.is_empty()
|
||||
&& s.elements.is_empty()
|
||||
&& s.faces.is_empty()
|
||||
&& s.edges.is_empty()
|
||||
&& s.geometry.iter().all(|g| cells.contains(&g.body) && matches!(&g.entity, Entity::Face(f) if f.starts_with("partition:")))
|
||||
})
|
||||
};
|
||||
let stale: Vec<(String, String, String)> = model
|
||||
.ties()
|
||||
.iter()
|
||||
.filter(|(n, t)| t.main == format!("{n}_main") && t.secondary == format!("{n}_secondary"))
|
||||
.filter(|(_, t)| cut_faces_of_cells(&t.main) && cut_faces_of_cells(&t.secondary))
|
||||
.map(|(n, t)| (n.clone(), t.main.clone(), t.secondary.clone()))
|
||||
.collect();
|
||||
for (tie, main, secondary) in stale {
|
||||
model.remove_tie(&tie)?;
|
||||
for set in [main, secondary] {
|
||||
if model.set_users(&set).is_empty() {
|
||||
model.remove_set(&set)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When the two sides' nodes coincide one to one (within `tol`) and their faces are the same
|
||||
/// in number, the merge: each node of side `b` to the node of side `a` at its place (or the other
|
||||
/// way round, so the lower label survives).
|
||||
@@ -533,14 +612,15 @@ mod tests {
|
||||
#[test]
|
||||
fn hex_and_tet_cells_are_tied_and_matching_hex_cells_merged() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
// A tet cell next to a hex cell: tied, main the coarser side.
|
||||
// A tet cell next to a hex cell: tied, the hex cell main.
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(r["cells"][0]["kind"], "tet4", "{r}");
|
||||
assert_eq!(r["cells"][1]["kind"], "hex8", "{r}");
|
||||
let i = &r["interfaces"][0];
|
||||
assert_eq!(i["joined"], "tied", "{r}");
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
for set in ["tie_P_1_main", "tie_P_1_secondary"] {
|
||||
assert_eq!((i["main"].as_str(), i["secondary"].as_str(), i["tie"].as_str()), (Some("Body1/b"), Some("Body1/a"), Some("tie_Body1_a")), "{r}");
|
||||
for set in ["tie_Body1_a_main", "tie_Body1_a_secondary"] {
|
||||
assert!(s.model.resolve_set(set).unwrap().faces.len() > 4, "{set}");
|
||||
}
|
||||
// Both cells hex at one size: the plane faces match node for node and merge.
|
||||
@@ -676,6 +756,71 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A box cut by planes into cells, `methods` per cell, a heat source in every cell and
|
||||
/// convection on its outer faces: the heat flows of the steady solve.
|
||||
fn cut_box_heat(cuts: &[(&str, f64)], methods: Value, size: f64) -> (Value, Value) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10, "body_name": "Box"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
for (i, (plane, offset)) in cuts.iter().enumerate() {
|
||||
let name = format!("D{i}");
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": plane, "offset": offset}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{i}"), "datum": name})).unwrap();
|
||||
}
|
||||
let r = s.execute("mesh.cells", &json!({"methods": methods, "default": "hex", "size": size})).unwrap();
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let outer: Vec<Value> = bodies
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|b| {
|
||||
b["faces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|f| !f["name"].as_str().unwrap().starts_with("partition:"))
|
||||
.map(|f| json!({"body": b["name"], "face": f["name"]}))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": s.model.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "skin", "geometry": outer}},
|
||||
{"command": "model.material.create", "params": {"name": "M", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0.3}, "thermal": {"conductivity": 0.5}}},
|
||||
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "M", "kind": {"type": "solid"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Q", "type": "heat_source", "set": "all", "power": 1}},
|
||||
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.01, "sink": 25}},
|
||||
{"command": "model.step.create", "params": {"name": "Steady", "procedure": {"type": "heat_transfer"}, "loads": ["Q", "Air"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
let h = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap();
|
||||
(r, h)
|
||||
}
|
||||
|
||||
/// A tet cell among hex cells (#389, #288): the hex cells sweep together and share their
|
||||
/// nodes, the tet cell is tied once (one tie for all its cut faces, the hex cells main), and
|
||||
/// the heat balances to round-off.
|
||||
#[test]
|
||||
fn a_tet_cell_among_swept_hex_cells_is_tied_once_and_the_heat_balances() {
|
||||
let (r, h) = cut_box_heat(&[("yz", 10.0), ("yz", 20.0), ("zx", 10.0)], json!({"Box/b/a/a": "tet"}), 2.5);
|
||||
let interfaces = r["interfaces"].as_array().unwrap();
|
||||
let tied: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "tied").collect();
|
||||
assert_eq!(tied.len(), 3, "{r}");
|
||||
assert!(tied.iter().all(|i| i["secondary"] == "Box/b/a/a" && i["tie"] == "tie_Box_b_a_a"), "{r}");
|
||||
let merged: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "merged").collect();
|
||||
assert_eq!(merged.len(), 4, "{r}");
|
||||
// Box/a/a and Box/a/b meet across y = 10, the third cut.
|
||||
assert!(merged.iter().any(|i| i["cells"] == json!(["Box/a/a", "Box/a/b"]) && i["partition"] == "P2"), "{r}");
|
||||
let flows = &h["heat_flows"];
|
||||
let supplied = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Q").unwrap()["heat"].as_f64().unwrap();
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-9 * supplied, "{flows}");
|
||||
assert!(h["min_temperature"]["value"].as_f64().unwrap() > 25.0, "{h}");
|
||||
}
|
||||
|
||||
/// Cells cut from one box by planes, one of them (`y = 10`) cutting only the cell left of
|
||||
/// `x = 10`, so a cut ends on another (a T-junction): with no seeds the cells sweep together
|
||||
/// and every interface merges node for node (#288), and the mesh fills the box.
|
||||
|
||||
@@ -1681,4 +1681,91 @@ pub(crate) mod tests {
|
||||
let v = s.execute("results.contour", &json!({"step": "Squeeze", "field": "contact_shear"})).unwrap();
|
||||
assert!(v["contour"]["range"]["max"]["value"].as_f64().unwrap() > 1.0, "{v}");
|
||||
}
|
||||
|
||||
/// A chip with a pad on it, both in a pocket of air (tet4), the chip and pad hex8 at other
|
||||
/// sizes: tie `air` holds the chip's and the pad's sides on the air, tie `chip` the pad's
|
||||
/// underside on the chip. The pad's bottom edge is on both ties' secondary surfaces, and the
|
||||
/// chip's top edge, the main nodes there, follows the air: two constraints per edge node that
|
||||
/// nearly repeat each other.
|
||||
fn chip_pad_air(s: &mut Session) {
|
||||
let (_dir, opened) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 6, "corner": [0, 0, 0], "body_name": "Air"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 3, "corner": [6, 6, 0], "operation": "cut"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 2, "corner": [6, 6, 0], "operation": "new_body", "body_name": "Chip"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 1, "corner": [6, 6, 2], "operation": "new_body", "body_name": "Pad"}},
|
||||
]));
|
||||
*s = opened;
|
||||
std::mem::forget(_dir);
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Air", "size": 2.0})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Chip", "size": 0.8})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Pad", "size": 0.45})).unwrap();
|
||||
let m = &s.model;
|
||||
// Element faces of a body all of whose nodes satisfy `on`.
|
||||
let faces = |body: &str, on: &dyn Fn([f64; 3]) -> bool| -> Vec<Value> {
|
||||
m.body_elements(body)
|
||||
.into_iter()
|
||||
.flat_map(|e| (1..=m.element(e).unwrap().kind.faces().len() as u8).map(move |f| (e, f)))
|
||||
.filter(|&(e, f)| {
|
||||
let ns = m.face_nodes(fieldcraft_model::ElementFace { element: e, face: f }).unwrap();
|
||||
ns.iter().all(|n| on(m.node(*n).unwrap().xyz))
|
||||
})
|
||||
.map(|(e, f)| json!({"element": e, "face": f}))
|
||||
.collect()
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
let side = |p: [f64; 3]| near(p[0], 6.0) || near(p[0], 14.0) || near(p[1], 6.0) || near(p[1], 14.0);
|
||||
let pocket = |p: [f64; 3]| {
|
||||
(side(p) && p[2] < 3.0 + 1e-6 && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[0]) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[1]))
|
||||
|| (near(p[2], 3.0) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[0]) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[1]))
|
||||
};
|
||||
let outer = |p: [f64; 3]| near(p[0], 0.0) || near(p[0], 20.0) || near(p[1], 0.0) || near(p[1], 20.0) || near(p[2], 6.0);
|
||||
let mut sides = faces("Chip", &|p| side(p));
|
||||
sides.extend(faces("Pad", &|p| side(p)));
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "chip", "elements": m.body_elements("Chip")}},
|
||||
{"command": "model.set.create", "params": {"name": "pocket", "faces": faces("Air", &pocket)}},
|
||||
{"command": "model.set.create", "params": {"name": "sides", "faces": sides}},
|
||||
{"command": "model.set.create", "params": {"name": "chip_top", "faces": faces("Chip", &|p| near(p[2], 2.0))}},
|
||||
{"command": "model.set.create", "params": {"name": "pad_bottom", "faces": faces("Pad", &|p| near(p[2], 2.0))}},
|
||||
{"command": "model.set.create", "params": {"name": "outside", "faces": faces("Air", &outer)}},
|
||||
{"command": "model.material.create", "params": {"name": "M", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0.3}, "thermal": {"conductivity": 0.5}}},
|
||||
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "M", "kind": {"type": "solid"}}},
|
||||
{"command": "interaction.tie", "params": {"name": "air", "main": "pocket", "secondary": "sides", "position_tolerance": 0.1}},
|
||||
{"command": "interaction.tie", "params": {"name": "chip", "main": "chip_top", "secondary": "pad_bottom", "position_tolerance": 0.1}},
|
||||
{"command": "model.load.create", "params": {"name": "Q", "type": "heat_source", "set": "chip", "power": 1}},
|
||||
{"command": "model.load.create", "params": {"name": "Room", "type": "convection", "set": "outside", "film": 0.01, "sink": 25}},
|
||||
{"command": "model.step.create", "params": {"name": "Steady", "procedure": {"type": "heat_transfer"}, "loads": ["Q", "Room"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
}
|
||||
|
||||
/// #389: a node on the secondary surfaces of two ties follows their main surfaces together,
|
||||
/// so the heat balances to round-off and no temperature drops below the surroundings'. (With
|
||||
/// a constraint per tie, this model put 335 W more in than the 128 W supplied, and some
|
||||
/// temperatures below 0.)
|
||||
#[test]
|
||||
fn nodes_in_two_ties_keep_the_heat_balance() {
|
||||
let mut s = Session::default();
|
||||
chip_pad_air(&mut s);
|
||||
let r = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap();
|
||||
let flows = &r["heat_flows"];
|
||||
let supplied = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Q").unwrap()["heat"].as_f64().unwrap();
|
||||
assert!((supplied - 128.0).abs() < 1e-9, "{flows}");
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-9 * supplied, "{flows}");
|
||||
assert!(r["min_temperature"]["value"].as_f64().unwrap() > 25.0, "{r}");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("ties `air`, `chip`"), "{warnings}");
|
||||
}
|
||||
|
||||
/// Two ties on one interface in opposite directions hold every node there twice: an error
|
||||
/// naming both, not a field.
|
||||
#[test]
|
||||
fn ties_both_ways_on_one_interface_are_an_error_naming_them() {
|
||||
let mut s = Session::default();
|
||||
chip_pad_air(&mut s);
|
||||
s.execute("interaction.tie", &json!({"name": "back", "main": "pad_bottom", "secondary": "chip_top", "position_tolerance": 0.1})).unwrap();
|
||||
let e = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap_err().to_string();
|
||||
assert!(e.contains("tie `back`") && e.contains("tie `chip`"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -64,13 +64,37 @@ use crate::{Error, Result};
|
||||
pub struct Row<'m> {
|
||||
pub terms: Vec<(usize, f64)>,
|
||||
pub pivot: usize,
|
||||
/// The name of the coupling it comes from (for messages).
|
||||
/// The name of the coupling or tie it comes from (for messages).
|
||||
pub coupling: &'m str,
|
||||
/// What made it.
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
/// What a constraint row comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Coupling,
|
||||
/// A tie: its secondary node follows a weighted average of main nodes.
|
||||
Tie,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn what(self) -> &'static str {
|
||||
match self {
|
||||
Source::Coupling => "coupling",
|
||||
Source::Tie => "tie",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A coefficient below this fraction of a row's largest one is round-off, not a constraint.
|
||||
const NEGLIGIBLE: f64 = 1e-10;
|
||||
|
||||
/// A tie row whose free coefficients, once the constraints before it are substituted, are all
|
||||
/// below this fraction of the coefficients that cancelled to give them nearly repeats those
|
||||
/// constraints: eliminating it would divide by round-off (#389), so it is a conflict.
|
||||
const NEARLY_REPEATED: f64 = 1e-4;
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
@@ -133,14 +157,14 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
|
||||
if r[a] != 0.0 {
|
||||
terms.push((rp(3 + b)?, r[a]));
|
||||
}
|
||||
rows.push(Row { terms, pivot, coupling: name });
|
||||
rows.push(Row { terms, pivot, coupling: name, source: Source::Coupling });
|
||||
}
|
||||
// Shell and beam nodes also turn with the point: θ_s = θ_r.
|
||||
if rotations(n) {
|
||||
for k in coupled(3..6) {
|
||||
let pivot =
|
||||
DofKind::from_direction(k).ok_or_else(|| Error::Internal("direction".into())).and_then(|kind| mesh.dof(n, kind))?;
|
||||
rows.push(Row { terms: vec![(pivot, 1.0), (rp(k)?, -1.0)], pivot, coupling: name });
|
||||
rows.push(Row { terms: vec![(pivot, 1.0), (rp(k)?, -1.0)], pivot, coupling: name, source: Source::Coupling });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +216,7 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push(Row { terms, pivot, coupling: name });
|
||||
rows.push(Row { terms, pivot, coupling: name, source: Source::Coupling });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,6 +441,8 @@ impl Reduction {
|
||||
// Dependent expressions in free and prescribed degrees of freedom, and for each free
|
||||
// degree of freedom the expressions that use it (possibly listed twice).
|
||||
let mut exprs: Vec<Vec<(usize, f64)>> = Vec::new();
|
||||
// The row each expression came from (for messages).
|
||||
let mut made_by: Vec<(Source, &str)> = Vec::new();
|
||||
let mut users: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for row in rows {
|
||||
let scale = row.terms.iter().fold(0.0f64, |m, t| m.max(t.1.abs()));
|
||||
@@ -432,6 +458,7 @@ impl Reduction {
|
||||
Some(State::Inactive) | None => return Err(Error::Internal("a constraint on a missing degree of freedom".into())),
|
||||
}
|
||||
}
|
||||
let gross: f64 = expanded.iter().map(|t| t.1.abs()).sum();
|
||||
let terms = combine(expanded);
|
||||
let candidate = |t: &&(usize, f64)| state.get(t.0) == Some(&State::Free) && t.1.abs() > NEGLIGIBLE * scale;
|
||||
let intended = terms.iter().filter(candidate).find(|t| t.0 == row.pivot);
|
||||
@@ -439,6 +466,30 @@ impl Reduction {
|
||||
Some(b) if b.1.abs() >= t.1.abs() => Some(b),
|
||||
_ => Some(t),
|
||||
});
|
||||
// A tie whose secondary node is held already: what is left of the row must be a
|
||||
// constraint of its own, not the difference of two near copies of one.
|
||||
if row.source == Source::Tie
|
||||
&& intended.is_none()
|
||||
&& let Some(&(_, c)) = largest
|
||||
&& c.abs() < NEARLY_REPEATED * gross
|
||||
{
|
||||
let mut others: Vec<String> = row
|
||||
.terms
|
||||
.iter()
|
||||
.filter_map(|t| match state.get(t.0) {
|
||||
Some(State::Dependent(e)) => made_by.get(*e).map(|(k, n)| format!("{} `{n}`", k.what())),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|o| *o != format!("tie `{}`", row.coupling))
|
||||
.collect();
|
||||
others.sort_unstable();
|
||||
others.dedup();
|
||||
return Err(Error::Invalid(format!(
|
||||
"tie `{}` conflicts with {}: they hold the same nodes almost alike (a node secondary in two ties, or two ties on one interface), which over-constrains the model; keep one of them there",
|
||||
row.coupling,
|
||||
if others.is_empty() { "the constraints before it".to_string() } else { others.join(", ") }
|
||||
)));
|
||||
}
|
||||
let Some(&(pivot, cp)) = intended.or(largest) else {
|
||||
// Only prescribed degrees of freedom (or round-off) are left.
|
||||
let value = |t: &(usize, f64)| t.1 * values.get(t.0).copied().unwrap_or(0.0);
|
||||
@@ -474,6 +525,7 @@ impl Reduction {
|
||||
users.entry(dof).or_default().push(index);
|
||||
}
|
||||
exprs.push(expr);
|
||||
made_by.push((row.source, row.coupling));
|
||||
if let Some(s) = state.get_mut(pivot) {
|
||||
*s = State::Dependent(index);
|
||||
}
|
||||
|
||||
+88
-30
@@ -21,6 +21,15 @@
|
||||
//! flat faces exactly (the tie patch test), because the dual mortar conditions are satisfied by
|
||||
//! the linear field on both sides.
|
||||
//!
|
||||
//! **Nodes in several ties.** A node on the secondary surfaces of several ties (two secondary
|
||||
//! surfaces meeting at an edge: a partition's cut faces and a body's outer faces, a chip's
|
||||
//! underside and its sides) gets one constraint, over the main faces of all of them together, as
|
||||
//! if they were one tie. Two constraints, one per tie, would nearly repeat each other, and
|
||||
//! eliminating the second divides by round-off: the solution is then silently wrong (#389).
|
||||
//! Two ties holding one interface both ways (a node following a node that follows it) are an
|
||||
//! error naming them, and so are constraints that still nearly repeat each other (a tie and a
|
||||
//! coupling on one node, longer loops; [`crate::constraints`]).
|
||||
//!
|
||||
//! **Pairing.** Each secondary face takes the main faces whose boxes, grown by the position
|
||||
//! tolerance, overlap its own, and that it overlaps along its normal with a mean gap within the
|
||||
//! tolerance (by default half the thickness of the main element). A secondary node with no main
|
||||
@@ -43,7 +52,7 @@ use fieldcraft_fem::contact::{Aabb, Grid, bounds};
|
||||
use fieldcraft_fem::mortar::integrate;
|
||||
use fieldcraft_model::{Model, Resolved};
|
||||
|
||||
use crate::constraints::Row;
|
||||
use crate::constraints::{Row, Source};
|
||||
use crate::contact::faces;
|
||||
use crate::system::Mesh;
|
||||
use crate::{Error, Result};
|
||||
@@ -122,10 +131,10 @@ pub(crate) struct TieNodes<'a, F> {
|
||||
pub faces: F,
|
||||
}
|
||||
|
||||
/// The constraint rows of every tie, one per secondary node and kind in `kinds` (ties by name,
|
||||
/// secondary nodes by slot, kinds in order); `dof(slot, kind)` numbers the degrees of freedom.
|
||||
/// The same mortar weights tie displacements (the translations) and temperatures (one scalar
|
||||
/// per node). Warnings name the secondary nodes left free.
|
||||
/// The constraint rows of the ties, one per secondary node (of one tie or several) and kind in
|
||||
/// `kinds` (secondary nodes by slot, kinds in order); `dof(slot, kind)` numbers the degrees of
|
||||
/// freedom. The same mortar weights tie displacements (the translations) and temperatures (one
|
||||
/// scalar per node). Warnings name the secondary nodes left free and those several ties share.
|
||||
pub(crate) fn rows_of<'m, F>(
|
||||
model: &'m Model,
|
||||
nodes: &TieNodes<'_, F>,
|
||||
@@ -136,7 +145,9 @@ pub(crate) fn rows_of<'m, F>(
|
||||
where
|
||||
F: Fn(&str, &str) -> Result<Vec<TieFace>>,
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
// Per secondary node (slot): the ties it is a secondary node of, and its main nodes'
|
||||
// weights M_AJ summed over them.
|
||||
let mut held: BTreeMap<usize, (Vec<&'m str>, BTreeMap<usize, f64>)> = BTreeMap::new();
|
||||
let corners = |f: &TieFace| -> Vec<Vec3> { f.nodes.iter().map(|n| nodes.xyz.get(*n).copied().unwrap_or_default()).collect() };
|
||||
let label = |n: usize| nodes.labels.get(n).copied().unwrap_or(0);
|
||||
for (name, tie) in model.ties() {
|
||||
@@ -147,15 +158,16 @@ where
|
||||
let reach = |f: &TieFace| tie.position_tolerance.unwrap_or(0.5 * f.thickness);
|
||||
let boxes: Vec<Aabb> = main.iter().zip(&main_x).map(|(f, x)| bounds(x, reach(f))).collect();
|
||||
let grid = Grid::new(&boxes, GRID_CELLS_PER_FACE * boxes.len() + 64);
|
||||
// Per secondary node (slot), its main nodes' weights M_AJ, in face order.
|
||||
let mut weights: BTreeMap<usize, BTreeMap<usize, f64>> = BTreeMap::new();
|
||||
let mut candidates = Vec::new();
|
||||
for f in &secondary {
|
||||
let xs = corners(f);
|
||||
let first = f.nodes.first().map_or(0, |n| label(*n));
|
||||
let coefficients = dual(&xs).ok_or_else(|| Error::Invalid(format!("{owner}: a secondary face at node {first} has no area")))?;
|
||||
for n in &f.nodes {
|
||||
weights.entry(*n).or_default();
|
||||
let (ties, _) = held.entry(*n).or_default();
|
||||
if ties.last() != Some(&name.as_str()) {
|
||||
ties.push(name.as_str());
|
||||
}
|
||||
}
|
||||
grid.candidates_in(&bounds(&xs, 0.0), &mut candidates);
|
||||
for &c in &candidates {
|
||||
@@ -179,37 +191,83 @@ where
|
||||
continue;
|
||||
}
|
||||
for (a, s) in f.nodes.iter().enumerate() {
|
||||
let row = weights.entry(*s).or_default();
|
||||
let (_, row) = held.entry(*s).or_default();
|
||||
for (j, mn) in m.nodes.iter().enumerate() {
|
||||
*row.entry(*mn).or_insert(0.0) += local[a][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free = Vec::new();
|
||||
for (node, row) in &weights {
|
||||
let total: f64 = row.values().sum();
|
||||
if !(total.abs() > 0.0 && total.is_finite()) {
|
||||
free.push(label(*node));
|
||||
continue;
|
||||
}
|
||||
let terms: Vec<(usize, f64)> = row.iter().map(|(j, w)| (*j, w / total)).filter(|(_, w)| w.abs() > NEGLIGIBLE).collect();
|
||||
for &kind in kinds {
|
||||
let pivot = dof(*node, kind)?;
|
||||
let mut row = vec![(pivot, 1.0)];
|
||||
for (j, w) in &terms {
|
||||
row.push((dof(*j, kind)?, -w));
|
||||
}
|
||||
out.push(Row { terms: row, pivot, coupling: name.as_str() });
|
||||
}
|
||||
// One constraint per secondary node, over the main faces of all its ties together: two ties
|
||||
// holding one node (secondary surfaces meeting at an edge) would otherwise give it two
|
||||
// constraints that nearly repeat each other (#389).
|
||||
let mut free: BTreeMap<&str, Vec<u32>> = BTreeMap::new();
|
||||
let mut shared: BTreeMap<Vec<&str>, Vec<u32>> = BTreeMap::new();
|
||||
// Per held node: its ties and the main nodes it follows, with their weights.
|
||||
let mut follows: BTreeMap<usize, (&Vec<&'m str>, Vec<(usize, f64)>)> = BTreeMap::new();
|
||||
for (node, (ties, row)) in &held {
|
||||
let Some(&name) = ties.first() else { continue };
|
||||
let total: f64 = row.values().sum();
|
||||
if !(total.abs() > 0.0 && total.is_finite()) {
|
||||
free.entry(name).or_default().push(label(*node));
|
||||
continue;
|
||||
}
|
||||
if ties.len() > 1 {
|
||||
shared.entry(ties.clone()).or_default().push(label(*node));
|
||||
}
|
||||
let terms: Vec<(usize, f64)> = row.iter().map(|(j, w)| (*j, w / total)).filter(|(_, w)| w.abs() > NEGLIGIBLE).collect();
|
||||
follows.insert(*node, (ties, terms));
|
||||
}
|
||||
// A node may not follow a node that follows it (a tie each way on one interface): the two
|
||||
// constraints would hold both twice.
|
||||
for (node, (ties, terms)) in &follows {
|
||||
for (j, _) in terms {
|
||||
let Some((back, theirs)) = follows.get(j) else { continue };
|
||||
if theirs.iter().any(|(k, _)| k == node) {
|
||||
let names = |t: &Vec<&str>| t.iter().map(|n| format!("tie `{n}`")).collect::<Vec<_>>().join(" and ");
|
||||
let at = nodes.xyz.get(*node).copied().unwrap_or_default();
|
||||
return Err(Error::Invalid(format!(
|
||||
"{} and {} hold one interface both ways: node {} at ({:.6}, {:.6}, {:.6}) follows node {}, which follows it back, over-constraining both; tie each interface one way only",
|
||||
names(ties),
|
||||
names(back),
|
||||
label(*node),
|
||||
at[0],
|
||||
at[1],
|
||||
at[2],
|
||||
label(*j)
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(first) = free.first() {
|
||||
warnings.push(format!(
|
||||
"{owner}: {} secondary node(s) have no main face within the position tolerance and stay free (node {first} first)",
|
||||
free.len()
|
||||
));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (node, (ties, terms)) in &follows {
|
||||
let Some(&name) = ties.first() else { continue };
|
||||
for &kind in kinds {
|
||||
let pivot = dof(*node, kind)?;
|
||||
let mut row = vec![(pivot, 1.0)];
|
||||
for (j, w) in terms {
|
||||
row.push((dof(*j, kind)?, -w));
|
||||
}
|
||||
out.push(Row { terms: row, pivot, coupling: name, source: Source::Tie });
|
||||
}
|
||||
}
|
||||
for (name, list) in &free {
|
||||
warnings.push(format!(
|
||||
"tie `{name}`: {} secondary node(s) have no main face within the position tolerance and stay free (node {} first)",
|
||||
list.len(),
|
||||
list.first().copied().unwrap_or(0)
|
||||
));
|
||||
}
|
||||
for (ties, list) in &shared {
|
||||
let names: Vec<String> = ties.iter().map(|t| format!("`{t}`")).collect();
|
||||
warnings.push(format!(
|
||||
"ties {}: {} node(s) are secondary nodes of each, and follow their main surfaces together (node {} first)",
|
||||
names.join(", "),
|
||||
list.len(),
|
||||
list.first().copied().unwrap_or(0)
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user