Compare commits

..
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 18569155bc mesh: octree measures a wall where a large triangle meets the cell
The thickness criterion read each boundary triangle's wall once, at its
centre. A planar face is a few large triangles, so a face beside a pocket
reported the wall its centres face (60 mm through the stepped block)
while the wall beside the pocket was 5 mm: the cells there stayed coarse,
the core in the wall was notched, and its padding was dragged across
the pocket and crushed (4 uncertified elements, then the retry cap).

On a triangle larger than the cell the wall is now also measured where
the triangle is nearest the cell, held to the thinnest wall the plan
chose the levels for. stepped-block now meshes; the other corpus parts
keep their meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 19:20:17 -04:00
33 changed files with 282 additions and 1054 deletions

No files matched your search

+1 -4
View File
@@ -1427,10 +1427,7 @@ pub const NODE_RELAX: &[Field] = &[
.help("Guarded: each move keeps the worst element around the node from getting worse. Strong: move the nodes of the bad elements and morph the mesh round them, nodes on CAD edges held.") .help("Guarded: each move keeps the worst element around the node from getting worse. Strong: move the nodes of the bad elements and morph the mesh round them, nodes on CAD edges held.")
.def("\"guarded\""), .def("\"guarded\""),
range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"), range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"),
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("five element lengths"), positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("three element lengths"),
choice("guards", "Keep a morph", &[("valid", "Unless an element inverts"), ("strict", "Only when it improves the elements")])
.help("Strong: valid keeps every morph that inverts no element; strict keeps one only when the worst element it touches improves and no element gets longer.")
.hint("valid"),
int("iterations", "Iterations", 1, 200).help("Guarded: sweeps over the nodes (1 to 100). Strong: morph rounds.").hint("5 (strong: 20)"), int("iterations", "Iterations", 1, 200).help("Guarded: sweeps over the nodes (1 to 100). Strong: morph rounds.").hint("5 (strong: 20)"),
range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"), range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"),
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")]) choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
+1 -108
View File
@@ -54,81 +54,6 @@ const MAX_ELEMENTS: usize = 5_000_000;
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0; const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take). /// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take).
const MIN_RELATIVE_TOLERANCE: f64 = 1e-5; const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
/// The share of the body's volume the chords of its curved faces may cost at the plan's size: a
/// linear element's face spans a chord of the surface, and the sag between them is volume lost
/// (or gained, in a hole).
const CHORD_VOLUME_BUDGET: f64 = 0.005;
/// The principal curvatures' magnitudes, larger first, of an analytic surface at `p` (none for a
/// plane or a free-form surface).
fn curvatures(s: &FaceSurface, p: V3) -> Option<(f64, f64)> {
let off_axis = |origin: V3, axis: V3| {
let v = std::array::from_fn(|k| p[k] - origin[k]);
let a = unit(axis)?;
let along = dot(v, a);
let r: V3 = std::array::from_fn(|k| v[k] - along * a[k]);
Some(dot(r, r).sqrt())
};
let k = match *s {
FaceSurface::Plane { .. } => return None,
FaceSurface::Cylinder { radius, .. } => (1.0 / radius, 0.0),
FaceSurface::Sphere { radius, .. } => (1.0 / radius, 1.0 / radius),
FaceSurface::Cone { apex, axis, half_angle } => (half_angle.cos() / off_axis(apex, axis)?, 0.0),
// Round the tube 1/minor; round the axis cos φ / ρ, with ρ the distance from the axis.
FaceSurface::Torus { center, axis, major, minor } => {
let rho = off_axis(center, axis)?;
(1.0 / minor, ((rho - major) / (minor * rho)).abs())
}
};
let k = (k.0.abs(), k.1.abs());
(k.0.is_finite() && k.1.is_finite()).then_some(if k.0 >= k.1 { k } else { (k.1, k.0) })
}
/// The largest element size whose chords cost each curved face at most [`CHORD_VOLUME_BUDGET`]
/// of the body's `volume`, with the curved face that needs the finest size and its radius.
///
/// A linear face of size h across a surface of principal curvatures κ1, κ2 sags h²(κ1 + κ2)/12
/// on average below it, so a face of area A costs A h²(κ1 + κ2)/12 of volume. A face's share is
/// bounded two ways, and the looser bound is enough: its cost of all the faces against the body's volume (a
/// small blend costs little whatever its curvature), and per face its cost against the volume it
/// bounds itself, the turning angle per element (a thin tube's wall, whose inner and outer chords nearly
/// cancel, needs no finer size than a solid bar of its radius).
fn curvature_size(surfaces: &[Option<FaceSurface>], mesh: &fieldcraft_geometry::SurfaceMesh, volume: f64) -> Option<(f64, usize, f64)> {
let n = surfaces.len();
// Per face: ∫(κ1 + κ2) dA, area, ∫ κ1 (κ1 + κ2) dA, ∫ κ1 dA.
let mut sums = vec![[0.0f64; 4]; n];
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some(Some(surface)) = surfaces.get(f as usize) else { continue };
let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied().unwrap_or_default());
let centroid = std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0);
let Some((k1, k2)) = curvatures(surface, centroid) else { continue };
let area = 0.5 * {
let n = cross(std::array::from_fn(|k| b[k] - a[k]), std::array::from_fn(|k| c[k] - a[k]));
dot(n, n).sqrt()
};
if let Some(s) = sums.get_mut(f as usize) {
s[0] += area * (k1 + k2);
s[1] += area;
s[2] += area * k1 * (k1 + k2);
s[3] += area * k1;
}
}
let e = CHORD_VOLUME_BUDGET;
// The whole body's cost against its volume, then the face that needs the finest size by its
// own turning angle, never finer than the body's bound.
let total: f64 = sums.iter().map(|s| s[0]).sum();
let global = (12.0 * e * volume.abs() / total).sqrt();
let (local, face, radius) = sums
.iter()
.enumerate()
.filter(|(_, s)| s[1] > 0.0 && s[2] > 0.0 && s[3] > 0.0)
// A circle split into arcs of angle θ loses θ²/6 of its area, a sphere's θ²/3: h² κ1 (κ1 + κ2)/6.
.map(|(i, s)| ((6.0 * e * s[1] / s[2]).sqrt(), i, s[1] / s[3]))
.filter(|(h, _, _)| h.is_finite() && *h > 0.0)
.min_by(|a, b| a.0.total_cmp(&b.0))?;
let h = global.max(local);
h.is_finite().then_some((h, face, radius))
}
pub const PLAN: CommandSpec = CommandSpec { pub const PLAN: CommandSpec = CommandSpec {
id: "mesh.plan", id: "mesh.plan",
@@ -924,18 +849,6 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
h = through; h = through;
} }
} }
// The curved faces: fine enough that the chords keep the volume.
let chords = shape.tessellate(1e-3 * diagonal)?;
if let Some((c, face, r)) = curvature_size(&surfaces, &chords, m.volume)
&& c < h
{
let name = shape.faces().get(face).map_or_else(|| format!("face {face}"), |f| f.name.clone());
notes.push(format!(
"size {c:.4} mm: the chords of {name} (radius ~{r:.4} mm) cost at most {:.1} % of the volume",
100.0 * CHORD_VOLUME_BUDGET
));
h = c;
}
let floor = (m.volume.abs() / max_elements as f64).cbrt(); let floor = (m.volume.abs() / max_elements as f64).cbrt();
if h < floor { if h < floor {
notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)")); notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)"));
@@ -2530,24 +2443,6 @@ mod acceptance {
accept(&mut s, &r, 0.65, 10.0); accept(&mut s, &r, 0.65, 10.0);
} }
#[test]
fn curved_bodies_mesh_within_a_percent_of_their_volume_at_the_plan_s_size() {
// The plan's size keeps the chords of a torus (swept) and a cylinder (butterfly) within
// the volume budget; the butterfly's rim is divided by its length, not its class's mean.
for cmds in [
json!([{"command": "solid.torus", "params": {"major": 30.0, "minor": 8.0, "body_name": "B"}}]),
json!([{"command": "solid.cylinder", "params": {"radius": 10.0, "height": 40.0, "body_name": "B"}}]),
] {
let (_d, mut s) = open(cmds.clone());
let body = s.execute("geometry.bodies", &json!({})).unwrap()[0]["volume"].as_f64().unwrap();
let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap();
assert!(plan["notes"].to_string().contains("the chords of"), "{plan}");
s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
let mesh = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
assert!(((mesh - body) / body).abs() < 0.01, "{cmds}: mesh {mesh} against {body}");
}
}
/// The crank arm sample, whole (#455, #190): its sides are at an angle to the axes and its /// The crank arm sample, whole (#455, #190): its sides are at an angle to the axes and its
/// root arcs meet the bosses at a kink, so it takes the ring-and-pillow layout: each boss an /// root arcs meet the bosses at a kink, so it takes the ring-and-pillow layout: each boss an
/// annulus from its bore or pedal hole to its circle through every level, the arm a carved /// annulus from its bore or pedal hole to its circle through every level, the arm a carved
@@ -2567,9 +2462,7 @@ mod acceptance {
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap(); let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
let layout = r["layout"].as_str().unwrap(); let layout = r["layout"].as_str().unwrap();
assert!(layout.contains("2 circle(s) ringed") && layout.contains("pillow layer"), "{r}"); assert!(layout.contains("2 circle(s) ringed") && layout.contains("pillow layer"), "{r}");
// The rims are divided by their own length (no chord longer than the size), which accept(&mut s, &r, 0.5, 7.0);
// thins the bore's ring elements a little at 2.5 mm (edge ratio 7.1).
accept(&mut s, &r, 0.5, 7.5);
} }
} }
+2 -6
View File
@@ -202,12 +202,8 @@ fn squeezed_junction_elements_are_reported_not_meshed() {
p["min_quality"] = json!(0.3); p["min_quality"] = json!(0.3);
let model = s.model.clone(); let model = s.model.clone();
let e = s.execute("mesh.hex.project", &p).unwrap_err().to_string(); let e = s.execute("mesh.hex.project", &p).unwrap_err().to_string();
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36; with // Smoothing leaves the worst element in the squeezed ring block (34) or in block 36.
// the cup's rim divided by its own length, the junction's corner (block 13) folds instead, assert!(e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36")), "{e}");
// and the validity gate refuses it.
let squeezed = e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36"));
let folded = e.contains("inverted, twisted or degenerate") && e.contains("block 13");
assert!(squeezed || folded, "{e}");
assert_eq!(s.model, model); assert_eq!(s.model, model);
} }
+4 -9
View File
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const RELAX: CommandSpec = CommandSpec { pub const RELAX: CommandSpec = CommandSpec {
id: "mesh.node.relax", id: "mesh.node.relax",
label: "Relax Nodes", label: "Relax Nodes",
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default five times the bad elements' mean edge length), "guards"?: "valid" (default) | "strict" (strong: when a cluster's morph is kept)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A cluster's morph is kept whenever no element it touches inverts (`"guards": "valid"`); with `"guards": "strict"` it is kept (at full or a halved scale) only when besides no element gets longer than the longest was, and the worst element it touches improves. The result is the round with the highest smallest scaled Jacobian, never lower than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#, params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default three times the bad elements' mean edge length)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A round is kept (at full or a halved scale) only when nothing inverts, no element gets longer than the longest was, and the worst element it touches improves; the result never has a lower smallest scaled Jacobian than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
form: Some(super::forms::NODE_RELAX), form: Some(super::forms::NODE_RELAX),
kind: CommandKind::Model, kind: CommandKind::Model,
requires: &[Requirement::Mesh], requires: &[Requirement::Mesh],
@@ -522,11 +522,6 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive)) .ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
.map(Some)?, .map(Some)?,
}; };
let guards = match super::opt::<String>(p, "guards")?.as_deref() {
None | Some("valid") => relax::Guards::Valid,
Some("strict") => relax::Guards::Strict,
Some(_) => return Err(EngineError::param("guards", ProblemKind::NotAChoice)),
};
let rounds = match p.get("iterations") { let rounds = match p.get("iterations") {
None => STRONG_ROUNDS, None => STRONG_ROUNDS,
Some(v) => v Some(v) => v
@@ -548,7 +543,7 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Params("no mesh nodes in the region to relax".into())); return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
} }
let g = design_geometry(s, &touched(&s.model, &nodes.iter().map(|&n| (n, 1.0)).collect()))?; let g = design_geometry(s, &touched(&s.model, &nodes.iter().map(|&n| (n, 1.0)).collect()))?;
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds, guards }, &g)?; let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds }, &g)?;
let field_name = match super::opt::<String>(p, "field")? { let field_name = match super::opt::<String>(p, "field")? {
Some(n) => n, Some(n) => n,
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()), None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
@@ -614,8 +609,8 @@ fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
Some("strong") => return relax_strong(s, p), Some("strong") => return relax_strong(s, p),
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)), Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
} }
if ["below", "reach", "guards"].iter().any(|k| p.get(*k).is_some()) { if p.get("below").is_some() || p.get("reach").is_some() {
return Err(EngineError::Params("`below`, `reach` and `guards` go with \"mode\": \"strong\"".into())); return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
} }
let (w, sphere) = weights(&s.model, p)?; let (w, sphere) = weights(&s.model, p)?;
if w.is_empty() { if w.is_empty() {
+1 -4
View File
@@ -275,8 +275,7 @@ fn on_box_edge(p: [f64; 3]) -> bool {
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() { fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
let (_d, mut s, _) = filleted_box(); let (_d, mut s, _) = filleted_box();
let before = s.model.clone(); let before = s.model.clone();
// The tet mesher leaves no element below the skill's 0.3, so relax those below 0.45. let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.3})).unwrap();
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.45})).unwrap();
let (b, a) = (&r["before"], &r["after"]); let (b, a) = (&r["before"], &r["after"]);
// The worst tets may not improve: their corners can all be held on the box's edges. // The worst tets may not improve: their corners can all be held on the box's edges.
assert!(a["min_scaled_jacobian"].as_f64().unwrap() >= b["min_scaled_jacobian"].as_f64().unwrap(), "{r}"); assert!(a["min_scaled_jacobian"].as_f64().unwrap() >= b["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
@@ -350,8 +349,6 @@ fn strong_mode_parameters_are_checked() {
json!({"mode": "strong", "nodes": [1], "iterations": 201}), json!({"mode": "strong", "nodes": [1], "iterations": 201}),
json!({"below": 0.5}), json!({"below": 0.5}),
json!({"nodes": [1], "reach": 1}), json!({"nodes": [1], "reach": 1}),
json!({"nodes": [1], "guards": "strict"}),
json!({"mode": "strong", "nodes": [1], "guards": "loose"}),
json!({"nodes": [1], "mode": "strong", "layers": 1}), json!({"nodes": [1], "mode": "strong", "layers": 1}),
] { ] {
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}"); assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
+37 -13
View File
@@ -11,7 +11,8 @@
//! 2. **Grid** ([`grid`]). Root cubes over the part's box, refined as a 27-tree where a B-rep //! 2. **Grid** ([`grid`]). Root cubes over the part's box, refined as a 27-tree where a B-rep
//! face curves (its normals turn by more than [`OctreeOptions::curvature_angle`] inside a //! face curves (its normals turn by more than [`OctreeOptions::curvature_angle`] inside a
//! cell), where the part is thin (fewer than [`OctreeOptions::thickness_cells`] cells across a //! cell), where the part is thin (fewer than [`OctreeOptions::thickness_cells`] cells across a
//! wall, measured along the inward normal of each boundary triangle to a face that faces it) //! wall, measured along the inward normal of a boundary triangle to a face that faces it, at
//! the triangle's centre and, on a triangle larger than the cell, where it is nearest the cell)
//! and where a patch is narrow, then strongly balanced and closed under the transition //! and where a patch is narrow, then strongly balanced and closed under the transition
//! templates. The levels the thinnest wall and narrowest patch need are found before the grid //! templates. The levels the thinnest wall and narrowest patch need are found before the grid
//! is built, and the root size is chosen with them (see [`octree_hex_mesh`]'s plan): a part //! is built, and the root size is chosen with them (see [`octree_hex_mesh`]'s plan): a part
@@ -500,17 +501,29 @@ fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
/// ray that starts near a convex edge leaves through the face beyond the edge after a distance /// ray that starts near a convex edge leaves through the face beyond the edge after a distance
/// that only measures how near the edge it started; the faces of a wall face each other. /// that only measures how near the edge it started; the faces of a wall face each other.
fn triangle_thickness(g: &Geometry) -> Vec<f64> { fn triangle_thickness(g: &Geometry) -> Vec<f64> {
(0..g.surface.triangles().len() as u32).map(|t| thickness_at(g, t, g.triangle_centre(t))).collect()
}
/// The distance through the part from point `p` of boundary triangle `t` (see
/// [`triangle_thickness`]).
fn thickness_at(g: &Geometry, t: u32, p: P) -> f64 {
let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0; let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0;
let opposed = -libm::cos(WALL_OPENING.to_radians()); let opposed = -libm::cos(WALL_OPENING.to_radians());
(0..g.surface.triangles().len() as u32) let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
.map(|t| { match g.ray(p, n.map(|x| -x), far, t) {
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]); Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s,
match g.ray(g.triangle_centre(t), n.map(|x| -x), far, t) { _ => f64::INFINITY,
Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s, }
_ => f64::INFINITY, }
}
}) fn longest_edge(a: P, b: P, c: P) -> f64 {
.collect() norm(sub(b, a)).max(norm(sub(c, b))).max(norm(sub(a, c)))
}
/// `p` on a triangle moved a millionth of the way to its centroid, so a ray from it does not
/// start on an edge it shares with a neighbour.
fn toward_centre(p: P, [a, b, c]: [P; 3]) -> P {
[0, 1, 2].map(|k| p[k] + 1e-6 * ((a[k] + b[k] + c[k]) / 3.0 - p[k]))
} }
fn centroid(points: &[P], hx: &[u32; 8]) -> P { fn centroid(points: &[P], hx: &[u32; 8]) -> P {
@@ -629,7 +642,8 @@ impl Context<'_> {
let mut first: BTreeMap<u32, P> = BTreeMap::new(); let mut first: BTreeMap<u32, P> = BTreeMap::new();
for t in self.g.overlapping(&bx) { for t in self.g.overlapping(&bx) {
let [a, b, c] = self.g.triangle(t); let [a, b, c] = self.g.triangle(t);
if geom::dist2(centre, geom::closest_on_triangle(centre, a, b, c)) > reach * reach { let near = geom::closest_on_triangle(centre, a, b, c);
if geom::dist2(centre, near) > reach * reach {
continue; continue;
} }
let n = self.g.normals.get(t as usize).copied().unwrap_or([0.0; 3]); let n = self.g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
@@ -638,8 +652,18 @@ impl Context<'_> {
if key.0 < self.options.curvature_levels && dot(f, n) < cos { if key.0 < self.options.curvature_levels && dot(f, n) < cos {
return Ok(true); return Ok(true);
} }
// (With a relative margin, so cells planned at exactly the size a wall needs stay.) // The wall at the triangle's centre, and, on a triangle larger than the cell, where it
if self.thickness.get(t as usize).is_some_and(|&th| th * (1.0 + 1e-9) < self.options.thickness_cells * e) { // is nearest the cell: a planar face beside a pocket is a few triangles whose centres
// may face a far wall while the wall here is thin. That reading is held to the
// thinnest wall the plan chose the levels for (towards a wedge's blade the wall
// thins to nothing). (With a relative margin, so cells planned at exactly the size a
// wall needs stay.)
let need = self.options.thickness_cells * e;
let thin = |th: f64| th * (1.0 + 1e-9) < need;
if self.thickness.get(t as usize).is_some_and(|&th| thin(th)) {
return Ok(true);
}
if longest_edge(a, b, c) > e && thin(thickness_at(self.g, t, toward_centre(near, [a, b, c])).max(self.needs.wall)) {
return Ok(true); return Ok(true);
} }
// Narrow patches (a hole's wall, a thin rim) need cells across them. // Narrow patches (a hole's wall, a thin rim) need cells across them.
+41
View File
@@ -182,3 +182,44 @@ fn a_size_box_refines_the_grid_inside_it() {
let e = octree_hex_mesh(&s, &deep, &shallow).unwrap_err().to_string(); let e = octree_hex_mesh(&s, &deep, &shallow).unwrap_err().to_string();
assert!(e.contains("max_depth"), "{e}"); assert!(e.contains("max_depth"), "{e}");
} }
/// A prism: the polygon `profile` in the x–z plane (counter-clockwise, star-shaped from its
/// first point) extruded from y = 0 to `depth`. Faces 0 and 1 are the caps, 2 + i the side on
/// edge i.
fn prism_surface(profile: &[[f64; 2]], depth: f64) -> BoundarySurface {
let n = profile.len();
let mut p: Vec<P> = profile.iter().map(|q| [q[0], 0.0, q[1]]).collect();
p.extend(profile.iter().map(|q| [q[0], depth, q[1]]));
let (f, b) = (|i: usize| (i % n) as u32, |i: usize| (n + i % n) as u32);
let (mut tris, mut face) = (Vec::new(), Vec::new());
for i in 1..n - 1 {
tris.extend([[f(0), f(i), f(i + 1)], [b(0), b(i + 1), b(i)]]);
face.extend([0, 1]);
}
for i in 0..n {
tris.extend([[f(i), b(i), b(i + 1)], [f(i), b(i + 1), f(i + 1)]]);
face.extend([2 + i as u32; 2]);
}
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
}
#[test]
fn a_wall_is_measured_where_a_large_triangle_meets_the_cell() {
// A 30 mm block with a 3 mm flange: the bottom face is two triangles whose centres lie under
// the block, so their centre rays measure 30, but under the flange the wall is 3 thick.
let s = prism_surface(&[[0.0, 0.0], [60.0, 0.0], [60.0, 3.0], [50.0, 3.0], [50.0, 30.0], [0.0, 30.0]], 40.0);
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
let thickness = triangle_thickness(&g);
let bottom: Vec<u32> = (0..s.triangles().len() as u32).filter(|&t| s.tri_face()[t as usize] == 2).collect();
assert!(bottom.iter().all(|&t| thickness[t as usize] == 30.0), "{thickness:?}");
let o = OctreeOptions::default();
let needs = Needs::of(&g, &thickness, &o);
let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let field = UniformSize(100.0);
let ctx = Context::new(&g, &field, &o, &thickness, identity, 3.0, ([0.0; 3], [60.0, 40.0, 30.0]), needs).unwrap();
assert_eq!(ctx.origin, [-1.5, -2.5, -1.5]);
// Root cells of 3 on the bottom face, clear of every other face: under the flange
// (x 52.5..55.5, 3 cells across need cells of 1) and under the block (x 28.5..31.5).
assert!(ctx.needs_split((0, [18, 7, 0])).unwrap(), "under the flange");
assert!(!ctx.needs_split((0, [10, 7, 0])).unwrap(), "under the block");
}
+3 -17
View File
@@ -514,17 +514,11 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
// lengths). A class runs through every block its edges are parallel in, so its edges can // lengths). A class runs through every block its edges are parallel in, so its edges can
// differ a lot in length (a cup's wall, 4 mm thick, and its floor's radial edges, twice // differ a lot in length (a cup's wall, 4 mm thick, and its floor's radial edges, twice
// that); the longest edge alone would cut the thin blocks into flat slivers. No element is // that); the longest edge alone would cut the thin blocks into flat slivers. No element is
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry. An edge // longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry.
// that bends with a curved target is the exception: its elements' sides are chords of the
// surface, and one longer than the size cuts off more of the part than the size allows (a
// butterfly's rim shares its class with the shorter sides of its middle square), so a class
// takes at least the geometric mean of its bent edges' lengths in sizes (rounded,
// as the mean; an annulus's rim and bore balance each other as its straight edges do).
let (classes, nclass) = edge_classes(t); let (classes, nclass) = edge_classes(t);
let mut class_log = vec![(0.0f64, 0usize); nclass]; let mut class_log = vec![(0.0f64, 0usize); nclass];
// Per class, its longest edge in element sizes. // Per class, its longest edge in element sizes.
let mut class_max = vec![0.0f64; nclass]; let mut class_max = vec![0.0f64; nclass];
let mut class_bent = vec![(0.0f64, 0usize); nclass];
let mut seen: BTreeSet<[u32; 2]> = BTreeSet::new(); let mut seen: BTreeSet<[u32; 2]> = BTreeSet::new();
for (block, cls) in t.blocks.iter().zip(&classes) { for (block, cls) in t.blocks.iter().zip(&classes) {
for d in 0..3 { for d in 0..3 {
@@ -556,12 +550,6 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
if let Some(m) = class_max.get_mut(cls[d]) { if let Some(m) = class_max.get_mut(cls[d]) {
*m = m.max(measured); *m = m.max(measured);
} }
if len - dist(pa, pb) > tol
&& let Some(l) = class_bent.get_mut(cls[d])
{
l.0 += libm::log(measured);
l.1 += 1;
}
if let Some(l) = class_log.get_mut(cls[d]).filter(|_| len > tol) { if let Some(l) = class_log.get_mut(cls[d]).filter(|_| len > tol) {
l.0 += libm::log(measured); l.0 += libm::log(measured);
l.1 += 1; l.1 += 1;
@@ -627,11 +615,9 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
.iter() .iter()
.zip(&class_max) .zip(&class_max)
.zip(&class_arc) .zip(&class_arc)
.zip(&class_bent) .map(|((&(sum, k), &longest), &arc)| {
.map(|(((&(sum, k), &longest), &arc), &(bent_sum, bent_k))| {
let mean = if k == 0 { 1.0 } else { libm::exp(sum / k as f64).round() }; let mean = if k == 0 { 1.0 } else { libm::exp(sum / k as f64).round() };
let bent = if bent_k == 0 { 0 } else { libm::exp(bent_sum / bent_k as f64).round().min(crate::block::MAX_DIVISIONS as f64) as usize }; (mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).clamp(1, crate::block::MAX_DIVISIONS)
(mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).max(bent).clamp(1, crate::block::MAX_DIVISIONS)
}) })
.collect(); .collect();
for &(b, d, n) in &opts.divisions { for &(b, d, n) in &opts.divisions {
+1 -1
View File
@@ -939,7 +939,7 @@ pub fn apply_field(
} }
mod strong; mod strong;
pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph}; pub use strong::{MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+14 -48
View File
@@ -254,37 +254,21 @@ impl State<'_> {
} }
} }
/// When [`morph`] keeps a cluster's morph.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Guards {
/// Kept whenever no element it touches inverts (more invalid elements than before).
#[default]
Valid,
/// Kept (at full or a halved scale) only when, besides, the worst element it touches is no
/// worse, the smooth objective there drops and no element gets longer than the longest was.
Strict,
}
/// Default reach of [`morph`], in mean edge lengths of the bad elements.
pub const DEFAULT_REACH_LENGTHS: f64 = 5.0;
/// What [`morph`] does. /// What [`morph`] does.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct MorphOptions { pub struct MorphOptions {
/// Elements below this quality are the bad ones. /// Elements below this quality are the bad ones.
pub below: f64, pub below: f64,
/// The radius round the bad nodes whose nodes follow them; `None`: /// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
/// [`DEFAULT_REACH_LENGTHS`] times the bad elements' mean edge length. /// elements' mean edge length.
pub reach: Option<f64>, pub reach: Option<f64>,
/// Most rounds (bad nodes moved, the region morphed after them). /// Most rounds (bad nodes moved, the region morphed after them).
pub rounds: usize, pub rounds: usize,
/// When a cluster's morph is kept.
pub guards: Guards,
} }
impl Default for MorphOptions { impl Default for MorphOptions {
fn default() -> Self { fn default() -> Self {
MorphOptions { below: 0.7, reach: None, rounds: 20, guards: Guards::Valid } MorphOptions { below: 0.7, reach: None, rounds: 20 }
} }
} }
@@ -396,9 +380,8 @@ impl State<'_> {
/// Morph round one cluster of bad nodes: their own optimisation (the rest held) gives their /// Morph round one cluster of bad nodes: their own optimisation (the rest held) gives their
/// displacements, the harmonic extension carries them to the nodes within `reach`, applied at /// displacements, the harmonic extension carries them to the nodes within `reach`, applied at
/// full or a halved scale when that improves the elements it touches. The region's size and /// full or a halved scale when that improves the elements it touches. The region's size and
/// whether it was kept. With [`Guards::Valid`] the full morph is kept unless an element /// whether it was kept.
/// it touches inverts; with [`Guards::Strict`] only a scale that improves the elements is. fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, guards: Guards, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect(); let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect();
let mut undo = Vec::new(); let mut undo = Vec::new();
let mut steps: BTreeMap<u32, f64> = bad.iter().map(|&n| (n, 0.1)).collect(); let mut steps: BTreeMap<u32, f64> = bad.iter().map(|&n| (n, 0.1)).collect();
@@ -435,11 +418,7 @@ impl State<'_> {
} }
} }
let now = self.certified(&touched); let now = self.certified(&touched);
let keep = match guards { if now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio) {
Guards::Valid => now.invalid <= was.invalid,
Guards::Strict => now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio),
};
if keep {
return Ok((region.len(), true)); return Ok((region.len(), true));
} }
self.restore(undo); self.restore(undo);
@@ -515,11 +494,10 @@ impl State<'_> {
/// the reach by harmonic interpolation (the graph Laplacian solved with the bad nodes' /// the reach by harmonic interpolation (the graph Laplacian solved with the bad nodes'
/// displacements and zero beyond the reach and at held nodes). Face nodes are put back on their /// displacements and zero beyond the reach and at held nodes). Face nodes are put back on their
/// face, edge and vertex nodes do not move, nodes outside the reach stay exactly as they are. A /// face, edge and vertex nodes do not move, nodes outside the reach stay exactly as they are. A
/// cluster's morph is kept as long as no element it touches inverts ([`Guards::Valid`]), or, with /// cluster's morph is kept, at full or a halved scale, only if no more elements are invalid, no element
/// [`Guards::Strict`], at full or a halved scale only if besides no element gets longer than the /// gets longer than the longest one was, the worst element it touches is no worse and the smooth
/// longest one was, the worst element it touches is no worse and the smooth objective there drops. /// objective there drops. Only nodes of `nodes`
/// The result is the round with the highest smallest quality (never below the start). Only nodes /// move. The model is not changed: the result has the new positions.
/// of `nodes` move. The model is not changed: the result has the new positions.
pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geometry: &dyn Constraints) -> Result<Morphed> { pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geometry: &dyn Constraints) -> Result<Morphed> {
if !(options.below > 0.0 && options.below <= 1.0) { if !(options.below > 0.0 && options.below <= 1.0) {
return Err(Error::Invalid(format!("below must be in (0, 1], got {}", options.below))); return Err(Error::Invalid(format!("below must be in (0, 1], got {}", options.below)));
@@ -540,14 +518,6 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
let (_, _, mean_before) = state.qualities(); let (_, _, mean_before) = state.qualities();
let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed; let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
let mut rounds = Vec::new(); let mut rounds = Vec::new();
// The best state of the run: the highest smallest quality, then the fewest elements below
// `below`, then the highest mean; the start until a round beats it.
let score = |s: &State<'_>| {
let (q, min, mean) = s.qualities();
(min, q.iter().filter(|v| **v < options.below).count(), mean)
};
let better = |a: (f64, usize, f64), b: (f64, usize, f64)| a.0 > b.0 || (a.0 == b.0 && (a.1 < b.1 || (a.1 == b.1 && a.2 > b.2)));
let mut best = (score(&state), original.clone());
for round in 0..options.rounds { for round in 0..options.rounds {
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds"); fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
fieldcraft_model::progress::count(round as f64); fieldcraft_model::progress::count(round as f64);
@@ -569,7 +539,7 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
if bad.is_empty() { if bad.is_empty() {
break; break;
} }
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { DEFAULT_REACH_LENGTHS * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 }); let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { 3.0 * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
if !(reach > 0.0) { if !(reach > 0.0) {
break; break;
} }
@@ -578,22 +548,18 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
let mut round_out = Round { bad_elements: bad_elems.len(), bad_nodes: bad.len(), reach, clusters: clusters.len(), ..Round::default() }; let mut round_out = Round { bad_elements: bad_elems.len(), bad_nodes: bad.len(), reach, clusters: clusters.len(), ..Round::default() };
for cluster in &clusters { for cluster in &clusters {
fieldcraft_model::progress::check()?; fieldcraft_model::progress::check()?;
let (region, kept) = state.morph_cluster(cluster, reach, options.guards, &|s: &State<'_>, n| movable(s, n))?; let (region, kept) = state.morph_cluster(cluster, reach, &|s: &State<'_>, n| movable(s, n))?;
round_out.region_nodes += region; round_out.region_nodes += region;
round_out.kept += usize::from(kept); round_out.kept += usize::from(kept);
} }
let now = score(&state); let (_, min, mean) = state.qualities();
(round_out.min_quality, round_out.mean_quality) = (now.0, now.2); (round_out.min_quality, round_out.mean_quality) = (min, mean);
if better(now, best.0) {
best = (now, state.pos.clone());
}
let any = round_out.kept > 0; let any = round_out.kept > 0;
rounds.push(round_out); rounds.push(round_out);
if !any { if !any {
break; break;
} }
} }
state.pos = best.1;
let mut after = region_quality(&state); let mut after = region_quality(&state);
let (_, _, mut mean_after) = state.qualities(); let (_, _, mut mean_after) = state.qualities();
// Never worse than the start. // Never worse than the start.
+2 -18
View File
@@ -360,7 +360,7 @@ fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
let (original, m) = distorted_annulus(6, 0.06); let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original.clone() }; let geo = Geo { model: original.clone() };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect(); let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let options = MorphOptions { below: 0.9, reach: None, rounds: 40, ..MorphOptions::default() }; let options = MorphOptions { below: 0.9, reach: None, rounds: 40 };
let r = morph(&m, &nodes, options, &geo).unwrap(); let r = morph(&m, &nodes, options, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}: {:?}", r.before.min_quality, r.after.min_quality, r.rounds); assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}: {:?}", r.before.min_quality, r.after.min_quality, r.rounds);
assert!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after); assert!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after);
@@ -389,7 +389,7 @@ fn strong_relaxation_moves_only_nodes_within_the_reach() {
let geo = Geo { model: original }; let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect(); let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let reach = 0.4; let reach = 0.4;
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20, ..MorphOptions::default() }, &geo).unwrap(); let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20 }, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality, "{:?}", r.rounds); assert!(r.after.min_quality > r.before.min_quality, "{:?}", r.rounds);
for n in r.positions.keys() { for n in r.positions.keys() {
assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.2 + 2.0 * reach, "node {n} far from the bad spot moved"); assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.2 + 2.0 * reach, "node {n} far from the bad spot moved");
@@ -413,22 +413,6 @@ fn strong_relaxation_untangles_inverted_elements() {
assert!(r.after.min_quality > 0.5, "{:?}", r.after); assert!(r.after.min_quality > 0.5, "{:?}", r.after);
} }
/// Both guards keep the result valid and never below the start; the valid guards (keep a morph
/// unless it inverts an element) lift the worst element at least as far as the strict ones.
#[test]
fn strong_relaxation_guards_valid_and_strict() {
let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let run = |guards| morph(&m, &nodes, MorphOptions { below: 0.9, rounds: 40, guards, ..MorphOptions::default() }, &geo).unwrap();
let (valid, strict) = (run(Guards::Valid), run(Guards::Strict));
for r in [&valid, &strict] {
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
assert!(r.after.min_quality >= r.before.min_quality, "{:?} → {:?}", r.before.min_quality, r.after.min_quality);
}
assert!(valid.after.min_quality >= strict.after.min_quality - 1e-9, "valid {:?}, strict {:?}", valid.after, strict.after);
}
/// A good mesh is left as it is; bad options are errors. /// A good mesh is left as it is; bad options are errors.
#[test] #[test]
fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() { fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() {
+6 -179
View File
@@ -170,9 +170,7 @@ pub enum Step {
pub struct Raw { pub struct Raw {
slots: Vec<(u32, [u32; 4], [u32; 4], [u32; 4])>, slots: Vec<(u32, [u32; 4], [u32; 4], [u32; 4])>,
tets_len: usize, tets_len: usize,
/// The free list's length, and its last entries (those the change may take). free: Vec<u32>,
free_len: usize,
free_tail: Vec<u32>,
vtets: Vec<(u32, Vec<u32>)>, vtets: Vec<(u32, Vec<u32>)>,
live: usize, live: usize,
} }
@@ -201,21 +199,6 @@ pub struct TetComplex {
frozen: Vec<u32>, frozen: Vec<u32>,
mark: Vec<u32>, mark: Vec<u32>,
epoch: u32, epoch: u32,
/// Change clock: `touched[v]` is the clock of the last change to a tetrahedron of `v`'s star
/// (made, removed, reshaped or restored), `failed[t]` the clock at which every topological
/// operation on slot `t` last failed ([`Self::known_to_fail`]).
clock: u64,
touched: Vec<u64>,
failed: Vec<u64>,
/// The clock at which removing an edge `(a, b)` (sorted), or a face from apex `a`'s side to apex `b` (sorted face)
/// last failed: the operation reads only the tetrahedra holding both apexes.
failed_edges: IdMap<(u32, u32), u64>,
failed_faces: IdMap<(u32, u32, [u32; 3]), u64>,
/// Per slot: the quality ([`super::measure::quality`]) and one more than the clock it was
/// computed at; current while no vertex of the tetrahedron was touched since.
quality_cache: std::cell::RefCell<Vec<(u64, f64)>>,
/// The clock at which smoothing a vertex last found no move.
failed_smoothing: Vec<u64>,
live: usize, live: usize,
max_tets: usize, max_tets: usize,
max_points: usize, max_points: usize,
@@ -298,13 +281,6 @@ impl TetComplex {
frozen: Vec::new(), frozen: Vec::new(),
mark: vec![0; tets.len()], mark: vec![0; tets.len()],
epoch: 0, epoch: 0,
clock: 0,
touched: vec![0; points.len()],
failed: Vec::new(),
failed_edges: IdMap::default(),
failed_faces: IdMap::default(),
failed_smoothing: Vec::new(),
quality_cache: std::cell::RefCell::new(Vec::new()),
live: tets.len(), live: tets.len(),
max_tets, max_tets,
max_points, max_points,
@@ -379,125 +355,6 @@ impl TetComplex {
if let Some(s) = self.kinds.get_mut(v as usize) { if let Some(s) = self.kinds.get_mut(v as usize) {
*s = k; *s = k;
} }
self.touch_star(v);
}
// ---- change clock ----
fn touch(&mut self, vs: &[u32]) {
self.clock += 1;
for &v in vs {
if let Some(s) = self.touched.get_mut(v as usize) {
*s = self.clock;
}
}
}
/// Touch `v` and every vertex of its star (moving `v` reshapes those tetrahedra).
fn touch_star(&mut self, v: u32) {
let mut vs = vec![v];
for &t in self.vtets.get(v as usize).map(Vec::as_slice).unwrap_or_default() {
vs.extend(self.tets.get(t as usize).copied().unwrap_or([NONE; 4]));
}
self.touch(&vs);
}
/// The last change near tetrahedron `t`: in the stars of its vertices and of its neighbours'
/// vertices, the region every topological operation on `t` reads.
fn stamp(&self, t: u32) -> Result<u64> {
let mut m = 0;
let mut look = |vs: [u32; 4]| {
for v in vs {
m = m.max(self.touched.get(v as usize).copied().unwrap_or(u64::MAX));
}
};
look(self.tet(t)?);
for n in self.adj(t)? {
if n != NONE {
look(self.tet(n)?);
}
}
Ok(m)
}
fn touched_at(&self, v: u32) -> u64 {
self.touched.get(v as usize).copied().unwrap_or(u64::MAX)
}
/// Record that removing edge `ab` failed now.
pub fn mark_edge_failed(&mut self, a: u32, b: u32) {
self.failed_edges.insert(sorted2(a, b), self.clock);
}
/// Whether removing edge `ab` failed and no tetrahedron around `a` or `b` changed since.
pub fn edge_known_to_fail(&self, a: u32, b: u32) -> bool {
self.failed_edges.get(&sorted2(a, b)).is_some_and(|&f| self.touched_at(a).max(self.touched_at(b)) <= f)
}
/// Record that removing `face` between apexes `a` and `b` failed now.
pub fn mark_face_failed(&mut self, a: u32, b: u32, face: [u32; 3]) {
self.failed_faces.insert((a, b, sorted3(face)), self.clock);
}
/// Whether removing `face` between `a` and `b` failed and nothing around them changed since.
pub fn face_known_to_fail(&self, a: u32, b: u32, face: [u32; 3]) -> bool {
self.failed_faces.get(&(a, b, sorted3(face))).is_some_and(|&f| self.touched_at(a).max(self.touched_at(b)) <= f)
}
/// The quality of live tetrahedron `t`, computed once per change of its vertices.
pub fn slot_quality(&self, t: u32) -> Result<f64> {
let vs = self.tet(t)?;
let stamp = vs.iter().map(|&v| self.touched_at(v)).max().unwrap_or(u64::MAX);
let i = t as usize;
if let Some(&(c, q)) = self.quality_cache.borrow().get(i)
&& c > stamp
{
return Ok(q);
}
let q = super::measure::quality(&self.coords(vs)?);
let mut cache = self.quality_cache.borrow_mut();
if cache.len() <= i {
cache.resize(i + 1, (0, 0.0));
}
if let Some(e) = cache.get_mut(i) {
*e = (self.clock + 1, q);
}
Ok(q)
}
/// Record that smoothing `v` found no move now.
pub fn mark_smoothing_failed(&mut self, v: u32) {
let i = v as usize;
if self.failed_smoothing.len() <= i {
self.failed_smoothing.resize(i + 1, 0);
}
if let Some(f) = self.failed_smoothing.get_mut(i) {
*f = self.clock;
}
}
/// Whether smoothing `v` found no move and no tetrahedron around it changed since.
pub fn smoothing_known_to_fail(&self, v: u32) -> bool {
self.failed_smoothing.get(v as usize).is_some_and(|&f| f > 0 && self.touched_at(v) <= f)
}
/// Record that every topological operation on `t` failed now.
pub fn mark_failed(&mut self, t: u32) {
let i = t as usize;
if self.failed.len() <= i {
self.failed.resize(i + 1, 0);
}
if let Some(f) = self.failed.get_mut(i) {
*f = self.clock;
}
}
/// Whether the topological operations on `t` failed and nothing they read has changed
/// since, so trying again would fail the same way (they are deterministic functions of that
/// region).
pub fn known_to_fail(&self, t: u32) -> Result<bool> {
let Some(&f) = self.failed.get(t as usize) else { return Ok(false) };
Ok(f > 0 && self.stamp(t)? <= f)
} }
pub fn slots(&self) -> usize { pub fn slots(&self) -> usize {
@@ -567,7 +424,6 @@ impl TetComplex {
pub fn set_curve(&mut self, a: u32, b: u32, curve: Option<u32>) { pub fn set_curve(&mut self, a: u32, b: u32, curve: Option<u32>) {
let key = sorted2(a, b); let key = sorted2(a, b);
self.touch(&[a, b]);
let before = match curve { let before = match curve {
Some(c) => self.curves.insert(key, c), Some(c) => self.curves.insert(key, c),
None => self.curves.remove(&key), None => self.curves.remove(&key),
@@ -832,7 +688,6 @@ impl TetComplex {
self.pts.push(p); self.pts.push(p);
self.kinds.push(kind); self.kinds.push(kind);
self.vtets.push(Vec::new()); self.vtets.push(Vec::new());
self.touched.push(self.clock);
if self.journal.recording { if self.journal.recording {
self.journal.steps.push(Step::Added { v }); self.journal.steps.push(Step::Added { v });
} }
@@ -840,7 +695,7 @@ impl TetComplex {
} }
/// The data a change of `slots` and of the incidence of `verts` would overwrite. /// The data a change of `slots` and of the incidence of `verts` would overwrite.
fn snapshot(&self, slots: &[u32], verts: &[u32], takes: usize) -> Raw { fn snapshot(&self, slots: &[u32], verts: &[u32]) -> Raw {
let mut ids: Vec<u32> = slots.to_vec(); let mut ids: Vec<u32> = slots.to_vec();
ids.sort_unstable(); ids.sort_unstable();
ids.dedup(); ids.dedup();
@@ -856,8 +711,7 @@ impl TetComplex {
}) })
.collect(), .collect(),
tets_len: self.tets.len(), tets_len: self.tets.len(),
free_len: self.free.len(), free: self.free.clone(),
free_tail: self.free.get(self.free.len().saturating_sub(takes)..).unwrap_or_default().to_vec(),
vtets: vs.into_iter().filter_map(|v| Some((v, self.vtets.get(v as usize)?.clone()))).collect(), vtets: vs.into_iter().filter_map(|v| Some((v, self.vtets.get(v as usize)?.clone()))).collect(),
live: self.live, live: self.live,
} }
@@ -865,12 +719,6 @@ impl TetComplex {
/// Put back what [`Self::snapshot`] saved. /// Put back what [`Self::snapshot`] saved.
fn restore(&mut self, raw: Raw) { fn restore(&mut self, raw: Raw) {
let mut touched: Vec<u32> = raw.vtets.iter().map(|(v, _)| *v).collect();
for (t, vs, _, _) in &raw.slots {
touched.extend(self.tets.get(*t as usize).copied().unwrap_or([NONE; 4]));
touched.extend(vs);
}
self.touch(&touched);
for (v, l) in raw.vtets { for (v, l) in raw.vtets {
if let Some(s) = self.vtets.get_mut(v as usize) { if let Some(s) = self.vtets.get_mut(v as usize) {
*s = l; *s = l;
@@ -886,8 +734,7 @@ impl TetComplex {
self.adj.truncate(raw.tets_len); self.adj.truncate(raw.tets_len);
self.tags.truncate(raw.tets_len); self.tags.truncate(raw.tets_len);
self.mark.truncate(raw.tets_len); self.mark.truncate(raw.tets_len);
self.free.truncate(raw.free_len.saturating_sub(raw.free_tail.len())); self.free = raw.free;
self.free.extend(raw.free_tail);
self.live = raw.live; self.live = raw.live;
} }
@@ -897,7 +744,6 @@ impl TetComplex {
self.pts.pop(); self.pts.pop();
self.kinds.pop(); self.kinds.pop();
self.vtets.pop(); self.vtets.pop();
self.touched.pop();
return Ok(()); return Ok(());
} }
Err(corrupt("dropping a vertex in use")) Err(corrupt("dropping a vertex in use"))
@@ -909,7 +755,6 @@ impl TetComplex {
let slot = self.pts.get_mut(v as usize).ok_or_else(|| corrupt("vertex id"))?; let slot = self.pts.get_mut(v as usize).ok_or_else(|| corrupt("vertex id"))?;
let from = *slot; let from = *slot;
*slot = p; *slot = p;
self.touch_star(v);
if self.journal.recording { if self.journal.recording {
self.journal.steps.push(Step::Moved { v, from }); self.journal.steps.push(Step::Moved { v, from });
} }
@@ -1117,7 +962,7 @@ impl TetComplex {
for &t in old { for &t in old {
verts.extend(self.tet(t)?); verts.extend(self.tet(t)?);
} }
Some(Box::new(self.snapshot(&slots, &verts, change.new.len()))) Some(Box::new(self.snapshot(&slots, &verts)))
} else { } else {
None None
}; };
@@ -1157,9 +1002,6 @@ impl TetComplex {
} }
} }
} }
let mut changed: Vec<u32> = removed.iter().flat_map(|r| r.vs).collect();
changed.extend(change.new.iter().flatten());
self.touch(&changed);
self.live = self.live - old.len() + ids.len(); self.live = self.live - old.len() + ids.len();
if self.journal.recording { if self.journal.recording {
self.journal.steps.push(Step::Replaced { removed, added: change.new.clone(), raw: raw.unwrap_or_default() }); self.journal.steps.push(Step::Replaced { removed, added: change.new.clone(), raw: raw.unwrap_or_default() });
@@ -1191,18 +1033,7 @@ impl TetComplex {
if self.curve(vs[k], vs[l]).is_some() { if self.curve(vs[k], vs[l]).is_some() {
return Ok(false); return Ok(false);
} }
// Each neighbour gains a boundary face (both, when they are one tetrahedron); one left let raw = self.journal.recording.then(|| Box::new(self.snapshot(&[t, ns[k], ns[l]], &vs)));
// with every face on the boundary would float free of the mesh, joined to it by its
// vertices alone (a singular stiffness).
for m in [k, l] {
let n = ns[m];
let gained = if ns[k] == ns[l] { 2 } else { 1 };
let open = self.adj(n)?.iter().filter(|&&x| x == NONE).count();
if open + gained >= 4 {
return Ok(false);
}
}
let raw = self.journal.recording.then(|| Box::new(self.snapshot(&[t, ns[k], ns[l]], &vs, 0)));
for m in [k, l] { for m in [k, l] {
let n = ns[m]; let n = ns[m];
let nn = self.adj(n)?; let nn = self.adj(n)?;
@@ -1213,7 +1044,6 @@ impl TetComplex {
} }
} }
self.unlink(t, vs); self.unlink(t, vs);
self.touch(&vs);
if let Some(s) = self.tets.get_mut(t as usize) { if let Some(s) = self.tets.get_mut(t as usize) {
*s = [DEAD; 4]; *s = [DEAD; 4];
} }
@@ -1281,18 +1111,15 @@ impl TetComplex {
if let Some(s) = self.pts.get_mut(v as usize) { if let Some(s) = self.pts.get_mut(v as usize) {
*s = from; *s = from;
} }
self.touch_star(v);
} }
Step::Added { v } => { Step::Added { v } => {
if v as usize + 1 == self.pts.len() { if v as usize + 1 == self.pts.len() {
self.pts.pop(); self.pts.pop();
self.kinds.pop(); self.kinds.pop();
self.vtets.pop(); self.vtets.pop();
self.touched.pop();
} }
} }
Step::Curve { edge, curve } => { Step::Curve { edge, curve } => {
self.touch(&[edge.0, edge.1]);
match curve { match curve {
Some(c) => self.curves.insert(edge, c), Some(c) => self.curves.insert(edge, c),
None => self.curves.remove(&edge), None => self.curves.remove(&edge),
+23 -38
View File
@@ -24,7 +24,7 @@ use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, fac
use super::measure::{self, P}; use super::measure::{self, P};
use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality}; use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality};
use super::refine::{Context, MAX_CAVITY, MIN_GAP}; use super::refine::{Context, MAX_CAVITY, MIN_GAP};
use super::smooth::{smooth_vertex, smooth_vertex_below}; use super::smooth::smooth_vertex;
/// Settings of the schedule. /// Settings of the schedule.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@@ -53,8 +53,6 @@ pub struct ImproveStats {
pub smoothed: usize, pub smoothed: usize,
pub topological: usize, pub topological: usize,
pub inserted: usize, pub inserted: usize,
/// Insertions tried (kept or undone).
pub attempted: usize,
/// The worst quality (smallest dihedral sine) after each pass. /// The worst quality (smallest dihedral sine) after each pass.
pub worst: Vec<f64>, pub worst: Vec<f64>,
pub stopped: Option<String>, pub stopped: Option<String>,
@@ -83,8 +81,8 @@ fn indicators(cx: &TetComplex) -> Result<[f64; 8]> {
let mut out = [0.0; 8]; let mut out = [0.0; 8];
out[0] = f64::INFINITY; out[0] = f64::INFINITY;
let mut n = 0usize; let mut n = 0usize;
for (t, _) in cx.live_tets() { for (_, vs) in cx.live_tets() {
let q = cx.slot_quality(t)?; let q = tet_quality(cx, vs)?;
out[0] = out[0].min(q); out[0] = out[0].min(q);
for (i, s) in SIN_DEG.iter().enumerate() { for (i, s) in SIN_DEG.iter().enumerate() {
out[i + 1] += q.min(*s); out[i + 1] += q.min(*s);
@@ -104,8 +102,8 @@ fn progressed(now: &[f64; 8], before: &[f64; 8]) -> bool {
/// Smooth every movable vertex of a tetrahedron below `good`. Returns the moves. /// Smooth every movable vertex of a tetrahedron below `good`. Returns the moves.
fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result<usize> { fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result<usize> {
let mut want = vec![false; cx.point_count()]; let mut want = vec![false; cx.point_count()];
for (t, vs) in cx.live_tets() { for (_, vs) in cx.live_tets() {
if cx.slot_quality(t)? < good { if tet_quality(cx, vs)? < good {
for v in vs { for v in vs {
if let Some(w) = want.get_mut(v as usize) { if let Some(w) = want.get_mut(v as usize) {
*w = true; *w = true;
@@ -151,7 +149,7 @@ fn delete_flat(cx: &mut TetComplex, t: u32) -> Result<bool> {
/// edge ([`ops::collapse`]), which takes the tetrahedron away. /// edge ([`ops::collapse`]), which takes the tetrahedron away.
fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> { fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
let vs = cx.tet(t)?; let vs = cx.tet(t)?;
if cx.slot_quality(t)? > 0.1 { if tet_quality(cx, vs)? > 0.1 {
return Ok(false); return Ok(false);
} }
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?); let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
@@ -184,7 +182,7 @@ fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> { pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
let mut changes = 0; let mut changes = 0;
for &t in list { for &t in list {
if !cx.is_live(t) || cx.known_to_fail(t)? { if !cx.is_live(t) {
continue; continue;
} }
let vs = cx.tet(t)?; let vs = cx.tet(t)?;
@@ -203,25 +201,20 @@ pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
changes += 1; changes += 1;
continue; continue;
} }
let mut removed = false;
for k in 0..4 { for k in 0..4 {
if remove_face(cx, t, k)? { if remove_face(cx, t, k)? {
changes += 1; changes += 1;
removed = true;
break; break;
} }
} }
if !removed {
cx.mark_failed(t);
}
} }
Ok(changes) Ok(changes)
} }
fn below(cx: &TetComplex, good: f64) -> Result<Vec<u32>> { fn below(cx: &TetComplex, good: f64) -> Result<Vec<u32>> {
let mut out = Vec::new(); let mut out = Vec::new();
for (t, _) in cx.live_tets() { for (t, vs) in cx.live_tets() {
if cx.slot_quality(t)? < good { if tet_quality(cx, vs)? < good {
out.push(t); out.push(t);
} }
} }
@@ -295,8 +288,7 @@ fn change_quality(cx: &mut TetComplex, len: usize) -> Result<(f64, f64)> {
} }
/// The composite insertion of §3.5 (see the module docs). Returns whether it was kept. /// The composite insertion of §3.5 (see the module docs). Returns whether it was kept.
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, attempted: &mut usize, good: f64, p: Point3, kind: VertexKind, site: Site) -> Result<bool> { fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
*attempted += 1;
let was = cx.journal.recording; let was = cx.journal.recording;
cx.journal.recording = true; cx.journal.recording = true;
let len = cx.journal.steps.len(); let len = cx.journal.steps.len();
@@ -311,15 +303,8 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, attempted: &mut usize, goo
if !cx.is_used(v) { if !cx.is_used(v) {
break; break;
} }
// Only the tetrahedra below the threshold, as in the passes. let star = cx.star(v)?;
let mut star = cx.star(v)?; if topological_pass(cx, &star)? == 0 {
let mut keep = Vec::with_capacity(star.len());
for &t in &star {
keep.push(cx.slot_quality(t)? < good);
}
let mut flags = keep.into_iter();
star.retain(|_| flags.next().unwrap_or(false));
if star.is_empty() || topological_pass(cx, &star)? == 0 {
break; break;
} }
} }
@@ -327,7 +312,7 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, attempted: &mut usize, goo
let mut around: Vec<u32> = cx.neighbours(v)?; let mut around: Vec<u32> = cx.neighbours(v)?;
around.push(v); around.push(v);
for u in around { for u in around {
smooth_vertex_below(cx, ctx, u, good)?; smooth_vertex(cx, ctx, u)?;
} }
} }
let (before, after) = change_quality(cx, len)?; let (before, after) = change_quality(cx, len)?;
@@ -352,7 +337,7 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, attempted: &mut usize, goo
} }
/// Try inserting a vertex in each tetrahedron of `list` (§4.2). Returns the insertions kept. /// Try inserting a vertex in each tetrahedron of `list` (§4.2). Returns the insertions kept.
fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usize, good: f64, attempted: &mut usize) -> Result<usize> { fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usize, good: f64) -> Result<usize> {
let mut kept = 0; let mut kept = 0;
for &t in list { for &t in list {
if kept >= budget { if kept >= budget {
@@ -362,7 +347,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
continue; continue;
} }
let vs = cx.tet(t)?; let vs = cx.tet(t)?;
if cx.slot_quality(t)? >= good { if tet_quality(cx, vs)? >= good {
continue; continue;
} }
let ns = cx.adj(t)?; let ns = cx.adj(t)?;
@@ -389,7 +374,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
let target = measure::add(measure::mid(pa, pb), measure::scale(inward, 0.4 * l / len)); let target = measure::add(measure::mid(pa, pb), measure::scale(inward, 0.4 * l / len));
if let Ok(p) = Point3::try_from(target) if let Ok(p) = Point3::try_from(target)
&& let Some(Walk::Inside(at)) = cx.walk(t, &p, 1 << 12)? && let Some(Walk::Inside(at)) = cx.walk(t, &p, 1 << 12)?
&& attempt_insert(cx, ctx, attempted, good, p, VertexKind::Interior, Site::Interior(at))? && attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(at))?
{ {
done = true; done = true;
} }
@@ -406,7 +391,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
let kind = VertexKind::Face(tags[k]); let kind = VertexKind::Face(tags[k]);
if let Some(p) = ctx.place(kind, g) if let Some(p) = ctx.place(kind, g)
&& cx.is_live(t) && cx.is_live(t)
&& attempt_insert(cx, ctx, attempted, good, p, kind, Site::Face(t, k))? && attempt_insert(cx, ctx, p, kind, Site::Face(t, k))?
{ {
done = true; done = true;
} }
@@ -415,7 +400,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
if !done && cx.is_live(t) && cx.tet(t)? == vs { if !done && cx.is_live(t) && cx.tet(t)? == vs {
let g = measure::centroid(&cx.coords(vs)?); let g = measure::centroid(&cx.coords(vs)?);
if let Ok(p) = Point3::try_from(g) if let Ok(p) = Point3::try_from(g)
&& attempt_insert(cx, ctx, attempted, good, p, VertexKind::Interior, Site::Interior(t))? && attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(t))?
{ {
done = true; done = true;
} }
@@ -445,7 +430,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
}; };
let m = measure::mid(cx.xyz(a)?, cx.xyz(b)?); let m = measure::mid(cx.xyz(a)?, cx.xyz(b)?);
if let Some(p) = ctx.place(kind, m) if let Some(p) = ctx.place(kind, m)
&& attempt_insert(cx, ctx, attempted, good, p, kind, Site::Edge(a, b))? && attempt_insert(cx, ctx, p, kind, Site::Edge(a, b))?
{ {
done = true; done = true;
break; break;
@@ -494,8 +479,8 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result
// The worst 3.5% of the tetrahedra, or (desperation pass, §4.2) every one with a // The worst 3.5% of the tetrahedra, or (desperation pass, §4.2) every one with a
// dihedral angle below 40° or above 140° (sine below sin 40°). // dihedral angle below 40° or above 140° (sine below sin 40°).
let mut scored: Vec<(f64, u32)> = Vec::new(); let mut scored: Vec<(f64, u32)> = Vec::new();
for (t, _) in cx.live_tets() { for (t, vs) in cx.live_tets() {
scored.push((cx.slot_quality(t)?, t)); scored.push((tet_quality(cx, vs)?, t));
} }
scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let list: Vec<u32> = if failed == 1 { let list: Vec<u32> = if failed == 1 {
@@ -505,7 +490,7 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result
scored.iter().take(n).map(|s| s.1).collect() scored.iter().take(n).map(|s| s.1).collect()
}; };
let budget = o.max_insertions - stats.inserted; let budget = o.max_insertions - stats.inserted;
stats.inserted += insertion_pass(cx, ctx, &list, budget, o.good, &mut stats.attempted)?; stats.inserted += insertion_pass(cx, ctx, &list, budget, o.good)?;
} }
let now = indicators(cx)?; let now = indicators(cx)?;
carried = Some(now); carried = Some(now);
@@ -563,7 +548,7 @@ mod tests {
let q = Point3::try_from(q).unwrap(); let q = Point3::try_from(q).unwrap();
if let Some(Walk::Inside(at)) = cx.walk(t, &q, 1 << 12).unwrap() { if let Some(Walk::Inside(at)) = cx.walk(t, &q, 1 << 12).unwrap() {
tried += 1; tried += 1;
assert!(!attempt_insert(&mut cx, &ctx, &mut 0, SIN_40, q, VertexKind::Interior, Site::Interior(at)).unwrap()); assert!(!attempt_insert(&mut cx, &ctx, q, VertexKind::Interior, Site::Interior(at)).unwrap());
} }
} }
} }
+5 -192
View File
@@ -74,41 +74,12 @@ pub fn dihedral_cosines(x: &[P; 4]) -> [f64; 6] {
}) })
} }
/// The quality objective of a valid tetrahedron: the smaller of its smallest dihedral sine and /// The quality objective: the smallest signed dihedral sine (1 is impossible; the regular
/// its weighted scaled Jacobian ([`JACOBIAN_WEIGHT`]; the regular tetrahedron scores /// tetrahedron scores √(8/9) ≈ 0.943). Positive for a valid tetrahedron, 0 for a flat one,
/// √(8/9) ≈ 0.943). The dihedral sine /// negative for an inverted one (then the sine of largest magnitude, so worse inversions score
/// catches slivers and caps; it does not see needles, whose dihedral angles can all be fine /// lower). With `sin θ_ij = 6V ℓ_ij / (2A_k · 2A_l)`, the extreme sine is found on squared
/// while a corner is nearly flat (Shewchuk 2002), and the scaled Jacobian /// lengths and areas, with one square root.
/// `√2 · 6V / (ℓ₁ℓ₂ℓ₃)` at the worst corner, the measure analysts check (the skill's ≥ 0.2,
/// [`crate::quality`]), does. 0 for a flat tetrahedron; for an inverted one the negative sine of
/// largest magnitude, so worse inversions score lower. Extremes are found on squared lengths and
/// areas, with one square root each.
pub fn quality(x: &[P; 4]) -> f64 { pub fn quality(x: &[P; 4]) -> f64 {
quality_in_order(&canonical_order(x))
}
/// The corners reordered by an even permutation that depends only on the corners' positions
/// (sorted by coordinates, the last two swapped back when the sort was odd), so a measure
/// computed on it gives the same bits for every listing of a tetrahedron with the same
/// orientation: a removal tried from either side of a ring sees the same qualities.
fn canonical_order(x: &[P; 4]) -> [P; 4] {
let less = |a: &P, b: &P| a[0] < b[0] || (a[0] == b[0] && (a[1] < b[1] || (a[1] == b[1] && a[2] < b[2])));
let mut y = *x;
let mut odd = false;
// A sorting network on four (pairs 01, 23, 02, 13, 12), counting swaps for the parity.
for (i, j) in [(0, 1), (2, 3), (0, 2), (1, 3), (1, 2)] {
if less(&y[j], &y[i]) {
y.swap(i, j);
odd = !odd;
}
}
if odd {
y.swap(2, 3);
}
y
}
fn quality_in_order(x: &[P; 4]) -> f64 {
let v6 = volume6(x); let v6 = volume6(x);
if v6 == 0.0 || !v6.is_finite() { if v6 == 0.0 || !v6.is_finite() {
return 0.0; return 0.0;
@@ -124,124 +95,9 @@ fn quality_in_order(x: &[P; 4]) -> f64 {
pick = if v6 > 0.0 { pick.min(r) } else { pick.max(r) }; pick = if v6 > 0.0 { pick.min(r) } else { pick.max(r) };
} }
let q = v6 * pick.sqrt(); let q = v6 * pick.sqrt();
let q = if v6 > 0.0 { q.min(JACOBIAN_WEIGHT * scaled_jacobian(x, v6)) } else { q };
if q.is_finite() { q } else { 0.0 } if q.is_finite() { q } else { 0.0 }
} }
/// The corners of a tetrahedron and the other three corners of each.
const CORNERS: [(usize, [usize; 3]); 4] = [(0, [1, 2, 3]), (1, [0, 2, 3]), (2, [0, 1, 3]), (3, [0, 1, 2])];
/// The squared product of the three edge lengths at each corner.
fn corner_products(x: &[P; 4]) -> [f64; 4] {
CORNERS.map(|(c, o)| {
o.iter().fold(1.0, |m, &j| {
let e = sub(x[j], x[c]);
m * dot(e, e)
})
})
}
/// The scaled Jacobian at the worst corner of a tetrahedron with six times the volume `v6`
/// (as [`crate::quality`] reports it).
fn scaled_jacobian(x: &[P; 4], v6: f64) -> f64 {
let worst = corner_products(x).iter().fold(0.0_f64, |m, &p| m.max(p));
std::f64::consts::SQRT_2 * v6 / worst.sqrt()
}
/// The weight of the scaled Jacobian in [`quality`]: the meshing skill's limit, a scaled
/// Jacobian of 0.3, counts as much as a 30° dihedral angle (sin 30°, the improvement's target,
/// [`super::improve::ImproveOptions::target`]), so the Jacobian decides only for tetrahedra near
/// that limit (needles) and the dihedral sine everywhere else.
pub const JACOBIAN_WEIGHT: f64 = 0.5 / 0.3;
/// [`quality`] and its gradient with respect to corner `i`, for a positive tetrahedron: the
/// gradient of the term [`quality`] picks (the smallest dihedral sine, or the scaled Jacobian at
/// the worst corner, `∇ ln J = ∇V₆ / V₆ − Σ ∇ℓ / ℓ` over that corner's edges), analytic, as Klingner and
/// Shewchuk (§3.1) and Freitag and Ollivier-Gooch use for smoothing. With
/// `s = V₆ ℓ / (N_k N_l)` (`N` twice a face's area),
/// `∇s = s (∇V₆ / V₆ + ∇ℓ / ℓ − ∇N_k / N_k − ∇N_l / N_l)`, where `∇V₆` at a corner is its
/// opposite face's area vector, `∇ℓ` the unit edge vector at an end of the edge and, for a face
/// `(p, q, r)` with unit normal `n̂`, `∇_p N = (q − r) × n̂`. A flat or inverted tetrahedron
/// gets a zero gradient (smoothing only moves within valid positions).
pub fn quality_gradient(x: &[P; 4], i: usize) -> (f64, P) {
let q = quality(x);
let v6 = volume6(x);
if !(v6 > 0.0 && q > 0.0) || i > 3 {
return (q, [0.0; 3]);
}
let crosses = [0, 1, 2, 3].map(|k| face_cross(x, k));
let n2 = crosses.map(|c| dot(c, c));
// The edge whose sine is smallest (the same comparison as `quality`).
let mut pick = (f64::INFINITY, EDGE_FACES[0]);
for e in EDGE_FACES {
let (a, b, k, l) = e;
let d = sub(x[a], x[b]);
let r = dot(d, d) / (n2[k] * n2[l]);
if r < pick.0 {
pick = (r, e);
}
}
// The scaled Jacobian branch, when it is the smaller.
let products = corner_products(x);
let (c, cp) = products.iter().enumerate().fold((0, 0.0_f64), |m, (c, &p)| if p > m.1 { (c, p) } else { m });
let sj = JACOBIAN_WEIGHT * std::f64::consts::SQRT_2 * v6 / cp.sqrt();
let sine = v6 * pick.0.sqrt();
let face = |k: usize| -> [usize; 3] {
match k {
0 => [1, 2, 3],
1 => [0, 2, 3],
2 => [0, 1, 3],
_ => [0, 1, 2],
}
};
// ∇V₆ at corner i: the area vector of the face opposite i, signed so that V₆ grows along it.
let gi = crosses[i];
let gv = if dot(gi, sub(x[i], x[face(i)[0]])) < 0.0 { scale(gi, -1.0) } else { gi };
if sj < sine {
// ∇ ln sj = ∇V₆ / V₆ − Σ ∇ℓ / ℓ over the corner's edges at i.
let (corner, others) = CORNERS[c];
let mut g = scale(gv, 1.0 / v6);
for j in others {
let (p, o) = if i == corner {
(x[i], x[j])
} else if i == j {
(x[i], x[corner])
} else {
continue;
};
let e = sub(p, o);
g = sub(g, scale(e, 1.0 / dot(e, e)));
}
let g = scale(g, q);
return if g.iter().all(|c| c.is_finite()) { (q, g) } else { (q, [0.0; 3]) };
}
let (a, b, k, l) = pick.1;
let len = dist(x[a], x[b]);
let gl = if i == a {
scale(sub(x[a], x[b]), 1.0 / len)
} else if i == b {
scale(sub(x[b], x[a]), 1.0 / len)
} else {
[0.0; 3]
};
// ∇N of the face opposite m at corner i (zero when the face does not hold i).
let gn = |m: usize| -> P {
let f = face(m);
let Some(pos) = f.iter().position(|&c| c == i) else { return [0.0; 3] };
let n = crosses[m];
let nn = norm(n);
if !(nn > 0.0) {
return [0.0; 3];
}
let (qv, rv) = (x[f[(pos + 1) % 3]], x[f[(pos + 2) % 3]]);
scale(cross(sub(qv, rv), n), 1.0 / nn)
};
let (nk, nl) = (n2[k].sqrt(), n2[l].sqrt());
let g = add(add(scale(gv, 1.0 / v6), scale(gl, 1.0 / len)), scale(add(scale(gn(k), 1.0 / nk), scale(gn(l), 1.0 / nl)), -1.0));
let g = scale(g, q);
if g.iter().all(|c| c.is_finite()) { (q, g) } else { (q, [0.0; 3]) }
}
/// The smallest and largest dihedral angle in degrees (reports only). /// The smallest and largest dihedral angle in degrees (reports only).
pub fn dihedral_range(x: &[P; 4]) -> (f64, f64) { pub fn dihedral_range(x: &[P; 4]) -> (f64, f64) {
let (s, c) = (dihedral_sines(x), dihedral_cosines(x)); let (s, c) = (dihedral_sines(x), dihedral_cosines(x));
@@ -365,49 +221,6 @@ mod tests {
assert!(circumcenter(&flat).is_none()); assert!(circumcenter(&flat).is_none());
} }
/// Every even reordering of the corners gives the same quality, bit for bit.
#[test]
fn quality_does_not_depend_on_the_corner_order() {
let x = positive([[0.1, 0.0, 0.05], [1.0, 0.2, 0.0], [0.3, 0.9, 0.1], [0.4, 0.35, 0.7]]);
let q = quality(&x);
for p in [[1, 2, 0, 3], [2, 0, 1, 3], [0, 2, 3, 1], [3, 1, 0, 2], [1, 0, 3, 2], [2, 3, 0, 1]] {
assert_eq!(quality(&p.map(|i| x[i])).to_bits(), q.to_bits(), "{p:?}");
}
}
/// A needle: every dihedral angle above 20°, yet one corner nearly flat; the objective sees
/// it through the scaled Jacobian.
#[test]
fn a_needle_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 0.866, 0.0], [0.5, 0.29, 12.0]]);
let (lo, hi) = dihedral_range(&x);
assert!(lo > 20.0 && hi < 120.0, "{lo} {hi}");
assert!(quality(&x) < 0.15 * JACOBIAN_WEIGHT, "{}", quality(&x));
}
/// The analytic gradient matches central differences of the quality away from ties, on
/// either branch.
#[test]
fn quality_gradient_matches_central_differences() {
let sliverish = positive([[0.1, 0.0, 0.05], [1.0, 0.2, 0.0], [0.3, 0.9, 0.1], [0.4, 0.35, 0.7]]);
let needle = positive([[0.0, 0.0, 0.0], [1.0, 0.1, 0.0], [0.45, 0.85, 0.05], [0.55, 0.3, 6.0]]);
for x in [sliverish, needle] {
for i in 0..4 {
let (q, g) = quality_gradient(&x, i);
assert_eq!(q, quality(&x));
let h = 1e-7;
for c in 0..3 {
let (mut a, mut b) = (x, x);
a[i][c] += h;
b[i][c] -= h;
let fd = (quality(&a) - quality(&b)) / (2.0 * h);
assert!((fd - g[c]).abs() < 1e-6, "corner {i} axis {c}: {fd} vs {}", g[c]);
}
}
}
assert_eq!(quality_gradient(&[[0.0; 3]; 4], 0).1, [0.0; 3]);
}
#[test] #[test]
fn sliver_scores_low() { fn sliver_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.01]]); let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.01]]);
+2 -31
View File
@@ -559,26 +559,6 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
} }
} }
/// The tetrahedra (1-based) that share none of their faces with another one, in a mesh of more
/// than one: each is a separate piece of boundary, joined to the mesh by vertices or an edge.
fn detached_tets(tets: &[[u32; 4]]) -> Vec<u32> {
if tets.len() < 2 {
return Vec::new();
}
let face = |t: &[u32; 4], k: usize| {
let mut f = [t[(k + 1) % 4], t[(k + 2) % 4], t[(k + 3) % 4]];
f.sort_unstable();
f
};
let mut count: std::collections::BTreeMap<[u32; 3], u32> = std::collections::BTreeMap::new();
for t in tets {
for k in 0..4 {
*count.entry(face(t, k)).or_default() += 1;
}
}
tets.iter().enumerate().filter(|(_, t)| (0..4).all(|k| count.get(&face(t, k)).copied().unwrap_or(0) < 2)).map(|(i, _)| i as u32 + 1).collect()
}
/// The constrained Delaunay tetrahedralization itself as a [`QualityMesh`] (no quality stage), /// The constrained Delaunay tetrahedralization itself as a [`QualityMesh`] (no quality stage),
/// through the same validity gate: every tetrahedron admissible, the volume equal to what its /// through the same validity gate: every tetrahedron admissible, the volume equal to what its
/// boundary encloses. /// boundary encloses.
@@ -596,18 +576,9 @@ pub fn raw_mesh(surface: &BoundarySurface, cdt: &TetMesh) -> Result<QualityMesh>
Ok(QualityMesh { points: cdt.points.clone(), elements: cdt.tets.iter().map(|t| t.to_vec()).collect(), kind: ElementKind::Tet4, boundary, report }) Ok(QualityMesh { points: cdt.points.clone(), elements: cdt.tets.iter().map(|t| t.to_vec()).collect(), kind: ElementKind::Tet4, boundary, report })
} }
/// The validity gate of a linear mesh: every tetrahedron admissible, joined to the others by a /// The validity gate of a linear mesh: every tetrahedron admissible, and the volume equal to
/// face (when there are several), and the volume equal to what its boundary encloses within /// what its boundary encloses within 1e-9 (no overlap, no gap).
/// 1e-9 (no overlap, no gap).
pub fn linear_gate(points: &[[f64; 3]], tets: &[[u32; 4]], boundary_volume: f64) -> Result<GateReport> { pub fn linear_gate(points: &[[f64; 3]], tets: &[[u32; 4]], boundary_volume: f64) -> Result<GateReport> {
let detached = detached_tets(tets);
if !detached.is_empty() {
let list = detached.iter().take(10).map(u32::to_string).collect::<Vec<_>>().join(", ");
return Err(Error::Failed(format!(
"mesh validity gate: {} tetrahedra share no face with the rest of the mesh ({list}): held by vertices alone, they would make the stiffness singular",
detached.len()
)));
}
let coords = |t: &[u32; 4]| -> Result<[[f64; 3]; 4]> { let coords = |t: &[u32; 4]| -> Result<[[f64; 3]; 4]> {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("gate: vertex id".into())); let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("gate: vertex id".into()));
Ok([p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?]) Ok([p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])
+46 -93
View File
@@ -31,7 +31,7 @@ pub fn tet_quality(cx: &TetComplex, vs: [u32; 4]) -> Result<f64> {
pub fn worst(cx: &TetComplex, tets: &[u32]) -> Result<f64> { pub fn worst(cx: &TetComplex, tets: &[u32]) -> Result<f64> {
let mut q = f64::INFINITY; let mut q = f64::INFINITY;
for &t in tets { for &t in tets {
q = q.min(cx.slot_quality(t)?); q = q.min(tet_quality(cx, cx.tet(t)?)?);
} }
Ok(q) Ok(q)
} }
@@ -47,17 +47,13 @@ pub const MAX_RING: usize = 10;
pub const BOUNDARY_FLIP_COS: f64 = 0.939_692_620_785_908_4; pub const BOUNDARY_FLIP_COS: f64 = 0.939_692_620_785_908_4;
/// Klincsek's table: the best worst quality of a triangulation of the chain `v_i … v_j` /// Klincsek's table: the best worst quality of a triangulation of the chain `v_i … v_j`
/// (with the closing edge `v_i v_j`), and the apex `k` that achieves it. Only triangulations /// (with the closing edge `v_i v_j`), and the apex `k` that achieves it.
/// better than `floor` matter: an entry that cannot beat it holds `floor`, and a triangle is fn klincsek(m: usize, q: &mut dyn FnMut(usize, usize, usize) -> Result<f64>) -> Result<(Vec<f64>, Vec<usize>)> {
/// evaluated only when its two sub-chains beat it, which skips most of the table when the
/// removal cannot pay off (the decision and the triangulation chosen are the same as without
/// the floor).
fn klincsek(m: usize, floor: f64, q: &mut dyn FnMut(usize, usize, usize) -> Result<f64>) -> Result<(Vec<f64>, Vec<usize>)> {
let mut best = vec![f64::INFINITY; m * m]; let mut best = vec![f64::INFINITY; m * m];
let mut arg = vec![0usize; m * m]; let mut arg = vec![0usize; m * m];
for i in (0..m.saturating_sub(2)).rev() { for i in (0..m.saturating_sub(2)).rev() {
for j in i + 2..m { for j in i + 2..m {
let mut b = floor; let mut b = f64::NEG_INFINITY;
let mut a = i + 1; let mut a = i + 1;
for k in i + 1..j { for k in i + 1..j {
let sub = best.get(i * m + k).copied().unwrap_or(f64::NEG_INFINITY).min(best.get(k * m + j).copied().unwrap_or(f64::NEG_INFINITY)); let sub = best.get(i * m + k).copied().unwrap_or(f64::NEG_INFINITY).min(best.get(k * m + j).copied().unwrap_or(f64::NEG_INFINITY));
@@ -119,31 +115,12 @@ pub fn remove_edge_at(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<boo
if cx.curve(a, b).is_some() || !vs.contains(&a) || !vs.contains(&b) { if cx.curve(a, b).is_some() || !vs.contains(&a) || !vs.contains(&b) {
return Ok(false); return Ok(false);
} }
// A removal that failed fails again until a tetrahedron around the edge changes.
if cx.edge_known_to_fail(a, b) {
return Ok(false);
}
let removed = remove_edge_ring(cx, t, a, b)?;
if !removed {
cx.mark_edge_failed(a, b);
}
Ok(removed)
}
/// [`remove_edge_at`] past its checks.
fn remove_edge_ring(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<bool> {
let Some(ring) = cx.edge_ring_at(t, a, b)? else { return Ok(false) }; let Some(ring) = cx.edge_ring_at(t, a, b)? else { return Ok(false) };
let m = ring.verts.len(); let m = ring.verts.len();
if m < 3 || m > MAX_RING + usize::from(!ring.closed) { if m < 3 || m > MAX_RING + usize::from(!ring.closed) {
return Ok(false); return Ok(false);
} }
let mut v = ring.verts.clone(); let v = ring.verts.clone();
if ring.closed {
// Start a closed ring at its smallest vertex, so the triangulation chosen does not
// depend on the tetrahedron the ring was walked from.
let first = v.iter().enumerate().min_by_key(|(_, id)| **id).map_or(0, |(i, _)| i);
v.rotate_left(first);
}
let x = |id: u32| cx.xyz(id); let x = |id: u32| cx.xyz(id);
let (xa, xb) = (x(a)?, x(b)?); let (xa, xb) = (x(a)?, x(b)?);
// An open ring runs from boundary face (a, b, v_0) to (a, b, v_{m−1}): both must lie on one // An open ring runs from boundary face (a, b, v_0) to (a, b, v_{m−1}): both must lie on one
@@ -173,7 +150,7 @@ fn remove_edge_ring(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<bool>
Ok(measure::quality(&[pi, pj, pk, xa]).min(measure::quality(&[pi, pk, pj, xb]))) Ok(measure::quality(&[pi, pj, pk, xa]).min(measure::quality(&[pi, pk, pj, xb])))
}; };
// Triangulate the polygon v_0 … v_{m−1}, closed by the edge v_{m−1} v_0. // Triangulate the polygon v_0 … v_{m−1}, closed by the edge v_{m−1} v_0.
let (best, arg) = klincsek(m, before, &mut q)?; let (best, arg) = klincsek(m, &mut q)?;
let total = best.get(m - 1).copied().unwrap_or(f64::NEG_INFINITY); let total = best.get(m - 1).copied().unwrap_or(f64::NEG_INFINITY);
if !(total > before) { if !(total > before) {
return Ok(false); return Ok(false);
@@ -222,59 +199,37 @@ pub fn remove_face(cx: &mut TetComplex, t: u32, k: usize) -> Result<bool> {
let us = cx.tet(u)?; let us = cx.tet(u)?;
let face = face_toward(vs, k); let face = face_toward(vs, k);
let b = us.iter().copied().find(|v| !face.contains(v)).unwrap_or(NONE); let b = us.iter().copied().find(|v| !face.contains(v)).unwrap_or(NONE);
if b == NONE || cx.curve(a, b).is_some() || cx.face_known_to_fail(a, b, face) || cx.edge_tet(a, b)?.is_some() { if b == NONE || cx.curve(a, b).is_some() || cx.edge_tet(a, b)?.is_some() {
return Ok(false); return Ok(false);
} }
let removed = remove_faces_between(cx, t, u, a, b, face)?;
if !removed {
cx.mark_face_failed(a, b, face);
}
Ok(removed)
}
/// [`remove_face`] past its checks: tetrahedra `t` (apex `a`) and `u` (apex `b`) share `face`.
fn remove_faces_between(cx: &mut TetComplex, t: u32, u: u32, a: u32, b: u32, face: [u32; 3]) -> Result<bool> {
// Sandwiched faces between a and b: faces f with tetrahedra (a ∪ f) and (b ∪ f). Grow the // Sandwiched faces between a and b: faces f with tetrahedra (a ∪ f) and (b ∪ f). Grow the
// set from the given face across shared edges while every new tetrahedron (a, b, e) on the // set from the given face across shared edges while every new tetrahedron (a, b, e) on the
// ring stays better than the worst removed one; take the best prefix found (a greedy form of // ring stays better than the worst removed one; take the best prefix found (a greedy form of
// Shewchuk's optimal search, which visits the same candidates). // Shewchuk's optimal search, which visits the same candidates).
// Each face with the tetrahedron (a ∪ face). let mut faces: Vec<[u32; 3]> = vec![face];
let mut faces: Vec<([u32; 3], u32)> = vec![(face, t)];
let mut old: Vec<u32> = vec![t, u]; let mut old: Vec<u32> = vec![t, u];
let mut best: Option<(f64, Vec<u32>, Vec<[u32; 4]>)> = None; let mut best: Option<(f64, Vec<u32>, Vec<[u32; 4]>)> = None;
let mut removed_q = worst(cx, &old)?;
let mut made: IdMap<(u32, u32), ([u32; 4], f64)> = IdMap::default();
for _round in 0..8 { for _round in 0..8 {
let removed_q = worst(cx, &old)?;
// The boundary edges of the face set (edges in exactly one face), oriented as in the face // The boundary edges of the face set (edges in exactly one face), oriented as in the face
// seen from a, with the face's tetrahedron on a's side: each gives the new tetrahedron // seen from a: each gives the new tetrahedron (a, b, e) positive.
// (a, b, e) positive. let mut count: IdMap<(u32, u32), (u32, u32, usize)> = IdMap::default();
let mut count: IdMap<(u32, u32), (u32, u32, u32, usize)> = IdMap::default(); for f in &faces {
for &(f, ta) in &faces {
for i in 0..3 { for i in 0..3 {
let (p, q) = (f[i], f[(i + 1) % 3]); let (p, q) = (f[i], f[(i + 1) % 3]);
let e = count.entry(sorted2(p, q)).or_insert((p, q, ta, 0)); let e = count.entry(sorted2(p, q)).or_insert((p, q, 0));
e.3 += 1; e.2 += 1;
} }
} }
let mut ring: Vec<(u32, u32, u32)> = count.values().filter(|e| e.3 == 1).map(|e| (e.0, e.1, e.2)).collect(); let mut ring: Vec<(u32, u32)> = count.values().filter(|e| e.2 == 1).map(|e| (e.0, e.1)).collect();
ring.sort_unstable(); ring.sort_unstable();
let mut new = Vec::with_capacity(ring.len()); let mut new = Vec::with_capacity(ring.len());
let mut q = f64::INFINITY; let mut q = f64::INFINITY;
for &(p, r, _) in &ring { for &(p, r) in &ring {
// A ring edge stays on the ring until a face grows across it: its tetrahedron is // face (p, r, x) has a on its positive side, so (p, r, a, b)… orient explicitly.
// worked out once. let cand = [p, r, b, a];
let (cand, cq) = match made.get(&(p, r)) { let cand = if cx.orient(cand)? == Sign::Positive { cand } else { [r, p, b, a] };
Some(&c) => c, q = q.min(tet_quality(cx, cand)?);
None => {
// face (p, r, x) has a on its positive side, so (p, r, a, b)… orient explicitly.
let cand = [p, r, b, a];
let cand = if cx.orient(cand)? == Sign::Positive { cand } else { [r, p, b, a] };
let c = (cand, tet_quality(cx, cand)?);
made.insert((p, r), c);
c
}
};
q = q.min(cq);
new.push(cand); new.push(cand);
} }
if q > removed_q && best.as_ref().is_none_or(|(bq, _, _)| q > *bq) { if q > removed_q && best.as_ref().is_none_or(|(bq, _, _)| q > *bq) {
@@ -282,12 +237,11 @@ fn remove_faces_between(cx: &mut TetComplex, t: u32, u: u32, a: u32, b: u32, fac
} }
// Grow: a sandwiched neighbour face across a ring edge. // Grow: a sandwiched neighbour face across a ring edge.
let mut grown = false; let mut grown = false;
for &(p, r, ta) in &ring { for &(p, r) in &ring {
if let Some((f, na, nb)) = sandwiched_across(cx, a, b, p, r, ta, &faces)? { if let Some((f, ta, tb)) = sandwiched_across(cx, a, b, p, r, &faces)? {
faces.push((f, na)); faces.push(f);
old.push(na); old.push(ta);
old.push(nb); old.push(tb);
removed_q = removed_q.min(worst(cx, &[na, nb])?);
grown = true; grown = true;
break; break;
} }
@@ -303,28 +257,27 @@ fn remove_faces_between(cx: &mut TetComplex, t: u32, u: u32, a: u32, b: u32, fac
/// A face `(p, r, x)` sandwiched between `a` and `b` (tetrahedra `a ∪ f` and `b ∪ f` both exist) /// A face `(p, r, x)` sandwiched between `a` and `b` (tetrahedra `a ∪ f` and `b ∪ f` both exist)
/// next to edge `pr`, not already in `faces`. /// next to edge `pr`, not already in `faces`.
fn sandwiched_across(cx: &TetComplex, a: u32, b: u32, p: u32, r: u32, ta: u32, faces: &[([u32; 3], u32)]) -> Result<Option<([u32; 3], u32, u32)>> { fn sandwiched_across(cx: &mut TetComplex, a: u32, b: u32, p: u32, r: u32, faces: &[[u32; 3]]) -> Result<Option<([u32; 3], u32, u32)>> {
// ta = (a, p, r, y) holds the face set's face on edge pr; the other tetrahedron of a around let Some(ring) = cx.edge_ring(p, r)? else { return Ok(None) };
// pr in a face set's neighbourhood lies across its face (a, p, r). // Tetrahedra around pr containing a, and containing b.
let va = cx.tet(ta)?; for &ta in &ring.tets {
let Some(ky) = va.iter().position(|&v| v != a && v != p && v != r) else { return Ok(None) }; let va = cx.tet(ta)?;
let n = cx.adj(ta)?.get(ky).copied().unwrap_or(NONE); if !va.contains(&a) {
if n == NONE { continue;
return Ok(None); }
} let x = va.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE);
let vn = cx.tet(n)?; if x == NONE || x == b {
let x = vn.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE); continue;
if x == NONE || x == b || !vn.contains(&a) { }
return Ok(None); let f = [p, r, x];
} if faces.iter().any(|g| sorted3(*g) == sorted3(f)) {
let f = [p, r, x]; continue;
if faces.iter().any(|(g, _)| sorted3(*g) == sorted3(f)) { }
return Ok(None); let k = va.iter().position(|&v| v == a).unwrap_or(0);
} let tb = cx.adj(ta)?.get(k).copied().unwrap_or(NONE);
let Some(k) = vn.iter().position(|&v| v == a) else { return Ok(None) }; if tb != NONE && cx.tet(tb)?.contains(&b) {
let tb = cx.adj(n)?.get(k).copied().unwrap_or(NONE); return Ok(Some((f, ta, tb)));
if tb != NONE && cx.tet(tb)?.contains(&b) { }
return Ok(Some((f, n, tb)));
} }
Ok(None) Ok(None)
} }
+14 -60
View File
@@ -207,42 +207,6 @@ impl LocalStar {
measure::quality(&y) measure::quality(&y)
} }
/// The quality of tetrahedron `k` with the vertex at `p`, and its gradient there: analytic
/// for a valid tetrahedron, central differences with step `delta` for a flat or inverted one
/// (whose signed measure has no single smooth branch).
fn quality_gradient_at(&self, k: usize, p: P, delta: f64) -> (f64, P) {
let Some((x, i)) = self.tets.get(k) else { return (f64::NEG_INFINITY, [0.0; 3]) };
let mut y = *x;
if let Some(s) = y.get_mut(*i) {
*s = p;
}
let (q, g) = measure::quality_gradient(&y, *i);
if q > 0.0 {
return (q, g);
}
let mut g = [0.0; 3];
for (c, gc) in g.iter_mut().enumerate() {
let (mut a, mut b) = (p, p);
a[c] += delta;
b[c] -= delta;
*gc = (self.quality_at(k, a) - self.quality_at(k, b)) / (2.0 * delta);
}
(q, g)
}
/// The worst quality at `p` over the tetrahedra in `order`, or the first one found that is
/// not above `floor` (then the move is refused whatever the others are).
fn worst_above(&self, order: &[usize], p: P, floor: f64) -> f64 {
let mut w = f64::INFINITY;
for &k in order {
w = w.min(self.quality_at(k, p));
if !(w > floor) {
return w;
}
}
w
}
fn worst_at(&self, p: P) -> f64 { fn worst_at(&self, p: P) -> f64 {
(0..self.tets.len()).map(|k| self.quality_at(k, p)).fold(f64::INFINITY, f64::min) (0..self.tets.len()).map(|k| self.quality_at(k, p)).fold(f64::INFINITY, f64::min)
} }
@@ -251,15 +215,7 @@ impl LocalStar {
/// Optimization-based smoothing of `v`: raise the worst quality of its star (see the module /// Optimization-based smoothing of `v`: raise the worst quality of its star (see the module
/// docs). Returns whether it moved. /// docs). Returns whether it moved.
pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool> { pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool> {
smooth_vertex_below(cx, ctx, v, f64::INFINITY) if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner {
}
/// [`smooth_vertex`], only when the worst tetrahedron around `v` is below `good` (Klingner and
/// Shewchuk smooth only around tetrahedra below their threshold).
pub fn smooth_vertex_below(cx: &mut TetComplex, ctx: &Context, v: u32, good: f64) -> Result<bool> {
// The optimization is a function of the star alone: one that found no move finds none
// again until a tetrahedron around `v` changes.
if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner || cx.smoothing_known_to_fail(v) {
return Ok(false); return Ok(false);
} }
let star = cx.star(v)?; let star = cx.star(v)?;
@@ -281,19 +237,25 @@ pub fn smooth_vertex_below(cx: &mut TetComplex, ctx: &Context, v: u32, good: f64
if !(len > 0.0 && len.is_finite()) { if !(len > 0.0 && len.is_finite()) {
return Ok(false); return Ok(false);
} }
let delta = 1e-6 * len;
let mut x = x0; let mut x = x0;
let mut worst = local.worst_at(x); let mut worst = local.worst_at(x);
if !(worst < good) {
return Ok(false);
}
let mut moved = false; let mut moved = false;
for _ in 0..8 { for _ in 0..8 {
// Qualities and analytic gradients of the tetrahedra near the worst. // Qualities and gradients (central differences) of the tetrahedra near the worst.
let mut qs = Vec::with_capacity(local.tets.len()); let mut qs = Vec::with_capacity(local.tets.len());
for k in 0..local.tets.len() { for k in 0..local.tets.len() {
let q = local.quality_at(k, x); let q = local.quality_at(k, x);
let g = if q <= worst + 0.1 { restrict(local.quality_gradient_at(k, x, 1e-6 * len).1, f) } else { [0.0; 3] }; let mut g = [0.0; 3];
qs.push((q, g)); if q <= worst + 0.1 {
for (c, gc) in g.iter_mut().enumerate() {
let (mut a, mut b) = (x, x);
a[c] += delta;
b[c] -= delta;
*gc = (local.quality_at(k, a) - local.quality_at(k, b)) / (2.0 * delta);
}
}
qs.push((q, restrict(g, f)));
} }
let active: Vec<P> = qs.iter().filter(|s| s.0 <= worst + 1e-4).map(|s| s.1).collect(); let active: Vec<P> = qs.iter().filter(|s| s.0 <= worst + 1e-4).map(|s| s.1).collect();
let d = min_norm(&active); let d = min_norm(&active);
@@ -310,15 +272,10 @@ pub fn smooth_vertex_below(cx: &mut TetComplex, ctx: &Context, v: u32, good: f64
alpha = alpha.min((q - worst) / (dd - gd)); alpha = alpha.min((q - worst) / (dd - gd));
} }
} }
// Trial positions check the worst tetrahedra first and stop at the first that does not
// beat the current worst.
let mut order: Vec<usize> = (0..qs.len()).collect();
let q_of = |i: usize| qs.get(i).map_or(f64::INFINITY, |s| s.0);
order.sort_by(|&i, &j| q_of(i).total_cmp(&q_of(j)).then(i.cmp(&j)));
let mut accepted = false; let mut accepted = false;
for _ in 0..6 { for _ in 0..6 {
let Some(p) = ctx.place(cx.kind(v), add(x, scale(d, alpha))) else { break }; let Some(p) = ctx.place(cx.kind(v), add(x, scale(d, alpha))) else { break };
let q = local.worst_above(&order, p.coords(), worst); let q = local.worst_at(p.coords());
if p.coords() != x && q > worst && cx.move_is_valid(v, &p, &star)? { if p.coords() != x && q > worst && cx.move_is_valid(v, &p, &star)? {
cx.move_point(v, p)?; cx.move_point(v, p)?;
x = p.coords(); x = p.coords();
@@ -333,8 +290,5 @@ pub fn smooth_vertex_below(cx: &mut TetComplex, ctx: &Context, v: u32, good: f64
break; break;
} }
} }
if !moved {
cx.mark_smoothing_failed(v);
}
Ok(moved) Ok(moved)
} }
+1 -113
View File
@@ -433,17 +433,7 @@ mod determinism {
let (p, t, f) = washer(); let (p, t, f) = washer();
let m = mesh(&p, &t, &f); let m = mesh(&p, &t, &f);
assert!(m.elements.len() > 100, "{:?}", m.report); assert!(m.elements.len() > 100, "{:?}", m.report);
// No needles either: the scaled Jacobian analysts check is above the skill's limit. assert_eq!(fingerprint(&m), 6_285_915_972_108_509_003, "{} tets, {} nodes", m.elements.len(), m.points.len());
let sj = m
.elements
.iter()
.map(|e| {
let xyz: Vec<[f64; 3]> = e.iter().map(|&v| m.points[v as usize]).collect();
crate::quality::element_quality(m.kind, &xyz).unwrap().get(crate::quality::Metric::ScaledJacobian).unwrap()
})
.fold(f64::INFINITY, f64::min);
assert!(sj >= 0.3, "min scaled Jacobian {sj}");
assert_eq!(fingerprint(&m), 13_918_536_535_603_991_107, "{} tets, {} nodes", m.elements.len(), m.points.len());
} }
/// Listing the triangles, their corners and the vertices in another order, and numbering /// Listing the triangles, their corners and the vertices in another order, and numbering
@@ -464,105 +454,3 @@ mod determinism {
} }
} }
} }
/// A cap tetrahedron (two boundary faces of one B-rep face) whose two other neighbours each have
/// three boundary faces: deleting it would leave them joined to nothing by a face, floating free
/// (a singular stiffness: the dome-on-a-plate mesh on Linux, #438), so it stays.
#[test]
fn deleting_a_cap_never_leaves_a_neighbour_floating() {
let p: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.3, 0.3, -1.0], [1.0, 1.0, 1.0]];
let pts: Vec<Point3> = p.iter().map(|c| Point3::try_from(*c).unwrap()).collect();
let positive = |t: [u32; 4]| if measure::volume6(&t.map(|v| pts[v as usize].coords())) > 0.0 { t } else { [t[1], t[0], t[2], t[3]] };
// The cap, and a tetrahedron on each of its faces 012 and 123.
let tets = [positive([0, 1, 2, 3]), positive([0, 1, 2, 4]), positive([1, 2, 3, 5])];
let key = |f: [u32; 3]| {
let mut f = f;
f.sort_unstable();
f
};
let mut boundary = Vec::new();
for (t, vs) in tets.iter().enumerate() {
for k in 0..4 {
let f = key(face_toward(*vs, k));
let shared = tets.iter().enumerate().any(|(u, ws)| u != t && (0..4).any(|j| key(face_toward(*ws, j)) == f));
if !shared {
boundary.push((t as u32, k, 0));
}
}
}
let mut cx = TetComplex::new(&p, vec![VertexKind::Corner; 6], &tets, &boundary, &[], 100, 100).unwrap();
assert!(!cx.delete_boundary_tet(0).unwrap(), "the cap was deleted, leaving its neighbours floating");
assert_eq!(cx.live_tets().count(), 3);
cx.validate().unwrap();
}
/// The validity gate refuses a tetrahedron that shares no face with the rest of the mesh, even
/// when the volume adds up: it says why instead of leaving the solver to find it singular.
#[test]
fn the_gate_refuses_a_detached_tetrahedron() {
let p = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [2.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 2.0]];
let tets = [[0, 1, 2, 3], [4, 5, 6, 7]];
let v = 2.0 / 6.0;
let e = super::linear_gate(&p, &tets, v).unwrap_err().to_string();
assert!(e.contains("share no face") && e.contains("(1, 2)"), "{e}");
assert!(super::linear_gate(&p, &tets[..1], v / 2.0).is_ok());
}
/// Undoing an edge removal or a face removal puts every slot back as it was.
#[test]
fn rollback_restores_every_slot() {
use crate::cdt::{CdtOptions, tetrahedralize};
use crate::surface::{BoundarySurface, SurfaceOptions};
let (p, t, f) = determinism::washer();
let s = BoundarySurface::new(&p, &t, &f, &SurfaceOptions::default()).unwrap();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let (mut cx, _) = crate::tet::complex_from_cdt(&s, &cdt, &crate::tet::QualityOptions::default()).unwrap();
let state = |cx: &TetComplex| -> Vec<([u32; 4], [u32; 4], [u32; 4])> {
(0..cx.slots() as u32).map(|t| (cx.tet(t).unwrap(), cx.adj(t).unwrap(), cx.tags(t).unwrap())).collect()
};
let mut changed = 0;
for (t, vs) in cx.live_tets().collect::<Vec<_>>() {
for &(i, j) in &EDGES {
for op in 0..2 {
let before = state(&cx);
cx.journal.recording = true;
let len = cx.journal.steps.len();
let did = if op == 0 { remove_edge_at(&mut cx, t, vs[i], vs[j]).unwrap() } else { remove_face(&mut cx, t, i).unwrap() };
changed += usize::from(did);
cx.rollback(len).unwrap();
cx.journal.steps.truncate(len);
cx.journal.recording = false;
assert_eq!(state(&cx), before, "tet {t} edge {i}{j} op {op}");
}
}
}
assert!(changed > 0);
}
/// The cached qualities match a fresh computation after improvement (smoothing, flips,
/// insertions and the rollbacks of rejected insertions).
#[test]
fn cached_qualities_match_fresh_ones() {
use crate::cdt::{CdtOptions, tetrahedralize};
use crate::size::UniformSize;
use crate::surface::{BoundarySurface, SurfaceOptions};
use crate::tet::geometry::Tessellation;
use crate::tet::refine::Context;
let (p, t, f) = determinism::washer();
let s = BoundarySurface::new(&p, &t, &f, &SurfaceOptions::default()).unwrap();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let (mut cx, curve_faces) = crate::tet::complex_from_cdt(&s, &cdt, &crate::tet::QualityOptions::default()).unwrap();
let (geometry, size) = (Tessellation::new(&s, Vec::new()), UniformSize(3.0));
let ctx = Context { size: &size, geometry: &geometry, curve_faces: &curve_faces };
// Fill the cache, then change the mesh.
for (t, _) in cx.live_tets().collect::<Vec<_>>() {
cx.slot_quality(t).unwrap();
}
let o = crate::tet::improve::ImproveOptions { max_passes: 3, ..Default::default() };
let stats = crate::tet::improve::improve(&mut cx, &ctx, &o).unwrap();
assert!(stats.attempted > 0 && stats.smoothed > 0, "{stats:?}");
for (t, vs) in cx.live_tets().collect::<Vec<_>>() {
assert_eq!(cx.slot_quality(t).unwrap().to_bits(), measure::quality(&cx.coords(vs).unwrap()).to_bits());
}
cx.validate().unwrap();
}
+5 -70
View File
@@ -534,34 +534,11 @@ impl Reduction {
}); });
// A tie whose secondary node is held already: what is left of the row must be a // 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. // constraint of its own, not the difference of two near copies of one.
let nearly_repeated = row.source == Source::Tie && intended.is_none() && largest.is_some_and(|t| t.1.abs() < NEARLY_REPEATED * gross); if row.source == Source::Tie
// Held by a prescribed value (the secondary surface meeting a boundary condition or an && intended.is_none()
// axis-aligned symmetry plane: a crosspoint), the row can only constrain main degrees && let Some(&(_, c)) = largest
// of freedom, and nearly repeated, what it adds is the mortar condition of a node && c.abs() < NEARLY_REPEATED * gross
// whose motion is already given: its weights on main nodes off the boundary do not {
// vanish (the dual basis spreads over the node's faces), so after the substitutions
// a small but real constraint is left, not round-off (1.3e-6 of the coefficients that
// cancelled to give it where a tet face meets a symmetry plane, #540; round-off would
// be ~1e-14 there). Eliminating it divides by that smallness; dropping it leaves the
// tie off by that fraction at the crosspoint, below the discretization error. So it
// is dropped, with the same threshold that calls it a near repeat. A secondary degree
// of freedom another constraint already eliminated (a node secondary in two ties, a
// tie and a coupling) is a real conflict and still refused (#389).
if nearly_repeated && state.get(row.pivot) == Some(&State::Fixed) {
// Still consistent with the prescribed values, or a contradiction.
let value = |t: &(usize, f64)| t.1 * values.get(t.0).copied().unwrap_or(0.0);
let residual: f64 = terms.iter().filter(|t| state.get(t.0) == Some(&State::Fixed)).map(value).sum();
let size: f64 = terms.iter().map(|t| value(t).abs()).sum();
if residual.abs() > 1e-9 * size.max(f64::MIN_POSITIVE) && residual.abs() > 0.0 {
return Err(Error::Invalid(format!(
"{} `{}` contradicts the prescribed values (boundary conditions or couplings hold its nodes in a way it cannot follow)",
row.source.what(),
row.coupling
)));
}
continue;
}
if nearly_repeated {
let mut others: Vec<String> = row let mut others: Vec<String> = row
.terms .terms
.iter() .iter()
@@ -881,48 +858,6 @@ impl Reduction {
mod tests { mod tests {
use super::*; use super::*;
fn tie(terms: Vec<(usize, f64)>, pivot: usize) -> Row<'static> {
Row { terms, pivot, coupling: "glue", source: Source::Tie }
}
/// Dofs `held` prescribed, dof 1 at `one` and the others at 0.
fn reduce(rows: &[Row], held: &[usize], one: f64) -> Result<Reduction> {
let layout = DofLayout::uniform(0..2, DofSet::TRANSLATIONS);
let budget = Budget { max_bytes: usize::MAX, pattern_entries: 0, matrices: 1 };
Reduction::new(
&layout,
std::iter::empty(),
&[0, 1],
rows,
|active| Ok((0..active.len()).map(|d| held.contains(&d).then_some(if d == 1 { one } else { 0.0 })).collect()),
budget,
)
}
/// A tie row of a secondary degree of freedom held by a boundary condition (dof 1), which
/// after substituting an earlier tie row (dof 0 following dofs 2 and 3) nearly repeats it, is
/// dropped (a crosspoint); dof 3 stays free.
#[test]
fn a_tie_row_on_a_held_degree_of_freedom_is_dropped() {
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(1, 1.0), (0, -1.0), (2, 0.5), (3, 0.5 - 1e-7)], 1)];
let r = reduce(&rows, &[1, 2], 0.0).unwrap();
assert_eq!(r.neq(), 3, "dofs 3, 4, 5 free");
}
/// Nearly repeated but inconsistent: on a secondary degree of freedom another tie already
/// eliminated (a node secondary in two ties) it is refused (#389), and on a held one whose
/// prescribed values it contradicts too.
#[test]
fn a_near_repeat_on_an_eliminated_degree_of_freedom_is_refused() {
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(0, 1.0), (2, -0.5), (3, -0.5 - 1e-7)], 0)];
let e = reduce(&rows, &[2], 0.0).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("almost alike"), "{e}");
// Dof 1 held at 1 and dof 2 at 0 while the row says dof 1 follows them.
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(1, 1.0), (0, -1.0), (2, 0.5), (3, 0.5 - 1e-7)], 1)];
let e = reduce(&rows, &[1, 2], 1.0).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("contradicts"), "{e}");
}
#[test] #[test]
fn reduced_matrix_budget_refuses_byte_counts_that_overflow() { fn reduced_matrix_budget_refuses_byte_counts_that_overflow() {
for matrices in [1, 2] { for matrices in [1, 2] {
+26 -5
View File
@@ -28,7 +28,7 @@ const MIN_SCALED_JACOBIAN: f64 = 0.3;
const MAX_EDGE_RATIO: f64 = 10.0; const MAX_EDGE_RATIO: f64 = 10.0;
/// The mesh's volume against the geometry's (§6.1): a larger difference is a missing cell or a /// The mesh's volume against the geometry's (§6.1): a larger difference is a missing cell or a
/// mesh too coarse for the curvature. /// mesh too coarse for the curvature.
const MAX_VOLUME_ERROR: f64 = 0.01; const MAX_VOLUME_ERROR: f64 = 0.02;
/// The most marked elements relaxed one by one; more than this is a clustered failure (the /// The most marked elements relaxed one by one; more than this is a clustered failure (the
/// blocks are wrong, §6.3), which relaxing does not fix. /// blocks are wrong, §6.3), which relaxing does not fix.
const MAX_RELAXED: usize = 20; const MAX_RELAXED: usize = 20;
@@ -52,17 +52,38 @@ const PLAIN_CUP: &str = r#"[
/// Why a part fails the skill's limits today, read from its numbers and its section picture. /// Why a part fails the skill's limits today, read from its numbers and its section picture.
/// Shown only while the part fails. /// Shown only while the part fails.
const CAUSES: &[(&str, &str)] = &[ const CAUSES: &[(&str, &str)] = &[
(
"bent-tube",
"the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.",
),
(
"filleted-bracket",
"no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).",
),
( (
"loft-transition", "loft-transition",
"the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).", "the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).",
), ),
( (
"sharp-wedge", "pinhole-block",
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). A column of wedge6 at the edge (§4) raises the SJ but not the ratio: any element with a corner on a 2.9° edge has sides at least 1/tan 2.9° ≈ 20 times its width there, twice the limit. The edge has to be blunted (defeatured) or the limit waived along it.", "the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.",
), ),
(
"sharp-wedge",
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.",
),
("sphere-on-plate", "no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked)."),
( (
"analytic-sphere", "analytic-sphere",
"no structured method takes a sphere (it has no face to sweep from or axis to revolve about), so the plan does not apply and the fallback's sphere template meshes it at the plan's size (2.45 mm, which keeps a sphere's chords to 0.75 % on paper), 1.1 % short of the volume: the template's elements at the cube's eight corners span more of the sphere than the size. A sphere template in the plan (§3) would size its caps; hex20 (`order: 2`) closes it too.", "the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).",
),
(
"analytic-torus",
"the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.",
),
(
"crank-arm",
"the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.",
), ),
( (
"cross-cylinders", "cross-cylinders",
@@ -497,7 +518,7 @@ with (a set on them finds nothing); `marked` the elements failing the limits bef
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio\n\ relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio\n\
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;\n\ stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;\n\
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and\n\ `verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and\n\
the volume is within 1 % (`pass (fallback)` when a fallback meshed it).\n\ the volume is within 2 % (`pass (fallback)` when a fallback meshed it).\n\
The section pictures cut each part through the middle of its longest side, marked elements red.\n\ The section pictures cut each part through the middle of its longest side, marked elements red.\n\
Times are wall clock on the machine that ran it.\n\n## Results\n\n", Times are wall clock on the machine that ran it.\n\n## Results\n\n",
); );
+3 -11
View File
@@ -398,21 +398,13 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant
- **Implemented** in `fieldcraft-mesh::tet::{ops, improve}` from the two papers: Klincsek's dynamic programming for edge - **Implemented** in `fieldcraft-mesh::tet::{ops, improve}` from the two papers: Klincsek's dynamic programming for edge
removal (interior edges; boundary edges of one flat B-rep face end in a 2-2 flip), multi-face removal grown greedily removal (interior edges; boundary edges of one flat B-rep face end in a 2-2 flip), multi-face removal grown greedily
over the sandwiched faces, nonsmooth optimization smoothing (active set, smallest vector of the convex hull of the over the sandwiched faces, nonsmooth optimization smoothing (active set, smallest vector of the convex hull of the
active gradients, line search; analytic gradients of the active measure, as §3.1 and Freitag–Ollivier-Gooch 1997 active gradients, line search; gradients by central differences), and the composite vertex insertion with rollback
use), and the composite vertex insertion with rollback
(barycentres of boundary faces, of the tetrahedron, midpoints of boundary edges, and a point inside off a flat (barycentres of boundary faces, of the tetrahedron, midpoints of boundary edges, and a point inside off a flat
boundary edge). Deviations: the inserted vertex takes the Delaunay cavity rather than the optimal cut of §3.4; each pass boundary edge). Deviations: the inserted vertex takes the Delaunay cavity rather than the optimal cut of §3.4; each pass
smooths and flips the tetrahedra below sin 40° and inserts only when the worst did not improve; passes and the smooths and flips the tetrahedra below sin 40° and inserts only when the worst did not improve; passes and the
insertion list are capped. Objective: the smaller of the minimum dihedral sine and the scaled Jacobian at the worst insertion list are capped. Objective: the minimum dihedral sine. Every operation is a checked replacement of a region
corner weighted so that a Jacobian of 0.3 counts as a 30° dihedral angle: the sine misses needles (every dihedral
angle fine, one corner nearly flat; Shewchuk 2002); the scaled Jacobian analysts check (Knupp 2001, B6.6) catches
them, and with the weight it decides only near that limit. Every operation is a checked replacement of a region
(positive by exact `orient3d`, faces matched with opposite orientation, removed vertices fully inside), so the mesh is (positive by exact `orient3d`, faces matched with opposite orientation, removed vertices fully inside), so the mesh is
valid after each step. Speed (#84): an operation (topological, per tetrahedron, edge and face; or a smoothing) that valid after each step.
failed is not tried again until a tetrahedron it reads changes (a change clock per vertex; exact, because the
operations are functions of that region and the quality is computed on the corners in a canonical order), live
tetrahedra's qualities are cached, Klincsek's table starts at the quality to beat, and the composite insertion runs
its flips and neighbour smoothing only where a tetrahedron is below the threshold.
**A3.7 Cheng, Dey, Edelsbrunner, Facello, Teng 2000, Sliver exudation.** **Verdict: reference.** **A3.7 Cheng, Dey, Edelsbrunner, Facello, Teng 2000, Sliver exudation.** **Verdict: reference.**
- **Citation.** J. ACM 47(5):883–904, 2000. DOI [10.1145/355483.355487](https://doi.org/10.1145/355483.355487). - **Citation.** J. ACM 47(5):883–904, 2000. DOI [10.1145/355483.355487](https://doi.org/10.1145/355483.355487).
+44 -34
View File
@@ -19,7 +19,7 @@ with (a set on them finds nothing); `marked` the elements failing the limits bef
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size; stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and `verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and
the volume is within 1 % (`pass (fallback)` when a fallback meshed it). the volume is within 2 % (`pass (fallback)` when a fallback meshed it).
The section pictures cut each part through the middle of its longest side, marked elements red. The section pictures cut each part through the middle of its longest side, marked elements red.
Times are wall clock on the machine that ran it. Times are wall clock on the machine that ran it.
@@ -27,41 +27,43 @@ Times are wall clock on the machine that ran it.
| part | method | symmetry | elements | min SJ | mean SJ | max edge ratio¹ | volume error | untagged faces | marked → after relax | duplicates | geometry.check | time s | verdict | section | | part | method | symmetry | elements | min SJ | mean SJ | max edge ratio¹ | volume error | untagged faces | marked → after relax | duplicates | geometry.check | time s | verdict | section |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bent-tube | plan (sweep) | – | 22272 (22272 hex8) | 0.998 | 0.998 | 1.4 | -2.8e-3 | 0 | 0 (not relaxed) | none | – | 3.7 | pass | [y=37.500](skill/bent-tube.png) | | bent-tube | plan (sweep) | – | 192 (192 hex8) | 0.861 | 0.865 | 5.0 | -1.7e-1 | 0 | 0 (not relaxed) | none | 8 near_faces, 2 sliver_face | 1.9 | fail (volume) | [y=37.500](skill/bent-tube.png) |
| bolted-assembly | plan (sweep), mesh.hex.generate (auto) | – | 6272 (6272 hex8) | 0.583 | 0.939 | 3.6 | 2.0e-4 | 0 | 0 (not relaxed) | 8 coincident, 0 cracks | – | 3.7 | pass | [x=50.000](skill/bolted-assembly.png) | | bolted-assembly | plan (sweep), mesh.hex.generate (auto) | – | 6272 (6272 hex8) | 0.583 | 0.939 | 3.6 | 2.0e-4 | 0 | 0 (not relaxed) | 8 coincident, 0 cracks | – | 3.9 | pass | [x=50.000](skill/bolted-assembly.png) |
| cantilever-i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 8.5 | pass | [x=100.000](skill/cantilever-i-beam.png) | | cantilever-i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 5.7 | pass | [x=100.000](skill/cantilever-i-beam.png) |
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 27.2 | no mesh | – | | cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 18.3 | no mesh | – |
| filleted-bracket | tet10 | – | 20020 (20020 tet10) | 0.320 | 0.562 | 4.1 | -6.8e-6 | 0 | 0 (not relaxed) | none | – | 12.7 | pass (fallback) | [x=30.000](skill/filleted-bracket.png) | | filleted-bracket | tet10 | – | 19557 (19557 tet10) | 0.042 | 0.519 | 9.1 | -6.7e-6 | 0 | 1709 (not relaxed) | none | – | 35.5 | fail (SJ) | [x=30.000](skill/filleted-bracket.png) |
| filleted-bracket-blocks | plan (sweep) | – | 75410 (75410 hex8) | 0.704 | 0.954 | 3.5 | 3.7e-5 | 0 | 0 (not relaxed) | none (after merging 2348 nodes) | – | 6.9 | pass | [x=30.000](skill/filleted-bracket-blocks.png) | | filleted-bracket-blocks | plan (sweep) | – | 75410 (75410 hex8) | 0.704 | 0.954 | 3.5 | 3.7e-5 | 0 | 0 (not relaxed) | none (after merging 2348 nodes) | – | 16.7 | pass | [x=30.000](skill/filleted-bracket-blocks.png) |
| i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 4.5 | pass | [z=100.000](skill/i-beam.png) | | i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 9.3 | pass | [z=100.000](skill/i-beam.png) |
| loft-transition | plan (sweep) | – | 2873 (2873 hex8) | 0.189 | 0.959 | 1.7 | -2.1e-3 | 0 | 4 → 4 (4 relaxed, 0 undone; min SJ 0.189 → 0.189, max ratio 1.7 → 1.7) | none | – | 0.9 | fail (SJ) | [z=25.000](skill/loft-transition.png) | | loft-transition | plan (sweep) | – | 2873 (2873 hex8) | 0.189 | 0.960 | 2.2 | -2.1e-3 | 0 | 4 → 4 (4 relaxed, 0 undone; min SJ 0.189 → 0.189, max ratio 1.7 → 2.2) | none | – | 2.3 | fail (SJ) | [z=25.000](skill/loft-transition.png) |
| perforated-plate | plan (project) | 2 plane(s) | 8576 (8576 hex8) | 0.707 | 0.888 | 3.0 | 7.7e-4 | 0 | 0 (not relaxed) | none | – | 9.5 | pass | [x=60.000](skill/perforated-plate.png) | | perforated-plate | plan (project) | 2 plane(s) | 8576 (8576 hex8) | 0.707 | 0.888 | 3.0 | 7.7e-4 | 0 | 0 (not relaxed) | none | – | 33.5 | pass | [x=60.000](skill/perforated-plate.png) |
| pinhole-block | plan (project) | 2 plane(s) | 40768 (40768 hex8) | 0.707 | 0.984 | 8.0 | 8.1e-5 | 0 | 0 (not relaxed) | none | – | 25.5 | pass | [y=25.000](skill/pinhole-block.png) | | pinhole-block | plan (project) | 2 plane(s) | 5068 (5068 hex8) | 0.707 | 0.942 | 18.4 | 8.1e-5 | 0 | 1680 (not relaxed) | none | 32 sliver_face | 24.0 | fail (ratio) | [y=25.000](skill/pinhole-block.png) |
| rounded-box | plan (project) | 2 plane(s) | 11520 (11520 hex8) | 0.717 | 0.973 | 4.0 | -4.1e-3 | 0 | 0 (not relaxed) | none | – | 8.7 | pass | [x=20.000](skill/rounded-box.png) | | rounded-box | plan (project) | 2 plane(s) | 9260 (9260 hex8) | 0.734 | 0.985 | 2.2 | -4.1e-3 | 0 | 0 (not relaxed) | none | – | 10.3 | pass | [x=20.000](skill/rounded-box.png) |
| shallow-groove | plan (sweep) | – | 1960 (1960 hex8) | 0.991 | 0.999 | 1.1 | 2.7e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/shallow-groove.png) | | shallow-groove | plan (sweep) | – | 1960 (1960 hex8) | 0.991 | 0.999 | 1.1 | 2.7e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/shallow-groove.png) |
| sharp-wedge | plan (sweep) | – | 1674 (1674 hex8) | 0.014 | 0.301 | 149.6 | 2.7e-15 | 0 | 1524 (not relaxed) | none | – | 0.2 | fail (SJ, ratio) | [x=40.000](skill/sharp-wedge.png) | | sharp-wedge | plan (sweep) | – | 1674 (1674 hex8) | 0.014 | 0.301 | 149.6 | 2.7e-15 | 0 | 1524 (not relaxed) | none | – | 0.2 | fail (SJ, ratio) | [x=40.000](skill/sharp-wedge.png) |
| sphere-on-plate | tet10 | – | 24175 (24175 tet10) | 0.341 | 0.604 | 4.2 | -9.6e-5 | 0 | 0 (not relaxed) | none | – | 6.5 | pass (fallback) | [y=0.000](skill/sphere-on-plate.png) | | sphere-on-plate | tet10 | – | 15073 (15073 tet10) | 0.121 | 0.558 | 6.2 | -1.1e-4 | 0 | 662 (not relaxed) | none | – | 5.0 | fail (SJ) | [y=0.000](skill/sphere-on-plate.png) |
| spline-vase | plan (sweep) | – | 4536 (4536 hex8) | 0.665 | 0.945 | 9.9 | -6.6e-3 | 0 | 0 (not relaxed) | none | – | 2.3 | pass | [z=-35.000](skill/spline-vase.png) | | spline-vase | plan (sweep) | – | 4536 (4536 hex8) | 0.665 | 0.945 | 9.9 | -6.6e-3 | 0 | 0 (not relaxed) | none | – | 0.8 | pass | [z=-35.000](skill/spline-vase.png) |
| stepped-block | plan (cells) | – | 1416 (1416 hex8) | 1.000 | 1.000 | 1.3 | -4.8e-15 | 0 | 0 (not relaxed) | none | – | 1.6 | pass | [x=30.000](skill/stepped-block.png) | | stepped-block | plan (cells) | – | 1416 (1416 hex8) | 1.000 | 1.000 | 1.3 | -4.8e-15 | 0 | 0 (not relaxed) | none | – | 0.5 | pass | [x=30.000](skill/stepped-block.png) |
| thin-ribs | plan (sweep) | – | 301308 (301308 hex8) | 0.999 | 1.000 | 1.3 | -3.4e-12 | 0 | 0 (not relaxed) | none | – | 34.7 | pass | [x=50.000](skill/thin-ribs.png) | | thin-ribs | plan (sweep) | – | 301308 (301308 hex8) | 0.999 | 1.000 | 1.3 | -3.4e-12 | 0 | 0 (not relaxed) | none | – | 34.0 | pass | [x=50.000](skill/thin-ribs.png) |
| thin-wall-enclosure | plan (sweep) | – | 124512 (124512 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 13.5 | pass | [x=40.000](skill/thin-wall-enclosure.png) | | thin-wall-enclosure | plan (sweep) | – | 124512 (124512 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 21.8 | pass | [x=40.000](skill/thin-wall-enclosure.png) |
| analytic-sphere | mesh.hex.project (sphere) | – | 3400 (3400 hex8) | 0.728 | 0.968 | 3.3 | -1.1e-2 | 0 | 0 (not relaxed) | none | – | 8.8 | fail (volume) | [z=0.000](skill/analytic-sphere.png) | | analytic-sphere | mesh.hex.project (sphere) | – | 864 (864 hex8) | 0.702 | 0.929 | 2.7 | -3.0e-2 | 0 | 0 (not relaxed) | none | – | 2.5 | fail (volume) | [z=0.000](skill/analytic-sphere.png) |
| analytic-torus | plan (sweep) | – | 48360 (48360 hex8) | 0.837 | 0.986 | 2.9 | -4.2e-3 | 0 | 0 (not relaxed) | none | – | 9.8 | pass | [x=0.000](skill/analytic-torus.png) | | analytic-torus | plan (sweep) | – | 1080 (1080 hex8) | 0.834 | 0.909 | 2.4 | -4.7e-2 | 0 | 0 (not relaxed) | none | – | 1.0 | fail (volume) | [x=0.000](skill/analytic-torus.png) |
| analytic-cylinder | plan (project) | – | 4347 (4347 hex8) | 0.859 | 0.985 | 2.4 | -4.9e-3 | 0 | 0 (not relaxed) | none | – | 7.3 | pass | [z=20.000](skill/analytic-cylinder.png) | | analytic-cylinder | plan (project) | – | 1700 (1700 hex8) | 0.845 | 0.970 | 1.9 | -1.6e-2 | 0 | 0 (not relaxed) | none | – | 1.4 | pass | [z=20.000](skill/analytic-cylinder.png) |
| analytic-pipe | plan (project) | – | 2244 (2244 hex8) | 0.997 | 0.997 | 1.3 | -3.3e-3 | 0 | 0 (not relaxed) | none | – | 3.6 | pass | [z=30.000](skill/analytic-pipe.png) | | analytic-pipe | plan (project) | – | 1008 (1008 hex8) | 0.996 | 0.996 | 1.4 | -5.0e-3 | 0 | 0 (not relaxed) | none | – | 1.0 | pass | [z=30.000](skill/analytic-pipe.png) |
| analytic-plate-hole | plan (project) | – | 5856 (5856 hex8) | 0.937 | 0.996 | 3.0 | 4.3e-5 | 0 | 0 (not relaxed) | none | – | 4.7 | pass | [x=100.000](skill/analytic-plate-hole.png) | | analytic-plate-hole | plan (project) | – | 5856 (5856 hex8) | 0.937 | 0.996 | 3.0 | 4.3e-5 | 0 | 0 (not relaxed) | none | – | 5.8 | pass | [x=100.000](skill/analytic-plate-hole.png) |
| crank-arm | plan (project) | – | 4014 (4014 hex8) | 0.578 | 0.933 | 5.8 | -8.4e-4 | 0 | 0 (not relaxed) | none | 4 short_edge, 2 sliver_face | 21.7 | pass | [x=81.000](skill/crank-arm.png) | | crank-arm | plan (project) | – | 1372 (1372 hex8) | 0.544 | 0.860 | 10.5 | -3.3e-3 | 0 | 9 (9 relaxations undone: worse) | none | 16 short_edge, 9 sliver_face | 48.2 | fail (ratio) | [x=81.000](skill/crank-arm.png) |
| upright-bracket | plan (sweep) | – | 1364 (1364 hex8) | 0.707 | 0.968 | 3.1 | 2.1e-4 | 0 | 0 (not relaxed) | none | – | 0.1 | pass | [x=30.000](skill/upright-bracket.png) | | upright-bracket | plan (sweep) | – | 1364 (1364 hex8) | 0.707 | 0.968 | 3.1 | 2.1e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/upright-bracket.png) |
| pi5-base | plan (project) | 1 plane(s) | 29852 (29852 hex8) | 0.683 | 0.969 | 4.9 | -3.0e-3 | 0 | 0 (not relaxed) | none | 8 short_edge, 4 sliver_face | 131.2 | pass | [x=56.000](skill/pi5-base.png) | | pi5-base | plan (project) | 1 plane(s) | 29852 (29852 hex8) | 0.683 | 0.969 | 4.9 | -3.0e-3 | 0 | 0 (not relaxed) | none | 8 short_edge, 4 sliver_face | 197.5 | pass | [x=56.000](skill/pi5-base.png) |
| pi5-lid | plan (project) | – | 5130 (5130 hex8) | 0.724 | 0.868 | 3.9 | -2.9e-4 | 0 | 0 (not relaxed) | none | – | 10.5 | pass | [x=56.000](skill/pi5-lid.png) | | pi5-lid | plan (project) | – | 5130 (5130 hex8) | 0.724 | 0.868 | 3.9 | -2.9e-4 | 0 | 0 (not relaxed) | none | – | 10.7 | pass | [x=56.000](skill/pi5-lid.png) |
| cup | plan (project) | – | 1258 (1258 hex8) | 0.702 | 0.929 | 5.0 | -3.5e-3 | 0 | 0 (not relaxed) | none | – | 3.0 | pass | [x=5.000](skill/cup.png) | | cup | plan (project) | – | 1114 (1114 hex8) | 0.701 | 0.921 | 5.0 | -4.5e-3 | 0 | 0 (not relaxed) | none | – | 3.0 | pass | [x=5.000](skill/cup.png) |
| plain-cup | mesh.hex.generate (auto) | – | 2000 (2000 hex8) | 0.820 | 0.995 | 4.7 | -2.3e-3 | 0 | 0 (not relaxed) | none | – | 1.3 | pass (fallback) | [x=0.000](skill/plain-cup.png) | | plain-cup | mesh.hex.generate (auto) | – | 2000 (2000 hex8) | 0.820 | 0.995 | 4.7 | -2.3e-3 | 0 | 0 (not relaxed) | none | – | 0.9 | pass (fallback) | [x=0.000](skill/plain-cup.png) |
## Why a part fails ## Why a part fails
Each earlier method's error, in the order the skill tries them, and for a part that fails the Each earlier method's error, in the order the skill tries them, and for a part that fails the
limits the cause read from its numbers and section picture. limits the cause read from its numbers and section picture.
- `bent-tube` (corpus: A 10 mm tube with a 1 mm wall, bent through R30: a thin-walled sweep along a line-arc-line path.):
- **fail (volume)**: the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.
- `bolted-assembly` (corpus: A base plate with a clamp block component placed twice, once turned 90 degrees: bodies placed by component transforms.): - `bolted-assembly` (corpus: A base plate with a clamp block component placed twice, once turned 90 degrees: bodies placed by component transforms.):
- Clamp body: mesh.plan.apply: mesh plan `Plan-Clamp_body`: the projected layout does not mesh: invalid input: node 3076 at [11.000000000000004, 10.0, 5.0] lies on the edge between nodes 2640 and 2715: new elements must meet the mesh face to face, with the same divisions… - Clamp body: mesh.plan.apply: mesh plan `Plan-Clamp_body`: the projected layout does not mesh: invalid input: node 3076 at [11.000000000000004, 10.0, 5.0] lies on the edge between nodes 2640 and 2715: new elements must meet the mesh face to face, with the same divisions…
- Clamp body (Clamp:2): mesh.plan.apply: mesh plan `Plan-Clamp_body__Clamp_2_`: the projected layout does not mesh: invalid input: node 5884 at [66.42857142857143, 5.000000000000001, 5.0] lies on the edge between nodes 984 and 1059: new elements must meet the mesh face to face, wi… - Clamp body (Clamp:2): mesh.plan.apply: mesh plan `Plan-Clamp_body__Clamp_2_`: the projected layout does not mesh: invalid input: node 5884 at [66.42857142857143, 5.000000000000001, 5.0] lies on the edge between nodes 984 and 1059: new elements must meet the mesh face to face, wi…
@@ -69,26 +71,34 @@ limits the cause read from its numbers and section picture.
- Cross: mesh.plan.apply: mesh plan `Plan-Cross`: the cells ["Cross"] (cuts []) do not mesh all-hex: body `Cross`: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7… - Cross: mesh.plan.apply: mesh plan `Plan-Cross`: the cells ["Cross"] (cuts []) do not mesh all-hex: body `Cross`: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7…
- Cross: mesh.hex.generate: body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across… - Cross: mesh.hex.generate: body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across…
- Cross: mesh.hex.project (sphere): body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across… - Cross: mesh.hex.project (sphere): body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across…
- Cross: mesh.tet.generate: invalid boundary surface: 28 defects; triangles 76 and 78 are oriented inconsistently across edge 9–230 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); triangles 77 and 78 are oriented inconsistently across edge 9–232 at (-15… - Cross: mesh.tet.generate: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across edge 0–565 at…
- **no mesh**: the boundary tessellation of the joined cylinders is invalid (inconsistent orientation at the two singular points), so every mesher refuses the body. - **no mesh**: the boundary tessellation of the joined cylinders is invalid (inconsistent orientation at the two singular points), so every mesher refuses the body.
- `filleted-bracket` (corpus: An 8 mm L-bracket with an R6 inside fillet and a hole through each leg: a blend beside holes.): - `filleted-bracket` (corpus: An 8 mm L-bracket with an R6 inside fillet and a hole through each leg: a blend beside holes.):
- Bracket: mesh.plan.apply: mesh plan `Plan-Bracket`: the cells ["Bracket"] (cuts []) do not mesh all-hex: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: lin… - Bracket: mesh.plan.apply: mesh plan `Plan-Bracket`: the cells ["Bracket"] (cuts []) do not mesh all-hex: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: lin…
- Bracket: mesh.hex.generate: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: linking face 6 has 2 boundary loops (periodic or holed linking faces are not swept… - Bracket: mesh.hex.generate: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: linking face 6 has 2 boundary loops (periodic or holed linking faces are not swept…
- Bracket: mesh.hex.project (sphere): body `Bracket`: meshing failed: 91 of 5104 elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian -0.760): block 1 cell [3, 3, 0] near (12.519, 11.405, 1.604); block 1 cell [3, 4, … - Bracket: mesh.hex.project (sphere): body `Bracket`: meshing failed: 120 of 5104 elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian -0.760): block 1 cell [4, 0, 0] near (20.373, 3.413, 3.606); block 1 cell [5, 0, …
- **fail (SJ)**: no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).
- `loft-transition` (corpus: A 40 mm square lofted to an R15 circle 50 mm above: a ruled free-form surface between unlike sections.): - `loft-transition` (corpus: A 40 mm square lofted to an R15 circle 50 mm above: a ruled free-form surface between unlike sections.):
- **fail (SJ)**: the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`). - **fail (SJ)**: the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).
- `pinhole-block` (corpus: A 50 mm block drilled through by sixteen 0.6 mm pinholes: features 80 times smaller than the part, genus 16.):
- **fail (ratio)**: the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.
- `sharp-wedge` (corpus: A wedge whose faces meet at under 3 degrees: a sharp edge with a needle-thin cross-section.): - `sharp-wedge` (corpus: A wedge whose faces meet at under 3 degrees: a sharp edge with a needle-thin cross-section.):
- **fail (SJ, ratio)**: the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). A column of wedge6 at the edge (§4) raises the SJ but not the ratio: any element with a corner on a 2.9° edge has sides at least 1/tan 2.9° ≈ 20 times its width there, twice the limit. The edge has to be blunted (defeatured) or the limit waived along it. - **fail (SJ, ratio)**: the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.
- `sphere-on-plate` (corpus: A sphere sunk 0.1 mm into a plate: the faces meet at under 6 degrees around a 2 mm circle.): - `sphere-on-plate` (corpus: A sphere sunk 0.1 mm into a plate: the faces meet at under 6 degrees around a 2 mm circle.):
- Plate: mesh.plan.apply: mesh plan `Plan-Plate`: the cells ["Plate"] (cuts []) do not mesh all-hex: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the … - Plate: mesh.plan.apply: mesh plan `Plan-Plate`: the cells ["Plate"] (cuts []) do not mesh all-hex: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the …
- Plate: mesh.hex.generate: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the sweep path; from face 5: linking face 11 has 2 boundary loops (periodic or … - Plate: mesh.hex.generate: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the sweep path; from face 5: linking face 11 has 2 boundary loops (periodic or …
- Plate: mesh.hex.project (sphere): body `Plate`: meshing failed: the mesh volume 32917.42909007507 is far from the body's 69353.30815403847: the blocks do not cover the part (every outer block face must stand for part of its surface) - Plate: mesh.hex.project (sphere): body `Plate`: meshing failed: the mesh volume 32014.728344815157 is far from the body's 69353.30815403847: the blocks do not cover the part (every outer block face must stand for part of its surface)
- **fail (SJ)**: no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked).
- `analytic-sphere` (corpus: Sphere r 20: curvature everywhere, no edges.): - `analytic-sphere` (corpus: Sphere r 20: curvature everywhere, no edges.):
- Sphere: mesh.plan.apply: mesh plan `Plan-Sphere`: the cells ["Sphere"] (cuts []) do not mesh all-hex: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to t… - Sphere: mesh.plan.apply: mesh plan `Plan-Sphere`: the cells ["Sphere"] (cuts []) do not mesh all-hex: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to t…
- Sphere: mesh.hex.generate: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to try; mapped: meshing failed: meshing failed: mesh validity gate: 8 inverted or… - Sphere: mesh.hex.generate: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to try; mapped: meshing failed: meshing failed: mesh validity gate: 8 inverted or…
- **fail (volume)**: no structured method takes a sphere (it has no face to sweep from or axis to revolve about), so the plan does not apply and the fallback's sphere template meshes it at the plan's size (2.45 mm, which keeps a sphere's chords to 0.75 % on paper), 1.1 % short of the volume: the template's elements at the cube's eight corners span more of the sphere than the size. A sphere template in the plan (§3) would size its caps; hex20 (`order: 2`) closes it too. - **fail (volume)**: the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).
- `analytic-torus` (corpus: Torus 30/8: genus 1 with doubly curved faces and no edges.):
- **fail (volume)**: the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.
- `crank-arm` (sample `crank-arm`: the 170 mm bicycle crank, mirror symmetric across y = 0):
- **fail (ratio)**: the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.
- `plain-cup` (the cup without its handle (#418)): - `plain-cup` (the cup without its handle (#418)):
- Cup: mesh.plan.apply: mesh plan `Plan-Cup`: the projected layout does not mesh: body `Cup`: meshing failed: the mesh volume 5012.205781249979 is far from the body's 16760.91944296662: the blocks do not cover the part (every outer block face must stand for part o… - Cup: mesh.plan.apply: mesh plan `Plan-Cup`: the projected layout does not mesh: body `Cup`: meshing failed: the mesh volume 4994.312243612882 is far from the body's 16760.91944296662: the blocks do not cover the part (every outer block face must stand for part o…
## Parts ## Parts
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 93 KiB