Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18569155bc |
No files matched your search
@@ -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.")
|
||||
.def("\"guarded\""),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
|
||||
|
||||
@@ -54,81 +54,6 @@ const MAX_ELEMENTS: usize = 5_000_000;
|
||||
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).
|
||||
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 {
|
||||
id: "mesh.plan",
|
||||
@@ -924,18 +849,6 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
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();
|
||||
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)"));
|
||||
@@ -2530,24 +2443,6 @@ mod acceptance {
|
||||
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
|
||||
/// 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
|
||||
@@ -2567,9 +2462,7 @@ mod acceptance {
|
||||
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
|
||||
let layout = r["layout"].as_str().unwrap();
|
||||
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
|
||||
// thins the bore's ring elements a little at 2.5 mm (edge ratio 7.1).
|
||||
accept(&mut s, &r, 0.5, 7.5);
|
||||
accept(&mut s, &r, 0.5, 7.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,12 +202,8 @@ fn squeezed_junction_elements_are_reported_not_meshed() {
|
||||
p["min_quality"] = json!(0.3);
|
||||
let model = s.model.clone();
|
||||
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
|
||||
// the cup's rim divided by its own length, the junction's corner (block 13) folds instead,
|
||||
// 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}");
|
||||
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36.
|
||||
assert!(e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36")), "{e}");
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const RELAX: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.relax",
|
||||
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),
|
||||
kind: CommandKind::Model,
|
||||
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))
|
||||
.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") {
|
||||
None => STRONG_ROUNDS,
|
||||
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()));
|
||||
}
|
||||
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")? {
|
||||
Some(n) => n,
|
||||
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(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
|
||||
}
|
||||
if ["below", "reach", "guards"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(EngineError::Params("`below`, `reach` and `guards` go with \"mode\": \"strong\"".into()));
|
||||
if p.get("below").is_some() || p.get("reach").is_some() {
|
||||
return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
|
||||
}
|
||||
let (w, sphere) = weights(&s.model, p)?;
|
||||
if w.is_empty() {
|
||||
|
||||
@@ -275,8 +275,7 @@ fn on_box_edge(p: [f64; 3]) -> bool {
|
||||
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
|
||||
let (_d, mut s, _) = filleted_box();
|
||||
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.45})).unwrap();
|
||||
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.3})).unwrap();
|
||||
let (b, a) = (&r["before"], &r["after"]);
|
||||
// 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}");
|
||||
@@ -350,8 +349,6 @@ fn strong_mode_parameters_are_checked() {
|
||||
json!({"mode": "strong", "nodes": [1], "iterations": 201}),
|
||||
json!({"below": 0.5}),
|
||||
json!({"nodes": [1], "reach": 1}),
|
||||
json!({"nodes": [1], "guards": "strict"}),
|
||||
json!({"mode": "strong", "nodes": [1], "guards": "loose"}),
|
||||
json!({"nodes": [1], "mode": "strong", "layers": 1}),
|
||||
] {
|
||||
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -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
|
||||
/// that only measures how near the edge it started; the faces of a wall face each other.
|
||||
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 opposed = -libm::cos(WALL_OPENING.to_radians());
|
||||
(0..g.surface.triangles().len() as u32)
|
||||
.map(|t| {
|
||||
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
match g.ray(g.triangle_centre(t), n.map(|x| -x), far, t) {
|
||||
Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s,
|
||||
_ => f64::INFINITY,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
match g.ray(p, n.map(|x| -x), far, t) {
|
||||
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 {
|
||||
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 {
|
||||
@@ -629,7 +642,8 @@ impl Context<'_> {
|
||||
let mut first: BTreeMap<u32, P> = BTreeMap::new();
|
||||
for t in self.g.overlapping(&bx) {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
return Ok(true);
|
||||
}
|
||||
// (With a relative margin, so cells planned at exactly the size a wall needs stay.)
|
||||
if self.thickness.get(t as usize).is_some_and(|&th| th * (1.0 + 1e-9) < self.options.thickness_cells * e) {
|
||||
// The wall at the triangle's centre, and, on a triangle larger than the cell, where it
|
||||
// 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);
|
||||
}
|
||||
// Narrow patches (a hole's wall, a thin rim) need cells across them.
|
||||
|
||||
@@ -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();
|
||||
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");
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry. An edge
|
||||
// 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).
|
||||
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry.
|
||||
let (classes, nclass) = edge_classes(t);
|
||||
let mut class_log = vec![(0.0f64, 0usize); nclass];
|
||||
// Per class, its longest edge in element sizes.
|
||||
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();
|
||||
for (block, cls) in t.blocks.iter().zip(&classes) {
|
||||
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]) {
|
||||
*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) {
|
||||
l.0 += libm::log(measured);
|
||||
l.1 += 1;
|
||||
@@ -627,11 +615,9 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
.iter()
|
||||
.zip(&class_max)
|
||||
.zip(&class_arc)
|
||||
.zip(&class_bent)
|
||||
.map(|(((&(sum, k), &longest), &arc), &(bent_sum, bent_k))| {
|
||||
.map(|((&(sum, k), &longest), &arc)| {
|
||||
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).max(bent).clamp(1, crate::block::MAX_DIVISIONS)
|
||||
(mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).clamp(1, crate::block::MAX_DIVISIONS)
|
||||
})
|
||||
.collect();
|
||||
for &(b, d, n) in &opts.divisions {
|
||||
|
||||
@@ -939,7 +939,7 @@ pub fn apply_field(
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests;
|
||||
@@ -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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MorphOptions {
|
||||
/// Elements below this quality are the bad ones.
|
||||
pub below: f64,
|
||||
/// The radius round the bad nodes whose nodes follow them; `None`:
|
||||
/// [`DEFAULT_REACH_LENGTHS`] times the bad elements' mean edge length.
|
||||
/// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
|
||||
/// elements' mean edge length.
|
||||
pub reach: Option<f64>,
|
||||
/// Most rounds (bad nodes moved, the region morphed after them).
|
||||
pub rounds: usize,
|
||||
/// When a cluster's morph is kept.
|
||||
pub guards: Guards,
|
||||
}
|
||||
|
||||
impl Default for MorphOptions {
|
||||
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
|
||||
/// 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
|
||||
/// whether it was kept. With [`Guards::Valid`] the full morph is kept unless an element
|
||||
/// it touches inverts; with [`Guards::Strict`] only a scale that improves the elements is.
|
||||
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, guards: Guards, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
|
||||
/// whether it was kept.
|
||||
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
|
||||
let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect();
|
||||
let mut undo = Vec::new();
|
||||
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 keep = match guards {
|
||||
Guards::Valid => now.invalid <= was.invalid,
|
||||
Guards::Strict => now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio),
|
||||
};
|
||||
if keep {
|
||||
if now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio) {
|
||||
return Ok((region.len(), true));
|
||||
}
|
||||
self.restore(undo);
|
||||
@@ -515,11 +494,10 @@ impl State<'_> {
|
||||
/// 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
|
||||
/// 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
|
||||
/// [`Guards::Strict`], at full or a halved scale only if besides no element gets longer than the
|
||||
/// longest one was, the worst element it touches is no worse and the smooth objective there drops.
|
||||
/// The result is the round with the highest smallest quality (never below the start). Only nodes
|
||||
/// of `nodes` move. The model is not changed: the result has the new positions.
|
||||
/// cluster's morph is kept, at full or a halved scale, only if no more elements are invalid, no element
|
||||
/// gets longer than the longest one was, the worst element it touches is no worse and the smooth
|
||||
/// objective there drops. Only nodes 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> {
|
||||
if !(options.below > 0.0 && options.below <= 1.0) {
|
||||
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 movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
|
||||
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 {
|
||||
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
|
||||
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() {
|
||||
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) {
|
||||
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() };
|
||||
for cluster in &clusters {
|
||||
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.kept += usize::from(kept);
|
||||
}
|
||||
let now = score(&state);
|
||||
(round_out.min_quality, round_out.mean_quality) = (now.0, now.2);
|
||||
if better(now, best.0) {
|
||||
best = (now, state.pos.clone());
|
||||
}
|
||||
let (_, min, mean) = state.qualities();
|
||||
(round_out.min_quality, round_out.mean_quality) = (min, mean);
|
||||
let any = round_out.kept > 0;
|
||||
rounds.push(round_out);
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.pos = best.1;
|
||||
let mut after = region_quality(&state);
|
||||
let (_, _, mut mean_after) = state.qualities();
|
||||
// Never worse than the start.
|
||||
|
||||
@@ -360,7 +360,7 @@ fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
|
||||
let (original, m) = distorted_annulus(6, 0.06);
|
||||
let geo = Geo { model: original.clone() };
|
||||
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();
|
||||
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);
|
||||
@@ -389,7 +389,7 @@ fn strong_relaxation_moves_only_nodes_within_the_reach() {
|
||||
let geo = Geo { model: original };
|
||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
||||
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);
|
||||
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");
|
||||
@@ -413,22 +413,6 @@ fn strong_relaxation_untangles_inverted_elements() {
|
||||
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.
|
||||
#[test]
|
||||
fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() {
|
||||
|
||||
@@ -170,9 +170,7 @@ pub enum Step {
|
||||
pub struct Raw {
|
||||
slots: Vec<(u32, [u32; 4], [u32; 4], [u32; 4])>,
|
||||
tets_len: usize,
|
||||
/// The free list's length, and its last entries (those the change may take).
|
||||
free_len: usize,
|
||||
free_tail: Vec<u32>,
|
||||
free: Vec<u32>,
|
||||
vtets: Vec<(u32, Vec<u32>)>,
|
||||
live: usize,
|
||||
}
|
||||
@@ -201,21 +199,6 @@ pub struct TetComplex {
|
||||
frozen: Vec<u32>,
|
||||
mark: Vec<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,
|
||||
max_tets: usize,
|
||||
max_points: usize,
|
||||
@@ -298,13 +281,6 @@ impl TetComplex {
|
||||
frozen: Vec::new(),
|
||||
mark: vec![0; tets.len()],
|
||||
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(),
|
||||
max_tets,
|
||||
max_points,
|
||||
@@ -379,125 +355,6 @@ impl TetComplex {
|
||||
if let Some(s) = self.kinds.get_mut(v as usize) {
|
||||
*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 {
|
||||
@@ -567,7 +424,6 @@ impl TetComplex {
|
||||
|
||||
pub fn set_curve(&mut self, a: u32, b: u32, curve: Option<u32>) {
|
||||
let key = sorted2(a, b);
|
||||
self.touch(&[a, b]);
|
||||
let before = match curve {
|
||||
Some(c) => self.curves.insert(key, c),
|
||||
None => self.curves.remove(&key),
|
||||
@@ -832,7 +688,6 @@ impl TetComplex {
|
||||
self.pts.push(p);
|
||||
self.kinds.push(kind);
|
||||
self.vtets.push(Vec::new());
|
||||
self.touched.push(self.clock);
|
||||
if self.journal.recording {
|
||||
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.
|
||||
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();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
@@ -856,8 +711,7 @@ impl TetComplex {
|
||||
})
|
||||
.collect(),
|
||||
tets_len: self.tets.len(),
|
||||
free_len: self.free.len(),
|
||||
free_tail: self.free.get(self.free.len().saturating_sub(takes)..).unwrap_or_default().to_vec(),
|
||||
free: self.free.clone(),
|
||||
vtets: vs.into_iter().filter_map(|v| Some((v, self.vtets.get(v as usize)?.clone()))).collect(),
|
||||
live: self.live,
|
||||
}
|
||||
@@ -865,12 +719,6 @@ impl TetComplex {
|
||||
|
||||
/// Put back what [`Self::snapshot`] saved.
|
||||
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 {
|
||||
if let Some(s) = self.vtets.get_mut(v as usize) {
|
||||
*s = l;
|
||||
@@ -886,8 +734,7 @@ impl TetComplex {
|
||||
self.adj.truncate(raw.tets_len);
|
||||
self.tags.truncate(raw.tets_len);
|
||||
self.mark.truncate(raw.tets_len);
|
||||
self.free.truncate(raw.free_len.saturating_sub(raw.free_tail.len()));
|
||||
self.free.extend(raw.free_tail);
|
||||
self.free = raw.free;
|
||||
self.live = raw.live;
|
||||
}
|
||||
|
||||
@@ -897,7 +744,6 @@ impl TetComplex {
|
||||
self.pts.pop();
|
||||
self.kinds.pop();
|
||||
self.vtets.pop();
|
||||
self.touched.pop();
|
||||
return Ok(());
|
||||
}
|
||||
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 from = *slot;
|
||||
*slot = p;
|
||||
self.touch_star(v);
|
||||
if self.journal.recording {
|
||||
self.journal.steps.push(Step::Moved { v, from });
|
||||
}
|
||||
@@ -1117,7 +962,7 @@ impl TetComplex {
|
||||
for &t in old {
|
||||
verts.extend(self.tet(t)?);
|
||||
}
|
||||
Some(Box::new(self.snapshot(&slots, &verts, change.new.len())))
|
||||
Some(Box::new(self.snapshot(&slots, &verts)))
|
||||
} else {
|
||||
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();
|
||||
if self.journal.recording {
|
||||
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() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Each neighbour gains a boundary face (both, when they are one tetrahedron); one left
|
||||
// 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)));
|
||||
let raw = self.journal.recording.then(|| Box::new(self.snapshot(&[t, ns[k], ns[l]], &vs)));
|
||||
for m in [k, l] {
|
||||
let n = ns[m];
|
||||
let nn = self.adj(n)?;
|
||||
@@ -1213,7 +1044,6 @@ impl TetComplex {
|
||||
}
|
||||
}
|
||||
self.unlink(t, vs);
|
||||
self.touch(&vs);
|
||||
if let Some(s) = self.tets.get_mut(t as usize) {
|
||||
*s = [DEAD; 4];
|
||||
}
|
||||
@@ -1281,18 +1111,15 @@ impl TetComplex {
|
||||
if let Some(s) = self.pts.get_mut(v as usize) {
|
||||
*s = from;
|
||||
}
|
||||
self.touch_star(v);
|
||||
}
|
||||
Step::Added { v } => {
|
||||
if v as usize + 1 == self.pts.len() {
|
||||
self.pts.pop();
|
||||
self.kinds.pop();
|
||||
self.vtets.pop();
|
||||
self.touched.pop();
|
||||
}
|
||||
}
|
||||
Step::Curve { edge, curve } => {
|
||||
self.touch(&[edge.0, edge.1]);
|
||||
match curve {
|
||||
Some(c) => self.curves.insert(edge, c),
|
||||
None => self.curves.remove(&edge),
|
||||
|
||||
@@ -24,7 +24,7 @@ use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, fac
|
||||
use super::measure::{self, P};
|
||||
use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality};
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -53,8 +53,6 @@ pub struct ImproveStats {
|
||||
pub smoothed: usize,
|
||||
pub topological: usize,
|
||||
pub inserted: usize,
|
||||
/// Insertions tried (kept or undone).
|
||||
pub attempted: usize,
|
||||
/// The worst quality (smallest dihedral sine) after each pass.
|
||||
pub worst: Vec<f64>,
|
||||
pub stopped: Option<String>,
|
||||
@@ -83,8 +81,8 @@ fn indicators(cx: &TetComplex) -> Result<[f64; 8]> {
|
||||
let mut out = [0.0; 8];
|
||||
out[0] = f64::INFINITY;
|
||||
let mut n = 0usize;
|
||||
for (t, _) in cx.live_tets() {
|
||||
let q = cx.slot_quality(t)?;
|
||||
for (_, vs) in cx.live_tets() {
|
||||
let q = tet_quality(cx, vs)?;
|
||||
out[0] = out[0].min(q);
|
||||
for (i, s) in SIN_DEG.iter().enumerate() {
|
||||
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.
|
||||
fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result<usize> {
|
||||
let mut want = vec![false; cx.point_count()];
|
||||
for (t, vs) in cx.live_tets() {
|
||||
if cx.slot_quality(t)? < good {
|
||||
for (_, vs) in cx.live_tets() {
|
||||
if tet_quality(cx, vs)? < good {
|
||||
for v in vs {
|
||||
if let Some(w) = want.get_mut(v as usize) {
|
||||
*w = true;
|
||||
@@ -151,7 +149,7 @@ fn delete_flat(cx: &mut TetComplex, t: u32) -> Result<bool> {
|
||||
/// edge ([`ops::collapse`]), which takes the tetrahedron away.
|
||||
fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
|
||||
let vs = cx.tet(t)?;
|
||||
if cx.slot_quality(t)? > 0.1 {
|
||||
if tet_quality(cx, vs)? > 0.1 {
|
||||
return Ok(false);
|
||||
}
|
||||
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> {
|
||||
let mut changes = 0;
|
||||
for &t in list {
|
||||
if !cx.is_live(t) || cx.known_to_fail(t)? {
|
||||
if !cx.is_live(t) {
|
||||
continue;
|
||||
}
|
||||
let vs = cx.tet(t)?;
|
||||
@@ -203,25 +201,20 @@ pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
|
||||
changes += 1;
|
||||
continue;
|
||||
}
|
||||
let mut removed = false;
|
||||
for k in 0..4 {
|
||||
if remove_face(cx, t, k)? {
|
||||
changes += 1;
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
cx.mark_failed(t);
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
fn below(cx: &TetComplex, good: f64) -> Result<Vec<u32>> {
|
||||
let mut out = Vec::new();
|
||||
for (t, _) in cx.live_tets() {
|
||||
if cx.slot_quality(t)? < good {
|
||||
for (t, vs) in cx.live_tets() {
|
||||
if tet_quality(cx, vs)? < good {
|
||||
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.
|
||||
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, attempted: &mut usize, good: f64, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
|
||||
*attempted += 1;
|
||||
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
|
||||
let was = cx.journal.recording;
|
||||
cx.journal.recording = true;
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
// Only the tetrahedra below the threshold, as in the passes.
|
||||
let mut star = cx.star(v)?;
|
||||
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 {
|
||||
let star = cx.star(v)?;
|
||||
if topological_pass(cx, &star)? == 0 {
|
||||
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)?;
|
||||
around.push(v);
|
||||
for u in around {
|
||||
smooth_vertex_below(cx, ctx, u, good)?;
|
||||
smooth_vertex(cx, ctx, u)?;
|
||||
}
|
||||
}
|
||||
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.
|
||||
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;
|
||||
for &t in list {
|
||||
if kept >= budget {
|
||||
@@ -362,7 +347,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
|
||||
continue;
|
||||
}
|
||||
let vs = cx.tet(t)?;
|
||||
if cx.slot_quality(t)? >= good {
|
||||
if tet_quality(cx, vs)? >= good {
|
||||
continue;
|
||||
}
|
||||
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));
|
||||
if let Ok(p) = Point3::try_from(target)
|
||||
&& 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;
|
||||
}
|
||||
@@ -406,7 +391,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
|
||||
let kind = VertexKind::Face(tags[k]);
|
||||
if let Some(p) = ctx.place(kind, g)
|
||||
&& 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;
|
||||
}
|
||||
@@ -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 {
|
||||
let g = measure::centroid(&cx.coords(vs)?);
|
||||
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;
|
||||
}
|
||||
@@ -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)?);
|
||||
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;
|
||||
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
|
||||
// dihedral angle below 40° or above 140° (sine below sin 40°).
|
||||
let mut scored: Vec<(f64, u32)> = Vec::new();
|
||||
for (t, _) in cx.live_tets() {
|
||||
scored.push((cx.slot_quality(t)?, t));
|
||||
for (t, vs) in cx.live_tets() {
|
||||
scored.push((tet_quality(cx, vs)?, t));
|
||||
}
|
||||
scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.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()
|
||||
};
|
||||
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)?;
|
||||
carried = Some(now);
|
||||
@@ -563,7 +548,7 @@ mod tests {
|
||||
let q = Point3::try_from(q).unwrap();
|
||||
if let Some(Walk::Inside(at)) = cx.walk(t, &q, 1 << 12).unwrap() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// its weighted scaled Jacobian ([`JACOBIAN_WEIGHT`]; the regular tetrahedron scores
|
||||
/// √(8/9) ≈ 0.943). The dihedral sine
|
||||
/// catches slivers and caps; it does not see needles, whose dihedral angles can all be fine
|
||||
/// while a corner is nearly flat (Shewchuk 2002), and the scaled Jacobian
|
||||
/// `√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.
|
||||
/// The quality objective: the smallest signed dihedral sine (1 is impossible; the regular
|
||||
/// tetrahedron scores √(8/9) ≈ 0.943). Positive for a valid tetrahedron, 0 for a flat one,
|
||||
/// negative for an inverted one (then the sine of largest magnitude, so worse inversions score
|
||||
/// lower). With `sin θ_ij = 6V ℓ_ij / (2A_k · 2A_l)`, the extreme sine is found on squared
|
||||
/// lengths and areas, with one square root.
|
||||
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);
|
||||
if v6 == 0.0 || !v6.is_finite() {
|
||||
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) };
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn dihedral_range(x: &[P; 4]) -> (f64, f64) {
|
||||
let (s, c) = (dihedral_sines(x), dihedral_cosines(x));
|
||||
@@ -365,49 +221,6 @@ mod tests {
|
||||
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]
|
||||
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]]);
|
||||
|
||||
@@ -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),
|
||||
/// through the same validity gate: every tetrahedron admissible, the volume equal to what its
|
||||
/// 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 })
|
||||
}
|
||||
|
||||
/// The validity gate of a linear mesh: every tetrahedron admissible, joined to the others by a
|
||||
/// face (when there are several), and the volume equal to what its boundary encloses within
|
||||
/// 1e-9 (no overlap, no gap).
|
||||
/// The validity gate of a linear mesh: every tetrahedron admissible, and the volume equal to
|
||||
/// what its boundary encloses within 1e-9 (no overlap, no gap).
|
||||
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 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])?])
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn tet_quality(cx: &TetComplex, vs: [u32; 4]) -> Result<f64> {
|
||||
pub fn worst(cx: &TetComplex, tets: &[u32]) -> Result<f64> {
|
||||
let mut q = f64::INFINITY;
|
||||
for &t in tets {
|
||||
q = q.min(cx.slot_quality(t)?);
|
||||
q = q.min(tet_quality(cx, cx.tet(t)?)?);
|
||||
}
|
||||
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;
|
||||
|
||||
/// 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
|
||||
/// better than `floor` matter: an entry that cannot beat it holds `floor`, and a triangle is
|
||||
/// 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>)> {
|
||||
/// (with the closing edge `v_i v_j`), and the apex `k` that achieves it.
|
||||
fn klincsek(m: usize, q: &mut dyn FnMut(usize, usize, usize) -> Result<f64>) -> Result<(Vec<f64>, Vec<usize>)> {
|
||||
let mut best = vec![f64::INFINITY; m * m];
|
||||
let mut arg = vec![0usize; m * m];
|
||||
for i in (0..m.saturating_sub(2)).rev() {
|
||||
for j in i + 2..m {
|
||||
let mut b = floor;
|
||||
let mut b = f64::NEG_INFINITY;
|
||||
let mut a = i + 1;
|
||||
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));
|
||||
@@ -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) {
|
||||
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 m = ring.verts.len();
|
||||
if m < 3 || m > MAX_RING + usize::from(!ring.closed) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut 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 v = ring.verts.clone();
|
||||
let x = |id: u32| cx.xyz(id);
|
||||
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
|
||||
@@ -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])))
|
||||
};
|
||||
// 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);
|
||||
if !(total > before) {
|
||||
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 face = face_toward(vs, k);
|
||||
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);
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// Shewchuk's optimal search, which visits the same candidates).
|
||||
// Each face with the tetrahedron (a ∪ face).
|
||||
let mut faces: Vec<([u32; 3], u32)> = vec![(face, t)];
|
||||
let mut faces: Vec<[u32; 3]> = vec![face];
|
||||
let mut old: Vec<u32> = vec![t, u];
|
||||
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 {
|
||||
let removed_q = worst(cx, &old)?;
|
||||
// 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
|
||||
// (a, b, e) positive.
|
||||
let mut count: IdMap<(u32, u32), (u32, u32, u32, usize)> = IdMap::default();
|
||||
for &(f, ta) in &faces {
|
||||
// seen from a: each gives the new tetrahedron (a, b, e) positive.
|
||||
let mut count: IdMap<(u32, u32), (u32, u32, usize)> = IdMap::default();
|
||||
for f in &faces {
|
||||
for i in 0..3 {
|
||||
let (p, q) = (f[i], f[(i + 1) % 3]);
|
||||
let e = count.entry(sorted2(p, q)).or_insert((p, q, ta, 0));
|
||||
e.3 += 1;
|
||||
let e = count.entry(sorted2(p, q)).or_insert((p, q, 0));
|
||||
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();
|
||||
let mut new = Vec::with_capacity(ring.len());
|
||||
let mut q = f64::INFINITY;
|
||||
for &(p, r, _) in &ring {
|
||||
// A ring edge stays on the ring until a face grows across it: its tetrahedron is
|
||||
// worked out once.
|
||||
let (cand, cq) = match made.get(&(p, r)) {
|
||||
Some(&c) => c,
|
||||
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);
|
||||
for &(p, r) in &ring {
|
||||
// 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] };
|
||||
q = q.min(tet_quality(cx, cand)?);
|
||||
new.push(cand);
|
||||
}
|
||||
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.
|
||||
let mut grown = false;
|
||||
for &(p, r, ta) in &ring {
|
||||
if let Some((f, na, nb)) = sandwiched_across(cx, a, b, p, r, ta, &faces)? {
|
||||
faces.push((f, na));
|
||||
old.push(na);
|
||||
old.push(nb);
|
||||
removed_q = removed_q.min(worst(cx, &[na, nb])?);
|
||||
for &(p, r) in &ring {
|
||||
if let Some((f, ta, tb)) = sandwiched_across(cx, a, b, p, r, &faces)? {
|
||||
faces.push(f);
|
||||
old.push(ta);
|
||||
old.push(tb);
|
||||
grown = true;
|
||||
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)
|
||||
/// 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)>> {
|
||||
// ta = (a, p, r, y) holds the face set's face on edge pr; the other tetrahedron of a around
|
||||
// pr in a face set's neighbourhood lies across its face (a, p, r).
|
||||
let va = cx.tet(ta)?;
|
||||
let Some(ky) = va.iter().position(|&v| v != a && v != p && v != r) else { return Ok(None) };
|
||||
let n = cx.adj(ta)?.get(ky).copied().unwrap_or(NONE);
|
||||
if n == NONE {
|
||||
return Ok(None);
|
||||
}
|
||||
let vn = cx.tet(n)?;
|
||||
let x = vn.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE);
|
||||
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)) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(k) = vn.iter().position(|&v| v == a) else { return Ok(None) };
|
||||
let tb = cx.adj(n)?.get(k).copied().unwrap_or(NONE);
|
||||
if tb != NONE && cx.tet(tb)?.contains(&b) {
|
||||
return Ok(Some((f, n, tb)));
|
||||
fn sandwiched_across(cx: &mut TetComplex, a: u32, b: u32, p: u32, r: u32, faces: &[[u32; 3]]) -> Result<Option<([u32; 3], u32, u32)>> {
|
||||
let Some(ring) = cx.edge_ring(p, r)? else { return Ok(None) };
|
||||
// Tetrahedra around pr containing a, and containing b.
|
||||
for &ta in &ring.tets {
|
||||
let va = cx.tet(ta)?;
|
||||
if !va.contains(&a) {
|
||||
continue;
|
||||
}
|
||||
let x = va.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE);
|
||||
if x == NONE || x == b {
|
||||
continue;
|
||||
}
|
||||
let f = [p, r, x];
|
||||
if faces.iter().any(|g| sorted3(*g) == sorted3(f)) {
|
||||
continue;
|
||||
}
|
||||
let k = va.iter().position(|&v| v == a).unwrap_or(0);
|
||||
let tb = cx.adj(ta)?.get(k).copied().unwrap_or(NONE);
|
||||
if tb != NONE && cx.tet(tb)?.contains(&b) {
|
||||
return Ok(Some((f, ta, tb)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -207,42 +207,6 @@ impl LocalStar {
|
||||
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 {
|
||||
(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
|
||||
/// docs). Returns whether it moved.
|
||||
pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool> {
|
||||
smooth_vertex_below(cx, ctx, v, f64::INFINITY)
|
||||
}
|
||||
|
||||
/// [`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) {
|
||||
if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner {
|
||||
return Ok(false);
|
||||
}
|
||||
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()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let delta = 1e-6 * len;
|
||||
let mut x = x0;
|
||||
let mut worst = local.worst_at(x);
|
||||
if !(worst < good) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut moved = false;
|
||||
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());
|
||||
for k in 0..local.tets.len() {
|
||||
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] };
|
||||
qs.push((q, g));
|
||||
let mut g = [0.0; 3];
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
for _ in 0..6 {
|
||||
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)? {
|
||||
cx.move_point(v, p)?;
|
||||
x = p.coords();
|
||||
@@ -333,8 +290,5 @@ pub fn smooth_vertex_below(cx: &mut TetComplex, ctx: &Context, v: u32, good: f64
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !moved {
|
||||
cx.mark_smoothing_failed(v);
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
@@ -433,17 +433,7 @@ mod determinism {
|
||||
let (p, t, f) = washer();
|
||||
let m = mesh(&p, &t, &f);
|
||||
assert!(m.elements.len() > 100, "{:?}", m.report);
|
||||
// No needles either: the scaled Jacobian analysts check is above the skill's limit.
|
||||
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());
|
||||
assert_eq!(fingerprint(&m), 6_285_915_972_108_509_003, "{} tets, {} nodes", m.elements.len(), m.points.len());
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
@@ -534,34 +534,11 @@ impl Reduction {
|
||||
});
|
||||
// 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.
|
||||
let nearly_repeated = row.source == Source::Tie && intended.is_none() && largest.is_some_and(|t| t.1.abs() < NEARLY_REPEATED * gross);
|
||||
// Held by a prescribed value (the secondary surface meeting a boundary condition or an
|
||||
// axis-aligned symmetry plane: a crosspoint), the row can only constrain main degrees
|
||||
// of freedom, and nearly repeated, what it adds is the mortar condition of a node
|
||||
// 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 {
|
||||
if row.source == Source::Tie
|
||||
&& intended.is_none()
|
||||
&& let Some(&(_, c)) = largest
|
||||
&& c.abs() < NEARLY_REPEATED * gross
|
||||
{
|
||||
let mut others: Vec<String> = row
|
||||
.terms
|
||||
.iter()
|
||||
@@ -881,48 +858,6 @@ impl Reduction {
|
||||
mod tests {
|
||||
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]
|
||||
fn reduced_matrix_budget_refuses_byte_counts_that_overflow() {
|
||||
for matrices in [1, 2] {
|
||||
|
||||
@@ -28,7 +28,7 @@ const MIN_SCALED_JACOBIAN: f64 = 0.3;
|
||||
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
|
||||
/// 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
|
||||
/// blocks are wrong, §6.3), which relaxing does not fix.
|
||||
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.
|
||||
/// Shown only while the part fails.
|
||||
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",
|
||||
"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",
|
||||
"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.",
|
||||
"pinhole-block",
|
||||
"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",
|
||||
"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",
|
||||
@@ -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\
|
||||
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\
|
||||
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\
|
||||
Times are wall clock on the machine that ran it.\n\n## Results\n\n",
|
||||
);
|
||||
|
||||
@@ -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
|
||||
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
|
||||
active gradients, line search; analytic gradients of the active measure, as §3.1 and Freitag–Ollivier-Gooch 1997
|
||||
use), and the composite vertex insertion with rollback
|
||||
active gradients, line search; gradients by central differences), 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
|
||||
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
|
||||
insertion list are capped. Objective: the smaller of the minimum dihedral sine and the scaled Jacobian at the worst
|
||||
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
|
||||
insertion list are capped. Objective: the minimum dihedral sine. 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
|
||||
valid after each step. Speed (#84): an operation (topological, per tetrahedron, edge and face; or a smoothing) that
|
||||
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.
|
||||
valid after each step.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 27.2 | 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-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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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.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 | – | 5.7 | pass | [x=100.000](skill/cantilever-i-beam.png) |
|
||||
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 18.3 | no mesh | – |
|
||||
| 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) | – | 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 | – | 9.3 | pass | [z=100.000](skill/i-beam.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 | – | 33.5 | pass | [x=60.000](skill/perforated-plate.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) | 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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-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) |
|
||||
| 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-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-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-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-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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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-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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 | – | 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 | – | 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.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 | – | 21.8 | pass | [x=40.000](skill/thin-wall-enclosure.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) | – | 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) | – | 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) | – | 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 | – | 5.8 | pass | [x=100.000](skill/analytic-plate-hole.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.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 | 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.7 | pass | [x=56.000](skill/pi5-lid.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 | – | 0.9 | pass (fallback) | [x=0.000](skill/plain-cup.png) |
|
||||
|
||||
## Why a part fails
|
||||
|
||||
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.
|
||||
|
||||
- `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.):
|
||||
- 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…
|
||||
@@ -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.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.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.
|
||||
- `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.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.):
|
||||
- **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.):
|
||||
- **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.):
|
||||
- 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.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.):
|
||||
- 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…
|
||||
- **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)):
|
||||
- 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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 93 KiB |