diff --git a/Cargo.lock b/Cargo.lock index 9974cf3..e24bf16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1353,6 +1353,7 @@ name = "fieldcraft-solver" version = "0.1.0" dependencies = [ "fieldcraft-fem", + "fieldcraft-mesh", "fieldcraft-model", "fieldcraft-parallel", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9f42b6b..cf8ecfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,14 @@ opt-level = 1 [profile.dev.package.fieldcraft-lbm] opt-level = 3 +# Meshing and the sparse solver are compute loops too: the tet quality stage and the +# mesh-convergence oracle cases (tests/oracle) run in tests. +[profile.dev.package.fieldcraft-mesh] +opt-level = 3 + +[profile.dev.package.fieldcraft-solver] +opt-level = 3 + # Tests and lints (`cargo xtask ci`, GitHub CI). Debug assertions are off because truck's # debug-only checks reject valid geometry (SolidCraft builds its tests the same way). [profile.ci] @@ -104,6 +112,12 @@ opt-level = 2 [profile.ci.package.fieldcraft-lbm] opt-level = 3 +[profile.ci.package.fieldcraft-mesh] +opt-level = 3 + +[profile.ci.package.fieldcraft-solver] +opt-level = 3 + [profile.release] opt-level = 3 lto = "thin" diff --git a/ROADMAP.md b/ROADMAP.md index 1f6b2c3..0279177 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s |---|---|---| | M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done | | M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress | -| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, few-ulp rounding repair checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress | +| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress | | M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | | M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started | | M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started | diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index ab0387c..d8cbb61 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -244,7 +244,7 @@ pub static CATALOGUE: &[Capability] = &[ "Tet Mesh", Mesh, "Tet", - "Constrained Delaunay tet mesh of a body: every boundary triangle kept, faces tagged (refinement comes next)", + "Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged", ), soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"), ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"), diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index a3c72d3..bb6a978 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -6,6 +6,9 @@ use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize}; use fieldcraft_mesh::delaunay::Triangulation; use fieldcraft_mesh::quality::{self, QualityReport}; use fieldcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions}; +use fieldcraft_mesh::tet::geometry::AnalyticSurface; +use fieldcraft_mesh::tet::improve::ImproveOptions; +use fieldcraft_mesh::tet::{Order, QualityOptions, TetQuality, quality_mesh, raw_mesh}; use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS}; use serde_json::{Value, json}; @@ -83,7 +86,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec { pub const TET_GENERATE: CommandSpec = CommandSpec { id: "mesh.tet.generate", label: "Generate Tet Mesh", - params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "max_steiner": 50000 (0..=10000000), "max_tets": 20000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary: every boundary triangle is a union of tet faces, Steiner points only on boundary edges, no quality refinement yet. Adds tet4 elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh rounded to doubles does not pass the validity gate"#, + params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh does not pass the validity gate"#, kind: CommandKind::Model, run: tet_generate, }; @@ -91,6 +94,8 @@ pub const TET_GENERATE: CommandSpec = CommandSpec { /// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for. const TET_MAX_STEINER: u64 = 10_000_000; const TET_MAX_TETS: u64 = 50_000_000; +const TET_MAX_POINTS: u64 = 20_000_000; +const TET_MAX_PASSES: u64 = 200; /// An optional whole-number parameter in `range`. pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive) -> Result { @@ -114,37 +119,116 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result Result { - let cdt_options = CdtOptions { - max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, - max_tets: optional_count(p, "max_tets", s.limits.max_tets as u64, 1..=TET_MAX_TETS)?, +/// An optional number in `range`. +fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive) -> Result { + let v = optional_number(p, key, default)?; + if range.contains(&v) { Ok(v) } else { Err(EngineError::Params(format!("`{key}` must be a number in {}..={}", range.start(), range.end()))) } +} + +/// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all. +fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> { + let d = QualityOptions::default(); + let size = match p.get("size") { + None => None, + Some(v) => Some( + v.as_f64() + .filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD) + .ok_or_else(|| EngineError::Params("`size` must be a positive number (mm)".into()))?, + ), }; - let (surface, body, face_names) = { + let order = match p.get("order") { + None => Order::Linear, + Some(v) => match v.as_str() { + Some("linear") => Order::Linear, + Some("quadratic") => Order::Quadratic, + _ => return Err(EngineError::Params("`order` must be \"linear\" or \"quadratic\"".into())), + }, + }; + let target = number_in(p, "quality_target", 40.0, 5.0..=60.0)?; + let improve_options = ImproveOptions { + max_passes: optional_count(p, "max_passes", d.improve_options.max_passes as u64, 0..=TET_MAX_PASSES)?, + good: fieldcraft_mesh::tet::geometry::sin_cos(target.to_radians()).0, + ..d.improve_options + }; + let quality = super::opt::(p, "quality")?.unwrap_or(true); + if !quality && order == Order::Quadratic { + return Err(EngineError::Params("quadratic elements need the quality stage (`quality`: true)".into())); + } + let options = QualityOptions { + size, + curvature_angle: number_in(p, "curvature_angle", d.curvature_angle.to_degrees(), 0.0..=90.0)?.to_radians(), + grading: number_in(p, "grading", d.grading, 0.05..=2.0)?, + improve_options, + order, + max_tets: optional_count(p, "max_tets", max_tets, 1..=TET_MAX_TETS)?, + max_points: optional_count(p, "max_points", d.max_points as u64, 1..=TET_MAX_POINTS)?, + ..d + }; + Ok((options, quality)) +} + +/// The exact surfaces of a body's faces, for the quality stage's projections (none when the +/// kernel cannot give them: the stage then keeps to the tessellation). +pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec> { + use fieldcraft_geometry::FaceSurface as F; + shape + .face_surfaces() + .unwrap_or_default() + .into_iter() + .map(|f| { + f.map(|f| match f { + F::Plane { normal, point } => AnalyticSurface::Plane { normal, point }, + F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius }, + F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle }, + F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius }, + F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor }, + }) + }) + .collect() +} + +fn quality_json(q: &TetQuality) -> Value { + json!({ + "tets": q.tets, + "min_dihedral": q.min_dihedral, + "max_dihedral": q.max_dihedral, + "min_radius_ratio": q.min_radius_ratio, + "mean_radius_ratio": q.mean_radius_ratio, + "slivers": q.slivers, + "inadmissible": q.inadmissible, + }) +} + +fn tet_generate(s: &mut Session, p: &Value) -> Result { + let (options, quality) = quality_options(p, s.limits.max_tets as u64)?; + let cdt_options = + CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets }; + let (surface, body, face_names, analytic) = { let Input { mesh, body, faces } = surface_input(s, p)?; + let analytic = s.design.as_deref().and_then(|d| d.body(&body)).filter(|_| p.get("body").is_some()).map(analytic_surfaces).unwrap_or_default(); let options = surface_options(p, &mesh)?; - (BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces) + (BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces, analytic) }; let cdt = tetrahedralize(&surface, &cdt_options)?; - let gate = cdt.gate(surface.volume())?.into_result()?; + let mesh = if quality { quality_mesh(&surface, &cdt, analytic, &options)? } else { raw_mesh(&surface, &cdt)? }; - let (first_node, first_element) = super::fresh_labels(&s.model, cdt.points.len(), cdt.tets.len())?; - for (i, xyz) in cdt.points.iter().enumerate() { + let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?; + for (i, xyz) in mesh.points.iter().enumerate() { s.model.set_node(first_node + i as u32, *xyz)?; } let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?; - for (i, t) in cdt.tets.iter().enumerate() { + for (i, e) in mesh.elements.iter().enumerate() { let label = first_element + i as u32; - s.model.set_element(label, ElementKind::Tet4, t.iter().map(|&v| first_node + v).collect())?; + s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?; s.model.tag_element(label, body_tag)?; } let mut face_tags: Vec> = vec![None; face_names.len()]; - for b in &cdt.boundary { - let face = surface.tri_face().get(b.triangle as usize).copied().unwrap_or(u32::MAX) as usize; + for b in &mesh.boundary { + let face = b.face as usize; let tag = match face_tags.get(face).copied().flatten() { Some(t) => t, None => { - let name = - face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary triangle {} has no geometry face", b.triangle)))?; + let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?; let t = s.model.geometry_tag(&GeometryRef::face(body.clone(), name.clone())?)?; if let Some(slot) = face_tags.get_mut(face) { *slot = Some(t); @@ -152,20 +236,33 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result { t } }; - let number = b.tet4_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no tet4 face number", b.opposite)))?; + let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?; s.model.tag_element_face(first_element + b.tet, number, tag)?; } + let r = &mesh.report; Ok(json!({ "body": body, - "nodes": cdt.points.len(), - "elements": cdt.tets.len(), + "kind": mesh.kind.name(), + "nodes": mesh.points.len(), + "elements": mesh.elements.len(), "first_node": first_node, "first_element": first_element, "steiner": cdt.steiner.len(), "cavities": cdt.stats.cavities, - "boundary_faces": cdt.boundary.len(), - "volume": gate.volume, - "boundary_volume": surface.volume(), + "boundary_faces": mesh.boundary.len(), + "volume": r.volume, + "boundary_volume": r.boundary_volume, + "surface_volume": surface.volume(), + "size": r.size, + "quality": { + "cdt": quality_json(&r.input), + "mesh": quality_json(&r.output), + "refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::(), + "odt_moves": r.odt_moves, + "improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted}, + "straightened": r.straightened, + "stopped": r.stopped, + }, })) } @@ -656,6 +753,53 @@ mod tests { assert!(r["elements"].as_u64().unwrap() > 0, "{r}"); } + /// The quality stage: a graded, improved tet4 mesh with its report, and tet10 with mid-edge + /// nodes on the bore; the raw tetrahedralization on request. + #[test] + fn tet_generate_quality_report_and_quadratic_elements() { + use std::sync::Arc; + + use fieldcraft_geometry::open_design; + use fieldcraft_geometry::sample::{Plate, plate_design}; + + let plate = Plate::default(); + let design = Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap()); + let mut s = Session { design: Some(design.clone()), ..Session::default() }; + let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6})).unwrap(); + let q = &r["quality"]; + assert_eq!(q["mesh"]["inadmissible"], json!(0), "{r}"); + assert_eq!(q["mesh"]["slivers"], json!(0), "{r}"); + assert!(q["mesh"]["min_dihedral"].as_f64().unwrap() > 15.0, "{r}"); + assert!(q["mesh"]["max_dihedral"].as_f64().unwrap() < 165.0, "{r}"); + assert!(q["cdt"]["slivers"].as_u64().unwrap() > 0, "{r}"); + // The mesh fills what its boundary encloses, which is the plate within the chordal error. + let (volume, boundary) = (r["volume"].as_f64().unwrap(), r["boundary_volume"].as_f64().unwrap()); + assert!((volume - boundary).abs() <= 1e-9 * boundary, "{r}"); + let exact = plate.width * plate.depth * plate.thickness - std::f64::consts::PI * 0.25 * plate.hole_diameter.powi(2) * plate.thickness; + // The bore's chordal error at 0.2 mm is at most 2/3 · 0.2 · its area (≈ 42 mm³). + assert!((volume - exact).abs() < 42.0, "{volume} vs {exact}"); + + // Quadratic: mid-edge nodes on the bore's cylinder. + let mut s = Session { design: Some(design), ..Session::default() }; + let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6, "order": "quadratic"})).unwrap(); + assert_eq!(r["kind"], json!("tet10"), "{r}"); + assert!(s.model.elements().values().all(|e| e.kind == fieldcraft_model::ElementKind::Tet10)); + let report = s.execute("mesh.quality", &json!({})).unwrap(); + assert_eq!(report["valid"].as_u64().unwrap(), r["elements"].as_u64().unwrap(), "{report}"); + // Nodes on the bore (within the tessellation's reach of its axis) lie on its radius. + let (cx, cy) = (plate.depth / 2.0, plate.depth / 2.0); + let radius = plate.hole_diameter / 2.0; + let mut on_bore = 0; + for n in s.model.nodes().values() { + let d = ((n.xyz[0] - cx).powi(2) + (n.xyz[1] - cy).powi(2)).sqrt(); + if (d - radius).abs() < 0.05 { + on_bore += 1; + assert!((d - radius).abs() < 1e-9, "a bore node at {:?} is {d} from the axis", n.xyz); + } + } + assert!(on_bore > 20, "{on_bore}"); + } + #[test] fn tet_generate_on_an_imported_surface_and_an_open_one() { use std::sync::Arc; @@ -671,12 +815,17 @@ mod tests { for mesh in [tet, open] { s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 })); } - let r = s.execute("mesh.tet.generate", &json!({"surface": 0})).unwrap(); + let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap(); assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4))); let element = s.model.element(1).unwrap(); assert_eq!(element.nodes.len(), 4); + // Refined to a size, the flat-faced tetrahedron keeps its volume exactly. + let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "size": 0.3})).unwrap(); + assert!(r["elements"].as_u64().unwrap() > 1, "{r}"); + assert!((r["volume"].as_f64().unwrap() - 1.0 / 6.0).abs() < 1e-12, "{r}"); + let total = s.model.elements().len(); assert!(s.execute("mesh.tet.generate", &json!({"surface": 1})).is_err(), "an open surface bounds no volume"); - assert_eq!(s.model.elements().len(), 1); + assert_eq!(s.model.elements().len(), total); } #[test] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 773b10c..7f370b8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,8 @@ pub(crate) mod file; mod geometry; mod hex; pub(crate) mod mesh; + +pub use mesh::analytic_surfaces; mod model; pub(crate) mod octree; mod results; diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index bfe52b9..c4b8446 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -82,7 +82,7 @@ pub const FLUID: CommandSpec = CommandSpec { pub const PROBE: CommandSpec = CommandSpec { id: "solve.probe", label: "Probe Results", - params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#, + params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#, kind: CommandKind::Model, run: probe, }; @@ -489,13 +489,21 @@ fn probe(s: &mut Session, p: &Value) -> Result { } let (nodes, located) = match (p.get("nodes"), p.get("at")) { (Some(_), None) => (labels(p, "nodes", MAX_PROBE)?, None), + (None, None) if p.get("set").is_some() => { + let name = opt::(p, "set")?.unwrap_or_default(); + let set = s.model.resolve_set(&name)?; + if set.nodes.len() > MAX_PROBE { + return Err(EngineError::Params(format!("set `{name}` has {} nodes; a probe takes at most {MAX_PROBE}", set.nodes.len()))); + } + (set.nodes, None) + } (None, Some(_)) => { let at = points(p, "at", MAX_PROBE)?; let found = nearest_nodes(&s.model, result.nodes(), &at)?; (found.iter().map(|(l, _, _)| *l).collect(), Some(found)) } (None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) || p.get("elements").is_some() => (Vec::new(), None), - _ => return Err(EngineError::Params("give either `nodes` or `at`".into())), + _ => return Err(EngineError::Params("give one of `nodes`, `at` or `set`".into())), }; let mut out = match result.as_ref() { StepResult::Explicit(r) => { @@ -505,6 +513,16 @@ fn probe(s: &mut Session, p: &Value) -> Result { StepResult::Implicit(r) => probe_implicit(r, &step, &nodes, p)?, StepResult::Fluid(r) => probe_fluid(r, p)?, }; + // The largest von Mises stress among the probed nodes (static results). + let peak = out.get("nodes").and_then(Value::as_array).and_then(|entries| { + entries + .iter() + .filter_map(|e| Some((e.get("node")?.as_u64()?, e.get("von_mises")?.as_f64()?))) + .fold(None, |best: Option<(u64, f64)>, (n, v)| if best.is_none_or(|b| v > b.1) { Some((n, v)) } else { best }) + }); + if let (Some((node, value)), Some(obj)) = (peak, out.as_object_mut()) { + obj.insert("max_von_mises".into(), json!({"node": node, "value": value})); + } if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) { for (entry, (_, xyz, distance)) in entries.iter_mut().zip(found) { if let Some(obj) = entry.as_object_mut() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ed38e55..0ebb31b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,8 @@ pub mod autosave; pub mod catalogue; pub mod cmd; + +pub use cmd::analytic_surfaces; pub mod history; pub mod results; pub mod sample; @@ -365,7 +367,8 @@ mod tests { json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}), json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}), json!({"path": 7}), - json!({"body": "Body1", "tolerance": 0.05}), + // (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.) + json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}), json!({"body": null, "tolerance": "fine"}), json!({"elements": [u32::MAX, -1, 1.5]}), json!({"elements": {}, "bins": -5}), @@ -385,7 +388,7 @@ mod tests { json!({"polygon": [[0, 0], [1, 0], [0, 1]], "holes": [[[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]], [[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]]], "size": 0.2}), json!({"polygon": [["a", 0], [1, null], {}], "size": "big", "kind": 7}), json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e300, "kind": "tri6", "max_elements": 1e300}), - json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1}), + json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1, "max_points": 3000, "max_passes": 1}), // Structured hex meshing. json!({"size": -1, "order": 9, "method": "x"}), json!({"size": 1e-300}), @@ -515,15 +518,34 @@ mod tests { json!({"body": "Body1", "tolerance": 1e-300}), json!({"body": "Body1", "tolerance": 1e300}), json!({"body": "Body2", "tolerance": 0.1}), - json!({"body": "Body1", "tolerance": 0.5, "surface": 0}), - json!({"body": "Body1", "tolerance": 0.5, "weld": 0}), - json!({"body": "Body1", "tolerance": 0.5, "weld": 1e-3}), - json!({"body": "Body1", "tolerance": 0.5, "weld": f64::MIN_POSITIVE}), - json!({"body": "Body1", "tolerance": 0.5, "weld": 0, "weld_distance": 0}), - json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 1e300}), - json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 0.05}), - json!({"body": "Body1", "tolerance": 0.5, "feature_angle": 1e-300}), - json!({"body": "Body1", "tolerance": 0.5, "feature_angle": null}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "surface": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 1e-3}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": f64::MIN_POSITIVE}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0, "weld_distance": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 1e300}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 0.05}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": 1e-300}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": null}), + // The quality stage's own parameters, hostile, with small caps so this stays fast. + json!({"body": "Body1", "tolerance": 0.5, "size": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": -1}), + json!({"body": "Body1", "tolerance": 0.5, "size": 1e300}), + json!({"body": "Body1", "tolerance": 0.5, "size": 1e-300, "max_points": 2000, "max_tets": 5000}), + json!({"body": "Body1", "tolerance": 0.5, "size": "fine"}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "cubic"}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "quadratic", "quality": false}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality": "yes"}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 1e300}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": -1}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": 1e-300, "max_points": 2000}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": f64::MAX}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1e9}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 0}), + json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 30, "max_passes": 1}), + json!({"body": "Body1", "tolerance": 0.5, "size": 0.5, "max_tets": 3000, "max_passes": 1}), json!({"body": "Body1", "size": 1e-300}), json!({"body": "Body1", "size": 1e300, "order": 2}), json!({"body": "Body1", "size": 4, "method": "sweep", "order": 2}), diff --git a/crates/fem/src/dense.rs b/crates/fem/src/dense.rs index 4d4137e..8c76b77 100644 --- a/crates/fem/src/dense.rs +++ b/crates/fem/src/dense.rs @@ -106,11 +106,13 @@ pub fn inverse3(m: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> { } /// The inverse and determinant of a Jacobian that maps a reference element onto a valid one: -/// `None` unless det J is positive beyond round-off relative to the product of its row norms. +/// `None` unless it is admissible by FieldCraft's one validity criterion +/// ([`fieldcraft_model::admissible_jacobian`], the one the meshers' validity gate proves). pub fn positive_inverse(j: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> { - let (inv, det) = inverse3(j)?; - let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product(); - (det > 1e-12 * norms).then_some((inv, det)) + if !fieldcraft_model::admissible_jacobian(j) { + return None; + } + inverse3(j) } /// The eigenvalues and unit eigenvectors (the columns of the matrix returned) of a symmetric diff --git a/crates/fem/src/element.rs b/crates/fem/src/element.rs index 3a6e2f8..a34a819 100644 --- a/crates/fem/src/element.rs +++ b/crates/fem/src/element.rs @@ -35,7 +35,7 @@ use fieldcraft_model::ElementKind; use crate::basis::Basis; -use crate::dense::{Cholesky, Matrix, inverse3, positive_inverse}; +use crate::dense::{Cholesky, Matrix, positive_inverse}; use crate::dof::{DofSet, ElementDofs, Internal}; use crate::material::{Elasticity, stress}; use crate::quadrature::{Point, Rule}; @@ -356,7 +356,7 @@ impl<'a> Solid<'a> { fn centre_jacobian(&self) -> Result<([[f64; 3]; 3], f64)> { let s = (self.ty.shape)([0.0; 3]); - inverse3(&self.jacobian(&s)).filter(|(_, d)| *d > 0.0).ok_or_else(|| self.degenerate()) + positive_inverse(&self.jacobian(&s)).ok_or_else(|| self.degenerate()) } /// The parts of the stiffness with the incompatible modes: K_uu (when `with_uu`), K_ua diff --git a/crates/fem/src/uniform.rs b/crates/fem/src/uniform.rs index b1cdb79..e12b675 100644 --- a/crates/fem/src/uniform.rs +++ b/crates/fem/src/uniform.rs @@ -30,7 +30,7 @@ use fieldcraft_model::Elastic; -use crate::dense::inverse3; +use crate::dense::positive_inverse; use crate::material::Elasticity; use crate::quadrature::Rule; use crate::shape::{self, HEX_CORNERS}; @@ -72,11 +72,7 @@ impl UniformStrain<8> { } } } - let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("hex8"))?; - let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product(); - if !(det > 1e-12 * norms) { - return Err(degenerate("hex8")); - } + let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("hex8"))?; let w = det * p.weight; volume += w; for (((bi, vi), d), n) in b.iter_mut().zip(nodal_volume.iter_mut()).zip(s.derivatives()).zip(s.values()) { @@ -172,15 +168,9 @@ impl UniformStrain<4> { /// The linear tetrahedron of corners `xyz` (the model's tet4 node order). pub fn tet4(xyz: &[[f64; 3]; 4]) -> Result { fieldcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?; - let [x0, x1, x2, x3] = *xyz; - let e = |x: [f64; 3]| [x[0] - x0[0], x[1] - x0[1], x[2] - x0[2]]; // J[a][c] = ∂x_c/∂ξ_a: the edges from corner 1. - let j = [e(x1), e(x2), e(x3)]; - let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("tet4"))?; - let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product(); - if !(det > 1e-12 * norms) { - return Err(degenerate("tet4")); - } + let j = fieldcraft_model::tet4_jacobian(xyz); + let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("tet4"))?; let volume = det / 6.0; let s = shape::tet4([0.25; 3]); let mut b = [[0.0; 3]; 4]; diff --git a/crates/mesh/src/cdt/complex.rs b/crates/mesh/src/cdt/complex.rs index 3c055bb..a43a206 100644 --- a/crates/mesh/src/cdt/complex.rs +++ b/crates/mesh/src/cdt/complex.rs @@ -8,43 +8,12 @@ //! the hull. The hull is the bounding box the recovery adds around the surface, so every Steiner //! point is strictly inside it and no ghost tetrahedra are needed. -use std::collections::HashMap; -use std::hash::{BuildHasherDefault, Hasher}; +pub(super) use crate::tet::complex::{IdMap, NONE, face_toward, sorted3}; use crate::delaunay::Triangulation; use crate::predicates::{GenericPoint, Sign, generic_insphere_sos, generic_lex, generic_orient3d}; use crate::{Error, Result}; -/// A multiplicative hasher for small integer keys (vertex triples). The maps it serves are only -/// looked up, never iterated, so its order cannot leak into the output. -#[derive(Default)] -pub(super) struct IdHasher(u64); - -impl Hasher for IdHasher { - fn finish(&self) -> u64 { - self.0 - } - - fn write(&mut self, bytes: &[u8]) { - for &b in bytes { - self.write_u64(u64::from(b)); - } - } - - fn write_u32(&mut self, x: u32) { - self.write_u64(u64::from(x)); - } - - fn write_u64(&mut self, x: u64) { - self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); - } -} - -/// A hash map keyed by vertex ids. -pub(super) type IdMap = HashMap>; - -/// No tetrahedron / no vertex. -pub(super) const NONE: u32 = u32::MAX; /// Marks a recycled tetrahedron slot. const DEAD: u32 = u32::MAX - 1; @@ -52,25 +21,6 @@ pub(super) fn corrupt(what: &str) -> Error { Error::Failed(format!("constrained tetrahedralization is inconsistent: {what}")) } -/// The face of `vs` opposite slot `k`, ordered so that vertex `k` is on its positive side -/// ([`Complex::volume`] of the face plus that vertex is positive). -pub(super) fn face_toward(vs: [u32; 4], k: usize) -> [u32; 3] { - let [a, b, c, d] = vs; - match k { - 0 => [b, d, c], - 1 => [a, c, d], - 2 => [a, d, b], - _ => [a, b, c], - } -} - -/// A triangle's vertices sorted: the key of an unoriented face. -pub(super) fn sorted3([a, b, c]: [u32; 3]) -> [u32; 3] { - let mut f = [a, b, c]; - f.sort_unstable(); - f -} - /// The working tetrahedralization. #[derive(Debug, Clone)] pub(super) struct Complex { diff --git a/crates/mesh/src/cdt/mod.rs b/crates/mesh/src/cdt/mod.rs index fb1ad30..f004522 100644 --- a/crates/mesh/src/cdt/mod.rs +++ b/crates/mesh/src/cdt/mod.rs @@ -34,9 +34,10 @@ //! then the Steiner points, each with its segment and parameter, rounded to doubles; and for each //! boundary tetrahedron face the input triangle it lies on (so the B-rep face tag carries over). //! Rounding a Steiner point to the nearest doubles moves it off its segment by under an ulp and -//! can, on a nearly flat tetrahedron, flip it (§5.1). [`round`] then tries the doubles a few ulps -//! around such a point for one that keeps every incident tetrahedron positive; [`TetMesh::gate`] -//! runs the validity gate on the rounded mesh and any failure left is reported, never hidden. +//! can, on a nearly flat tetrahedron, flip it (§5.1); nearly cospherical input also gives +//! tetrahedra that are positive yet flat to rounding. [`repair`] removes them by flips that keep +//! the boundary (§5.1); [`TetMesh::gate`] runs the validity gate on the rounded mesh and any +//! failure left is reported, never hidden. //! //! # Determinism and bounds //! Same surface → same mesh: the Delaunay step is deterministic, segments and facets are @@ -46,11 +47,11 @@ mod complex; mod facets; mod meet; -mod round; +mod repair; mod segments; #[cfg(test)] -mod tests; +pub(crate) mod tests; use std::collections::HashMap; @@ -122,9 +123,9 @@ pub struct CdtStats { pub gift_wrapped_tets: usize, /// Tetrahedra in the box at the end. pub box_tets: usize, - /// Steiner points moved by a few ulps from their nearest doubles to keep every incident - /// tetrahedron positive ([`round`]). - pub rounding_adjusted: usize, + /// Tetrahedra that were flat, inverted or inadmissible on the rounded coordinates and were + /// removed by flips ([`repair`]). + pub repaired: usize, } /// The constrained Delaunay tetrahedralization of a closed surface (see the module docs). @@ -355,7 +356,7 @@ impl Recovery { } let mut stats = self.stats; stats.box_tets = self.cx.live_count(); - stats.rounding_adjusted = round::repair(&mut points, &tets, n)?; + stats.repaired = repair::repair(&points, &mut tets, &mut boundary, self.options.max_tets)?; Ok(TetMesh { points, steiner, tets, boundary, stats }) } } diff --git a/crates/mesh/src/cdt/repair.rs b/crates/mesh/src/cdt/repair.rs new file mode 100644 index 0000000..0e80373 --- /dev/null +++ b/crates/mesh/src/cdt/repair.rs @@ -0,0 +1,96 @@ +//! Repairing the tetrahedra that rounding to doubles leaves flat, inverted or inadmissible. +//! +//! The exact tetrahedralization is valid, but its Steiner points are rounded to doubles, which +//! can flip a nearly flat tetrahedron (Diazzi et al. 2023, §5.1), and nearly cospherical input +//! (a tessellated sphere, a cylinder) gives tetrahedra that are positive yet flat to rounding: +//! their normalized Jacobian is below FieldCraft's validity criterion +//! ([`fieldcraft_model::admissible_jacobian`]), so a solver would refuse them. Following §5.1, +//! such tetrahedra are removed by flips that never touch the boundary, on the rounded +//! coordinates and decided by exact `orient3d`: edge removal (which includes the 3-2 and 4-4 +//! flips) around each of their edges and face removal (the 2-3 flip and multi-face removal) +//! across each of their faces, each applied only when it improves the worst tetrahedron it +//! changes ([`crate::tet::ops`]). Every boundary edge is held fixed, so every input triangle +//! stays a union of tetrahedron faces. The passes are capped; whatever is left is reported by the +//! validity gate, never hidden. + +use fieldcraft_model::{normalized_jacobian, tet4_jacobian}; + +use super::BoundaryFace; +use crate::tet::complex::{EDGES, NONE, TetComplex, VertexKind}; +use crate::tet::ops::{remove_edge_at, remove_face}; +use crate::tet::refine::feature_curves; +use crate::{Error, Result}; + +/// A tetrahedron is repaired when its normalized Jacobian is at most this (a margin of a +/// thousand above the validity criterion, so rounding differences cannot bring it back). +const REPAIR_BELOW: f64 = 1e3 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN; +/// Passes over the tetrahedra still to repair. +const MAX_PASSES: usize = 8; + +fn needs_repair(points: &[[f64; 3]], t: &[u32; 4]) -> bool { + let p = |v: u32| points.get(v as usize).copied().unwrap_or([f64::NAN; 3]); + !(normalized_jacobian(&tet4_jacobian(&[p(t[0]), p(t[1]), p(t[2]), p(t[3])])) > REPAIR_BELOW) +} + +/// Repair the rounded mesh in place; the number of tetrahedra that needed it. +pub(super) fn repair(points: &[[f64; 3]], tets: &mut Vec<[u32; 4]>, boundary: &mut Vec, max_tets: usize) -> Result { + let bad = tets.iter().filter(|t| needs_repair(points, t)).count(); + if bad == 0 { + return Ok(0); + } + let tags: Vec<(u32, usize, u32)> = boundary.iter().map(|b| (b.tet, usize::from(b.opposite), b.triangle)).collect(); + let kinds = vec![VertexKind::Corner; points.len()]; + let mut cx = TetComplex::with_invalid(points, kinds, tets, &tags, &[], points.len(), max_tets.max(tets.len() * 2))?; + // The segments: boundary edges between two different input triangles (never by angle). + // Edges inside one input triangle (between its pieces) may be flipped; the triangle stays + // covered. + let (_, segments) = feature_curves(&cx, -2.0)?; + for ((a, b), c) in segments { + cx.set_curve(a, b, Some(c)); + } + for _ in 0..MAX_PASSES { + let targets: Vec<(u32, [u32; 4])> = cx.live_tets().filter(|(_, vs)| needs_repair(points, vs)).collect(); + if targets.is_empty() { + break; + } + let mut changed = false; + for (t, vs) in targets { + if !cx.is_live(t) || cx.tet(t)? != vs { + continue; + } + for &(i, j) in &EDGES { + if cx.is_live(t) && cx.tet(t)? == vs && remove_edge_at(&mut cx, t, vs[i], vs[j])? { + changed = true; + break; + } + } + // A flat tetrahedron lying on one input triangle (two of its faces are pieces of + // it): delete it; its two other faces cover the same piece of the triangle. + if cx.is_live(t) && cx.tet(t)? == vs && cx.delete_boundary_tet(t)? { + changed = true; + continue; + } + if cx.is_live(t) && cx.tet(t)? == vs { + for k in 0..4 { + if remove_face(&mut cx, t, k)? { + changed = true; + break; + } + } + } + } + if !changed { + break; + } + } + let c = cx.compact()?; + if c.source.len() != points.len() || c.source.iter().enumerate().any(|(i, &s)| s as usize != i) { + return Err(Error::Failed("repair removed a vertex".into())); + } + *tets = c.tets; + *boundary = c.boundary.into_iter().map(|(tet, k, tag)| BoundaryFace { tet, opposite: k as u8, triangle: tag }).collect(); + if boundary.iter().any(|b| b.triangle == NONE) { + return Err(Error::Failed("repair lost a boundary face's triangle".into())); + } + Ok(bad) +} diff --git a/crates/mesh/src/cdt/round.rs b/crates/mesh/src/cdt/round.rs deleted file mode 100644 index 1dddce3..0000000 --- a/crates/mesh/src/cdt/round.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Rounding the Steiner points to doubles without inverting tetrahedra. -//! -//! The exact tetrahedralization is valid, but a Steiner point rounded to its nearest doubles is -//! off its segment by up to half an ulp per coordinate, which flips a tetrahedron whose volume is -//! below that scale (Diazzi et al. 2023, §5.1, §6.2: the nearly flat ones, typically four Steiner -//! points on nearly parallel boundary edges). For each tetrahedron that is not positive after -//! rounding, each of its Steiner points tries the doubles within [`REACH`] ulps of its rounded -//! coordinates, nearest first, and takes the first with which every tetrahedron around it is -//! positive (exact `orient3d` on the doubles). Surface vertices never move. The moves are a few -//! ulps, so boundary faces stay on their triangles to rounding precision. The number of passes is -//! capped; what is left is for the validity gate to report. -//! -//! This is the interim answer. The paper's own (§5.1) improves the exact mesh by flips that never -//! cross a facet before rounding, which removes most nearly flat tetrahedra instead of nudging -//! their vertices; it belongs with the flips of the quality stage. - -use crate::predicates::{Point3, Sign, tet_volume_sign}; -use crate::{Error, Result}; - -/// How far, in ulps per coordinate, a rounded Steiner point may move. -const REACH: i32 = 2; -/// Passes over the tetrahedra that are not positive. -const MAX_PASSES: usize = 4; - -fn positive(points: &[[f64; 3]], t: &[u32; 4]) -> Result { - let p = |v: u32| -> Result { - let c = points.get(v as usize).copied().ok_or_else(|| Error::Failed("rounding: vertex id out of range".into()))?; - Point3::try_from(c) - }; - Ok(tet_volume_sign(&p(t[0])?, &p(t[1])?, &p(t[2])?, &p(t[3])?) == Sign::Positive) -} - -/// `x` moved by `k` ulps. -fn step(mut x: f64, k: i32) -> f64 { - for _ in 0..k.unsigned_abs() { - x = if k > 0 { x.next_up() } else { x.next_down() }; - } - x -} - -/// Offsets within [`REACH`], nearest (smallest total) first, in a fixed order. -fn offsets() -> Vec<[i32; 3]> { - let r = -REACH..=REACH; - let mut o: Vec<[i32; 3]> = r.clone().flat_map(|a| r.clone().flat_map(move |b| (-REACH..=REACH).map(move |c| [a, b, c]))).collect(); - o.retain(|d| d != &[0, 0, 0]); - o.sort_by_key(|d| (d.iter().map(|k| k.abs()).sum::(), *d)); - o -} - -/// Move Steiner points (ids `>= surface`) so every tetrahedron is positive where a few-ulp move -/// does it. The number of points moved. -pub(super) fn repair(points: &mut [[f64; 3]], tets: &[[u32; 4]], surface: usize) -> Result { - let mut bad = Vec::new(); - for (i, t) in tets.iter().enumerate() { - if !positive(points, t)? { - bad.push(i); - } - } - if bad.is_empty() { - return Ok(0); - } - let mut around: Vec> = vec![Vec::new(); points.len()]; - for (i, t) in tets.iter().enumerate() { - for &v in t { - if (v as usize) >= surface - && let Some(a) = around.get_mut(v as usize) - { - a.push(i as u32); - } - } - } - let offsets = offsets(); - let mut moved = vec![false; points.len()]; - for _ in 0..MAX_PASSES { - let mut still = Vec::new(); - for &i in &bad { - let t = *tets.get(i).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?; - if positive(points, &t)? { - continue; - } - let mut fixed = false; - for v in t.into_iter().filter(|&v| (v as usize) >= surface) { - let Some(origin) = points.get(v as usize).copied() else { continue }; - let incident = around.get(v as usize).cloned().unwrap_or_default(); - for d in &offsets { - let candidate: [f64; 3] = std::array::from_fn(|k| step(origin[k], d[k])); - if candidate.iter().any(|x| !crate::predicates::is_admissible(*x)) { - continue; - } - if let Some(p) = points.get_mut(v as usize) { - *p = candidate; - } - let mut ok = true; - for &j in &incident { - let u = tets.get(j as usize).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?; - if !positive(points, u)? { - ok = false; - break; - } - } - if ok { - fixed = true; - if let Some(m) = moved.get_mut(v as usize) { - *m = true; - } - break; - } - if let Some(p) = points.get_mut(v as usize) { - *p = origin; - } - } - if fixed { - break; - } - } - if !fixed { - still.push(i); - } - } - if still.is_empty() || still.len() == bad.len() { - break; - } - bad = still; - } - Ok(moved.iter().filter(|m| **m).count()) -} diff --git a/crates/mesh/src/cdt/tests.rs b/crates/mesh/src/cdt/tests.rs index fdbb4c8..9e574b4 100644 --- a/crates/mesh/src/cdt/tests.rs +++ b/crates/mesh/src/cdt/tests.rs @@ -10,7 +10,7 @@ use crate::vec3::{cross, dot, norm, sub}; /// A closed surface from points and triangles, each triangle oriented outward from `centre` /// (the shapes here are star-shaped about it). -fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface { +pub(crate) fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface { let oriented: Vec<[u32; 3]> = triangles .iter() .map(|&[a, b, c]| { @@ -47,7 +47,7 @@ fn check(s: &BoundarySurface, m: &TetMesh) { } } -fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { +pub(crate) fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, (i >> 1 & 1) as f64, (i >> 2 & 1) as f64]).collect(); let quads = [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]]; let t = quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect(); @@ -98,7 +98,7 @@ fn schonhardt_prism_gets_steiner_points_and_conforms() { } /// A UV sphere: many cospherical vertices. -fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { +pub(crate) fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { let mut p = vec![[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]]; for r in 1..rings { let th = std::f64::consts::PI * r as f64 / rings as f64; diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs index a5626aa..4a536fd 100644 --- a/crates/mesh/src/lib.rs +++ b/crates/mesh/src/lib.rs @@ -18,6 +18,7 @@ pub mod quad; pub mod quality; pub mod size; pub mod surface; +pub mod tet; mod vec3; #[derive(Debug, Clone, PartialEq, thiserror::Error)] diff --git a/crates/mesh/src/quality/bernstein.rs b/crates/mesh/src/quality/bernstein.rs index 50dc4dc..ed3ee1f 100644 --- a/crates/mesh/src/quality/bernstein.rs +++ b/crates/mesh/src/quality/bernstein.rs @@ -296,6 +296,12 @@ impl Bernstein { Ok((a, b)) } + /// The coefficients of the used slots, in a fixed order shared by every polynomial on the + /// same domain (so the coefficients of a vector-valued polynomial's components line up). + pub fn coefficients(&self) -> Vec { + self.multis().iter().map(|m| self.get(m)).collect() + } + /// The smallest and largest coefficient: bounds of the polynomial on its domain. pub fn bounds(&self) -> (f64, f64) { self.multis().iter().map(|m| self.get(m)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c), hi.max(c))) diff --git a/crates/mesh/src/quality/jacobian.rs b/crates/mesh/src/quality/jacobian.rs index e5fbadb..815211a 100644 --- a/crates/mesh/src/quality/jacobian.rs +++ b/crates/mesh/src/quality/jacobian.rs @@ -32,7 +32,7 @@ pub(crate) const HEX_CORNERS: [[usize; 3]; 8] = [[0, 0, 0], [1, 0, 0], [1, 1, 0] /// Hex edges in the order of hex20's mid-side nodes. pub(crate) const HEX_EDGES: [(usize, usize); 12] = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]; /// Tet edges in the order of tet10's mid-side nodes. -pub(crate) const TET_EDGES: [(usize, usize); 6] = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)]; +pub const TET_EDGES: [(usize, usize); 6] = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)]; /// A Jacobian determinant is positive when it exceeds this fraction of its largest magnitude. /// Anything smaller is degenerate to rounding and counts as invalid. @@ -280,15 +280,35 @@ impl Piece { } } -/// Decide the sign of a Jacobian determinant on the whole reference domain. -pub(crate) fn certify(det: Bernstein) -> Result { +/// An upper bound of `|∂x/∂ξ_a|` over the whole element for one Jacobian column (a vector +/// polynomial in Bernstein form): the longest control vector, since the polynomial stays in the +/// convex hull of its control points. +fn column_norm_bound(column: &[Bernstein; 3]) -> f64 { + let [x, y, z] = column.each_ref().map(Bernstein::coefficients); + x.iter().zip(&y).zip(&z).map(|((a, b), c)| (a * a + b * b + c * c).sqrt()).fold(0.0, f64::max) +} + +/// The determinant's floor for [`certify`] that proves FieldCraft's validity criterion +/// ([`fieldcraft_model::admissible_jacobian`]) on the whole element: with `N` the product of the +/// columns' norm bounds, `det J(ξ) > 2 · MIN_NORMALIZED_JACOBIAN · N` everywhere gives +/// `det J(ξ) / Π|J_a(ξ)| > MIN_NORMALIZED_JACOBIAN` at every point, so the solvers, which check +/// the same criterion at their integration points, accept every element the gate accepts. The +/// factor 2 covers the rounding of the solvers' own Jacobian evaluation. +pub(crate) fn admissible_floor(columns: &[[Bernstein; 3]; 3]) -> f64 { + let n: f64 = columns.iter().map(column_norm_bound).product(); + 2.0 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN * n +} + +/// Decide whether a Jacobian determinant exceeds `floor` (and [`RELATIVE_TOLERANCE`] of its +/// largest magnitude) on the whole reference domain. +pub(crate) fn certify(det: Bernstein, floor: f64) -> Result { let (lo, hi) = det.bounds(); let volume = det.integral(); let scale = lo.abs().max(hi.abs()); - if !scale.is_finite() || !volume.is_finite() { + if !scale.is_finite() || !volume.is_finite() || !floor.is_finite() { return Err(Error::Invalid("the element's Jacobian is not a finite number".into())); } - let tol = RELATIVE_TOLERANCE * scale; + let tol = (RELATIVE_TOLERANCE * scale).max(floor); let ratio_of = |v: f64| if scale > 0.0 { v / scale } else { 0.0 }; let verts = det.factors().iter().map(|f| (0..=f.dim).map(|k| simplex_vertex(k, 1).map(|x| x as f64)).collect()).collect(); let mut stack = vec![Piece::new(det, verts, 0)]; diff --git a/crates/mesh/src/quality/mod.rs b/crates/mesh/src/quality/mod.rs index f16841c..b81f4ac 100644 --- a/crates/mesh/src/quality/mod.rs +++ b/crates/mesh/src/quality/mod.rs @@ -14,16 +14,22 @@ //! Inputs are hostile: coordinates must be finite and within [`fieldcraft_model::MAX_COORD`], //! and every metric has a defined value on degenerate and inverted elements. //! -//! Linear tetrahedra take their validity from the exact `orient3d` ([`crate::predicates`]); the -//! other kinds from the floating-point Bernstein certificate, which counts a Jacobian within -//! [`jacobian::RELATIVE_TOLERANCE`] of zero as degenerate. +//! Validity is FieldCraft's one criterion, shared with the solvers: an element is admissible when +//! its normalized Jacobian `det J / Π|∂x/∂ξ_a|` exceeds +//! [`fieldcraft_model::MIN_NORMALIZED_JACOBIAN`] ([`fieldcraft_model::admissible_jacobian`]). +//! Linear tetrahedra take their orientation from the exact `orient3d` ([`crate::predicates`]) and +//! must also pass the criterion on the solvers' own Jacobian (the edges of the input coordinates), +//! so the gate and the solvers decide every tet4 alike. The other kinds use the floating-point +//! Bernstein certificate, which proves the criterion on the whole element (and counts a Jacobian +//! within [`jacobian::RELATIVE_TOLERANCE`] of zero as degenerate); the solvers check it only at +//! their integration points, so whatever passes the gate passes the solvers. mod bernstein; pub mod jacobian; mod report; pub(crate) mod shape; -use fieldcraft_model::{ElementKind, check_coordinates}; +use fieldcraft_model::{ElementKind, admissible_jacobian, check_coordinates, tet4_jacobian}; pub use jacobian::{JacobianCheck, Validity}; pub use report::{ @@ -156,15 +162,19 @@ fn checked(kind: ElementKind, xyz: &[[f64; 3]], p: &[[f64; 3]]) -> Result<(Optio { let [a, b, c, d]: [[f64; 3]; 4] = corners(p)?; let volume = shape::det(shape::sub(b, a), shape::sub(c, a), shape::sub(d, a)) / 6.0; + // FieldCraft's one criterion, on the solvers' own Jacobian (edges of the input + // coordinates); the exact sign settles the orientation of the rest. + let admissible = admissible_jacobian(&tet4_jacobian(&corners(xyz)?)); let (validity, ratio) = match sign { - Sign::Positive => (Validity::Valid, 1.0), + Sign::Positive if admissible => (Validity::Valid, 1.0), + Sign::Positive | Sign::Zero => (Validity::Invalid, 0.0), Sign::Negative => (Validity::Invalid, -1.0), - Sign::Zero => (Validity::Invalid, 0.0), }; return Ok((None, JacobianCheck { validity, ratio, volume })); } let jacobian::Jacobian { columns, det } = jacobian::jacobian(kind, p)?; - Ok((Some(columns), jacobian::certify(det)?)) + let floor = jacobian::admissible_floor(&columns); + Ok((Some(columns), jacobian::certify(det, floor)?)) } /// The exact orientation of a tetrahedron (positive for node 4 on the side of face 1-2-3's diff --git a/crates/mesh/src/quality/tests.rs b/crates/mesh/src/quality/tests.rs index 0e894f8..dee4977 100644 --- a/crates/mesh/src/quality/tests.rs +++ b/crates/mesh/src/quality/tests.rs @@ -78,9 +78,12 @@ fn inverted_and_flat_tetrahedra() { assert_eq!(metric(&q, Metric::AspectRatio), f64::INFINITY); assert!(q.metrics.iter().all(|(_, v)| !v.is_nan())); - // A sliver with an exactly positive orientation is valid, however thin (exact orient3d). + // A sliver with an exactly positive orientation but a normalized Jacobian below the one + // criterion the solvers share is invalid; a thin one above it is valid. let sliver = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, 1e-30]]; - assert_eq!(element_validity(ElementKind::Tet4, &sliver).unwrap().validity, Validity::Valid); + assert_eq!(element_validity(ElementKind::Tet4, &sliver).unwrap().validity, Validity::Invalid); + let thin = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, 1e-9]]; + assert_eq!(element_validity(ElementKind::Tet4, &thin).unwrap().validity, Validity::Valid); let below = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, -1e-30]]; assert_eq!(element_validity(ElementKind::Tet4, &below).unwrap().validity, Validity::Invalid); diff --git a/crates/mesh/src/tet/complex.rs b/crates/mesh/src/tet/complex.rs new file mode 100644 index 0000000..070ed96 --- /dev/null +++ b/crates/mesh/src/tet/complex.rs @@ -0,0 +1,1191 @@ +//! The mutable tetrahedral mesh of the quality stage: explicit vertices (doubles), tetrahedra in +//! tet4 order with neighbours, the boundary faces with their B-rep face tags, and the feature +//! edges (subsegments) with their curve. +//! +//! Every change goes through [`TetComplex::replace`]: a set of tetrahedra is replaced by another +//! set filling the same region. The replacement is checked before anything is changed — every new +//! tetrahedron positive by the exact `orient3d` and admissible by FieldCraft's validity criterion, every face of the region's outside matched once, +//! every new face either shared by two new tetrahedra, matched outside, or declared a new +//! boundary face — and refused (no change) otherwise. So every operation built on it (flips, edge +//! removal, insertion) either keeps a valid, conforming mesh or does nothing, and every operation +//! can be undone by the inverse replacement ([`Journal`]). +//! +//! Layout: per tetrahedron four vertex ids in tet4 order (`det[b − a, c − a, d − a] > 0`), four +//! neighbour ids (neighbour `k` across the face opposite vertex `k`, [`NONE`] on the boundary) and +//! four face tags (the B-rep face of a boundary face, [`NONE`] inside). Slots of removed +//! tetrahedra are recycled in a fixed order, so the same input gives the same mesh. + +use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; + +use fieldcraft_model::{normalized_jacobian, tet4_jacobian}; + +use crate::predicates::{Point3, Sign, insphere, tet_volume_sign}; +use crate::{Error, Result}; + +/// Every tetrahedron a change creates has a normalized Jacobian above this: FieldCraft's validity +/// criterion ([`fieldcraft_model::admissible_jacobian`]) with a margin of a thousand, so the +/// finished mesh passes the gate and the solvers whatever the operations did. +pub const ADMISSIBLE_MARGIN: f64 = 1e3 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN; + +/// No tetrahedron, no vertex, no tag. +pub const NONE: u32 = u32::MAX; +/// Marks a recycled tetrahedron slot. +const DEAD: u32 = u32::MAX - 1; + +/// A multiplicative hasher for small integer keys. The maps it serves are only looked up or +/// drained in a sorted order, never iterated into output. +#[derive(Default)] +pub struct IdHasher(u64); + +impl Hasher for IdHasher { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.write_u64(u64::from(b)); + } + } + + fn write_u32(&mut self, x: u32) { + self.write_u64(u64::from(x)); + } + + fn write_u64(&mut self, x: u64) { + self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); + } +} + +/// A hash map keyed by vertex ids. +pub type IdMap = HashMap>; + +pub(crate) fn corrupt(what: &str) -> Error { + Error::Failed(format!("tetrahedral mesh is inconsistent: {what}")) +} + +/// The face of `vs` opposite slot `k`, ordered so that vertex `k` is on its positive side (the +/// face's right-hand normal points into the tetrahedron). +pub fn face_toward(vs: [u32; 4], k: usize) -> [u32; 3] { + let [a, b, c, d] = vs; + match k { + 0 => [b, d, c], + 1 => [a, c, d], + 2 => [a, d, b], + _ => [a, b, c], + } +} + +/// A triangle's vertices sorted: the key of an unoriented face. +pub fn sorted3([a, b, c]: [u32; 3]) -> [u32; 3] { + let mut f = [a, b, c]; + f.sort_unstable(); + f +} + +/// A tetrahedron's vertices sorted: the key of an unoriented tetrahedron. +pub fn sorted4(vs: [u32; 4]) -> [u32; 4] { + let mut k = vs; + k.sort_unstable(); + k +} + +/// An edge's vertices sorted. +pub fn sorted2(a: u32, b: u32) -> (u32, u32) { + if a < b { (a, b) } else { (b, a) } +} + +/// The six edges of a tetrahedron, as slot pairs (the tet10 mid-side node order). +pub use crate::quality::jacobian::TET_EDGES as EDGES; + +/// What a vertex may do when the mesh is improved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VertexKind { + /// Fixed: a corner of the boundary (where feature curves meet, or an isolated input vertex + /// that must stay). + Corner, + /// On a feature curve (between B-rep faces): moves along it. + Curve(u32), + /// On a B-rep face: moves on it. + Face(u32), + /// Inside: moves freely. + Interior, +} + +/// A replacement of tetrahedra ([`TetComplex::replace`]). +#[derive(Debug, Clone, Default)] +pub struct Change { + /// The tetrahedra removed. + pub old: Vec, + /// The tetrahedra added, tet4 order. + pub new: Vec<[u32; 4]>, + /// Boundary faces the change creates, oriented into the mesh (as [`face_toward`] gives them + /// for the new tetrahedron), with their B-rep face tag. A change + /// that lists some may also remove boundary faces of the old tetrahedra; one that lists none + /// keeps the boundary exactly. + pub new_boundary: Vec<([u32; 3], u32)>, + /// Feature edges the change removes (split or collapsed; the caller registers what replaces + /// them). + pub drop_curves: Vec<(u32, u32)>, +} + +/// A removed tetrahedron: its vertices and face tags, enough to put it back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Removed { + pub vs: [u32; 4], + pub tags: [u32; 4], +} + +/// One undoable step. +#[derive(Debug, Clone)] +pub enum Step { + /// The tetrahedra removed, and those added (by vertices: slots are recycled). + Replaced { + removed: Vec, + added: Vec<[u32; 4]>, + raw: Box, + }, + Deleted { + removed: Removed, + raw: Box, + }, + Moved { + v: u32, + from: Point3, + }, + Added { + v: u32, + }, + Curve { + edge: (u32, u32), + curve: Option, + }, +} + +/// The data a change overwrites, to put it back exactly: the slots it touches (vertices, +/// neighbours, face tags), the slot count, the free list, the vertices' incidence lists and the +/// live count. +#[derive(Debug, Clone, Default)] +pub struct Raw { + slots: Vec<(u32, [u32; 4], [u32; 4], [u32; 4])>, + tets_len: usize, + free: Vec, + vtets: Vec<(u32, Vec)>, + live: usize, +} + +/// The steps since a checkpoint, newest last. +#[derive(Debug, Clone, Default)] +pub struct Journal { + pub steps: Vec, + pub recording: bool, +} + +/// The mutable tetrahedral mesh (see the module docs). +#[derive(Debug, Clone)] +pub struct TetComplex { + pts: Vec, + kinds: Vec, + tets: Vec<[u32; 4]>, + adj: Vec<[u32; 4]>, + tags: Vec<[u32; 4]>, + free: Vec, + /// The tetrahedra holding each vertex (complete even where a vertex's tetrahedra touch + /// only at it, so stars never miss a piece). + vtets: Vec>, + curves: IdMap<(u32, u32), u32>, + mark: Vec, + epoch: u32, + live: usize, + max_tets: usize, + max_points: usize, + pub journal: Journal, +} + +/// Where a walk toward a point ended ([`TetComplex::walk`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Walk { + /// In this (closed) tetrahedron. + Inside(u32), + /// The point is beyond boundary face `k` of this tetrahedron. + Outside(u32, usize), +} + +/// A boundary face given to [`TetComplex::new`]: tetrahedron, the slot of the vertex opposite the +/// face, and the B-rep face tag. +pub type BoundaryTag = (u32, usize, u32); + +impl TetComplex { + /// A complex from vertices, tetrahedra (tet4 order, positive), boundary faces and feature + /// edges. Faces shared by two tetrahedra become neighbours; every other face must be listed + /// in `boundary`. Caps: `max_points` vertices and `max_tets` tetrahedra at any time. + pub fn new( + points: &[[f64; 3]], + kinds: Vec, + tets: &[[u32; 4]], + boundary: &[BoundaryTag], + curves: &[((u32, u32), u32)], + max_points: usize, + max_tets: usize, + ) -> Result { + Self::build(points, kinds, tets, boundary, curves, max_points, max_tets, true) + } + + /// [`Self::new`] for a mesh that may have flat or inverted tetrahedra (a tetrahedralization + /// rounded to doubles, to be repaired by the operations, which only create positive ones). + pub fn with_invalid( + points: &[[f64; 3]], + kinds: Vec, + tets: &[[u32; 4]], + boundary: &[BoundaryTag], + curves: &[((u32, u32), u32)], + max_points: usize, + max_tets: usize, + ) -> Result { + Self::build(points, kinds, tets, boundary, curves, max_points, max_tets, false) + } + + #[allow(clippy::too_many_arguments)] + fn build( + points: &[[f64; 3]], + kinds: Vec, + tets: &[[u32; 4]], + boundary: &[BoundaryTag], + curves: &[((u32, u32), u32)], + max_points: usize, + max_tets: usize, + strict: bool, + ) -> Result { + if points.len() > max_points || points.len() >= NONE as usize - 1 { + return Err(Error::Limit(format!("{} vertices; the limit is {max_points}", points.len()))); + } + if tets.len() > max_tets || tets.len() >= DEAD as usize { + return Err(Error::Limit(format!("{} tetrahedra; the limit is {max_tets}", tets.len()))); + } + if kinds.len() != points.len() { + return Err(Error::Invalid("one vertex kind per point".into())); + } + let pts = points.iter().map(|p| Point3::try_from(*p)).collect::>>()?; + let mut cx = TetComplex { + vtets: vec![Vec::new(); pts.len()], + pts, + kinds, + tets: Vec::with_capacity(tets.len()), + adj: vec![[NONE; 4]; tets.len()], + tags: vec![[NONE; 4]; tets.len()], + free: Vec::new(), + curves: IdMap::default(), + mark: vec![0; tets.len()], + epoch: 0, + live: tets.len(), + max_tets, + max_points, + journal: Journal::default(), + }; + let n = cx.pts.len() as u32; + let mut faces: IdMap<[u32; 3], (u32, usize)> = IdMap::default(); + for (t, vs) in tets.iter().enumerate() { + if vs.iter().any(|&v| v >= n) || (0..4).any(|i| (i + 1..4).any(|j| vs[i] == vs[j])) { + return Err(Error::Invalid(format!("tetrahedron {t} has a bad vertex list {vs:?}"))); + } + if strict && cx.orient(*vs)? != Sign::Positive { + return Err(Error::Invalid(format!("tetrahedron {t} is not positive"))); + } + cx.tets.push(*vs); + cx.link(t as u32, *vs); + for k in 0..4 { + let key = sorted3(face_toward(*vs, k)); + match faces.remove(&key) { + Some((u, j)) => { + cx.set_adj(t as u32, k, u)?; + cx.set_adj(u, j, t as u32)?; + } + None => { + faces.insert(key, (t as u32, k)); + } + } + } + } + for &(t, k, tag) in boundary { + let vs = cx.tet(t)?; + if k > 3 || cx.adj(t)?.get(k).copied() != Some(NONE) { + return Err(Error::Invalid(format!("boundary face {k} of tetrahedron {t} is not on the boundary"))); + } + faces.remove(&sorted3(face_toward(vs, k))); + if let Some(s) = cx.tags.get_mut(t as usize).and_then(|f| f.get_mut(k)) { + *s = tag; + } + } + if !faces.is_empty() { + return Err(Error::Invalid(format!("{} faces have one tetrahedron and no boundary tag", faces.len()))); + } + for &(e, c) in curves { + cx.curves.insert(sorted2(e.0, e.1), c); + } + Ok(cx) + } + + // ---- access ---- + + pub fn point_count(&self) -> usize { + self.pts.len() + } + + pub fn point(&self, v: u32) -> Result { + self.pts.get(v as usize).copied().ok_or_else(|| corrupt("vertex id")) + } + + pub fn xyz(&self, v: u32) -> Result<[f64; 3]> { + self.point(v).map(|p| p.coords()) + } + + pub fn coords(&self, vs: [u32; 4]) -> Result<[[f64; 3]; 4]> { + Ok([self.xyz(vs[0])?, self.xyz(vs[1])?, self.xyz(vs[2])?, self.xyz(vs[3])?]) + } + + pub fn kind(&self, v: u32) -> VertexKind { + self.kinds.get(v as usize).copied().unwrap_or(VertexKind::Corner) + } + + pub fn set_kind(&mut self, v: u32, k: VertexKind) { + if let Some(s) = self.kinds.get_mut(v as usize) { + *s = k; + } + } + + pub fn slots(&self) -> usize { + self.tets.len() + } + + pub fn live_count(&self) -> usize { + self.live + } + + pub fn max_tets(&self) -> usize { + self.max_tets + } + + pub fn max_points(&self) -> usize { + self.max_points + } + + pub fn is_live(&self, t: u32) -> bool { + self.tets.get(t as usize).is_some_and(|vs| vs[0] != DEAD) + } + + pub fn tet(&self, t: u32) -> Result<[u32; 4]> { + self.tets.get(t as usize).copied().filter(|vs| vs[0] != DEAD).ok_or_else(|| corrupt("tetrahedron id")) + } + + pub fn adj(&self, t: u32) -> Result<[u32; 4]> { + self.adj.get(t as usize).copied().ok_or_else(|| corrupt("tetrahedron id")) + } + + pub fn tags(&self, t: u32) -> Result<[u32; 4]> { + self.tags.get(t as usize).copied().ok_or_else(|| corrupt("tetrahedron id")) + } + + pub fn live_tets(&self) -> impl Iterator + '_ { + self.tets.iter().enumerate().filter(|(_, vs)| vs[0] != DEAD).map(|(t, vs)| (t as u32, *vs)) + } + + /// Whether a vertex is still used by a tetrahedron. + pub fn is_used(&self, v: u32) -> bool { + self.vtets.get(v as usize).is_some_and(|l| !l.is_empty()) + } + + /// The curve of a feature edge. + pub fn curve(&self, a: u32, b: u32) -> Option { + self.curves.get(&sorted2(a, b)).copied() + } + + pub fn curve_edges(&self) -> Vec<((u32, u32), u32)> { + let mut e: Vec<_> = self.curves.iter().map(|(k, v)| (*k, *v)).collect(); + e.sort_unstable(); + e + } + + pub fn set_curve(&mut self, a: u32, b: u32, curve: Option) { + let key = sorted2(a, b); + let before = match curve { + Some(c) => self.curves.insert(key, c), + None => self.curves.remove(&key), + }; + if self.journal.recording { + self.journal.steps.push(Step::Curve { edge: key, curve: before }); + } + } + + // ---- predicates ---- + + /// The exact orientation of four vertices (positive: tet4 order). + pub fn orient(&self, [a, b, c, d]: [u32; 4]) -> Result { + Ok(tet_volume_sign(&self.point(a)?, &self.point(b)?, &self.point(c)?, &self.point(d)?)) + } + + /// The exact orientation of three vertices and a point. + pub fn orient_with(&self, [a, b, c]: [u32; 3], p: &Point3) -> Result { + Ok(tet_volume_sign(&self.point(a)?, &self.point(b)?, &self.point(c)?, p)) + } + + /// Whether `p` lies strictly inside the circumsphere of the positive tetrahedron `vs`. + pub fn in_sphere(&self, vs: [u32; 4], p: &Point3) -> Result { + let [a, b, c, d] = [self.point(vs[0])?, self.point(vs[1])?, self.point(vs[2])?, self.point(vs[3])?]; + // Shewchuk's insphere is positive inside for orient3d(a, b, c, d) > 0, which is the + // negative tet4 orientation. + Ok(insphere(&a, &b, &c, &d, p) == Sign::Negative) + } + + // ---- traversal ---- + + fn next_epoch(&mut self) { + self.epoch = self.epoch.wrapping_add(1); + if self.epoch == 0 { + self.mark.iter_mut().for_each(|m| *m = 0); + self.epoch = 1; + } + } + + fn visited(&self, t: u32) -> bool { + self.mark.get(t as usize).is_some_and(|&m| m == self.epoch) + } + + fn visit(&mut self, t: u32) { + if let Some(m) = self.mark.get_mut(t as usize) { + *m = self.epoch; + } + } + + /// A live tetrahedron containing vertex `v`. + pub fn incident(&self, v: u32) -> Result { + self.vtets.get(v as usize).and_then(|l| l.first()).copied().ok_or_else(|| corrupt("vertex without a tetrahedron")) + } + + /// The tetrahedra containing vertex `v`, by slot. + pub fn star(&mut self, v: u32) -> Result> { + let mut out = self.vtets.get(v as usize).cloned().unwrap_or_default(); + if out.is_empty() { + return Err(corrupt("vertex without a tetrahedron")); + } + out.sort_unstable(); + Ok(out) + } + + fn link(&mut self, t: u32, vs: [u32; 4]) { + for v in vs { + if let Some(l) = self.vtets.get_mut(v as usize) { + l.push(t); + } + } + } + + fn unlink(&mut self, t: u32, vs: [u32; 4]) { + for v in vs { + if let Some(l) = self.vtets.get_mut(v as usize) + && let Some(i) = l.iter().position(|&u| u == t) + { + l.swap_remove(i); + } + } + } + + /// The vertices joined to `v` by an edge, sorted. + pub fn neighbours(&mut self, v: u32) -> Result> { + let mut out = Vec::new(); + for t in self.star(v)? { + out.extend(self.tet(t)?.into_iter().filter(|&u| u != v)); + } + out.sort_unstable(); + out.dedup(); + Ok(out) + } + + /// A tetrahedron containing edge `ab`, if the edge exists. + pub fn edge_tet(&mut self, a: u32, b: u32) -> Result> { + let t = self.incident(a)?; + if self.tet(t)?.contains(&b) { + return Ok(Some(t)); + } + Ok(self.star(a)?.into_iter().find(|&t| self.tet(t).is_ok_and(|vs| vs.contains(&b)))) + } + + /// The ring of tetrahedra around edge `ab`: the link vertices `v_0, …, v_{m−1}` and the + /// tetrahedra `t_i = (a, b, v_i, v_{i+1})` (positive in that order). For an interior edge the + /// ring is closed (`t_{m−1}` joins `v_{m−1}` to `v_0`, `closed` is true); for a boundary edge + /// it is open: `m` link vertices and `m − 1` tetrahedra, from boundary face to boundary face. + pub fn edge_ring(&mut self, a: u32, b: u32) -> Result> { + let Some(t0) = self.edge_tet(a, b)? else { return Ok(None) }; + self.edge_ring_at(t0, a, b) + } + + /// [`Self::edge_ring`] from a tetrahedron `t0` known to hold the edge. `None` when the + /// tetrahedra around the edge do not form one fan (an edge pinched between two groups), where + /// no operation around the edge applies. + pub fn edge_ring_at(&mut self, t0: u32, a: u32, b: u32) -> Result> { + // Orient the first tetrahedron as (a, b, c, d) positive. + let (c, d) = { + let vs = self.tet(t0)?; + let others: Vec = vs.iter().copied().filter(|&v| v != a && v != b).collect(); + let (&c, &d) = match (others.first(), others.get(1)) { + (Some(c), Some(d)) => (c, d), + _ => return Ok(None), + }; + if self.orient([a, b, c, d])? == Sign::Positive { (c, d) } else { (d, c) } + }; + let mut verts = vec![c, d]; + let mut tets = vec![t0]; + let cap = self.max_tets.min(1 << 16); + // Forward: across the face (a, b, v_{i+1}) opposite v_i. + let mut closed = false; + let (mut t, mut prev, mut cur) = (t0, c, d); + loop { + let vs = self.tet(t)?; + let Some(k) = vs.iter().position(|&v| v == prev) else { return Ok(None) }; + let n = self.adj(t)?.get(k).copied().unwrap_or(NONE); + if n == NONE { + break; + } + if n == t0 { + closed = true; + break; + } + let nv = self.tet(n)?; + let Some(next) = nv.iter().copied().find(|&v| v != a && v != b && v != cur) else { return Ok(None) }; + tets.push(n); + if next != c { + verts.push(next); + } + prev = cur; + cur = next; + t = n; + if tets.len() > cap { + return Ok(None); + } + } + if closed { + // The last tetrahedron joins the last link vertex back to v_0. + if verts.len() != tets.len() { + return Ok(None); + } + let mut ring = EdgeRing { a, b, verts, tets, closed: true }; + self.orient_ring(&mut ring)?; + return Ok(Some(ring)); + } + // Backward from t0: across the face (a, b, v_0) opposite v_1. + let (mut t, mut prev, mut cur) = (t0, d, c); + loop { + let vs = self.tet(t)?; + let Some(k) = vs.iter().position(|&v| v == prev) else { return Ok(None) }; + let n = self.adj(t)?.get(k).copied().unwrap_or(NONE); + if n == NONE { + break; + } + let nv = self.tet(n)?; + let Some(next) = nv.iter().copied().find(|&v| v != a && v != b && v != cur) else { return Ok(None) }; + tets.insert(0, n); + verts.insert(0, next); + prev = cur; + cur = next; + t = n; + if tets.len() > cap { + return Ok(None); + } + } + let mut ring = EdgeRing { a, b, verts, tets, closed: false }; + self.orient_ring(&mut ring)?; + Ok(Some(ring)) + } + + /// Make the ring run so that `(a, b, v_i, v_{i+1})` is positive, judged by its first + /// tetrahedron that is not flat (the first one may be flat or inverted in a mesh being + /// repaired). + fn orient_ring(&self, ring: &mut EdgeRing) -> Result<()> { + let m = ring.verts.len(); + for i in 0..ring.tets.len() { + let (Some(&p), Some(&q)) = (ring.verts.get(i), ring.verts.get((i + 1) % m)) else { break }; + match self.orient([ring.a, ring.b, p, q])? { + Sign::Positive => return Ok(()), + Sign::Negative => { + ring.verts.reverse(); + if ring.closed { + // t_i joined v_i and v_{i+1}; reversed, the wrap tetrahedron stays last. + let last = ring.tets.pop(); + ring.tets.reverse(); + ring.tets.extend(last); + } else { + ring.tets.reverse(); + } + return Ok(()); + } + Sign::Zero => {} + } + } + Ok(()) + } + + /// Walk from tetrahedron `start` toward `p` (visibility walk, faces tried in a fixed + /// pseudo-random order so it cannot cycle forever), at most `cap` steps. + pub fn walk(&self, start: u32, p: &Point3, cap: usize) -> Result> { + let mut t = start; + let mut rng: u32 = 0x9E37_79B9; + for _ in 0..cap { + let vs = self.tet(t)?; + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + let first = (rng % 4) as usize; + let mut moved = false; + for i in 0..4 { + let k = (first + i) % 4; + if self.orient_with(face_toward(vs, k), p)? == Sign::Negative { + let n = self.adj(t)?.get(k).copied().unwrap_or(NONE); + if n == NONE { + return Ok(Some(Walk::Outside(t, k))); + } + t = n; + moved = true; + break; + } + } + if !moved { + return Ok(Some(Walk::Inside(t))); + } + } + Ok(None) + } + + // ---- mutation ---- + + fn set_adj(&mut self, t: u32, k: usize, n: u32) -> Result<()> { + let slot = self.adj.get_mut(t as usize).and_then(|a| a.get_mut(k)).ok_or_else(|| corrupt("neighbour slot"))?; + *slot = n; + Ok(()) + } + + /// Add a vertex (no tetrahedron uses it yet). + pub fn add_point(&mut self, p: Point3, kind: VertexKind) -> Result { + if self.pts.len() >= self.max_points { + return Err(Error::Limit(format!("{} vertices; the limit is {}", self.pts.len(), self.max_points))); + } + let v = self.pts.len() as u32; + self.pts.push(p); + self.kinds.push(kind); + self.vtets.push(Vec::new()); + if self.journal.recording { + self.journal.steps.push(Step::Added { v }); + } + Ok(v) + } + + /// The data a change of `slots` and of the incidence of `verts` would overwrite. + fn snapshot(&self, slots: &[u32], verts: &[u32]) -> Raw { + let mut ids: Vec = slots.to_vec(); + ids.sort_unstable(); + ids.dedup(); + let mut vs: Vec = verts.to_vec(); + vs.sort_unstable(); + vs.dedup(); + Raw { + slots: ids + .into_iter() + .filter_map(|t| { + let i = t as usize; + Some((t, *self.tets.get(i)?, *self.adj.get(i)?, *self.tags.get(i)?)) + }) + .collect(), + tets_len: self.tets.len(), + free: self.free.clone(), + vtets: vs.into_iter().filter_map(|v| Some((v, self.vtets.get(v as usize)?.clone()))).collect(), + live: self.live, + } + } + + /// Put back what [`Self::snapshot`] saved. + fn restore(&mut self, raw: Raw) { + for (v, l) in raw.vtets { + if let Some(s) = self.vtets.get_mut(v as usize) { + *s = l; + } + } + for (t, vs, adj, tags) in raw.slots { + let i = t as usize; + if let (Some(a), Some(b), Some(c)) = (self.tets.get_mut(i), self.adj.get_mut(i), self.tags.get_mut(i)) { + (*a, *b, *c) = (vs, adj, tags); + } + } + self.tets.truncate(raw.tets_len); + self.adj.truncate(raw.tets_len); + self.tags.truncate(raw.tets_len); + self.mark.truncate(raw.tets_len); + self.free = raw.free; + self.live = raw.live; + } + + /// Remove the last vertex added, when no tetrahedron uses it (an insertion that failed). + pub fn drop_last_point(&mut self, v: u32) -> Result<()> { + if v as usize + 1 == self.pts.len() && !self.is_used(v) { + self.pts.pop(); + self.kinds.pop(); + self.vtets.pop(); + return Ok(()); + } + Err(corrupt("dropping a vertex in use")) + } + + /// Move a vertex. The caller has checked that every tetrahedron around it stays positive + /// ([`Self::move_is_valid`]). + pub fn move_point(&mut self, v: u32, p: Point3) -> Result<()> { + let slot = self.pts.get_mut(v as usize).ok_or_else(|| corrupt("vertex id"))?; + let from = *slot; + *slot = p; + if self.journal.recording { + self.journal.steps.push(Step::Moved { v, from }); + } + Ok(()) + } + + /// Whether every tetrahedron of `star` (around `v`) stays positive and admissible with `v` + /// at `p`. + pub fn move_is_valid(&self, v: u32, p: &Point3, star: &[u32]) -> Result { + for &t in star { + let vs = self.tet(t)?; + let k = vs.iter().position(|&u| u == v).ok_or_else(|| corrupt("star"))?; + if self.orient_with(face_toward(vs, k), p)? != Sign::Positive { + return Ok(false); + } + let mut x = self.coords(vs)?; + if let Some(c) = x.get_mut(k) { + *c = p.coords(); + } + if !(normalized_jacobian(&tet4_jacobian(&x)) > ADMISSIBLE_MARGIN) { + return Ok(false); + } + } + Ok(true) + } + + fn alloc(&mut self, vs: [u32; 4]) -> Result { + if let Some(t) = self.free.pop() { + let slot = self.tets.get_mut(t as usize).ok_or_else(|| corrupt("free slot"))?; + *slot = vs; + if let Some(a) = self.adj.get_mut(t as usize) { + *a = [NONE; 4]; + } + if let Some(a) = self.tags.get_mut(t as usize) { + *a = [NONE; 4]; + } + self.link(t, vs); + return Ok(t); + } + if self.tets.len() >= self.max_tets || self.tets.len() >= DEAD as usize - 1 { + return Err(Error::Limit(format!("more than {} tetrahedra", self.max_tets))); + } + self.tets.push(vs); + self.adj.push([NONE; 4]); + self.tags.push([NONE; 4]); + self.mark.push(0); + let t = (self.tets.len() - 1) as u32; + self.link(t, vs); + Ok(t) + } + + /// Replace tetrahedra (see the module docs). `Ok(None)` when the change is not a valid + /// replacement; nothing is changed then. `Ok(Some(ids))`: the new tetrahedra's ids, in the + /// order of `change.new`. + pub fn replace(&mut self, change: &Change) -> Result>> { + let old = &change.old; + if old.is_empty() || change.new.is_empty() { + return Ok(None); + } + let n_pts = self.pts.len() as u32; + // Old region: its outside faces, keyed by sorted vertices. + self.next_epoch(); + for &t in old { + if !self.is_live(t) || self.visited(t) { + return Ok(None); + } + self.visit(t); + } + // key -> (neighbour outside or NONE, the neighbour's slot pointing back, boundary tag, + // the face as the old tetrahedron saw it) + let mut outside: IdMap<[u32; 3], (u32, usize, u32, [u32; 3])> = IdMap::default(); + let mut old_edges: Vec<(u32, u32)> = Vec::new(); + for &t in old { + let vs = self.tet(t)?; + let ns = self.adj(t)?; + let tg = self.tags(t)?; + for &(i, j) in &EDGES { + old_edges.push(sorted2(vs[i], vs[j])); + } + for k in 0..4 { + let n = ns[k]; + if n != NONE && self.visited(n) { + continue; + } + let key = sorted3(face_toward(vs, k)); + let back = if n == NONE { + 0 + } else { + let nv = self.tet(n)?; + let nn = self.adj(n)?; + (0..4).find(|&j| nn[j] == t && sorted3(face_toward(nv, j)) == key).ok_or_else(|| corrupt("neighbour does not point back"))? + }; + if outside.insert(key, (n, back, tg[k], face_toward(vs, k))).is_some() { + return Ok(None); + } + } + } + let neighbours: Vec = outside.values().map(|e| e.0).filter(|&n| n != NONE).collect(); + // New tetrahedra: positive, distinct vertices. + for vs in &change.new { + if vs.iter().any(|&v| v >= n_pts) || (0..4).any(|i| (i + 1..4).any(|j| vs[i] == vs[j])) { + return Ok(None); + } + if self.orient(*vs)? != Sign::Positive || !(normalized_jacobian(&tet4_jacobian(&self.coords(*vs)?)) > ADMISSIBLE_MARGIN) { + return Ok(None); + } + } + if self.live - old.len() + change.new.len() > self.max_tets { + return Err(Error::Limit(format!("more than {} tetrahedra", self.max_tets))); + } + // Match the new faces. + let mut inner: IdMap<[u32; 3], (usize, usize)> = IdMap::default(); + let mut links: Vec<(usize, usize, Link)> = Vec::with_capacity(change.new.len() * 4); + let declared: IdMap<[u32; 3], ([u32; 3], u32)> = change.new_boundary.iter().map(|(f, tag)| (sorted3(*f), (*f, *tag))).collect(); + if declared.len() != change.new_boundary.len() { + return Ok(None); + } + let mut used_declared = 0usize; + for (i, vs) in change.new.iter().enumerate() { + for k in 0..4 { + let key = sorted3(face_toward(*vs, k)); + if let Some((j, l)) = inner.remove(&key) { + // Two tetrahedra on opposite sides of their shared face. + let other = change.new.get(j).map(|u| face_toward(*u, l)).unwrap_or([NONE; 3]); + if !same_cycle(face_toward(*vs, k), [other[0], other[2], other[1]]) { + return Ok(None); + } + links.push((i, k, Link::New(j))); + links.push((j, l, Link::New(i))); + continue; + } + inner.insert(key, (i, k)); + } + } + let mut pending: Vec<([u32; 3], (usize, usize))> = inner.into_iter().collect(); + pending.sort_unstable(); + for (key, (i, k)) in pending { + let face = change.new.get(i).map(|vs| face_toward(*vs, k)).unwrap_or([NONE; 3]); + let declared_face = declared.get(&key).copied(); + used_declared += usize::from(declared_face.is_some()); + if let Some((n, back, tag, seen)) = outside.remove(&key) { + // The new tetrahedron lies on the side of the face the old one did. + if !same_cycle(face, seen) { + return Ok(None); + } + links.push((i, k, if n == NONE { Link::Boundary(declared_face.map_or(tag, |d| d.1)) } else { Link::Old(n, back) })); + } else if let Some((oriented, tag)) = declared_face { + // A new boundary face, oriented as declared (into the mesh). + if !same_cycle(face, oriented) { + return Ok(None); + } + links.push((i, k, Link::Boundary(tag))); + } else { + return Ok(None); + } + } + if used_declared != declared.len() { + return Ok(None); + } + // What is left outside must be boundary faces the change removes. + if outside.values().any(|(n, _, _, _)| *n != NONE) || (!outside.is_empty() && change.new_boundary.is_empty()) { + return Ok(None); + } + // Feature edges strictly inside the old region must survive (or be the split one). + if !self.curves.is_empty() { + let mut kept: Vec<(u32, u32)> = change.new.iter().flat_map(|vs| EDGES.iter().map(move |&(i, j)| sorted2(vs[i], vs[j]))).collect(); + kept.sort_unstable(); + for e in &old_edges { + if self.curves.contains_key(e) && !change.drop_curves.iter().any(|&(a, b)| sorted2(a, b) == *e) && kept.binary_search(e).is_err() { + return Ok(None); + } + } + } + // A vertex the change removes (in no new tetrahedron) must have every tetrahedron around + // it in the old region; otherwise a tetrahedron outside would keep a vertex whose faces + // were taken away. + let mut kept_vertices: Vec = change.new.iter().flatten().copied().collect(); + kept_vertices.sort_unstable(); + kept_vertices.dedup(); + let mut gone: Vec = Vec::new(); + for &t in old { + for v in self.tet(t)? { + if kept_vertices.binary_search(&v).is_err() && !gone.contains(&v) { + gone.push(v); + } + } + } + if !gone.is_empty() { + let region: IdMap = old.iter().map(|&t| (t, ())).collect(); + for v in gone { + for t in self.star(v)? { + if !region.contains_key(&t) { + return Ok(None); + } + } + } + } + // Commit (saving what it overwrites when the journal records). + let raw = if self.journal.recording { + let mut slots: Vec = old.clone(); + slots.extend(neighbours.iter().copied()); + // Slots the allocations below may take from the free list. + slots.extend(self.free.iter().rev().take(change.new.len()).copied()); + let mut verts: Vec = change.new.iter().flatten().copied().collect(); + for &t in old { + verts.extend(self.tet(t)?); + } + Some(Box::new(self.snapshot(&slots, &verts))) + } else { + None + }; + let mut removed = Vec::with_capacity(old.len()); + for &t in old { + let vs = self.tet(t)?; + removed.push(Removed { vs, tags: self.tags(t)? }); + self.unlink(t, vs); + if let Some(s) = self.tets.get_mut(t as usize) { + *s = [DEAD; 4]; + } + } + // Recycle in a fixed order: the lowest slots are handed out first. + let mut freed: Vec = old.clone(); + freed.sort_unstable_by(|a, b| b.cmp(a)); + self.free.extend(freed); + let mut ids = Vec::with_capacity(change.new.len()); + for vs in &change.new { + ids.push(self.alloc(*vs)?); + } + for (i, k, link) in links { + let t = *ids.get(i).ok_or_else(|| corrupt("new tetrahedron"))?; + match link { + Link::New(j) => { + let u = *ids.get(j).ok_or_else(|| corrupt("new tetrahedron"))?; + self.set_adj(t, k, u)?; + } + Link::Old(n, back) => { + self.set_adj(t, k, n)?; + self.set_adj(n, back, t)?; + } + Link::Boundary(tag) => { + self.set_adj(t, k, NONE)?; + if let Some(s) = self.tags.get_mut(t as usize).and_then(|f| f.get_mut(k)) { + *s = tag; + } + } + } + } + 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() }); + } + Ok(Some(ids)) + } + + /// Delete a tetrahedron that has exactly two boundary faces with the same tag meeting at an + /// edge that is not a feature edge: its two other faces become boundary faces with that tag + /// (the surface is retriangulated across the other diagonal of the quadrilateral). Only for a + /// flat tetrahedron, whose removal does not change the domain; the caller decides that. + /// Returns whether it was deleted. + pub fn delete_boundary_tet(&mut self, t: u32) -> Result { + if !self.is_live(t) { + return Ok(false); + } + let vs = self.tet(t)?; + let ns = self.adj(t)?; + let tags = self.tags(t)?; + let bnd: Vec = (0..4).filter(|&k| ns[k] == NONE).collect(); + let &[i, j] = bnd.as_slice() else { return Ok(false) }; + let tag = tags[i]; + if tags[j] != tag || tag == NONE { + return Ok(false); + } + // The two boundary faces share the edge of the two other vertices. + let inner: Vec = (0..4).filter(|&k| k != i && k != j).collect(); + let &[k, l] = inner.as_slice() else { return Ok(false) }; + if self.curve(vs[k], vs[l]).is_some() { + return Ok(false); + } + 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)?; + let back = (0..4).find(|&x| nn[x] == t).ok_or_else(|| corrupt("neighbour does not point back"))?; + self.set_adj(n, back, NONE)?; + if let Some(s) = self.tags.get_mut(n as usize).and_then(|f| f.get_mut(back)) { + *s = tag; + } + } + self.unlink(t, vs); + if let Some(s) = self.tets.get_mut(t as usize) { + *s = [DEAD; 4]; + } + self.free.push(t); + self.live -= 1; + if self.journal.recording { + self.journal.steps.push(Step::Deleted { removed: Removed { vs, tags }, raw: raw.unwrap_or_default() }); + } + Ok(true) + } + + /// Check the data structure: neighbours point back across the same face (seen from opposite + /// sides), boundary faces have no neighbour, every used vertex's tetrahedron holds it, the + /// live count is right. For tests and diagnostics. + pub fn validate(&self) -> Result<()> { + let mut live = 0; + for (t, vs) in self.live_tets() { + live += 1; + if self.orient(vs)? != Sign::Positive { + return Err(corrupt(&format!("tetrahedron {t} is not positive"))); + } + let ns = self.adj(t)?; + for k in 0..4 { + let n = ns[k]; + if n == NONE { + continue; + } + let nv = self.tet(n).map_err(|_| corrupt(&format!("tetrahedron {t} has a dead neighbour {n}")))?; + let nn = self.adj(n)?; + let f = face_toward(vs, k); + let Some(j) = (0..4).find(|&j| nn[j] == t) else { return Err(corrupt(&format!("neighbour {n} of {t} does not point back"))) }; + let g = face_toward(nv, j); + if !same_cycle(f, [g[0], g[2], g[1]]) { + return Err(corrupt(&format!("tetrahedra {t} and {n} do not share face {f:?} oppositely ({g:?})"))); + } + } + } + if live != self.live { + return Err(corrupt(&format!("live count {} but {live} live tetrahedra", self.live))); + } + let mut held = 0usize; + for (v, l) in self.vtets.iter().enumerate() { + for &t in l { + if !self.tet(t).is_ok_and(|vs| vs.contains(&(v as u32))) { + return Err(corrupt(&format!("vertex {v} lists tetrahedron {t} that does not hold it"))); + } + } + held += l.len(); + } + if held != 4 * live { + return Err(corrupt("vertex incidence does not match the tetrahedra")); + } + Ok(()) + } + + /// Undo every step recorded since the journal had `len` steps. + pub fn rollback(&mut self, len: usize) -> Result<()> { + let recording = self.journal.recording; + self.journal.recording = false; + while self.journal.steps.len() > len { + let Some(step) = self.journal.steps.pop() else { break }; + match step { + Step::Replaced { raw, .. } | Step::Deleted { raw, .. } => self.restore(*raw), + Step::Moved { v, from } => { + if let Some(s) = self.pts.get_mut(v as usize) { + *s = from; + } + } + Step::Added { v } => { + if v as usize + 1 == self.pts.len() { + self.pts.pop(); + self.kinds.pop(); + self.vtets.pop(); + } + } + Step::Curve { edge, curve } => { + match curve { + Some(c) => self.curves.insert(edge, c), + None => self.curves.remove(&edge), + }; + } + } + } + self.journal.recording = recording; + Ok(()) + } + + /// The live mesh, compacted: vertices still used (renumbered in order), tetrahedra in slot + /// order, boundary faces `(tet, opposite slot, tag)`, and the old id of each vertex. + pub fn compact(&self) -> Result { + let mut id = vec![NONE; self.pts.len()]; + let mut points = Vec::new(); + let mut source = Vec::new(); + for (v, p) in self.pts.iter().enumerate() { + if self.is_used(v as u32) { + if let Some(s) = id.get_mut(v) { + *s = points.len() as u32; + } + points.push(p.coords()); + source.push(v as u32); + } + } + let mut tets = Vec::with_capacity(self.live); + let mut boundary = Vec::new(); + for (t, vs) in self.live_tets() { + let mapped = vs.map(|v| id.get(v as usize).copied().unwrap_or(NONE)); + if mapped.contains(&NONE) { + return Err(corrupt("a tetrahedron uses a removed vertex")); + } + let ns = self.adj(t)?; + let tg = self.tags(t)?; + for k in 0..4 { + if ns[k] == NONE { + boundary.push((tets.len() as u32, k, tg[k])); + } + } + tets.push(mapped); + } + let mut curves = Vec::new(); + for ((a, b), c) in self.curve_edges() { + let (ia, ib) = (id.get(a as usize).copied().unwrap_or(NONE), id.get(b as usize).copied().unwrap_or(NONE)); + if ia != NONE && ib != NONE { + curves.push(((ia, ib), c)); + } + } + Ok(Compacted { points, tets, boundary, curves, source }) + } +} + +/// Whether `g` is `f` with its vertices rotated (the same oriented triangle). +fn same_cycle(f: [u32; 3], g: [u32; 3]) -> bool { + g == f || g == [f[1], f[2], f[0]] || g == [f[2], f[0], f[1]] +} + +/// How a new tetrahedron's face is matched in [`TetComplex::replace`]. +#[derive(Debug, Clone, Copy)] +enum Link { + New(usize), + Old(u32, usize), + Boundary(u32), +} + +/// The ring around an edge ([`TetComplex::edge_ring`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EdgeRing { + pub a: u32, + pub b: u32, + pub verts: Vec, + pub tets: Vec, + pub closed: bool, +} + +/// A compacted mesh ([`TetComplex::compact`]). +#[derive(Debug, Clone, PartialEq)] +pub struct Compacted { + pub points: Vec<[f64; 3]>, + pub tets: Vec<[u32; 4]>, + pub boundary: Vec<(u32, usize, u32)>, + pub curves: Vec<((u32, u32), u32)>, + pub source: Vec, +} diff --git a/crates/mesh/src/tet/geometry.rs b/crates/mesh/src/tet/geometry.rs new file mode 100644 index 0000000..aa4340f --- /dev/null +++ b/crates/mesh/src/tet/geometry.rs @@ -0,0 +1,740 @@ +//! Where boundary vertices may go: projection onto the B-rep faces and the feature curves +//! between them. +//! +//! The quality stage inserts and moves boundary vertices; each one stays on its B-rep face (or +//! feature curve) by projection. [`BoundaryGeometry`] answers the projection. Two sources: +//! - [`AnalyticSurface`]: the exact surface of a face when the geometry kernel knows it (plane, +//! cylinder, cone, sphere, torus), with closed-form closest points; +//! - [`Tessellation`]: the boundary surface's own triangles of that face, the fallback for +//! free-form faces (the projection is then accurate to the tessellation's chordal tolerance). +//! +//! A point on a feature curve between faces `A` and `B` is projected onto the curve's polyline +//! in the tessellation, then refined by alternating projections onto `A` and `B` when both are +//! analytic (von Neumann's alternating projections converge to the intersection for surfaces +//! meeting transversally); the result is kept only when it stays close to the polyline. + +use crate::size::SizeField; +use crate::surface::BoundarySurface; + +use super::measure::{self, P, add, cross, dot, norm, scale, sub}; + +/// A face's exact surface. Axes are unit vectors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AnalyticSurface { + Plane { + normal: P, + point: P, + }, + Cylinder { + origin: P, + axis: P, + radius: f64, + }, + /// Apex, axis pointing into the cone, half-angle in radians. + Cone { + apex: P, + axis: P, + half_angle: f64, + }, + Sphere { + center: P, + radius: f64, + }, + Torus { + center: P, + axis: P, + major: f64, + minor: f64, + }, +} + +fn unit(v: P) -> Option

{ + let l = norm(v); + (l > 0.0 && l.is_finite()).then(|| scale(v, 1.0 / l)) +} + +/// `(sin x, cos x)` for `|x| ≤ π` from their Taylor series (to 20 terms, accurate to rounding), +/// so the result is the same on every platform (the C library's `sin` and `cos` are not +/// correctly rounded and differ between platforms). +pub fn sin_cos(x: f64) -> (f64, f64) { + let x = x.clamp(-std::f64::consts::PI, std::f64::consts::PI); + let (mut s, mut c) = (0.0, 0.0); + let (mut ts, mut tc) = (x, 1.0); + for k in 0..20 { + s += ts; + c += tc; + let n = 2.0 * k as f64; + ts *= -x * x / ((n + 2.0) * (n + 3.0)); + tc *= -x * x / ((n + 1.0) * (n + 2.0)); + } + (s, c) +} + +/// Any unit vector perpendicular to `a`. +fn perpendicular(a: P) -> P { + let t = if a[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + unit(cross(a, t)).unwrap_or([0.0, 0.0, 1.0]) +} + +impl AnalyticSurface { + /// The closest point on the (untrimmed) surface, or `None` where it is not unique (on an + /// axis) or the surface's data is not finite. + pub fn project(&self, p: P) -> Option

{ + let out = match *self { + AnalyticSurface::Plane { normal, point } => { + let n = unit(normal)?; + sub(p, scale(n, dot(sub(p, point), n))) + } + AnalyticSurface::Cylinder { origin, axis, radius } => { + let a = unit(axis)?; + let d = sub(p, origin); + let foot = add(origin, scale(a, dot(d, a))); + let r = unit(sub(p, foot))?; + add(foot, scale(r, radius)) + } + AnalyticSurface::Sphere { center, radius } => add(center, scale(unit(sub(p, center))?, radius)), + AnalyticSurface::Cone { apex, axis, half_angle } => { + let a = unit(axis)?; + let d = sub(p, apex); + let along = dot(d, a); + let radial = sub(d, scale(a, along)); + let r = unit(radial).unwrap_or_else(|| perpendicular(a)); + // The generator through p's half-plane: direction g = cos·a + sin·r. + let (s, c) = sin_cos(half_angle); + let g = add(scale(a, c), scale(r, s)); + let t = dot(d, g).max(0.0); + add(apex, scale(g, t)) + } + AnalyticSurface::Torus { center, axis, major, minor } => { + let a = unit(axis)?; + let d = sub(p, center); + let radial = sub(d, scale(a, dot(d, a))); + let r = unit(radial)?; + let ring = add(center, scale(r, major)); + add(ring, scale(unit(sub(p, ring))?, minor)) + } + }; + out.iter().all(|x| x.is_finite()).then_some(out) + } + + /// The unit normal (up to sign) at a point on the surface, if defined there. + pub fn normal_at(&self, q: P) -> Option

{ + let n = match *self { + AnalyticSurface::Plane { normal, .. } => normal, + AnalyticSurface::Cylinder { origin, axis, .. } => { + let a = unit(axis)?; + let d = sub(q, origin); + sub(d, scale(a, dot(d, a))) + } + AnalyticSurface::Sphere { center, .. } => sub(q, center), + AnalyticSurface::Cone { apex, axis, .. } => { + // Normal to both the generator through q and the circle through q. + let a = unit(axis)?; + let g = sub(q, apex); + let tangent = cross(a, g); + cross(g, tangent) + } + AnalyticSurface::Torus { center, axis, major, .. } => { + let a = unit(axis)?; + let d = sub(q, center); + let r = unit(sub(d, scale(a, dot(d, a))))?; + sub(q, add(center, scale(r, major))) + } + }; + unit(n) + } + + /// The smallest radius of curvature at `p` (a cone's depends on the distance to its axis). + pub fn radius_at(&self, p: P) -> f64 { + match *self { + AnalyticSurface::Cone { apex, axis, half_angle } => { + let Some(a) = unit(axis) else { return f64::INFINITY }; + let d = sub(p, apex); + let rho = norm(sub(d, scale(a, dot(d, a)))); + // Normal curvature of the circle of radius rho: cos(α)/rho. + rho / sin_cos(half_angle).1.abs().max(1e-12) + } + AnalyticSurface::Plane { .. } => f64::INFINITY, + AnalyticSurface::Cylinder { radius, .. } | AnalyticSurface::Sphere { radius, .. } => radius.abs(), + AnalyticSurface::Torus { minor, .. } => minor.abs(), + } + } +} + +/// sin 10°: faces meeting at a smaller angle are treated as tangent along their curve. +const TRANSVERSAL_SIN: f64 = 0.173_648_177_666_930_35; + +/// The projections the quality stage needs. +pub trait BoundaryGeometry { + /// The closest point to `p` on B-rep face `face`, near `p` (within a few element sizes). + fn project_face(&self, face: u32, p: P) -> Option

; + /// The closest point to `p` on the feature curve between faces `faces` (equal for a sharp + /// edge inside one face). + fn project_curve(&self, faces: [u32; 2], p: P) -> Option

; +} + +/// A bounding-box tree over the boundary's triangles (or its feature segments) for +/// closest-point queries, filtered by tag. +#[derive(Debug, Clone)] +struct Tree { + nodes: Vec, + items: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct Node { + lo: P, + hi: P, + /// Children (left; right is left + 1) or a leaf over `items[start..end]`. + left: Option, + start: usize, + end: usize, +} + +const LEAF: usize = 6; + +impl Tree { + fn build(boxes: &[(P, P)]) -> Tree { + let mut items: Vec = (0..boxes.len() as u32).collect(); + let empty = Node { lo: [0.0; 3], hi: [0.0; 3], left: None, start: 0, end: 0 }; + let mut nodes = vec![empty]; + let mut work = vec![(0usize, 0usize, boxes.len())]; + while let Some((ni, start, end)) = work.pop() { + let Some(slice) = items.get_mut(start..end) else { continue }; + let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); + for &i in slice.iter() { + if let Some((a, b)) = boxes.get(i as usize) { + for k in 0..3 { + lo[k] = lo[k].min(a[k]); + hi[k] = hi[k].max(b[k]); + } + } + } + let mut node = Node { lo, hi, left: None, start, end }; + if end - start > LEAF { + let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]; + let axis = if ext[0] >= ext[1] && ext[0] >= ext[2] { + 0 + } else if ext[1] >= ext[2] { + 1 + } else { + 2 + }; + let key = |i: &u32| boxes.get(*i as usize).map_or(0.0, |(a, b)| a[axis] + b[axis]); + let mid = (end - start) / 2; + slice.select_nth_unstable_by(mid, |a, b| key(a).total_cmp(&key(b)).then(a.cmp(b))); + let left = nodes.len(); + nodes.push(empty); + nodes.push(empty); + node.left = Some(left); + work.push((left, start, start + mid)); + work.push((left + 1, start + mid, end)); + } + if let Some(n) = nodes.get_mut(ni) { + *n = node; + } + } + Tree { nodes, items } + } + + /// The item nearest to `p` among those `accept` takes, by `distance` (squared, with the + /// point and a fourth value carried along), searching boxes closer than the best so far. + fn nearest4(&self, p: P, accept: &dyn Fn(u32) -> bool, distance: &dyn Fn(u32) -> (f64, [f64; 4])) -> Option<[f64; 4]> { + let box_d2 = |n: &Node| -> f64 { + (0..3) + .map(|k| { + let d = (n.lo[k] - p[k]).max(0.0).max(p[k] - n.hi[k]); + d * d + }) + .sum() + }; + let mut best: Option<(f64, [f64; 4])> = None; + let mut stack = vec![0usize]; + let mut steps = 0usize; + while let Some(ni) = stack.pop() { + steps += 1; + if steps > 4 * self.nodes.len() + 16 { + break; + } + let Some(n) = self.nodes.get(ni) else { continue }; + if best.is_some_and(|(d, _)| box_d2(n) > d) { + continue; + } + match n.left { + Some(l) => { + let (a, b) = (self.nodes.get(l), self.nodes.get(l + 1)); + let (da, db) = (a.map_or(f64::INFINITY, box_d2), b.map_or(f64::INFINITY, box_d2)); + if da <= db { + stack.push(l + 1); + stack.push(l); + } else { + stack.push(l); + stack.push(l + 1); + } + } + None => { + for &i in self.items.get(n.start..n.end).unwrap_or_default() { + if !accept(i) { + continue; + } + let (d, q) = distance(i); + if best.is_none_or(|(b, _)| d < b) { + best = Some((d, q)); + } + } + } + } + } + best.map(|(_, q)| q) + } +} + +/// The closest point on triangle `abc` (Ericson, *Real-Time Collision Detection*, §5.1.5). +pub fn closest_on_triangle(p: P, a: P, b: P, c: P) -> P { + let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a)); + let (d1, d2) = (dot(ab, ap), dot(ac, ap)); + if d1 <= 0.0 && d2 <= 0.0 { + return a; + } + let bp = sub(p, b); + let (d3, d4) = (dot(ab, bp), dot(ac, bp)); + if d3 >= 0.0 && d4 <= d3 { + return b; + } + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + let v = d1 / (d1 - d3); + return add(a, scale(ab, v)); + } + let cp = sub(p, c); + let (d5, d6) = (dot(ab, cp), dot(ac, cp)); + if d6 >= 0.0 && d5 <= d6 { + return c; + } + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + let w = d2 / (d2 - d6); + return add(a, scale(ac, w)); + } + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + let w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return add(b, scale(sub(c, b), w)); + } + let den = 1.0 / (va + vb + vc); + let (v, w) = (vb * den, vc * den); + let q = add(a, add(scale(ab, v), scale(ac, w))); + if q.iter().all(|x| x.is_finite()) { q } else { a } +} + +/// The closest point on segment `ab`. +pub fn closest_on_segment(p: P, a: P, b: P) -> P { + let ab = sub(b, a); + let l2 = dot(ab, ab); + if !(l2 > 0.0) { + return a; + } + let t = (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0); + measure::lerp(a, b, t) +} + +/// The boundary surface as projection target, optionally with exact surfaces per face. +#[derive(Debug, Clone)] +pub struct Tessellation { + points: Vec

, + triangles: Vec<[u32; 3]>, + tri_face: Vec, + tri_tree: Tree, + /// Feature segments: vertices and the (sorted) face pair. + segments: Vec<([u32; 2], [u32; 2])>, + seg_tree: Tree, + analytic: Vec>, +} + +impl Tessellation { + /// The projection target of a boundary surface; `analytic[face]` gives a face's exact + /// surface when known. + pub fn new(surface: &BoundarySurface, analytic: Vec>) -> Tessellation { + let points = surface.points().to_vec(); + let triangles = surface.triangles().to_vec(); + let tri_face = surface.tri_face().to_vec(); + let pt = |v: u32| points.get(v as usize).copied().unwrap_or([0.0; 3]); + let boxes: Vec<(P, P)> = triangles + .iter() + .map(|t| { + let (a, b, c) = (pt(t[0]), pt(t[1]), pt(t[2])); + ([0, 1, 2].map(|k| a[k].min(b[k]).min(c[k])), [0, 1, 2].map(|k| a[k].max(b[k]).max(c[k]))) + }) + .collect(); + let tri_tree = Tree::build(&boxes); + let face_of = |t: u32| tri_face.get(t as usize).copied().unwrap_or(u32::MAX); + let segments: Vec<([u32; 2], [u32; 2])> = surface + .feature_edges() + .iter() + .map(|e| { + let (fa, fb) = (face_of(e.triangles[0]), face_of(e.triangles[1])); + (e.vertices, [fa.min(fb), fa.max(fb)]) + }) + .collect(); + let seg_boxes: Vec<(P, P)> = segments + .iter() + .map(|(v, _)| { + let (a, b) = (pt(v[0]), pt(v[1])); + ([0, 1, 2].map(|k| a[k].min(b[k])), [0, 1, 2].map(|k| a[k].max(b[k]))) + }) + .collect(); + let seg_tree = Tree::build(&seg_boxes); + Tessellation { points, triangles, tri_face, tri_tree, segments, seg_tree, analytic } + } + + fn pt(&self, v: u32) -> P { + self.points.get(v as usize).copied().unwrap_or([0.0; 3]) + } + + /// The exact surface of a face, if known. + pub fn analytic(&self, face: u32) -> Option<&AnalyticSurface> { + self.analytic.get(face as usize).and_then(|a| a.as_ref()) + } + + /// The closest point on the face's triangles, and the longest edge of the triangle it is + /// on (the local scale of the tessellation). + fn on_tessellation(&self, face: u32, p: P) -> Option<(P, f64)> { + let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face); + let dist = |t: u32| { + let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); + let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); + let q = closest_on_triangle(p, a, b, c); + let d = sub(q, p); + let longest = measure::dist(a, b).max(measure::dist(b, c)).max(measure::dist(c, a)); + (dot(d, d), [q[0], q[1], q[2], longest]) + }; + self.tri_tree.nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3])) + } + + /// The closest point on the curve's polyline, and the length of the segment it is on. + fn on_polyline(&self, faces: [u32; 2], p: P) -> Option<(P, f64)> { + let key = [faces[0].min(faces[1]), faces[0].max(faces[1])]; + let accept = |s: u32| self.segments.get(s as usize).is_some_and(|(_, f)| *f == key); + let dist = |s: u32| { + let v = self.segments.get(s as usize).map_or([0; 2], |(v, _)| *v); + let (a, b) = (self.pt(v[0]), self.pt(v[1])); + let q = closest_on_segment(p, a, b); + let d = sub(q, p); + (dot(d, d), [q[0], q[1], q[2], measure::dist(a, b)]) + }; + self.seg_tree.nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3])) + } +} + +impl BoundaryGeometry for Tessellation { + fn project_face(&self, face: u32, p: P) -> Option

{ + let (near, scale_len) = self.on_tessellation(face, p)?; + match self.analytic(face) { + // The exact surface's closest point, kept when it is near the tessellation's: the + // tessellation deviates from its surface by far less than half its triangles' size, + // so a point further away is a projection onto another part of the surface (the far + // side of a cylinder) and the tessellation's point is used. + Some(s) => { + let q = s.project(p)?; + let tol = (0.5 * scale_len).max(2.0 * measure::dist(p, near)); + Some(if measure::dist(q, near) <= tol { q } else { near }) + } + None => Some(near), + } + } + + fn project_curve(&self, faces: [u32; 2], p: P) -> Option

{ + let (near, seg_len) = self.on_polyline(faces, p)?; + let (Some(a), Some(b)) = (self.analytic(faces[0]), self.analytic(faces[1])) else { return Some(near) }; + if faces[0] == faces[1] { + return Some(near); + } + // Faces meeting tangentially (a blend's edge) define their curve badly: alternating + // projections crawl along it. There the polyline lies on both faces to the + // tessellation's accuracy and is kept. + let (Some(na), Some(nb)) = (a.normal_at(near), b.normal_at(near)) else { return Some(near) }; + if norm(cross(na, nb)) < TRANSVERSAL_SIN { + return Some(near); + } + let mut q = near; + for _ in 0..40 { + let Some(qa) = a.project(q) else { return Some(near) }; + let Some(qb) = b.project(qa) else { return Some(near) }; + let step = measure::dist(qb, q); + q = qb; + if step <= 1e-13 * (1.0 + norm(q)) { + break; + } + } + // Both surfaces must agree at the result, and it must stay near the polyline. + let gap = a.project(q).map_or(f64::INFINITY, |qa| measure::dist(qa, q)); + let tol = (0.5 * seg_len).max(2.0 * measure::dist(p, near)); + if gap <= 1e-9 * (1.0 + norm(q)) && measure::dist(q, near) <= tol { Some(q) } else { Some(near) } + } +} + +impl Tessellation { + /// The local thickness at each vertex: the distance to the nearest triangle facing it from + /// across the solid (a triangle that does not touch the vertex and whose outward normal + /// points back against the vertex's, by more than 120°). Infinite where nothing faces it. + /// Thin walls are this thick; elements larger than it would have to be flat. + pub fn thickness(&self) -> Vec { + let n = self.points.len(); + let mut normal = vec![[0.0; 3]; n]; + let tri_normal: Vec

= self + .triangles + .iter() + .map(|t| { + let (a, b, c) = (self.pt(t[0]), self.pt(t[1]), self.pt(t[2])); + cross(sub(b, a), sub(c, a)) + }) + .collect(); + for (t, m) in self.triangles.iter().zip(&tri_normal) { + for &v in t { + if let Some(x) = normal.get_mut(v as usize) { + *x = add(*x, *m); + } + } + } + (0..n as u32) + .map(|v| { + let p = self.pt(v); + let Some(nv) = normal.get(v as usize).and_then(|m| unit(*m)) else { return f64::INFINITY }; + let accept = |t: u32| { + let Some(tri) = self.triangles.get(t as usize) else { return false }; + !tri.contains(&v) && tri_normal.get(t as usize).and_then(|m| unit(*m)).is_some_and(|m| dot(m, nv) < -0.5) + }; + let dist = |t: u32| { + let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); + let q = closest_on_triangle(p, self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); + let d = sub(q, p); + (dot(d, d), [q[0], q[1], q[2], 0.0]) + }; + // Only what lies behind the surface (inside the solid) counts. + self.tri_tree.nearest4(p, &accept, &dist).map_or(f64::INFINITY, |r| { + let q = [r[0], r[1], r[2]]; + if dot(sub(q, p), nv) < 0.0 { measure::dist(q, p) } else { f64::INFINITY } + }) + }) + .collect() + } +} + +/// The curvature of the boundary, sampled at its vertices: for each edge inside one B-rep face, +/// the turn of the normal between its two triangles over the distance between their centroids +/// (a discrete estimate of the normal curvature across the edge), or the exact surface's radius. +/// Returns the smallest radius of curvature at each surface vertex (infinite where flat). +pub fn curvature_radii(surface: &BoundarySurface, geometry: &Tessellation) -> Vec { + let pts = surface.points(); + let tris = surface.triangles(); + let faces = surface.tri_face(); + let mut radius = vec![f64::INFINITY; pts.len()]; + let pt = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]); + let centroid = |t: &[u32; 3]| scale(add(add(pt(t[0]), pt(t[1])), pt(t[2])), 1.0 / 3.0); + for (i, (t, nb)) in tris.iter().zip(surface.neighbors()).enumerate() { + let face = faces.get(i).copied().unwrap_or(u32::MAX); + if let Some(s) = geometry.analytic(face) { + for &v in t { + if let Some(r) = radius.get_mut(v as usize) { + *r = r.min(s.radius_at(pt(v))); + } + } + continue; + } + let n = measure::triangle_normal(pt(t[0]), pt(t[1]), pt(t[2])); + for (e, &j) in nb.iter().enumerate() { + let Some(u) = tris.get(j as usize) else { continue }; + if j as usize <= i || faces.get(j as usize) != Some(&face) { + continue; + } + let m = measure::triangle_normal(pt(u[0]), pt(u[1]), pt(u[2])); + // The chord between the unit normals: the turn angle to second order, and only + // correctly rounded operations (the same on every platform). + let turn = norm(sub(n, m)); + let d = measure::dist(centroid(t), centroid(u)); + if !(turn > 1e-6) || !(d > 0.0) { + continue; + } + let r = d / turn; + for v in [t[e], t[(e + 1) % 3]] { + if let Some(x) = radius.get_mut(v as usize) { + *x = x.min(r); + } + } + } + } + radius +} + +/// A size field from a global size and the boundary's curvature, graded: the size at `x` is +/// `min(global, min_i (h_i + g |x − x_i|))` over curvature samples `h_i = θ r_i` (θ radians per +/// element along a curve of radius `r_i`), stored on a background grid relaxed by fast sweeping +/// and interpolated trilinearly. +#[derive(Debug, Clone)] +pub struct GradedSize { + global: f64, + lo: P, + cell: f64, + dims: [usize; 3], + values: Vec, +} + +/// The most nodes of the background grid. +pub const MAX_GRID_NODES: usize = 1 << 21; + +impl GradedSize { + /// `samples`: points with their local size. `grading`: the largest change of size per unit + /// length. + pub fn new(global: f64, samples: &[(P, f64)], lo: P, hi: P, grading: f64) -> GradedSize { + let diag = measure::dist(lo, hi).max(f64::MIN_POSITIVE); + let finest = samples.iter().map(|s| s.1).fold(global, f64::min).max(diag * 1e-4); + let mut cell = finest.max(diag / 64.0) * 0.5; + let dims_for = |c: f64| [0, 1, 2].map(|k| (((hi[k] - lo[k]) / c).ceil().max(1.0) as usize) + 2); + let mut dims = dims_for(cell); + while dims.iter().product::() > MAX_GRID_NODES { + cell *= 1.25; + dims = dims_for(cell); + } + let lo = [lo[0] - cell, lo[1] - cell, lo[2] - cell]; + let n = dims.iter().product::(); + let mut values = vec![global; n]; + let idx = |i: usize, j: usize, k: usize| (k * dims[1] + j) * dims[0] + i; + let g = grading.max(0.0); + // Seed: each sample's cell corners. + for &(x, h) in samples { + if !(h > 0.0 && h.is_finite()) { + continue; + } + let c = [0, 1, 2].map(|k| ((x[k] - lo[k]) / cell).floor().max(0.0) as usize); + for dk in 0..2 { + for dj in 0..2 { + for di in 0..2 { + let (i, j, k) = (c[0] + di, c[1] + dj, c[2] + dk); + if i >= dims[0] || j >= dims[1] || k >= dims[2] { + continue; + } + let node = [lo[0] + i as f64 * cell, lo[1] + j as f64 * cell, lo[2] + k as f64 * cell]; + let v = h + g * measure::dist(node, x); + if let Some(s) = values.get_mut(idx(i, j, k)) { + *s = s.min(v); + } + } + } + } + } + // Fast sweeping: eight sweep orders, each relaxing against the three axis neighbours + // behind the sweep direction (and diagonals through them over passes). + if g > 0.0 { + for _ in 0..2 { + for dir in 0..8 { + let rev = [dir & 1 != 0, dir & 2 != 0, dir & 4 != 0]; + for kk in 0..dims[2] { + let k = if rev[2] { dims[2] - 1 - kk } else { kk }; + for jj in 0..dims[1] { + let j = if rev[1] { dims[1] - 1 - jj } else { jj }; + for ii in 0..dims[0] { + let i = if rev[0] { dims[0] - 1 - ii } else { ii }; + let mut best = values.get(idx(i, j, k)).copied().unwrap_or(global); + let back = |x: usize, r: bool, n: usize| -> Option { + if r { (x + 1 < n).then_some(x + 1) } else { x.checked_sub(1) } + }; + if let Some(pi) = back(i, rev[0], dims[0]) { + best = best.min(values.get(idx(pi, j, k)).copied().unwrap_or(global) + g * cell); + } + if let Some(pj) = back(j, rev[1], dims[1]) { + best = best.min(values.get(idx(i, pj, k)).copied().unwrap_or(global) + g * cell); + } + if let Some(pk) = back(k, rev[2], dims[2]) { + best = best.min(values.get(idx(i, j, pk)).copied().unwrap_or(global) + g * cell); + } + if let Some(s) = values.get_mut(idx(i, j, k)) { + *s = best; + } + } + } + } + } + } + } + GradedSize { global, lo, cell, dims, values } + } + + fn at(&self, i: usize, j: usize, k: usize) -> f64 { + let i = i.min(self.dims[0] - 1); + let j = j.min(self.dims[1] - 1); + let k = k.min(self.dims[2] - 1); + self.values.get((k * self.dims[1] + j) * self.dims[0] + i).copied().unwrap_or(self.global) + } +} + +impl SizeField for GradedSize { + fn size(&self, p: [f64; 3]) -> f64 { + let f = [0, 1, 2].map(|k| ((p[k] - self.lo[k]) / self.cell).clamp(0.0, (self.dims[k] - 1) as f64)); + if f.iter().any(|x| !x.is_finite()) { + return self.global; + } + let c = f.map(|x| (x.floor() as usize).min(usize::MAX - 1)); + let t = [f[0] - c[0] as f64, f[1] - c[1] as f64, f[2] - c[2] as f64]; + let mut h = 0.0; + for dk in 0..2 { + for dj in 0..2 { + for di in 0..2 { + let w = (if di == 1 { t[0] } else { 1.0 - t[0] }) + * (if dj == 1 { t[1] } else { 1.0 - t[1] }) + * (if dk == 1 { t[2] } else { 1.0 - t[2] }); + h += w * self.at(c[0] + di, c[1] + dj, c[2] + dk); + } + } + } + if h > 0.0 && h.is_finite() { h.min(self.global) } else { self.global } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn analytic_projections() { + let cyl = AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 2.0], radius: 3.0 }; + let q = cyl.project([1.0, 1.0, 5.0]).unwrap(); + assert!((norm([q[0], q[1], 0.0]) - 3.0).abs() < 1e-12 && (q[2] - 5.0).abs() < 1e-12); + let s = AnalyticSurface::Sphere { center: [1.0, 0.0, 0.0], radius: 2.0 }; + assert!((measure::dist(s.project([5.0, 0.0, 0.0]).unwrap(), [3.0, 0.0, 0.0])) < 1e-12); + let pl = AnalyticSurface::Plane { normal: [0.0, 0.0, 1.0], point: [0.0, 0.0, 2.0] }; + assert_eq!(pl.project([1.0, 2.0, 7.0]), Some([1.0, 2.0, 2.0])); + let t = AnalyticSurface::Torus { center: [0.0; 3], axis: [0.0, 0.0, 1.0], major: 5.0, minor: 1.0 }; + let q = t.project([7.0, 0.0, 0.0]).unwrap(); + assert!(measure::dist(q, [6.0, 0.0, 0.0]) < 1e-12); + let cone = AnalyticSurface::Cone { apex: [0.0; 3], axis: [0.0, 0.0, 1.0], half_angle: std::f64::consts::FRAC_PI_4 }; + let q = cone.project([2.0, 0.0, 0.0]).unwrap(); + assert!((q[0] - q[2]).abs() < 1e-12 && (q[0] - 1.0).abs() < 1e-12, "{q:?}"); + assert!(cyl.project([0.0, 0.0, 1.0]).is_none()); + for x in [-3.0, -1.0, 0.0, 0.3, 1.5, 3.1] { + let (s, c) = sin_cos(x); + assert!((s - f64::sin(x)).abs() < 1e-15 && (c - f64::cos(x)).abs() < 1e-15, "{x}"); + } + } + + #[test] + fn closest_points() { + let (a, b, c) = ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + assert!(measure::dist(closest_on_triangle([0.2, 0.2, 3.0], a, b, c), [0.2, 0.2, 0.0]) < 1e-15); + assert_eq!(closest_on_triangle([-1.0, -1.0, 0.0], a, b, c), a); + let q = closest_on_triangle([1.0, 1.0, 0.0], a, b, c); + assert!(measure::dist(q, [0.5, 0.5, 0.0]) < 1e-15); + assert_eq!(closest_on_segment([0.5, 2.0, 0.0], a, b), [0.5, 0.0, 0.0]); + } + + #[test] + fn graded_size_grows_away_from_samples() { + let f = GradedSize::new(10.0, &[([0.0; 3], 1.0)], [0.0; 3], [20.0, 20.0, 20.0], 0.5); + let near = f.size([0.0; 3]); + let mid = f.size([4.0, 0.0, 0.0]); + let far = f.size([20.0, 20.0, 20.0]); + assert!((1.0..1.6).contains(&near), "{near}"); + assert!((mid - 3.0).abs() < 0.8, "{mid}"); + assert_eq!(far, 10.0); + assert!(f.size([f64::NAN, 0.0, 0.0]) > 0.0); + } +} diff --git a/crates/mesh/src/tet/improve.rs b/crates/mesh/src/tet/improve.rs new file mode 100644 index 0000000..4194959 --- /dev/null +++ b/crates/mesh/src/tet/improve.rs @@ -0,0 +1,501 @@ +//! Aggressive mesh improvement: the schedule of Klingner and Shewchuk (IMR 2007, §4.2, +//! Figure 4) over smoothing, topological transformations and vertex insertion, monotone in the +//! worst quality (the minimum dihedral sine). +//! +//! Each pass smooths; if that is not enough progress, a topological pass (edge removal, +//! multi-face removal, and deletion of flat tetrahedra lying on one B-rep face) follows; if still +//! not enough, an insertion pass tries new vertices in the worst tetrahedra (a boundary face's +//! barycentre first, then the tetrahedron's, then the midpoints of its boundary edges). An +//! insertion is a composite operation: the vertex goes in with a Delaunay cavity, is smoothed, +//! and topological passes and smoothing run around it; if the worst of the tetrahedra it made +//! or changed is not better than the worst of those it removed, everything is rolled back +//! ([`super::complex::Journal`]). Progress is judged on the worst quality and seven thresholded +//! means (§4.2); the schedule stops after three passes without progress, or at its pass and +//! insertion caps (reported). +//! +//! The paper chooses the inserted vertex's cavity by an optimal graph cut (§3.4); this +//! implementation uses the Delaunay cavity followed by the same smoothing and topological +//! repair, which the paper's own composite operation relies on to make insertion pay off. + +use crate::Result; +use crate::predicates::Point3; + +use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, face_toward, sorted4}; +use super::measure::{self, P}; +use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality}; +use super::refine::{Context, MAX_CAVITY}; +use super::smooth::smooth_vertex; + +/// Settings of the schedule. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ImproveOptions { + pub max_passes: usize, + pub max_insertions: usize, + /// Tetrahedra at or above this quality are left alone by the topological and smoothing + /// passes (default sin 40°, the paper's desperation threshold: a mesh whose tetrahedra all + /// have dihedral angles between 40° and 140° needs nothing more). + pub good: f64, + /// Insertion runs every pass while the worst quality is below this (sin 30°: dihedral + /// angles between 30° and 150°). + pub target: f64, +} + +impl Default for ImproveOptions { + fn default() -> Self { + ImproveOptions { max_passes: 10, max_insertions: 50_000, good: SIN_40, target: 0.5 } + } +} + +/// What the schedule did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ImproveStats { + pub passes: usize, + pub smoothed: usize, + pub topological: usize, + pub inserted: usize, + /// The worst quality (smallest dihedral sine) after each pass. + pub worst: Vec, + pub stopped: Option, +} + +/// sin 1°, 5°, 10°, 15°, 25°, 35°, 45° (the thresholds of §4.2), as literals so they are the same +/// everywhere. +pub const SIN_DEG: [f64; 7] = [ + 0.017_452_406_437_283_51, + 0.087_155_742_747_658_17, + 0.173_648_177_666_930_33, + 0.258_819_045_102_520_74, + 0.422_618_261_740_699_44, + 0.573_576_436_351_046, + std::f64::consts::FRAC_1_SQRT_2, +]; + +/// Most tetrahedra one insertion pass tries (the worst first). +pub const MAX_INSERTION_LIST: usize = 2000; + +/// sin 40°. +pub const SIN_40: f64 = 0.642_787_609_686_539_3; + +/// The worst quality and the seven thresholded means. +fn indicators(cx: &TetComplex) -> Result<[f64; 8]> { + let mut out = [0.0; 8]; + out[0] = f64::INFINITY; + let mut n = 0usize; + 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); + } + n += 1; + } + for m in out.iter_mut().skip(1) { + *m /= n.max(1) as f64; + } + Ok(out) +} + +fn progressed(now: &[f64; 8], before: &[f64; 8]) -> bool { + now[0] > before[0] || now.iter().zip(before).skip(1).any(|(a, b)| *a >= b + 1e-4) +} + +/// Smooth every movable vertex of a tetrahedron below `good`. Returns the moves. +fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result { + let mut want = vec![false; cx.point_count()]; + 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; + } + } + } + } + let mut moved = 0; + for (v, w) in want.iter().enumerate() { + if *w && smooth_vertex(cx, ctx, v as u32)? { + moved += 1; + } + } + Ok(moved) +} + +/// Delete a tetrahedron capping two boundary faces of one B-rep face that fold by at most 20° +/// ([`ops::BOUNDARY_FLIP_COS`]): its dihedral angle at their shared edge is at least 160°, and +/// its two other faces cover the same piece of surface. +fn delete_flat(cx: &mut TetComplex, t: u32) -> Result { + let vs = cx.tet(t)?; + let (ns, tags) = (cx.adj(t)?, cx.tags(t)?); + let bnd: Vec = (0..4).filter(|&k| ns[k] == NONE).collect(); + let &[i, j] = bnd.as_slice() else { return Ok(false) }; + if tags[i] != tags[j] { + return Ok(false); + } + let normal = |k: usize| -> Result

{ + let f = face_toward(vs, k); + Ok(measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?)) + }; + // The inward normals of the two faces of such a cap point nearly the same way. + let (a, b) = (normal(i)?, normal(j)?); + if !(measure::dot(a, b) >= ops::BOUNDARY_FLIP_COS) { + return Ok(false); + } + cx.delete_boundary_tet(t) +} + +/// A flat tetrahedron spanning two boundary faces of different B-rep faces that meet at a +/// nearly flat crease (two faces tangent along their curve): its two apexes lie almost on the +/// crease, so no flip or move fixes it. Collapse an apex face vertex onto an end of the crease +/// edge ([`ops::collapse`]), which takes the tetrahedron away. +fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result { + let vs = cx.tet(t)?; + if tet_quality(cx, vs)? > 0.1 { + return Ok(false); + } + let (ns, tags) = (cx.adj(t)?, cx.tags(t)?); + let bnd: Vec = (0..4).filter(|&k| ns[k] == NONE).collect(); + let &[i, j] = bnd.as_slice() else { return Ok(false) }; + if tags[i] == tags[j] { + return Ok(false); + } + // The faces opposite i and j share the edge of the two other vertices; vs[i] and vs[j] are + // the apexes (vs[i] lies on face j, vs[j] on face i). + let edge: Vec = (0..4).filter(|&k| k != i && k != j).map(|k| vs[k]).collect(); + let (ex, longest) = { + let x = cx.coords(vs)?; + (edge.clone(), measure::edge_range(&x).1) + }; + for apex in [vs[i], vs[j]] { + if !matches!(cx.kind(apex), VertexKind::Face(_)) { + continue; + } + for &e in &ex { + if ops::collapse(cx, apex, e, 2.0 * longest, 0.0)? { + return Ok(true); + } + } + } + Ok(false) +} + +/// Try to remove each tetrahedron of `list`: its edges, then its faces. Returns the changes. +pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result { + let mut changes = 0; + for &t in list { + if !cx.is_live(t) { + continue; + } + let vs = cx.tet(t)?; + if delete_flat(cx, t)? || collapse_into_crease(cx, t)? { + changes += 1; + continue; + } + let mut gone = false; + for &(i, j) in &EDGES { + if remove_edge_at(cx, t, vs[i], vs[j])? { + gone = true; + break; + } + } + if gone { + changes += 1; + continue; + } + for k in 0..4 { + if remove_face(cx, t, k)? { + changes += 1; + break; + } + } + } + Ok(changes) +} + +fn below(cx: &TetComplex, good: f64) -> Result> { + let mut out = Vec::new(); + for (t, vs) in cx.live_tets() { + if tet_quality(cx, vs)? < good { + out.push(t); + } + } + Ok(out) +} + +/// The quality changes since journal step `len`: the worst quality of the tetrahedra removed +/// (at their coordinates before) and of those made or moved (now). +fn change_quality(cx: &mut TetComplex, len: usize) -> Result<(f64, f64)> { + let steps: Vec = cx.journal.steps.get(len..).unwrap_or_default().to_vec(); + // Earliest position of each moved vertex. + let mut first: IdMap = IdMap::default(); + for s in &steps { + if let Step::Moved { v, from } = s { + first.entry(*v).or_insert(*from); + } + } + let old_xyz = |cx: &TetComplex, v: u32| -> Result

{ first.get(&v).map_or_else(|| cx.xyz(v), |p| Ok(p.coords())) }; + // Net removed and added tetrahedra by sorted vertices. + let mut net: IdMap<[u32; 4], (i32, [u32; 4])> = IdMap::default(); + let key = sorted4; + for s in &steps { + match s { + Step::Replaced { removed, added, .. } => { + for r in removed { + net.entry(key(r.vs)).or_insert((0, r.vs)).0 -= 1; + } + for &vs in added { + net.entry(key(vs)).or_insert((0, vs)).0 += 1; + } + } + Step::Deleted { removed, .. } => net.entry(key(removed.vs)).or_insert((0, removed.vs)).0 -= 1, + _ => {} + } + } + let (mut before, mut after) = (f64::INFINITY, f64::INFINITY); + let mut keys: Vec<[u32; 4]> = net.keys().copied().collect(); + keys.sort_unstable(); + for k in &keys { + let Some(&(c, vs)) = net.get(k) else { continue }; + if c < 0 { + let x = [old_xyz(cx, vs[0])?, old_xyz(cx, vs[1])?, old_xyz(cx, vs[2])?, old_xyz(cx, vs[3])?]; + before = before.min(measure::quality(&x)); + } + } + // The tetrahedra made, and those around moved vertices. + for k in &keys { + if let Some(&(c, vs)) = net.get(k) + && c > 0 + { + after = after.min(tet_quality(cx, vs)?); + } + } + let mut moved: Vec = first.keys().copied().collect(); + moved.sort_unstable(); + for v in moved { + if !cx.is_used(v) { + continue; + } + for t in cx.star(v)? { + let vs = cx.tet(t)?; + if net.get(&key(vs)).is_some_and(|e| e.0 > 0) { + continue; + } + after = after.min(tet_quality(cx, vs)?); + let x = [old_xyz(cx, vs[0])?, old_xyz(cx, vs[1])?, old_xyz(cx, vs[2])?, old_xyz(cx, vs[3])?]; + before = before.min(measure::quality(&x)); + } + } + Ok((before, after)) +} + +/// The composite insertion of §3.5 (see the module docs). Returns whether it was kept. +fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKind, site: Site) -> Result { + let was = cx.journal.recording; + cx.journal.recording = true; + let len = cx.journal.steps.len(); + let kept = (|| -> Result { + let Some(v) = ops::insert_delaunay(cx, p, kind, site, MAX_CAVITY)? else { return Ok(false) }; + for _ in 0..4 { + smooth_vertex(cx, ctx, v)?; + if !cx.is_used(v) { + break; + } + let star = cx.star(v)?; + if topological_pass(cx, &star)? == 0 { + break; + } + } + if cx.is_used(v) { + let mut around: Vec = cx.neighbours(v)?; + around.push(v); + for u in around { + smooth_vertex(cx, ctx, u)?; + } + } + let (before, after) = change_quality(cx, len)?; + Ok(after > before && after.is_finite()) + })(); + let keep = match kept { + Ok(k) => k, + Err(e) => { + cx.rollback(len)?; + cx.journal.recording = was; + return Err(e); + } + }; + if !keep { + cx.rollback(len)?; + } + if !was { + cx.journal.steps.truncate(len); + } + cx.journal.recording = was; + Ok(keep) +} + +/// 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) -> Result { + let mut kept = 0; + for &t in list { + if kept >= budget { + break; + } + if !cx.is_live(t) { + continue; + } + let vs = cx.tet(t)?; + if tet_quality(cx, vs)? >= good { + continue; + } + let ns = cx.adj(t)?; + let tags = cx.tags(t)?; + let mut done = false; + // A tetrahedron lying on two boundary faces that meet at a nearly flat edge (two faces + // that are tangent along their curve) is flat however its vertices move: the edge needs + // a second tetrahedron. A vertex inside, off the edge's midpoint along the inward + // normal, splits the edge's dihedral angle. + let bnd: Vec = (0..4).filter(|&k| ns[k] == NONE).collect(); + if let &[i, j] = bnd.as_slice() { + let edge: Vec = (0..4).filter(|&k| k != i && k != j).map(|k| vs[k]).collect(); + if let &[a, b] = edge.as_slice() { + let n = |k: usize| -> Result

{ + let f = face_toward(vs, k); + Ok(measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?)) + }; + let inward = measure::add(n(i)?, n(j)?); + let len = measure::norm(inward); + let (pa, pb) = (cx.xyz(a)?, cx.xyz(b)?); + let l = measure::dist(pa, pb); + // Unit inward normals within 25° of each other: |n_i + n_j| > 2 cos 12.5°. + if len > 1.952 { + 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, p, VertexKind::Interior, Site::Interior(at))? + { + done = true; + } + } + } + } + // Boundary faces' barycentres. + for k in 0..4 { + if done || ns[k] != NONE || tags[k] == NONE { + continue; + } + let f = face_toward(vs, k); + let g = measure::scale(measure::add(measure::add(cx.xyz(f[0])?, cx.xyz(f[1])?), cx.xyz(f[2])?), 1.0 / 3.0); + let kind = VertexKind::Face(tags[k]); + if let Some(p) = ctx.place(kind, g) + && cx.is_live(t) + && attempt_insert(cx, ctx, p, kind, Site::Face(t, k))? + { + done = true; + } + } + // The barycentre. + 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, p, VertexKind::Interior, Site::Interior(t))? + { + done = true; + } + } + // Boundary edges' midpoints. + if !done && cx.is_live(t) && cx.tet(t)? == vs { + for &(i, j) in &EDGES { + let (a, b) = (vs[i], vs[j]); + let Some(ring) = cx.edge_ring(a, b)? else { continue }; + if ring.closed { + continue; + } + let tag = ring + .tets + .first() + .and_then(|&r| { + let rv = cx.tet(r).ok()?; + let rt = cx.tags(r).ok()?; + let rn = cx.adj(r).ok()?; + (0..4).find(|&k| rn[k] == NONE && face_toward(rv, k).contains(&a) && face_toward(rv, k).contains(&b)).map(|k| rt[k]) + }) + .unwrap_or(NONE); + let kind = match cx.curve(a, b) { + Some(c) => VertexKind::Curve(c), + None if tag != NONE => VertexKind::Face(tag), + None => continue, + }; + let m = measure::mid(cx.xyz(a)?, cx.xyz(b)?); + if let Some(p) = ctx.place(kind, m) + && attempt_insert(cx, ctx, p, kind, Site::Edge(a, b))? + { + done = true; + break; + } + if !cx.is_live(t) { + break; + } + } + } + kept += usize::from(done); + } + Ok(kept) +} + +/// The improvement schedule (see the module docs). Each pass smooths and runs a topological +/// pass over the tetrahedra below the threshold, then an insertion pass when the worst +/// tetrahedron did not improve; it stops after three passes without progress (§4.2). +pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result { + let mut stats = ImproveStats::default(); + stats.smoothed += smoothing_pass(cx, ctx, o.good)?; + let list = below(cx, o.good)?; + stats.topological += topological_pass(cx, &list)?; + let mut failed = 0; + // A pass ends where the next begins: its indicators are the next one's starting point. + let mut carried: Option<[f64; 8]> = None; + while failed < 3 { + if stats.passes >= o.max_passes { + stats.stopped = Some(format!("improvement stopped after {} passes", o.max_passes)); + break; + } + stats.passes += 1; + let before = match carried.take() { + Some(b) => b, + None => indicators(cx)?, + }; + stats.smoothed += smoothing_pass(cx, ctx, o.good)?; + let list = below(cx, o.good)?; + stats.topological += topological_pass(cx, &list)?; + // Insertion when smoothing and flips did not make enough progress (§4.2), or left a + // tetrahedron below the target. + let mid = indicators(cx)?; + if (!progressed(&mid, &before) || mid[0] < o.target) && stats.inserted < o.max_insertions { + // 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, 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 = if failed == 1 { + scored.iter().filter(|s| s.0 < SIN_40).take(MAX_INSERTION_LIST).map(|s| s.1).collect() + } else { + let n = (scored.len() * 35).div_ceil(1000).clamp(1, MAX_INSERTION_LIST); + 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)?; + } + let now = indicators(cx)?; + carried = Some(now); + stats.worst.push(now[0]); + if progressed(&now, &before) { + failed = 0; + } else { + failed += 1; + } + } + if stats.stopped.is_none() && stats.inserted >= o.max_insertions { + stats.stopped = Some(format!("improvement stopped at {} inserted vertices", o.max_insertions)); + } + Ok(stats) +} diff --git a/crates/mesh/src/tet/measure.rs b/crates/mesh/src/tet/measure.rs new file mode 100644 index 0000000..cd2a7de --- /dev/null +++ b/crates/mesh/src/tet/measure.rs @@ -0,0 +1,230 @@ +//! Tetrahedron measures for the quality stage, on floating-point coordinates. +//! +//! Decisions use only `+ − × ÷ √` (correctly rounded in IEEE 754), so they are the same on every +//! platform: the minimum sine of the dihedral angles (the improvement objective, Klingner and +//! Shewchuk 2007, §2), the circumcentre and the radius–edge ratio (the refinement criterion, +//! Shewchuk 1998). Angles in degrees are for reports only. +//! +//! Sources: J. R. Shewchuk, *What is a good linear finite element? Interpolation, conditioning, +//! anisotropy, and quality measures*, 2002 (the dihedral-angle sine formula +//! `sin θ_ij = 3 V ℓ_ij / (2 A_k A_l)` and the volume–length and radius-ratio measures); +//! B. M. Klingner, J. R. Shewchuk, *Aggressive Tetrahedral Mesh Improvement*, IMR 2007, §2. + +pub(crate) use crate::vec3::{P, add, cross, dist, dot, lerp, mid, norm, scale, sub}; + +/// Six times the signed volume (positive in tet4 order). +pub fn volume6(x: &[P; 4]) -> f64 { + let [a, b, c, d] = *x; + dot(sub(b, a), cross(sub(c, a), sub(d, a))) +} + +/// The cross product of two edges of the face opposite vertex `k` (its length is twice the +/// face's area). +fn face_cross(x: &[P; 4], k: usize) -> P { + let [a, b, c] = match k { + 0 => [x[1], x[2], x[3]], + 1 => [x[0], x[2], x[3]], + 2 => [x[0], x[1], x[3]], + _ => [x[0], x[1], x[2]], + }; + cross(sub(b, a), sub(c, a)) +} + +/// Twice the area of the face opposite vertex `k`. +fn face_area2(x: &[P; 4], k: usize) -> f64 { + norm(face_cross(x, k)) +} + +/// The edges as `(i, j, k, l)`: edge `ij`, and the two other vertices (whose opposite faces meet +/// at the edge). +const EDGE_FACES: [(usize, usize, usize, usize); 6] = [(0, 1, 2, 3), (0, 2, 1, 3), (0, 3, 1, 2), (1, 2, 0, 3), (1, 3, 0, 2), (2, 3, 0, 1)]; + +/// The sines of the six dihedral angles, signed by orientation (all negative for an inverted +/// tetrahedron), in [`EDGE_FACES`] order; zeros for a degenerate one. +pub fn dihedral_sines(x: &[P; 4]) -> [f64; 6] { + let v6 = volume6(x); + let areas = [0, 1, 2, 3].map(|k| face_area2(x, k)); + EDGE_FACES.map(|(i, j, k, l)| { + let len = dist(x[i], x[j]); + // sin θ = 3 V ℓ / (2 A_k A_l) with V = v6 / 6 and A = area2 / 2. + let den = areas[k] * areas[l]; + let s = v6 * len / den; + if s.is_finite() { s } else { 0.0 } + }) +} + +/// The cosines of the six dihedral angles (from the outward face normals). +pub fn dihedral_cosines(x: &[P; 4]) -> [f64; 6] { + let normal = |k: usize| -> P { + let f: Vec

= (0..4).filter(|&i| i != k).filter_map(|i| x.get(i).copied()).collect(); + match f.as_slice() { + [a, b, c] => { + let n = cross(sub(*b, *a), sub(*c, *a)); + // Point it away from vertex k. + let o = x.get(k).copied().unwrap_or(*a); + if dot(n, sub(o, *a)) > 0.0 { scale(n, -1.0) } else { n } + } + _ => [0.0; 3], + } + }; + let n = [0, 1, 2, 3].map(normal); + EDGE_FACES.map(|(_, _, k, l)| { + let c = -dot(n[k], n[l]) / (norm(n[k]) * norm(n[l])); + if c.is_finite() { c.clamp(-1.0, 1.0) } else { 1.0 } + }) +} + +/// 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 { + let v6 = volume6(x); + if v6 == 0.0 || !v6.is_finite() { + return 0.0; + } + let area2sq = [0, 1, 2, 3].map(|k| { + let a = face_cross(x, k); + dot(a, a) + }); + let mut pick = if v6 > 0.0 { f64::INFINITY } else { 0.0 }; + for (i, j, k, l) in EDGE_FACES { + let e = sub(x[i], x[j]); + let r = dot(e, e) / (area2sq[k] * area2sq[l]); + pick = if v6 > 0.0 { pick.min(r) } else { pick.max(r) }; + } + let q = v6 * pick.sqrt(); + if q.is_finite() { q } else { 0.0 } +} + +/// 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)); + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for (sv, cv) in s.iter().zip(c) { + let a = sv.abs().atan2(cv).to_degrees(); + lo = lo.min(a); + hi = hi.max(a); + } + (lo, hi) +} + +/// The circumcentre, or `None` for a flat tetrahedron. +pub fn circumcenter(x: &[P; 4]) -> Option

{ + let [a, b, c, d] = *x; + let (b, c, d) = (sub(b, a), sub(c, a), sub(d, a)); + let den = 2.0 * dot(b, cross(c, d)); + if den == 0.0 || !den.is_finite() { + return None; + } + let num = add(add(scale(cross(c, d), dot(b, b)), scale(cross(d, b), dot(c, c))), scale(cross(b, c), dot(d, d))); + let o = add(a, scale(num, 1.0 / den)); + o.iter().all(|v| v.is_finite()).then_some(o) +} + +/// The circumradius (infinite for a flat tetrahedron). +pub fn circumradius(x: &[P; 4]) -> f64 { + circumcenter(x).map_or(f64::INFINITY, |o| dist(o, x[0])) +} + +/// The shortest and longest edge. +pub fn edge_range(x: &[P; 4]) -> (f64, f64) { + let mut lo = f64::INFINITY; + let mut hi = 0.0_f64; + for (i, j, _, _) in EDGE_FACES { + let l = x.get(i).zip(x.get(j)).map_or(0.0, |(a, b)| dist(*a, *b)); + lo = lo.min(l); + hi = hi.max(l); + } + (lo, hi) +} + +/// Circumradius over shortest edge (√6/4 ≈ 0.612 for the regular tetrahedron). +pub fn radius_edge(x: &[P; 4]) -> f64 { + circumradius(x) / edge_range(x).0 +} + +/// Three times the inradius over the circumradius, signed (1 for the regular tetrahedron). +pub fn radius_ratio(x: &[P; 4]) -> f64 { + let v6 = volume6(x); + let area2: f64 = (0..4).map(|k| face_area2(x, k)).sum(); + // r = 3V / A_total = (v6 / 2) / (area2 / 2) = v6 / area2. + let r = v6 / area2; + let q = 3.0 * r / circumradius(x); + if q.is_finite() { q } else { 0.0 } +} + +/// The centroid. +pub fn centroid(x: &[P; 4]) -> P { + let s = x.iter().fold([0.0; 3], |acc, p| add(acc, *p)); + scale(s, 0.25) +} + +/// The circumcentre of a triangle (in its plane), or `None` when degenerate. +pub fn triangle_circumcenter(a: P, b: P, c: P) -> Option

{ + let (u, v) = (sub(b, a), sub(c, a)); + let w = cross(u, v); + let den = 2.0 * dot(w, w); + if den == 0.0 || !den.is_finite() { + return None; + } + let num = add(scale(cross(w, u), dot(v, v)), scale(cross(v, w), dot(u, u))); + let o = add(a, scale(num, 1.0 / den)); + o.iter().all(|x| x.is_finite()).then_some(o) +} + +/// The unit normal of a triangle (zero when degenerate). +pub fn triangle_normal(a: P, b: P, c: P) -> P { + let n = cross(sub(b, a), sub(c, a)); + let l = norm(n); + if l > 0.0 && l.is_finite() { scale(n, 1.0 / l) } else { [0.0; 3] } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REGULAR: [P; 4] = [[1.0, 1.0, 1.0], [1.0, -1.0, -1.0], [-1.0, 1.0, -1.0], [-1.0, -1.0, 1.0]]; + + fn positive(mut x: [P; 4]) -> [P; 4] { + if volume6(&x) < 0.0 { + x.swap(0, 1); + } + x + } + + #[test] + fn regular_tetrahedron() { + let x = positive(REGULAR); + assert!((quality(&x) - (8.0f64 / 9.0).sqrt()).abs() < 1e-12, "{}", quality(&x)); + assert!((radius_ratio(&x) - 1.0).abs() < 1e-12); + assert!((radius_edge(&x) - 6f64.sqrt() / 4.0).abs() < 1e-12); + let (lo, hi) = dihedral_range(&x); + assert!((lo - 70.528_779).abs() < 1e-5 && (hi - lo).abs() < 1e-9, "{lo} {hi}"); + let o = circumcenter(&x).unwrap(); + assert!(norm(o) < 1e-12); + } + + #[test] + fn right_corner_and_inverted() { + let x = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + assert!(volume6(&x) > 0.0); + let (lo, hi) = dihedral_range(&x); + assert!((hi - 90.0).abs() < 1e-9 && (lo - 54.735_610).abs() < 1e-5, "{lo} {hi}"); + let mut y = x; + y.swap(1, 2); + assert!(quality(&y) < 0.0); + let flat = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]; + assert_eq!(quality(&flat), 0.0); + assert!(circumcenter(&flat).is_none()); + } + + #[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]]); + assert!(quality(&x) < 0.05 && quality(&x) > 0.0, "{}", quality(&x)); + assert!(radius_ratio(&x) < 0.1); + } +} diff --git a/crates/mesh/src/tet/mod.rs b/crates/mesh/src/tet/mod.rs new file mode 100644 index 0000000..3d5614d --- /dev/null +++ b/crates/mesh/src/tet/mod.rs @@ -0,0 +1,466 @@ +//! Tetrahedral mesh quality: Delaunay refinement to a size field, ODT smoothing and aggressive +//! improvement of a constrained Delaunay tetrahedralization ([`crate::cdt`]), then optionally +//! quadratic elements with their mid-edge nodes on the geometry. +//! +//! # Pipeline ([`quality_mesh`], `docs/meshing/literature.md` Part D Step 3) +//! 1. **Boundary entities.** The CDT's boundary faces carry their B-rep face; an edge between two +//! faces of different B-rep faces, or whose faces turn by more than the feature angle, is a +//! feature edge (curve). Vertices are corners (fixed), curve vertices (move along their +//! curve), face vertices (move on their face) or interior. +//! 2. **Size field** ([`geometry::GradedSize`]): the global size, limited near curved boundary +//! to `θ r` (θ radians per element along a curve of radius `r`, from the exact surface or +//! estimated from the tessellation) and in thin walls to their thickness +//! ([`geometry::Tessellation::thickness`]), graded at a bounded rate. +//! 3. **Coarsening** ([`refine::coarsen`]): edge collapses where the boundary recovery's +//! Steiner points and the tessellation are denser than the size field. +//! 4. **Refinement interleaved with smoothing** (Tournois et al. 2009): rounds of constrained +//! Delaunay refinement ([`refine`], Shewchuk 1998, Si 2010) and ODT smoothing ([`smooth`], +//! Chen–Xu 2004, Alliez et al. 2005), boundary vertices kept on their faces by projection +//! ([`geometry`]). +//! 5. **Aggressive improvement** ([`improve`], Klingner–Shewchuk 2007): smoothing, edge +//! removal, multi-face removal and vertex insertion, monotone in the worst dihedral sine. +//! 6. **Gate and report**: every tetrahedron admissible by FieldCraft's one criterion, the volume +//! equal to what the mesh's own boundary encloses; minimum and maximum dihedral angles and +//! radius ratios ([`TetQuality`]). +//! 7. **Quadratic elements** ([`quadratic`]): mid-edge nodes, projected onto the B-rep faces and +//! curves on the boundary; an element that would not be valid keeps straight edges there. +//! +//! Every loop is bounded (insertions, tetrahedra, passes); a stage that stops early says so in +//! the report. Everything is deterministic: fixed orders, no hashing into output, and only +//! correctly rounded arithmetic in decisions. + +// The tetrahedron kernels index their four corners and six edges by slot; ranges over them are +// clearer than iterator chains (as in the fem and solver crates). +#![allow(clippy::needless_range_loop)] + +pub mod complex; +pub mod geometry; +pub mod improve; +pub mod measure; +pub mod ops; +pub mod quadratic; +pub mod refine; +pub mod smooth; + +#[cfg(test)] +mod tests; + +use fieldcraft_model::ElementKind; + +use crate::cdt::TetMesh; +use crate::predicates::Point3; +use crate::quality::{GateOptions, GateReport, Metric, Validity, element_quality, enclosed_volume, validity_gate_elements}; +use crate::surface::BoundarySurface; +use crate::{Error, Result}; + +use complex::{IdMap, NONE, TetComplex, VertexKind, face_toward, sorted2}; +use geometry::{AnalyticSurface, GradedSize, Tessellation}; +use improve::{ImproveOptions, ImproveStats}; +use refine::{Context, RefineOptions, RefineStats}; + +/// Element order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Order { + Linear, + Quadratic, +} + +/// Settings of [`quality_mesh`]. +#[derive(Debug, Clone, PartialEq)] +pub struct QualityOptions { + /// Target edge length; `None`: a twentieth of the bounding-box diagonal. + pub size: Option, + /// Radians per element along curved boundary (0: no curvature refinement). + pub curvature_angle: f64, + /// Elements across a thin wall: the size is at most the local thickness over this (0: no + /// proximity sizing). + pub proximity: f64, + /// Largest change of size per unit length. + pub grading: f64, + /// Smallest size, as a fraction of the global size. + pub min_size: f64, + /// Radius–edge bound of the refinement. + pub radius_edge: f64, + /// Interleaved refinement and ODT rounds (0: no refinement). + pub rounds: usize, + /// ODT passes per round. + pub odt_passes: usize, + /// Run the improvement schedule. + pub improve: bool, + pub improve_options: ImproveOptions, + /// An edge whose two boundary faces turn by more than this many degrees is a feature edge. + pub feature_angle: f64, + pub order: Order, + pub max_tets: usize, + pub max_points: usize, +} + +impl Default for QualityOptions { + fn default() -> Self { + QualityOptions { + size: None, + curvature_angle: std::f64::consts::PI / 8.0, + proximity: 1.0, + grading: 0.3, + min_size: 0.02, + radius_edge: 2.0, + rounds: 3, + odt_passes: 3, + improve: true, + improve_options: ImproveOptions::default(), + feature_angle: 45.0, + order: Order::Linear, + max_tets: 5_000_000, + max_points: 1_000_000, + } + } +} + +/// Quality statistics of a tetrahedral mesh (corner geometry). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct TetQuality { + pub tets: usize, + pub min_dihedral: f64, + pub max_dihedral: f64, + pub min_radius_ratio: f64, + pub mean_radius_ratio: f64, + /// Tetrahedra with a dihedral angle below 10° or above 170°. + pub slivers: usize, + /// Tetrahedra not admissible by FieldCraft's validity criterion. + pub inadmissible: usize, +} + +/// The quality of tetrahedra given by corner coordinates, with the metrics `mesh.quality` +/// reports ([`crate::quality::element_quality`]). +pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result { + let mut s = TetQuality { min_dihedral: 180.0, min_radius_ratio: 1.0, ..TetQuality::default() }; + let mut sum = 0.0; + for t in tets { + let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("quality: vertex id".into())); + let q = element_quality(ElementKind::Tet4, &[p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])?; + let m = |metric: Metric| q.get(metric).unwrap_or(f64::NAN); + let (lo, hi, rr) = (m(Metric::MinDihedral), m(Metric::MaxDihedral), m(Metric::RadiusRatio)); + s.min_dihedral = s.min_dihedral.min(lo); + s.max_dihedral = s.max_dihedral.max(hi); + s.min_radius_ratio = s.min_radius_ratio.min(rr); + sum += rr; + s.slivers += usize::from(!(lo >= 10.0 && hi <= 170.0)); + s.inadmissible += usize::from(q.validity() != Some(Validity::Valid)); + } + s.tets = tets.len(); + if tets.is_empty() { + s.min_dihedral = 0.0; + s.min_radius_ratio = 0.0; + } else { + s.mean_radius_ratio = sum / tets.len() as f64; + } + Ok(s) +} + +/// A boundary face of the output: tetrahedron, the slot of its opposite vertex, B-rep face. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TaggedFace { + pub tet: u32, + pub opposite: u8, + pub face: u32, +} + +impl TaggedFace { + /// The face's 1-based number in the model's tet4 (and tet10) face order. + pub fn element_face(&self) -> Option { + let k = usize::from(self.opposite); + let n = ElementKind::Tet4.faces().iter().position(|f| !f.contains(&k))?; + u8::try_from(n + 1).ok() + } +} + +/// What [`quality_mesh`] did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct QualityReport { + /// The constrained Delaunay tetrahedralization it started from. + pub input: TetQuality, + pub output: TetQuality, + pub refine: Vec, + pub odt_moves: usize, + /// Edges collapsed where the input was denser than the size field. + pub collapsed: usize, + /// Boundary vertices that could not be moved onto their B-rep face or curve (the move would + /// have inverted a tetrahedron); they stay on the tessellation. + pub unsnapped: usize, + pub improve: ImproveStats, + /// The global size used. + pub size: f64, + /// Volume of the mesh, and what its boundary encloses. + pub volume: f64, + pub boundary_volume: f64, + /// Quadratic elements whose curved edges had to stay straight to be valid. + pub straightened: usize, + /// Why a stage stopped early, if one did. + pub stopped: Vec, +} + +/// The quality mesh. +#[derive(Debug, Clone, PartialEq)] +pub struct QualityMesh { + /// Corner vertices, then (quadratic) mid-edge nodes. + pub points: Vec<[f64; 3]>, + /// Elements in the model's node order (4 or 10 nodes). + pub elements: Vec>, + pub kind: ElementKind, + pub boundary: Vec, + pub report: QualityReport, +} + +fn bounds(points: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) { + let mut lo = [f64::INFINITY; 3]; + let mut hi = [f64::NEG_INFINITY; 3]; + for p in points { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + (lo, hi) +} + +/// The size field of a boundary: `global` limited by curvature, graded. +pub fn size_field(surface: &BoundarySurface, geometry: &Tessellation, global: f64, o: &QualityOptions) -> GradedSize { + let (lo, hi) = bounds(surface.points()); + let floor = o.min_size * global; + let mut samples = Vec::new(); + if o.curvature_angle > 0.0 { + let radii = geometry::curvature_radii(surface, geometry); + for (p, r) in surface.points().iter().zip(radii) { + let h = (o.curvature_angle * r).max(floor); + if h < global { + samples.push((*p, h)); + } + } + } + if o.proximity > 0.0 { + // Thin walls: elements no larger than the wall's thickness over `proximity`. + for (p, t) in surface.points().iter().zip(geometry.thickness()) { + let h = (t / o.proximity).max(floor); + if h < global { + samples.push((*p, h)); + } + } + } + GradedSize::new(global, &samples, lo, hi, o.grading) +} + +/// The CDT as a [`TetComplex`] with B-rep face tags, feature curves and vertex kinds; and the +/// feature curves' face pairs. +pub fn complex_from_cdt(surface: &BoundarySurface, cdt: &TetMesh, o: &QualityOptions) -> Result<(TetComplex, Vec<[u32; 2]>)> { + let tri_face = surface.tri_face(); + let tags: Vec<(u32, usize, u32)> = + cdt.boundary.iter().map(|b| (b.tet, usize::from(b.opposite), tri_face.get(b.triangle as usize).copied().unwrap_or(NONE))).collect(); + let kinds = vec![VertexKind::Interior; cdt.points.len()]; + let mut cx = + TetComplex::with_invalid(&cdt.points, kinds, &cdt.tets, &tags, &[], o.max_points.max(cdt.points.len()), o.max_tets.max(cdt.tets.len()))?; + let feature_cos = geometry::sin_cos(o.feature_angle.clamp(0.0, 180.0).to_radians()).1; + let (table, edges) = refine::feature_curves(&cx, feature_cos)?; + for &((a, b), c) in &edges { + cx.set_curve(a, b, Some(c)); + } + // Vertex kinds from the boundary faces and curve edges at each vertex. + let mut face_tag: Vec> = vec![None; cdt.points.len()]; + let mut mixed = vec![false; cdt.points.len()]; + for (t, vs) in cx.live_tets() { + let (ns, tg) = (cx.adj(t)?, cx.tags(t)?); + for k in 0..4 { + if ns[k] != NONE { + continue; + } + for v in face_toward(vs, k) { + let i = v as usize; + match face_tag.get(i).copied().flatten() { + None => { + if let Some(s) = face_tag.get_mut(i) { + *s = Some(tg[k]); + } + } + Some(x) if x != tg[k] => { + if let Some(m) = mixed.get_mut(i) { + *m = true; + } + } + _ => {} + } + } + } + } + let mut curve_count: IdMap> = IdMap::default(); + for &((a, b), c) in &edges { + curve_count.entry(a).or_default().push(c); + curve_count.entry(b).or_default().push(c); + } + for v in 0..cdt.points.len() as u32 { + let kind = match (face_tag.get(v as usize).copied().flatten(), curve_count.get(&v).map(Vec::as_slice)) { + (None, _) => VertexKind::Interior, + (Some(tag), None) if !mixed.get(v as usize).copied().unwrap_or(true) => VertexKind::Face(tag), + (Some(_), Some(&[c1, c2])) if c1 == c2 => VertexKind::Curve(c1), + _ => VertexKind::Corner, + }; + cx.set_kind(v, kind); + } + Ok((cx, table)) +} + +/// Move every face and curve vertex onto its B-rep face or curve where that keeps the +/// tetrahedra around it positive (and, when `guarded`, their worst quality at least the smaller +/// of what it was and sin 17.5°, so a finished mesh is not spoilt). Returns the vertices that +/// could not move. +fn snap_to_geometry(cx: &mut TetComplex, ctx: &Context, guarded: bool) -> Result { + let mut stuck = 0; + for v in 0..cx.point_count() as u32 { + if !cx.is_used(v) || !matches!(cx.kind(v), VertexKind::Face(_) | VertexKind::Curve(_)) { + continue; + } + let x = cx.xyz(v)?; + let Some(p) = ctx.place(cx.kind(v), x) else { continue }; + if p.coords() == x { + continue; + } + let star = cx.star(v)?; + let mut ok = cx.move_is_valid(v, &p, &star)?; + if ok && guarded { + let before = ops::worst(cx, &star)?; + cx.move_point(v, p)?; + let after = ops::worst(cx, &star)?; + if after < before.min(0.3) { + cx.move_point(v, Point3::try_from(x)?)?; + ok = false; + } + } else if ok { + cx.move_point(v, p)?; + } + stuck += usize::from(!ok); + } + Ok(stuck) +} + +/// The volume enclosed by the boundary faces of a mesh ([`enclosed_volume`]). +fn boundary_volume(points: &[[f64; 3]], tets: &[[u32; 4]], boundary: &[TaggedFace]) -> Result { + let mut triangles = Vec::with_capacity(boundary.len()); + for b in boundary { + let t = tets.get(b.tet as usize).ok_or_else(|| Error::Failed("boundary face of no tetrahedron".into()))?; + // face_toward points into the tetrahedron; outward is its reverse. + let f = face_toward(*t, usize::from(b.opposite)); + triangles.push([f[0] as usize, f[2] as usize, f[1] as usize]); + } + enclosed_volume(points, &triangles) +} + +/// Run the quality pipeline on a constrained Delaunay tetrahedralization of `surface` (see the +/// module docs). `analytic[face]` is the exact surface of a B-rep face when known. +pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec>, o: &QualityOptions) -> Result { + let mut report = QualityReport { input: tet_quality_stats(&cdt.points, &cdt.tets)?, ..QualityReport::default() }; + let geometry = Tessellation::new(surface, analytic); + let (lo, hi) = bounds(surface.points()); + let global = match o.size { + Some(h) if h > 0.0 && h.is_finite() => h, + Some(h) => return Err(Error::Invalid(format!("size {h} must be a positive number"))), + None => measure::dist(lo, hi) / 20.0, + }; + if !(global > 0.0 && global.is_finite()) { + return Err(Error::Invalid("the boundary has no extent".into())); + } + report.size = global; + let size = size_field(surface, &geometry, global, o); + let (mut cx, curve_faces) = complex_from_cdt(surface, cdt, o)?; + // Refinement and improvement keep boundary vertices on the tessellation, a piecewise-flat + // surface whose creases they can mesh around; the finished mesh then moves its boundary + // vertices onto the exact surfaces (and tet10 puts its mid-edge nodes there). Projecting onto + // the exact surfaces while improving leaves vertices on both sides of a blend's tangent + // edge (where two faces meet without a crease) too close to flat to separate. + let plain = Tessellation::new(surface, Vec::new()); + let ctx = Context { size: &size, geometry: &plain, curve_faces: &curve_faces }; + let exact = Context { size: &size, geometry: &geometry, curve_faces: &curve_faces }; + // Remove what rounding left flat first, so refinement starts from a valid mesh. + let flat = + |cx: &TetComplex| -> Vec<(u32, [u32; 4])> { cx.live_tets().filter(|(_, vs)| ops::tet_quality(cx, *vs).is_ok_and(|q| q < 1e-6)).collect() }; + let bad: Vec = flat(&cx).into_iter().map(|(t, _)| t).collect(); + if !bad.is_empty() { + improve::topological_pass(&mut cx, &bad)?; + for (_, vs) in flat(&cx) { + for v in vs { + smooth::smooth_vertex(&mut cx, &ctx, v)?; + } + } + } + // Coarsen what the boundary recovery made denser than the size field asks for. + report.collapsed = refine::coarsen(&mut cx, &ctx, 0.6, 1.4, 0.05, 6)?; + let ropt = RefineOptions { radius_edge: o.radius_edge, ..RefineOptions::default() }; + for _ in 0..o.rounds { + let stats = refine::refine(&mut cx, &ctx, &ropt)?; + let quiet = stats.interior + stats.boundary == 0; + let stopped = stats.stopped.clone(); + report.refine.push(stats); + for _ in 0..o.odt_passes { + report.odt_moves += smooth::odt_pass(&mut cx, &ctx)?; + } + if let Some(s) = stopped { + report.stopped.push(s); + break; + } + if quiet { + break; + } + } + if o.improve { + report.improve = improve::improve(&mut cx, &ctx, &o.improve_options)?; + if let Some(s) = &report.improve.stopped { + report.stopped.push(s.clone()); + } + } + // Put the boundary vertices on the exact surfaces (the tessellation is within its chordal + // tolerance of them). + report.unsnapped = snap_to_geometry(&mut cx, &exact, true)?; + let c = cx.compact()?; + let boundary: Vec = c.boundary.iter().map(|&(tet, k, face)| TaggedFace { tet, opposite: k as u8, face }).collect(); + report.output = tet_quality_stats(&c.points, &c.tets)?; + report.boundary_volume = boundary_volume(&c.points, &c.tets, &boundary)?; + let gate = linear_gate(&c.points, &c.tets, report.boundary_volume)?; + report.volume = gate.volume; + gate.into_result()?; + match o.order { + Order::Linear => { + Ok(QualityMesh { elements: c.tets.iter().map(|t| t.to_vec()).collect(), points: c.points, kind: ElementKind::Tet4, boundary, report }) + } + Order::Quadratic => { + let curve_of: IdMap<(u32, u32), u32> = c.curves.iter().map(|&(e, k)| (sorted2(e.0, e.1), k)).collect(); + let q = quadratic::tet10(&c.points, &c.tets, &boundary, &curve_of, &curve_faces, &geometry)?; + report.straightened = q.straightened; + Ok(QualityMesh { points: q.points, elements: q.elements, kind: ElementKind::Tet10, boundary, report }) + } + } +} + +/// 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. +pub fn raw_mesh(surface: &BoundarySurface, cdt: &TetMesh) -> Result { + let tri_face = surface.tri_face(); + let boundary: Vec = cdt + .boundary + .iter() + .map(|b| TaggedFace { tet: b.tet, opposite: b.opposite, face: tri_face.get(b.triangle as usize).copied().unwrap_or(NONE) }) + .collect(); + let stats = tet_quality_stats(&cdt.points, &cdt.tets)?; + let boundary_volume = boundary_volume(&cdt.points, &cdt.tets, &boundary)?; + let gate = linear_gate(&cdt.points, &cdt.tets, boundary_volume)?.into_result()?; + let report = QualityReport { input: stats, output: stats, volume: gate.volume, boundary_volume, ..QualityReport::default() }; + 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, 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 { + 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])?]) + }; + let elements = tets.iter().enumerate().map(|(i, t)| Ok((i as u32 + 1, ElementKind::Tet4, coords(t)?))).collect::>>()?; + validity_gate_elements(elements, &GateOptions { expected_volume: Some(boundary_volume), volume_tolerance: 1e-9 }) +} diff --git a/crates/mesh/src/tet/ops.rs b/crates/mesh/src/tet/ops.rs new file mode 100644 index 0000000..a2a2261 --- /dev/null +++ b/crates/mesh/src/tet/ops.rs @@ -0,0 +1,705 @@ +//! Local operations on the [`TetComplex`]: edge removal, face removal (2-3 flip and multi-face +//! removal), Delaunay vertex insertion with a star-shaped cavity, and the optimal-cavity vertex +//! insertion of the improvement stage. +//! +//! Every operation is a [`TetComplex::replace`], so it either keeps a valid mesh or changes +//! nothing; the topological ones are hill-climbing steps that are applied only when the worst new +//! tetrahedron is better than the worst one they remove. +//! +//! Sources: +//! - J. R. Shewchuk, *Two Discrete Optimization Algorithms for the Topological Improvement of +//! Tetrahedral Meshes*, 2002: edge removal by Klincsek's dynamic programming over the ring of +//! the edge (§2, including boundary edges ending in a 2-2 flip), and multi-face removal (§3). +//! - B. M. Klingner, J. R. Shewchuk, *Aggressive Tetrahedral Mesh Improvement*, IMR 2007, §3.4: +//! vertex insertion whose cavity maximizes the worst new tetrahedron (the cut of Figure 3). +//! - A. Bowyer 1981, D. F. Watson 1981; Shewchuk, *Tetrahedral Mesh Generation by Delaunay +//! Refinement*, SoCG 1998: Delaunay insertion; the cavity is shrunk until it is star-shaped from +//! the new vertex, so a mesh that is no longer exactly Delaunay (after smoothing) stays valid. + +use crate::Result; +use crate::predicates::{Point3, Sign}; + +use super::complex::{Change, EDGES, IdMap, NONE, TetComplex, VertexKind, face_toward, sorted2, sorted3}; +use super::measure::{self, P}; + +/// The quality objective of a tetrahedron given by vertex ids ([`measure::quality`]). +pub fn tet_quality(cx: &TetComplex, vs: [u32; 4]) -> Result { + Ok(measure::quality(&cx.coords(vs)?)) +} + +/// The worst quality of a set of tetrahedra. +pub fn worst(cx: &TetComplex, tets: &[u32]) -> Result { + let mut q = f64::INFINITY; + for &t in tets { + q = q.min(tet_quality(cx, cx.tet(t)?)?); + } + Ok(q) +} + +/// The largest ring an edge removal considers (Shewchuk 2002 recommends 7; up to 10). +pub const MAX_RING: usize = 10; + +/// The cosine of the largest angle (20°) between the normals of two boundary faces on one B-rep +/// face that a boundary edge removal (ending in a 2-2 flip) or the deletion of a tetrahedron +/// capping them may re-triangulate. The size field holds the faces of a curved B-rep face to +/// about 22.5° of turn per element, so faces of one B-rep face fold by about that much, and the +/// change moves the boundary by an element's chordal error. +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. +fn klincsek(m: usize, q: &mut dyn FnMut(usize, usize, usize) -> Result) -> Result<(Vec, Vec)> { + 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 = 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)); + if sub <= b { + continue; + } + let v = sub.min(q(i, k, j)?); + if v > b { + b = v; + a = k; + } + } + if let Some(s) = best.get_mut(i * m + j) { + *s = b; + } + if let Some(s) = arg.get_mut(i * m + j) { + *s = a; + } + } + } + Ok((best, arg)) +} + +/// The triangles `(i, k, j)` of the optimal triangulation of `v_i … v_j`. +fn triangles(arg: &[usize], m: usize, i: usize, j: usize, out: &mut Vec<(usize, usize, usize)>) { + let mut stack = vec![(i, j)]; + while let Some((i, j)) = stack.pop() { + if j < i + 2 { + continue; + } + let k = arg.get(i * m + j).copied().unwrap_or(i + 1); + out.push((i, k, j)); + stack.push((i, k)); + stack.push((k, j)); + } +} + +/// The two tetrahedra a ring triangle `(v_i, v_k, v_j)` (in ring order) makes with the edge's +/// ends: positive when the ring runs as in [`TetComplex::edge_ring`]. +fn ring_tets(a: u32, b: u32, vi: u32, vk: u32, vj: u32) -> [[u32; 4]; 2] { + [[vi, vj, vk, a], [vi, vk, vj, b]] +} + +/// Remove edge `ab` if that improves the worst tetrahedron around it (Shewchuk 2002 §2). A +/// boundary edge is removed only when its two boundary faces lie on the same B-rep face, are +/// nearly coplanar ([`BOUNDARY_FLIP_COS`]) and the edge is not a feature edge; the operation then +/// ends in a 2-2 flip of the boundary. Returns whether the mesh changed. +pub fn remove_edge(cx: &mut TetComplex, a: u32, b: u32) -> Result { + if cx.curve(a, b).is_some() { + return Ok(false); + } + let Some(t) = cx.edge_tet(a, b)? else { return Ok(false) }; + remove_edge_at(cx, t, a, b) +} + +/// [`remove_edge`] from a tetrahedron `t` that holds the edge. +pub fn remove_edge_at(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result { + let vs = cx.tet(t)?; + if cx.curve(a, b).is_some() || !vs.contains(&a) || !vs.contains(&b) { + return Ok(false); + } + 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 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 + // flat B-rep face, and they become (a, v_0, v_{m−1}) and (b, v_0, v_{m−1}) (a 2-2 flip). + let mut new_boundary = Vec::new(); + if !ring.closed { + let (first, last) = (ring.tets.first().copied().unwrap_or(NONE), ring.tets.last().copied().unwrap_or(NONE)); + let (v0, vl) = (v.first().copied().unwrap_or(NONE), v.last().copied().unwrap_or(NONE)); + let (Some(t0), Some(t1)) = (boundary_tag(cx, first, [a, b, v0])?, boundary_tag(cx, last, [a, b, vl])?) else { return Ok(false) }; + let n0 = measure::triangle_normal(xa, xb, x(v0)?); + let n1 = measure::triangle_normal(xa, x(vl)?, xb); + if t0 != t1 || !(measure::dot(n0, n1) >= BOUNDARY_FLIP_COS) { + return Ok(false); + } + // Oriented into the mesh, as the faces of the new tetrahedra (v_0, v_l, v_k, a) and + // (v_0, v_k, v_l, b) opposite v_k. + new_boundary.push(([v0, a, vl], t0)); + new_boundary.push(([v0, vl, b], t0)); + } + let before = worst(cx, &ring.tets)?; + let xv = v.iter().map(|&id| x(id)).collect::>>()?; + let mut q = |i: usize, k: usize, j: usize| -> Result { + let (pi, pk, pj) = match (xv.get(i), xv.get(k), xv.get(j)) { + (Some(p), Some(q), Some(r)) => (*p, *q, *r), + _ => return Ok(f64::NEG_INFINITY), + }; + 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, &mut q)?; + let total = best.get(m - 1).copied().unwrap_or(f64::NEG_INFINITY); + if !(total > before) { + return Ok(false); + } + let mut tris = Vec::new(); + triangles(&arg, m, 0, m - 1, &mut tris); + let mut new = Vec::with_capacity(2 * tris.len()); + for (i, k, j) in tris { + let (vi, vk, vj) = match (v.get(i), v.get(k), v.get(j)) { + (Some(p), Some(q), Some(r)) => (*p, *q, *r), + _ => return Ok(false), + }; + new.extend(ring_tets(a, b, vi, vk, vj)); + } + let change = Change { old: ring.tets.clone(), new, new_boundary, drop_curves: Vec::new() }; + Ok(cx.replace(&change)?.is_some()) +} + +/// The tag of the boundary face of tetrahedron `t` with vertices `face`, if it is one. +pub fn boundary_tag(cx: &TetComplex, t: u32, face: [u32; 3]) -> Result> { + if t == NONE { + return Ok(None); + } + let vs = cx.tet(t)?; + let key = sorted3(face); + let ns = cx.adj(t)?; + let tags = cx.tags(t)?; + for k in 0..4 { + if sorted3(face_toward(vs, k)) == key && ns[k] == NONE { + return Ok(Some(tags[k])); + } + } + Ok(None) +} + +/// Remove the interior face opposite vertex `k` of tetrahedron `t` by the best multi-face removal +/// that includes it (Shewchuk 2002 §3), if that improves the worst tetrahedron. With a single +/// face this is the 2-3 flip. Returns whether the mesh changed. +pub fn remove_face(cx: &mut TetComplex, t: u32, k: usize) -> Result { + let vs = cx.tet(t)?; + let u = cx.adj(t)?.get(k).copied().unwrap_or(NONE); + if u == NONE { + return Ok(false); + } + let a = vs[k]; + 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.edge_tet(a, b)?.is_some() { + return Ok(false); + } + // 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). + let mut faces: Vec<[u32; 3]> = vec![face]; + let mut old: Vec = vec![t, u]; + let mut best: Option<(f64, Vec, Vec<[u32; 4]>)> = None; + 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: 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, 0)); + e.2 += 1; + } + } + 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 { + // 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) { + best = Some((q, old.clone(), new)); + } + // Grow: a sandwiched neighbour face across a ring edge. + let mut grown = false; + 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; + } + } + if !grown { + break; + } + } + let Some((_, old, new)) = best else { return Ok(false) }; + let change = Change { old, new, new_boundary: Vec::new(), drop_curves: Vec::new() }; + Ok(cx.replace(&change)?.is_some()) +} + +/// 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: &mut TetComplex, a: u32, b: u32, p: u32, r: u32, faces: &[[u32; 3]]) -> Result> { + 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) +} + +/// How an inserted vertex is placed. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Site { + /// Inside the domain, in (or on) tetrahedron `t`. + Interior(u32), + /// On boundary face `k` of tetrahedron `t` (the vertex lies on that face's B-rep face). + Face(u32, usize), + /// On the boundary edge `ab` (a feature edge, which is split, or an edge inside one face). + Edge(u32, u32), +} + +/// Insert a vertex at `p` with the Delaunay cavity: the tetrahedra whose circumsphere contains +/// `p`, grown from the site's tetrahedra across interior faces, then shrunk until every face of +/// the cavity sees `p` (star-shaped). A boundary site also removes the boundary faces of the +/// cavity on the same B-rep face(s) whose circumcircle holds `p` and that connect to the site +/// without crossing a feature edge, and fans the new vertex to their outline. Returns the new +/// vertex, or `None` when no valid cavity exists (nothing changes then). +pub fn insert_delaunay(cx: &mut TetComplex, p: Point3, kind: VertexKind, site: Site, max_cavity: usize) -> Result> { + insert_delaunay_apart(cx, p, kind, site, max_cavity, 0.0) +} + +/// [`insert_delaunay`], refused when a vertex of the cavity lies closer than `min_gap` to `p` +/// (the nearest vertex of a Delaunay mesh is a vertex of the cavity), so refinement cannot +/// pile vertices onto one spot. +pub fn insert_delaunay_apart(cx: &mut TetComplex, p: Point3, kind: VertexKind, site: Site, max_cavity: usize, min_gap: f64) -> Result> { + let (roots, root_faces) = site_roots(cx, site)?; + if roots.is_empty() { + return Ok(None); + } + // Grow the conflict region. + let mut cavity: Vec = roots.clone(); + let mut inset: IdMap = cavity.iter().map(|&t| (t, ())).collect(); + let mut i = 0; + while let Some(&t) = cavity.get(i) { + i += 1; + if cavity.len() > max_cavity { + return Ok(None); + } + for n in cx.adj(t)? { + if n == NONE || inset.contains_key(&n) { + continue; + } + if cx.in_sphere(cx.tet(n)?, &p)? { + inset.insert(n, ()); + cavity.push(n); + } + } + } + if min_gap > 0.0 { + let px = p.coords(); + for &t in &cavity { + for v in cx.tet(t)? { + if measure::dist(cx.xyz(v)?, px) < min_gap { + return Ok(None); + } + } + } + } + let curve_split = match site { + Site::Edge(a, b) => cx.curve(a, b).map(|c| (a, b, c)), + _ => None, + }; + // Shrink until star-shaped; boundary faces of the cavity on the site's face(s) are removed + // when connected to the site and in conflict. + for _ in 0..max_cavity.max(8) { + let plan = plan_cavity(cx, &cavity, &p, site, &root_faces)?; + match plan { + Plan::Ready { new, new_boundary } => { + let len = cx.journal.steps.len(); + let v = cx.add_point(p, kind)?; + let new: Vec<[u32; 4]> = new.into_iter().map(|f| [f[0], f[1], f[2], v]).collect(); + let new_boundary: Vec<([u32; 3], u32)> = new_boundary.into_iter().map(|(e, tag)| ([e.0, e.1, v], tag)).collect(); + let change = + Change { old: cavity.clone(), new, new_boundary, drop_curves: curve_split.map(|(a, b, _)| (a, b)).into_iter().collect() }; + if cx.replace(&change)?.is_some() { + if let Some((a, b, c)) = curve_split { + cx.set_curve(a, b, None); + cx.set_curve(a, v, Some(c)); + cx.set_curve(v, b, Some(c)); + } + return Ok(Some(v)); + } + // Undo the vertex. + if cx.journal.recording { + cx.rollback(len)?; + } else { + cx.drop_last_point(v)?; + } + return Ok(None); + } + Plan::Drop(t) => { + if roots.contains(&t) { + return Ok(None); + } + cavity.retain(|&c| c != t); + // Keep the cavity connected to the roots. + cavity = connected(cx, &cavity, &roots)?; + } + Plan::Fail => return Ok(None), + } + } + Ok(None) +} + +/// The tetrahedra a site must remove, and its boundary faces (as sorted keys) it must remove. +fn site_roots(cx: &mut TetComplex, site: Site) -> Result<(Vec, Vec<[u32; 3]>)> { + Ok(match site { + Site::Interior(t) => (vec![t], Vec::new()), + Site::Face(t, k) => { + let vs = cx.tet(t)?; + if cx.adj(t)?.get(k) != Some(&NONE) { + return Ok((Vec::new(), Vec::new())); + } + (vec![t], vec![sorted3(face_toward(vs, k))]) + } + Site::Edge(a, b) => { + let Some(ring) = cx.edge_ring(a, b)? else { return Ok((Vec::new(), Vec::new())) }; + if ring.closed { + return Ok((Vec::new(), Vec::new())); + } + let mut faces = Vec::new(); + if let (Some(&v0), Some(&vl)) = (ring.verts.first(), ring.verts.last()) { + faces.push(sorted3([a, b, v0])); + faces.push(sorted3([a, b, vl])); + } + (ring.tets, faces) + } + }) +} + +enum Plan { + /// New tetrahedra as faces (to be joined to the new vertex) and new boundary edges. + Ready { + new: Vec<[u32; 3]>, + new_boundary: Vec<((u32, u32), u32)>, + }, + /// Drop this tetrahedron from the cavity and try again. + Drop(u32), + Fail, +} + +/// Plan the fill of a cavity from `p`. +fn plan_cavity(cx: &TetComplex, cavity: &[u32], p: &Point3, site: Site, root_faces: &[[u32; 3]]) -> Result { + let inset: IdMap = cavity.iter().map(|&t| (t, ())).collect(); + // Boundary faces of cavity tetrahedra: candidates for removal (only for boundary sites). + let mut bfaces: Vec<([u32; 3], [u32; 3], u32, u32)> = Vec::new(); // (oriented, sorted, tag, tet) + let mut outer: Vec<([u32; 3], u32)> = Vec::new(); // (oriented face, owning tet) + for &t in cavity { + let vs = cx.tet(t)?; + let ns = cx.adj(t)?; + let tags = cx.tags(t)?; + for k in 0..4 { + let n = ns[k]; + if n != NONE && inset.contains_key(&n) { + continue; + } + let f = face_toward(vs, k); + if n == NONE { + bfaces.push((f, sorted3(f), tags[k], t)); + } else { + outer.push((f, t)); + } + } + } + // Removed boundary faces D: for a boundary site, the root faces plus faces connected to them + // across non-feature edges, on the same tag, whose circumcircle contains p (approximately: + // the diametral sphere of the triangle). + let mut removed: Vec = Vec::new(); + if !matches!(site, Site::Interior(_)) { + let mut queue: Vec = bfaces.iter().enumerate().filter(|(_, f)| root_faces.contains(&f.1)).map(|(i, _)| i).collect(); + if queue.len() != root_faces.len() { + return Ok(Plan::Fail); + } + let px = p.coords(); + let mut taken = vec![false; bfaces.len()]; + for &i in &queue { + if let Some(s) = taken.get_mut(i) { + *s = true; + } + } + let mut qi = 0; + while let Some(&i) = queue.get(qi) { + qi += 1; + let Some(&(f, _, tag, _)) = bfaces.get(i) else { continue }; + for e in 0..3 { + let (u, w) = (f[e], f[(e + 1) % 3]); + if cx.curve(u, w).is_some() { + continue; + } + for (j, g) in bfaces.iter().enumerate() { + if taken.get(j).copied().unwrap_or(true) || g.2 != tag || !(g.0.contains(&u) && g.0.contains(&w)) { + continue; + } + let [ga, gb, gc] = [cx.xyz(g.0[0])?, cx.xyz(g.0[1])?, cx.xyz(g.0[2])?]; + let Some(o) = measure::triangle_circumcenter(ga, gb, gc) else { continue }; + let r = measure::dist(o, ga); + if measure::dist(o, px) < r { + if let Some(s) = taken.get_mut(j) { + *s = true; + } + queue.push(j); + } + } + } + } + removed = queue; + // Vertices strictly inside D would be deleted: only allowed for face vertices of the + // same tag that no kept face uses. + let mut in_kept: IdMap = IdMap::default(); + for (j, g) in bfaces.iter().enumerate() { + if !removed.contains(&j) { + for v in g.0 { + in_kept.insert(v, ()); + } + } + } + for &(f, _) in &outer { + for v in f { + in_kept.insert(v, ()); + } + } + for &i in &removed { + if let Some(g) = bfaces.get(i) { + for v in g.0 { + if !in_kept.contains_key(&v) && !matches!(cx.kind(v), VertexKind::Face(_) | VertexKind::Interior) { + return Ok(Plan::Fail); + } + } + } + } + } + // Every kept face must see p strictly. + let mut new = Vec::with_capacity(outer.len() + bfaces.len()); + for &(f, t) in &outer { + if cx.orient_with(f, p)? != Sign::Positive { + return Ok(Plan::Drop(t)); + } + new.push(f); + } + for (j, &(f, _, _, t)) in bfaces.iter().enumerate() { + if removed.contains(&j) { + continue; + } + if cx.orient_with(f, p)? != Sign::Positive { + return Ok(Plan::Drop(t)); + } + new.push(f); + } + // The outline of D (edges in exactly one removed face) becomes new boundary, fanned to p. + let mut new_boundary = Vec::new(); + if !removed.is_empty() { + let mut count: IdMap<(u32, u32), ((u32, u32), u32, usize)> = IdMap::default(); + for &i in &removed { + let Some(&(f, _, tag, _)) = bfaces.get(i) else { continue }; + for e in 0..3 { + let (u, w) = (f[e], f[(e + 1) % 3]); + let c = count.entry(sorted2(u, w)).or_insert(((u, w), tag, 0)); + c.2 += 1; + } + } + let split = match site { + Site::Edge(a, b) => Some(sorted2(a, b)), + _ => None, + }; + let mut edges: Vec<((u32, u32), u32)> = count.into_iter().filter(|(k, c)| c.2 == 1 && Some(*k) != split).map(|(_, c)| (c.0, c.1)).collect(); + edges.sort_unstable(); + new_boundary = edges; + } + // A cavity whose tetrahedra all go must still leave something to fill. + if new.is_empty() { + return Ok(Plan::Fail); + } + Ok(Plan::Ready { new, new_boundary }) +} + +/// The tetrahedra of `cavity` connected to the roots through faces inside the cavity. +fn connected(cx: &TetComplex, cavity: &[u32], roots: &[u32]) -> Result> { + let inset: IdMap = cavity.iter().map(|&t| (t, ())).collect(); + let mut seen: IdMap = IdMap::default(); + let mut out: Vec = Vec::new(); + for &r in roots { + if inset.contains_key(&r) && seen.insert(r, ()).is_none() { + out.push(r); + } + } + let mut i = 0; + while let Some(&t) = out.get(i) { + i += 1; + for n in cx.adj(t)? { + if n != NONE && inset.contains_key(&n) && seen.insert(n, ()).is_none() { + out.push(n); + } + } + } + Ok(out) +} + +/// The edges of a set of tetrahedra, sorted and unique. +pub fn edges_of(cx: &TetComplex, tets: &[u32]) -> Result> { + let mut out = Vec::with_capacity(tets.len() * 6); + for &t in tets { + let vs = cx.tet(t)?; + out.extend(EDGES.iter().map(|&(i, j)| sorted2(vs[i], vs[j]))); + } + out.sort_unstable(); + out.dedup(); + Ok(out) +} + +/// Collapse vertex `a` onto its neighbour `b` (edge contraction): every tetrahedron around `a` +/// takes `b` in its place and those holding the edge disappear. Allowed when `a` may move to `b` +/// without leaving its boundary entity — an interior vertex anywhere, a face vertex along an edge +/// inside its B-rep face, a curve vertex along its curve, never a corner — when the boundary +/// faces it changes keep their orientation within 30°, every new edge is at most `max_len`, and +/// the worst new tetrahedron is at least `min(worst old, floor)`. Returns whether it collapsed. +/// +/// Edge contraction is the coarsening operation of mesh adaptation by local modification (X. Li, +/// M. S. Shephard, M. W. Beall, *3D anisotropic mesh adaptation by mesh modification*, CMAME +/// 194, 2005); here it removes the boundary recovery's closely spaced Steiner points and dense +/// tessellation vertices where the size field asks for larger elements. +pub fn collapse(cx: &mut TetComplex, a: u32, b: u32, max_len: f64, floor: f64) -> Result { + let ka = cx.kind(a); + let allowed = match ka { + VertexKind::Corner => false, + VertexKind::Interior => true, + VertexKind::Curve(c) => cx.curve(a, b) == Some(c), + VertexKind::Face(tag) => { + if cx.curve(a, b).is_some() { + false + } else { + match cx.kind(b) { + VertexKind::Interior => false, + _ => { + // The edge must be a boundary edge inside face `tag`. + let Some(ring) = cx.edge_ring(a, b)? else { return Ok(false) }; + if ring.closed { + false + } else { + let ends = [ring.tets.first().copied(), ring.tets.last().copied()]; + let (v0, vl) = (ring.verts.first().copied().unwrap_or(NONE), ring.verts.last().copied().unwrap_or(NONE)); + boundary_tag(cx, ends[0].unwrap_or(NONE), [a, b, v0])? == Some(tag) + && boundary_tag(cx, ends[1].unwrap_or(NONE), [a, b, vl])? == Some(tag) + } + } + } + } + } + }; + if !allowed || cx.edge_tet(a, b)?.is_none() { + return Ok(false); + } + let star = cx.star(a)?; + let before = worst(cx, &star)?; + let xb = cx.xyz(b)?; + let mut new = Vec::with_capacity(star.len()); + let mut new_boundary = Vec::new(); + for &t in &star { + let vs = cx.tet(t)?; + if vs.contains(&b) { + continue; + } + let moved = vs.map(|v| if v == a { b } else { v }); + for v in moved { + if v != b && measure::dist(cx.xyz(v)?, xb) > max_len { + return Ok(false); + } + } + let (ns, tags) = (cx.adj(t)?, cx.tags(t)?); + for k in 0..4 { + if ns[k] != NONE || vs[k] == a { + continue; + } + let f = face_toward(vs, k); + let g = f.map(|v| if v == a { b } else { v }); + let n0 = measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?); + let n1 = measure::triangle_normal(cx.xyz(g[0])?, cx.xyz(g[1])?, cx.xyz(g[2])?); + if !(measure::dot(n0, n1) >= COLLAPSE_NORMAL_COS) { + return Ok(false); + } + new_boundary.push((g, tags[k])); + } + new.push(moved); + } + if new.is_empty() { + return Ok(false); + } + let mut after = f64::INFINITY; + for vs in &new { + after = after.min(tet_quality(cx, *vs)?); + } + if !(after > 0.0) || after < before.min(floor) { + return Ok(false); + } + // Curve edges at a move to b. + let mut drop = Vec::new(); + let mut add = Vec::new(); + if let VertexKind::Curve(c) = ka { + for u in cx.neighbours(a)? { + if cx.curve(a, u) == Some(c) { + drop.push((a, u)); + if u != b { + add.push((b, u, c)); + } + } + } + } + let change = Change { old: star, new, new_boundary, drop_curves: drop.clone() }; + if cx.replace(&change)?.is_none() { + return Ok(false); + } + for (x, y) in drop { + cx.set_curve(x, y, None); + } + for (x, y, c) in add { + cx.set_curve(x, y, Some(c)); + } + Ok(true) +} + +/// cos 30°: the largest turn of a boundary face's normal a collapse may cause. +const COLLAPSE_NORMAL_COS: f64 = 0.866_025_403_784_438_6; diff --git a/crates/mesh/src/tet/quadratic.rs b/crates/mesh/src/tet/quadratic.rs new file mode 100644 index 0000000..95caf9f --- /dev/null +++ b/crates/mesh/src/tet/quadratic.rs @@ -0,0 +1,120 @@ +//! Quadratic tetrahedra (tet10): a node on every edge, at the midpoint inside the domain and on +//! the geometry on the boundary — projected onto the edge's feature curve, or onto the B-rep face +//! of a boundary edge inside one face — so curved boundaries are represented to second order. +//! +//! A curved edge can make an element invalid (a thin element against a strongly curved face). +//! Every element is checked with the certified Jacobian test that the validity gate uses +//! ([`crate::quality::element_validity`], which proves FieldCraft's criterion on the whole +//! element); an element that fails gets its curved edges straightened (the node goes back to the +//! straight midpoint, for every element sharing that edge) and the elements around are checked +//! again. Straightening only ever moves nodes back to the linear mesh, which is valid, so the loop +//! ends; it is also capped by the number of edges. + +use fieldcraft_model::ElementKind; + +use crate::quality::{Validity, element_validity}; +use crate::{Error, Result}; + +use super::TaggedFace; +use super::complex::{IdMap, NONE, face_toward, sorted2}; +use super::geometry::{BoundaryGeometry, Tessellation}; +use super::measure; + +/// Tet10 mid-side nodes on edges 1-2, 2-3, 3-1, 1-4, 2-4, 3-4 (the model's node order). +use crate::quality::jacobian::TET_EDGES as MID_EDGES; + +/// The quadratic mesh. +#[derive(Debug, Clone, PartialEq)] +pub struct Quadratic { + pub points: Vec<[f64; 3]>, + pub elements: Vec>, + /// Edges left straight to keep their elements valid. + pub straightened: usize, +} + +/// Tet10 elements from tet4 ones (see the module docs). +pub fn tet10( + points: &[[f64; 3]], + tets: &[[u32; 4]], + boundary: &[TaggedFace], + curves: &IdMap<(u32, u32), u32>, + curve_faces: &[[u32; 2]], + geometry: &Tessellation, +) -> Result { + // Boundary edges and the B-rep face they lie on. + let mut edge_face: IdMap<(u32, u32), u32> = IdMap::default(); + for b in boundary { + let t = tets.get(b.tet as usize).ok_or_else(|| Error::Failed("tet10: boundary face of no tetrahedron".into()))?; + let f = face_toward(*t, usize::from(b.opposite)); + for i in 0..3 { + edge_face.entry(sorted2(f[i], f[(i + 1) % 3])).or_insert(b.face); + } + } + let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("tet10: vertex id".into())); + let mut out = points.to_vec(); + // Edge -> (node, straight midpoint). + let mut node: IdMap<(u32, u32), (u32, [f64; 3])> = IdMap::default(); + let mut elements = Vec::with_capacity(tets.len()); + for t in tets { + let mut e = t.to_vec(); + for &(i, j) in &MID_EDGES { + let key = sorted2(t[i], t[j]); + let id = match node.get(&key) { + Some(&(id, _)) => id, + None => { + let m = measure::mid(p(t[i])?, p(t[j])?); + let curved = match curves.get(&key) { + Some(&c) => curve_faces.get(c as usize).and_then(|f| geometry.project_curve(*f, m)), + None => edge_face.get(&key).and_then(|&f| geometry.project_face(f, m)), + }; + let id = u32::try_from(out.len()).map_err(|_| Error::Limit("too many nodes".into()))?; + out.push(curved.filter(|q| q.iter().all(|x| x.is_finite())).unwrap_or(m)); + node.insert(key, (id, m)); + id + } + }; + e.push(id); + } + elements.push(e); + } + // Straighten what makes an element invalid. + let coords = |out: &[[f64; 3]], e: &[u32]| -> Result> { + e.iter().map(|&v| out.get(v as usize).copied().ok_or_else(|| Error::Failed("tet10: node id".into()))).collect() + }; + let mut straightened = 0; + let mut check: Vec = (0..elements.len()).collect(); + let mut edge_elements: IdMap<(u32, u32), Vec> = IdMap::default(); + for (n, t) in tets.iter().enumerate() { + for &(i, j) in &MID_EDGES { + edge_elements.entry(sorted2(t[i], t[j])).or_default().push(n); + } + } + for _ in 0..=node.len() { + let mut next = Vec::new(); + for &n in &check { + let Some(e) = elements.get(n) else { continue }; + if element_validity(ElementKind::Tet10, &coords(&out, e)?)?.validity == Validity::Valid { + continue; + } + let t = tets.get(n).copied().unwrap_or([NONE; 4]); + for &(i, j) in &MID_EDGES { + let key = sorted2(t[i], t[j]); + let Some(&(id, m)) = node.get(&key) else { continue }; + if out.get(id as usize) != Some(&m) { + if let Some(s) = out.get_mut(id as usize) { + *s = m; + } + straightened += 1; + next.extend(edge_elements.get(&key).cloned().unwrap_or_default()); + } + } + } + next.sort_unstable(); + next.dedup(); + if next.is_empty() { + break; + } + check = next; + } + Ok(Quadratic { points: out, elements, straightened }) +} diff --git a/crates/mesh/src/tet/refine.rs b/crates/mesh/src/tet/refine.rs new file mode 100644 index 0000000..6f6d383 --- /dev/null +++ b/crates/mesh/src/tet/refine.rs @@ -0,0 +1,391 @@ +//! Constrained Delaunay refinement to a size field. +//! +//! A tetrahedron is bad when its radius–edge ratio exceeds a bound `B` (about 2) or its +//! circumradius exceeds the local target size; its circumcentre is inserted (Delaunay insertion, +//! [`ops::insert_delaunay`]). A circumcentre outside the domain, or inside the diametral sphere of +//! a boundary face (it *encroaches* the face), is not inserted; the encroached boundary face is +//! split instead, at its circumcentre when that lies inside it and otherwise at the midpoint of +//! its longest edge (which splits a feature edge when the edge is one), and every boundary point +//! is projected onto its B-rep face or feature curve ([`super::geometry`]). +//! +//! Sources: J. R. Shewchuk, *Tetrahedral Mesh Generation by Delaunay Refinement*, SoCG 1998 +//! (radius–edge refinement with encroachment rules, termination for B = 2 and input angles of 90° +//! or more); H. Si, *Constrained Delaunay tetrahedral mesh generation and refinement*, FEAD +//! 46(1–2), 2010 (refinement of a constrained Delaunay tetrahedralization with small input +//! angles). CAD boundaries have small angles, so termination is guaranteed here by bounds +//! instead: a boundary face smaller than a fraction of the local size is not split, a +//! tetrahedron whose point could not be inserted is not retried, and the number of insertions +//! and tetrahedra are capped (the stop is reported). + +use crate::predicates::{Point3, snap_to_domain}; +use crate::size::SizeField; +use crate::{Error, Result}; + +use super::complex::{IdMap, NONE, TetComplex, VertexKind, Walk, face_toward, sorted2, sorted4}; +use super::geometry::BoundaryGeometry; +use super::measure::{self, P}; +use super::ops::{self, Site}; + +/// What the boundary insertions project onto. +pub struct Context<'a> { + pub size: &'a dyn SizeField, + pub geometry: &'a dyn BoundaryGeometry, + /// The B-rep faces of each feature curve (by curve id). + pub curve_faces: &'a [[u32; 2]], +} + +impl Context<'_> { + /// The local target size, or `fallback` when the field misbehaves. + pub fn h(&self, p: P, fallback: f64) -> f64 { + let h = self.size.size(p); + if h > 0.0 && h.is_finite() { h } else { fallback } + } + + /// `p` moved onto the boundary entity of a vertex of `kind`, as an admissible point. + pub fn place(&self, kind: VertexKind, p: P) -> Option { + let q = match kind { + VertexKind::Interior => p, + VertexKind::Face(f) => self.geometry.project_face(f, p)?, + VertexKind::Curve(c) => self.geometry.project_curve(*self.curve_faces.get(c as usize)?, p)?, + VertexKind::Corner => return None, + }; + Point3::try_from(q.map(snap_to_domain)).ok() + } +} + +/// Refinement settings. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RefineOptions { + /// The radius–edge bound `B`. + pub radius_edge: f64, + /// A tetrahedron is too large when its circumradius exceeds this times the local size (the + /// regular tetrahedron with edge `h` has circumradius 0.61 h). + pub size_factor: f64, + /// Boundary faces whose circumradius is below this times the local size are not split. + pub min_face: f64, + /// A tetrahedron is refined for its shape (radius–edge ratio) only when its circumradius is + /// at least this times the local size; smaller ones are left to smoothing and improvement, + /// so badly shaped input near the boundary does not cascade into refinement far below the + /// target size. + pub min_shape_radius: f64, + /// Most vertices inserted. + pub max_insertions: usize, +} + +impl Default for RefineOptions { + fn default() -> Self { + RefineOptions { radius_edge: 2.0, size_factor: 0.95, min_face: 0.5, min_shape_radius: 0.5, max_insertions: 2_000_000 } + } +} + +/// What a refinement did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RefineStats { + pub interior: usize, + pub boundary: usize, + pub skipped: usize, + /// Why it stopped before every tetrahedron was good, if it did. + pub stopped: Option, +} + +/// Refinement never puts a vertex closer than this times the local size to another. +pub const MIN_GAP: f64 = 0.05; + +/// Most tetrahedra a Delaunay cavity may hold. +pub const MAX_CAVITY: usize = 4096; +/// Most steps of a point location walk. +const MAX_WALK: usize = 1 << 16; + +/// The boundary face of a tetrahedron whose diametral sphere holds `p`, if any (the largest). +fn encroached(cx: &TetComplex, tets: &[u32], p: P) -> Result> { + let mut best: Option<(f64, u32, usize)> = None; + for &t in tets { + let vs = cx.tet(t)?; + let ns = cx.adj(t)?; + for k in 0..4 { + if ns[k] != NONE { + continue; + } + let f = face_toward(vs, k); + let (a, b, c) = (cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?); + let Some(o) = measure::triangle_circumcenter(a, b, c) else { continue }; + let r = measure::dist(o, a); + if measure::dist(o, p) < r && best.is_none_or(|(br, _, _)| r > br) { + best = Some((r, t, k)); + } + } + } + Ok(best.map(|(_, t, k)| (t, k))) +} + +/// The tetrahedra whose circumsphere holds `p`, grown from `t` across interior faces. +fn conflicts(cx: &TetComplex, t: u32, p: &Point3) -> Result> { + let mut out = vec![t]; + let mut seen: IdMap = IdMap::default(); + seen.insert(t, ()); + let mut i = 0; + while let Some(&u) = out.get(i) { + i += 1; + if out.len() > MAX_CAVITY { + break; + } + for n in cx.adj(u)? { + if n != NONE && !seen.contains_key(&n) { + seen.insert(n, ()); + if cx.in_sphere(cx.tet(n)?, p)? { + out.push(n); + } + } + } + } + Ok(out) +} + +/// Split boundary face `k` of tetrahedron `t`: at its circumcentre when inside it, else at the +/// midpoint of its longest edge. Returns the new vertex. +pub fn split_face(cx: &mut TetComplex, ctx: &Context, t: u32, k: usize, min_face: f64) -> Result> { + let vs = cx.tet(t)?; + if cx.adj(t)?.get(k) != Some(&NONE) { + return Ok(None); + } + let tag = cx.tags(t)?.get(k).copied().unwrap_or(NONE); + let f = face_toward(vs, k); + let x = [cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?]; + let Some(o) = measure::triangle_circumcenter(x[0], x[1], x[2]) else { return Ok(None) }; + let r = measure::dist(o, x[0]); + let centroid = measure::scale(measure::add(measure::add(x[0], x[1]), x[2]), 1.0 / 3.0); + if r < min_face * ctx.h(centroid, r) { + return Ok(None); + } + // Barycentric coordinates of o (signed areas against the face normal). + let n = measure::cross(measure::sub(x[1], x[0]), measure::sub(x[2], x[0])); + let inside = (0..3).all(|i| { + let (a, b) = (x[(i + 1) % 3], x[(i + 2) % 3]); + measure::dot(measure::cross(measure::sub(b, a), measure::sub(o, a)), n) >= 0.0 + }); + if inside && tag != NONE { + // A point inside the diametral sphere of one of the face's feature edges encroaches it: + // split the edge instead (Shewchuk 1998). + for i in 0..3 { + let (u, w) = (f[i], f[(i + 1) % 3]); + if cx.curve(u, w).is_some() { + let (a, b) = (x[i], x[(i + 1) % 3]); + if measure::dist(o, measure::mid(a, b)) < 0.5 * measure::dist(a, b) { + return split_edge(cx, ctx, u, w, tag, min_face); + } + } + } + let kind = VertexKind::Face(tag); + let Some(p) = ctx.place(kind, o) else { return Ok(None) }; + // The projection may only move the point a little, and never next to a corner. + let shortest = (0..3).map(|i| measure::dist(x[i], x[(i + 1) % 3])).fold(f64::INFINITY, f64::min); + if measure::dist(p.coords(), o) > 0.5 * r || x.iter().any(|c| measure::dist(*c, p.coords()) < 0.25 * shortest) { + return Ok(None); + } + return ops::insert_delaunay_apart(cx, p, kind, Site::Face(t, k), MAX_CAVITY, MIN_GAP * ctx.h(o, r)); + } + // The longest edge. + let (mut u, mut w, mut len) = (f[0], f[1], -1.0); + for i in 0..3 { + let l = measure::dist(x[i], x[(i + 1) % 3]); + if l > len { + (u, w, len) = (f[i], f[(i + 1) % 3], l); + } + } + split_edge(cx, ctx, u, w, tag, min_face) +} + +/// Split boundary edge `uw` at its midpoint, projected onto its feature curve or onto B-rep face +/// `tag`. An edge shorter than `2 · min_face` times the local size is not split. +pub fn split_edge(cx: &mut TetComplex, ctx: &Context, u: u32, w: u32, tag: u32, min_face: f64) -> Result> { + let m = measure::mid(cx.xyz(u)?, cx.xyz(w)?); + let len = measure::dist(cx.xyz(u)?, cx.xyz(w)?); + if len < 2.0 * min_face * ctx.h(m, len) { + return Ok(None); + } + let kind = match cx.curve(u, w) { + Some(c) => VertexKind::Curve(c), + None if tag != NONE => VertexKind::Face(tag), + None => return Ok(None), + }; + let Some(p) = ctx.place(kind, m) else { return Ok(None) }; + // The projection may only move the midpoint a little (a quarter of the edge). + if measure::dist(p.coords(), m) > 0.25 * len { + return Ok(None); + } + ops::insert_delaunay_apart(cx, p, kind, Site::Edge(u, w), MAX_CAVITY, MIN_GAP * ctx.h(m, len)) +} + +/// Whether a tetrahedron is bad, and its circumcentre. +fn badness(x: &[P; 4], ctx: &Context, o: &RefineOptions) -> Option

{ + let c = measure::circumcenter(x)?; + let r = measure::dist(c, x[0]); + let (lmin, lmax) = measure::edge_range(x); + let h = ctx.h(measure::centroid(x), lmax); + ((r > o.radius_edge * lmin && r > o.min_shape_radius * h) || r > o.size_factor * h).then_some(c) +} + +/// Refine until no tetrahedron is bad (see the module docs). +pub fn refine(cx: &mut TetComplex, ctx: &Context, o: &RefineOptions) -> Result { + let mut stats = RefineStats::default(); + let mut queue: Vec = cx.live_tets().map(|(t, _)| t).collect(); + let mut given_up: IdMap<[u32; 4], ()> = IdMap::default(); + let mut head = 0usize; + let mut inserted = 0usize; + while head < queue.len() { + let t = queue.get(head).copied().unwrap_or(NONE); + head += 1; + // Keep the queue's memory bounded: drop the processed prefix now and then. + if head > 1 << 20 { + queue.drain(..head); + head = 0; + } + if !cx.is_live(t) { + continue; + } + let vs = cx.tet(t)?; + let key = sorted4(vs); + if given_up.contains_key(&key) { + continue; + } + let x = cx.coords(vs)?; + let Some(c) = badness(&x, ctx, o) else { continue }; + if inserted >= o.max_insertions { + stats.stopped = Some(format!("refinement stopped at {} inserted vertices", o.max_insertions)); + break; + } + if cx.live_count() + 64 >= cx.max_tets() || cx.point_count() + 64 >= cx.max_points() { + stats.stopped = Some(format!("refinement stopped at {} tetrahedra and {} vertices", cx.live_count(), cx.point_count())); + break; + } + let result = match Point3::try_from(c.map(snap_to_domain)) { + Err(_) => None, + Ok(p) => { + // The boundary face the circumcentre lies beyond or encroaches, if any. + let (inside, face) = match cx.walk(t, &p, MAX_WALK)? { + None => (None, None), + Some(Walk::Outside(tb, k)) => (None, Some((tb, k))), + Some(Walk::Inside(ti)) => (Some(ti), encroached(cx, &conflicts(cx, ti, &p)?, c)?), + }; + match (inside, face) { + (_, Some((tb, k))) => { + let v = split_face(cx, ctx, tb, k, o.min_face)?; + stats.boundary += usize::from(v.is_some()); + v + } + (Some(ti), None) => { + let v = ops::insert_delaunay_apart(cx, p, VertexKind::Interior, Site::Interior(ti), MAX_CAVITY, MIN_GAP * ctx.h(c, 1.0))?; + stats.interior += usize::from(v.is_some()); + v + } + (None, None) => None, + } + } + }; + match result { + Some(v) => { + inserted += 1; + queue.extend(cx.star(v)?); + // The bad tetrahedron may survive a boundary split: look at it again. + if cx.is_live(t) { + queue.push(t); + } + } + None => { + stats.skipped += 1; + given_up.insert(key, ()); + } + } + } + Ok(stats) +} + +/// The feature curves of a boundary: an edge between two boundary faces is a feature edge when +/// their tags differ or their normals turn by more than `feature_cos` (cosine). Returns the +/// curve table (face pairs) and the edges with their curve. +pub fn feature_curves(cx: &TetComplex, feature_cos: f64) -> Result<(Vec<[u32; 2]>, Vec<((u32, u32), u32)>)> { + // Boundary edge -> (tag, oriented normal) of each side. + let mut sides: IdMap<(u32, u32), Vec<(u32, P)>> = IdMap::default(); + for (t, vs) in cx.live_tets() { + let ns = cx.adj(t)?; + let tags = cx.tags(t)?; + for k in 0..4 { + if ns[k] != NONE { + continue; + } + let f = face_toward(vs, k); + let n = measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?); + for i in 0..3 { + sides.entry(sorted2(f[i], f[(i + 1) % 3])).or_default().push((tags[k], n)); + } + } + } + let mut keys: Vec<(u32, u32)> = sides.keys().copied().collect(); + keys.sort_unstable(); + let mut table: Vec<[u32; 2]> = Vec::new(); + let mut index: IdMap<[u32; 2], u32> = IdMap::default(); + let mut edges = Vec::new(); + for e in keys { + let s = sides.get(&e).map(Vec::as_slice).unwrap_or_default(); + let [(ta, na), (tb, nb)] = s else { + return Err(Error::Failed(format!("boundary edge {e:?} has {} faces", s.len()))); + }; + if ta != tb || measure::dot(*na, *nb) < feature_cos { + let pair = [(*ta).min(*tb), (*ta).max(*tb)]; + let id = match index.get(&pair) { + Some(&id) => id, + None => { + let id = table.len() as u32; + table.push(pair); + index.insert(pair, id); + id + } + }; + edges.push((e, id)); + } + } + Ok((table, edges)) +} + +/// Coarsening: collapse edges shorter than `short` times the local size, shortest (relative) +/// first, each onto whichever end it may move to ([`ops::collapse`]); new edges stay below +/// `long` times the size, and the worst tetrahedron around does not drop below +/// `min(its old worst, floor)`. At most `passes` passes. Returns the collapses. +pub fn coarsen(cx: &mut TetComplex, ctx: &Context, short: f64, long: f64, floor: f64, passes: usize) -> Result { + let mut done = 0; + for _ in 0..passes { + let mut edges: Vec<(f64, u32, u32)> = Vec::new(); + let mut seen: IdMap<(u32, u32), ()> = IdMap::default(); + for (_, vs) in cx.live_tets() { + for &(i, j) in &super::complex::EDGES { + let e = sorted2(vs[i], vs[j]); + if seen.insert(e, ()).is_some() { + continue; + } + let (a, b) = (cx.xyz(e.0)?, cx.xyz(e.1)?); + let l = measure::dist(a, b); + let h = ctx.h(measure::mid(a, b), l); + if l < short * h { + edges.push((l / h, e.0, e.1)); + } + } + } + edges.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1)).then(x.2.cmp(&y.2))); + let mut changed = 0; + for (_, a, b) in edges { + if !cx.is_used(a) || !cx.is_used(b) { + continue; + } + let (pa, pb) = (cx.xyz(a)?, cx.xyz(b)?); + let max_len = long * ctx.h(measure::mid(pa, pb), measure::dist(pa, pb)); + if ops::collapse(cx, a, b, max_len, floor)? || ops::collapse(cx, b, a, max_len, floor)? { + changed += 1; + } + } + done += changed; + if changed == 0 { + break; + } + } + Ok(done) +} diff --git a/crates/mesh/src/tet/smooth.rs b/crates/mesh/src/tet/smooth.rs new file mode 100644 index 0000000..81060aa --- /dev/null +++ b/crates/mesh/src/tet/smooth.rs @@ -0,0 +1,294 @@ +//! Vertex smoothing: optimal Delaunay triangulation (ODT) moves, and optimization-based smoothing +//! of the worst tetrahedron around a vertex. +//! +//! Boundary vertices move on their B-rep face (in its tangent plane, then projected) or along +//! their feature curve; corners never move. Every move keeps every tetrahedron around the vertex +//! positive (exact `orient3d`), or is not made. +//! +//! Sources: +//! - L. Chen, J. Xu, *Optimal Delaunay triangulations*, J. Comput. Math. 22(2), 2004; L. Chen, +//! M. Holst, *Efficient mesh optimization schemes based on Optimal Delaunay Triangulations*, +//! CMAME 200, 2011; P. Alliez, D. Cohen-Steiner, M. Yvinec, M. Desbrun, *Variational +//! tetrahedral meshing*, ACM TOG 24(3), 2005: the ODT update moves a vertex to the +//! volume-weighted average of the circumcentres of its tetrahedra, weighted here by the density +//! `h⁻³` of the size field (J. Tournois, C. Wormser, P. Alliez, M. Desbrun, *Interleaving +//! Delaunay refinement and optimization for practical isotropic tetrahedron mesh generation*, +//! ACM TOG 28(3), 2009, which interleaves these moves with refinement). +//! - L. Freitag, M. Jones, P. Plassmann, *An efficient parallel algorithm for mesh smoothing*, +//! IMR 1995; L. Freitag, C. Ollivier-Gooch, *Tetrahedral mesh improvement using swapping and +//! smoothing*, IJNME 40, 1997; Klingner and Shewchuk 2007, §3.1: nonsmooth optimization of the +//! worst quality around a vertex (active set, steepest ascent along the smallest vector in the +//! convex hull of the active gradients, line search), applied only when the worst improves. + +use crate::Result; + +use super::complex::{NONE, TetComplex, VertexKind, face_toward}; +use super::measure::{self, P, add, dot, scale, sub}; +use super::refine::Context; + +/// The worst quality around `v` with `v` at `p` (the tetrahedra of `star`). +fn star_quality(cx: &TetComplex, v: u32, p: P, star: &[u32]) -> Result { + let mut q = f64::INFINITY; + for &t in star { + let vs = cx.tet(t)?; + let mut x = cx.coords(vs)?; + for (i, &u) in vs.iter().enumerate() { + if u == v + && let Some(s) = x.get_mut(i) + { + *s = p; + } + } + q = q.min(measure::quality(&x)); + } + Ok(q) +} + +/// How `v` may move: `None` for a corner, the identity for an interior vertex, the tangent +/// plane's normal for a face vertex, the tangent line for a curve vertex. +#[derive(Debug, Clone, Copy)] +enum Freedom { + Free, + Plane(P), + Line(P), +} + +fn freedom(cx: &mut TetComplex, v: u32, star: &[u32]) -> Result> { + Ok(match cx.kind(v) { + VertexKind::Corner => None, + VertexKind::Interior => Some(Freedom::Free), + VertexKind::Face(_) => { + let mut n = [0.0; 3]; + for &t in star { + let vs = cx.tet(t)?; + let ns = cx.adj(t)?; + for k in 0..4 { + if ns[k] == NONE && vs[k] != v { + let f = face_toward(vs, k); + let (a, b, c) = (cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?); + n = add(n, measure::cross(sub(b, a), sub(c, a))); + } + } + } + let l = measure::norm(n); + if !(l > 0.0) { + return Ok(None); + } + Some(Freedom::Plane(scale(n, 1.0 / l))) + } + VertexKind::Curve(c) => { + let ends: Vec = cx.neighbours(v)?.into_iter().filter(|&u| cx.curve(v, u) == Some(c)).collect(); + let [a, b] = ends.as_slice() else { return Ok(None) }; + let d = sub(cx.xyz(*b)?, cx.xyz(*a)?); + let l = measure::norm(d); + if !(l > 0.0) { + return Ok(None); + } + Some(Freedom::Line(scale(d, 1.0 / l))) + } + }) +} + +fn restrict(d: P, f: Freedom) -> P { + match f { + Freedom::Free => d, + Freedom::Plane(n) => sub(d, scale(n, dot(d, n))), + Freedom::Line(t) => scale(t, dot(d, t)), + } +} + +/// Try moving `v` to `target` (restricted to its freedom and placed on its boundary entity), +/// at full, half and quarter steps; accept the first that keeps the star valid and its worst +/// quality at least `floor`. Returns whether it moved. +fn try_move(cx: &mut TetComplex, ctx: &Context, v: u32, star: &[u32], f: Freedom, target: P, floor: f64) -> Result { + let x = cx.xyz(v)?; + let d = restrict(sub(target, x), f); + for s in [1.0, 0.5, 0.25] { + let Some(p) = ctx.place(cx.kind(v), add(x, scale(d, s))) else { continue }; + if p.coords() == x || !cx.move_is_valid(v, &p, star)? { + continue; + } + if star_quality(cx, v, p.coords(), star)? >= floor { + cx.move_point(v, p)?; + return Ok(true); + } + } + Ok(false) +} + +/// The quality a smoothing move may not go below (sin 17.5°) unless the star was already worse. +const ODT_FLOOR: f64 = 0.3; + +/// One ODT pass over the vertices (ascending ids) that may move. Returns the number moved. +pub fn odt_pass(cx: &mut TetComplex, ctx: &Context) -> Result { + let mut moved = 0; + for v in 0..cx.point_count() as u32 { + if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner { + continue; + } + let star = cx.star(v)?; + let Some(f) = freedom(cx, v, &star)? else { continue }; + let (mut num, mut den) = ([0.0; 3], 0.0); + for &t in &star { + let x = cx.coords(cx.tet(t)?)?; + let vol = measure::volume6(&x) / 6.0; + let g = measure::centroid(&x); + let (_, lmax) = measure::edge_range(&x); + let c = measure::circumcenter(&x).filter(|c| measure::dist(*c, g) <= lmax).unwrap_or(g); + let h = ctx.h(g, lmax); + let w = vol / (h * h * h); + if !(w > 0.0 && w.is_finite()) { + continue; + } + num = add(num, scale(c, w)); + den += w; + } + if !(den > 0.0) { + continue; + } + let target = scale(num, 1.0 / den); + let before = star_quality(cx, v, cx.xyz(v)?, &star)?; + if try_move(cx, ctx, v, &star, f, target, before.min(ODT_FLOOR))? { + moved += 1; + } + } + Ok(moved) +} + +/// The smallest vector in the convex hull of `g` (Frank–Wolfe iterations). +fn min_norm(g: &[P]) -> P { + let Some(&first) = g.first() else { return [0.0; 3] }; + let mut d = first; + for _ in 0..64 { + let mut s = first; + let mut best = f64::INFINITY; + for &gi in g { + let v = dot(gi, d); + if v < best { + best = v; + s = gi; + } + } + let e = sub(d, s); + let gap = dot(e, d); + let ee = dot(e, e); + if !(gap > 1e-30) || !(ee > 0.0) { + break; + } + let gamma = (gap / ee).clamp(0.0, 1.0); + d = sub(d, scale(e, gamma)); + } + d +} + +/// The star of a vertex as plain coordinates: per tetrahedron, its four corners and the slot of +/// the moving vertex (so a trial position costs no lookups). +struct LocalStar { + tets: Vec<([P; 4], usize)>, +} + +impl LocalStar { + fn new(cx: &TetComplex, v: u32, star: &[u32]) -> Result { + let mut tets = Vec::with_capacity(star.len()); + for &t in star { + let vs = cx.tet(t)?; + let i = vs.iter().position(|&u| u == v).unwrap_or(0); + tets.push((cx.coords(vs)?, i)); + } + Ok(LocalStar { tets }) + } + + fn quality_at(&self, k: usize, p: P) -> f64 { + let Some((x, i)) = self.tets.get(k) else { return f64::NEG_INFINITY }; + let mut y = *x; + if let Some(s) = y.get_mut(*i) { + *s = p; + } + measure::quality(&y) + } + + fn worst_at(&self, p: P) -> f64 { + (0..self.tets.len()).map(|k| self.quality_at(k, p)).fold(f64::INFINITY, f64::min) + } +} + +/// 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 { + if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner { + return Ok(false); + } + let star = cx.star(v)?; + let Some(f) = freedom(cx, v, &star)? else { return Ok(false) }; + let local = LocalStar::new(cx, v, &star)?; + let x0 = cx.xyz(v)?; + // A length scale: the mean edge length from v. + let mut len = 0.0; + let mut count = 0usize; + for (x, i) in &local.tets { + for (k, p) in x.iter().enumerate() { + if k != *i { + len += measure::dist(*p, x0); + count += 1; + } + } + } + let len = len / count.max(1) as 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); + let mut moved = false; + for _ in 0..8 { + // 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 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

= qs.iter().filter(|s| s.0 <= worst + 1e-4).map(|s| s.1).collect(); + let d = min_norm(&active); + let dd = dot(d, d); + if !(dd > 1e-20) { + break; + } + // The step at which the linear model of an inactive tetrahedron meets the worst, capped + // to a fifth of the edge length. + let mut alpha = 0.2 * len / dd.sqrt(); + for &(q, g) in &qs { + let gd = dot(g, d); + if q > worst + 1e-4 && q <= worst + 0.1 && gd < dd { + alpha = alpha.min((q - worst) / (dd - gd)); + } + } + 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_at(p.coords()); + if p.coords() != x && q > worst && cx.move_is_valid(v, &p, &star)? { + cx.move_point(v, p)?; + x = p.coords(); + worst = q; + accepted = true; + moved = true; + break; + } + alpha *= 0.5; + } + if !accepted { + break; + } + } + Ok(moved) +} diff --git a/crates/mesh/src/tet/tests.rs b/crates/mesh/src/tet/tests.rs new file mode 100644 index 0000000..ed2fc78 --- /dev/null +++ b/crates/mesh/src/tet/tests.rs @@ -0,0 +1,252 @@ +use super::complex::*; +use super::measure; +use super::ops::*; +use crate::predicates::Point3; + +/// A cube [0, 1]³ split into six tetrahedra around the diagonal 0–6, boundary faces tagged by +/// cube side (0..6). +pub(crate) fn cube() -> TetComplex { + let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, ((i >> 1) & 1) as f64, ((i >> 2) & 1) as f64]).collect(); + // Vertex i = (x, y, z) bits; diagonal 0 → 7. + let raw = [[0, 1, 3, 7], [0, 3, 2, 7], [0, 2, 6, 7], [0, 6, 4, 7], [0, 4, 5, 7], [0, 5, 1, 7]]; + let pts: Vec = p.iter().map(|c| Point3::try_from(*c).unwrap()).collect(); + let tets: Vec<[u32; 4]> = raw + .iter() + .map(|t| { + let t = t.map(|v: usize| v as u32); + let x = t.map(|v| pts[v as usize].coords()); + if measure::volume6(&x) > 0.0 { t } else { [t[1], t[0], t[2], t[3]] } + }) + .collect(); + let side = |f: [u32; 3]| -> Option { + let c = f.map(|v| p[v as usize]); + for axis in 0..3 { + for (s, val) in [(0, 0.0), (1, 1.0)] { + if c.iter().all(|q| q[axis] == val) { + return Some((axis * 2 + s) as u32); + } + } + } + None + }; + let mut boundary = Vec::new(); + for (t, vs) in tets.iter().enumerate() { + for k in 0..4 { + if let Some(tag) = side(face_toward(*vs, k)) { + boundary.push((t as u32, k, tag)); + } + } + } + let kinds = vec![VertexKind::Corner; 8]; + TetComplex::new(&p, kinds, &tets, &boundary, &[], 1000, 10_000).unwrap() +} + +fn volume(cx: &TetComplex) -> f64 { + cx.live_tets().map(|(_, vs)| measure::volume6(&cx.coords(vs).unwrap()) / 6.0).sum() +} + +#[test] +fn cube_builds_and_rings() { + let mut cx = cube(); + assert_eq!(cx.live_count(), 6); + assert!((volume(&cx) - 1.0).abs() < 1e-15); + let ring = cx.edge_ring(0, 7).unwrap().unwrap(); + assert!(ring.closed); + assert_eq!(ring.verts.len(), 6); + assert_eq!(ring.tets.len(), 6); + let ring = cx.edge_ring(0, 1).unwrap().unwrap(); + assert!(!ring.closed); + assert_eq!(ring.verts.len(), ring.tets.len() + 1); +} + +#[test] +fn interior_insertion_and_rollback() { + let mut cx = cube(); + cx.journal.recording = true; + let start = cx.journal.steps.len(); + let p = Point3::try_from([0.5, 0.5, 0.5]).unwrap(); + let t = match cx.walk(0, &p, 100).unwrap().unwrap() { + Walk::Inside(t) => t, + w => panic!("{w:?}"), + }; + let v = insert_delaunay(&mut cx, p, VertexKind::Interior, Site::Interior(t), 1000).unwrap().unwrap(); + assert!(cx.is_used(v)); + assert!((volume(&cx) - 1.0).abs() < 1e-15); + assert!(cx.live_count() > 6); + cx.rollback(start).unwrap(); + cx.validate().unwrap(); + assert_eq!(cx.live_count(), 6); + assert_eq!(cx.point_count(), 8); + assert!((volume(&cx) - 1.0).abs() < 1e-15); +} + +#[test] +fn boundary_face_insertion_keeps_tags() { + let mut cx = cube(); + // A point on the z = 0 side. + let p = Point3::try_from([0.3, 0.6, 0.0]).unwrap(); + let mut site = None; + for (t, _) in cx.live_tets().collect::>() { + let vs = cx.tet(t).unwrap(); + for k in 0..4 { + if cx.adj(t).unwrap()[k] == NONE && cx.tags(t).unwrap()[k] == 4 { + let f = face_toward(vs, k).map(|v| cx.xyz(v).unwrap()); + // inside the triangle in xy + let s = |a: [f64; 3], b: [f64; 3]| (b[0] - a[0]) * (0.6 - a[1]) - (b[1] - a[1]) * (0.3 - a[0]); + let signs = [s(f[0], f[1]), s(f[1], f[2]), s(f[2], f[0])]; + if signs.iter().all(|x| *x > 0.0) || signs.iter().all(|x| *x < 0.0) { + site = Some(Site::Face(t, k)); + } + } + } + } + let v = insert_delaunay(&mut cx, p, VertexKind::Face(4), site.unwrap(), 1000).unwrap().unwrap(); + assert!(cx.is_used(v)); + assert!((volume(&cx) - 1.0).abs() < 1e-15); + let c = cx.compact().unwrap(); + let area: f64 = c + .boundary + .iter() + .filter(|b| b.2 == 4) + .map(|&(t, k, _)| { + let f = face_toward(c.tets[t as usize], k).map(|v| c.points[v as usize]); + 0.5 * measure::norm(measure::cross(measure::sub(f[1], f[0]), measure::sub(f[2], f[0]))) + }) + .sum(); + assert!((area - 1.0).abs() < 1e-15, "{area}"); +} + +#[test] +fn edge_and_face_removal_keep_the_mesh_valid() { + let mut cx = cube(); + let p = Point3::try_from([0.5, 0.45, 0.55]).unwrap(); + let Walk::Inside(t) = cx.walk(0, &p, 100).unwrap().unwrap() else { panic!() }; + insert_delaunay(&mut cx, p, VertexKind::Interior, Site::Interior(t), 1000).unwrap().unwrap(); + let mut changed = 0; + for _ in 0..3 { + let edges = edges_of(&cx, &cx.live_tets().map(|(t, _)| t).collect::>()).unwrap(); + for (a, b) in edges { + if cx.edge_tet(a, b).unwrap().is_some() && remove_edge(&mut cx, a, b).unwrap() { + changed += 1; + } + } + let tets: Vec = cx.live_tets().map(|(t, _)| t).collect(); + for t in tets { + for k in 0..4 { + if cx.is_live(t) && remove_face(&mut cx, t, k).unwrap() { + changed += 1; + } + } + } + } + assert!(changed > 0); + cx.validate().unwrap(); + assert!((volume(&cx) - 1.0).abs() < 1e-14); + for (_, vs) in cx.live_tets() { + assert_eq!(cx.orient(vs).unwrap(), crate::predicates::Sign::Positive); + } +} + +mod pipeline { + use super::super::geometry::AnalyticSurface; + use super::super::{Order, QualityOptions, quality_mesh}; + use crate::cdt::tests::{cube, surface, uv_sphere}; + use crate::cdt::{CdtOptions, tetrahedralize}; + use crate::surface::{BoundarySurface, SurfaceOptions}; + + /// A 10 mm cube with one B-rep face per side. + fn box_surface() -> BoundarySurface { + let (p, t) = cube(); + let p: Vec<[f64; 3]> = p.iter().map(|c| c.map(|x| 10.0 * x)).collect(); + let s = surface(&p, &t, [5.0; 3]); + // One face tag per side: the side's axis and end. + let faces: Vec = s + .triangles() + .iter() + .map(|t| { + let c = t.map(|v| s.points()[v as usize]); + (0..3).find_map(|k| [0.0, 10.0].iter().position(|&x| c.iter().all(|q| q[k] == x)).map(|e| (2 * k + e) as u32)).unwrap() + }) + .collect(); + BoundarySurface::new(s.points(), s.triangles(), &faces, &SurfaceOptions::default()).unwrap() + } + + #[test] + fn a_box_is_refined_improved_and_deterministic() { + let s = box_surface(); + let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap(); + let o = QualityOptions { size: Some(2.5), ..QualityOptions::default() }; + let a = quality_mesh(&s, &cdt, Vec::new(), &o).unwrap(); + let r = &a.report; + assert!(r.output.tets > 100, "{r:?}"); + assert_eq!(r.output.inadmissible, 0); + assert_eq!(r.output.slivers, 0, "{r:?}"); + assert!(r.output.min_dihedral > 25.0 && r.output.max_dihedral < 155.0, "{r:?}"); + // Flat faces: the volume is the box's, and each side keeps its tag on its own plane. + assert!((r.volume - 1000.0).abs() < 1e-9, "{}", r.volume); + for b in &a.boundary { + let t: [u32; 4] = a.elements[b.tet as usize].clone().try_into().unwrap(); + let f = super::super::complex::face_toward(t, b.opposite as usize).map(|v| a.points[v as usize]); + let (k, end) = ((b.face / 2) as usize, if b.face % 2 == 0 { 0.0 } else { 10.0 }); + assert!(f.iter().all(|p| p[k] == end), "face {} at {f:?}", b.face); + } + // The same input gives the same mesh, bit for bit. + let b = quality_mesh(&s, &cdt, Vec::new(), &o).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn a_sphere_keeps_its_boundary_on_the_sphere_to_second_order() { + let (p, t) = uv_sphere(10, 20); + let p: Vec<[f64; 3]> = p.iter().map(|c| c.map(|x| 5.0 * x)).collect(); + let s = surface(&p, &t, [0.0; 3]); + let faces = vec![0u32; s.triangles().len()]; + let s = BoundarySurface::new(s.points(), s.triangles(), &faces, &SurfaceOptions::default()).unwrap(); + let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap(); + let sphere = vec![Some(AnalyticSurface::Sphere { center: [0.0; 3], radius: 5.0 })]; + let o = QualityOptions { size: Some(1.5), order: Order::Quadratic, ..QualityOptions::default() }; + let m = quality_mesh(&s, &cdt, sphere, &o).unwrap(); + assert_eq!(m.kind, fieldcraft_model::ElementKind::Tet10); + assert_eq!(m.report.straightened, 0); + // Every node of a boundary face, corners and mid-edge nodes, lies on the sphere. + let mut on = 0; + for b in &m.boundary { + let e = &m.elements[b.tet as usize]; + let k = b.opposite as usize; + let corners: Vec = (0..4).filter(|&i| i != k).collect(); + let mids = + [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)].iter().enumerate().filter(|(_, (i, j))| *i != k && *j != k).map(|(n, _)| 4 + n); + for i in corners.into_iter().chain(mids) { + let q = m.points[e[i] as usize]; + let r = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt(); + assert!((r - 5.0).abs() < 1e-9, "a boundary node {i} of {e:?} at {q:?}, radius {r}; report {:?}", m.report); + on += 1; + } + } + assert!(on > 0); + assert!(m.report.output.min_dihedral > 15.0, "{:?}", m.report.output); + // Every element passes the certified check of the gate. + for e in &m.elements { + let xyz: Vec<[f64; 3]> = e.iter().map(|&v| m.points[v as usize]).collect(); + let v = crate::quality::element_validity(fieldcraft_model::ElementKind::Tet10, &xyz).unwrap(); + assert_eq!(v.validity, crate::quality::Validity::Valid); + } + } + + #[test] + fn caps_stop_the_stage_without_panicking() { + let s = box_surface(); + let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap(); + for o in [ + QualityOptions { size: Some(1e-3), max_points: 200, ..QualityOptions::default() }, + QualityOptions { size: Some(0.5), max_tets: 500, ..QualityOptions::default() }, + QualityOptions { size: Some(f64::NAN), ..QualityOptions::default() }, + QualityOptions { size: Some(-1.0), ..QualityOptions::default() }, + ] { + match quality_mesh(&s, &cdt, Vec::new(), &o) { + Ok(m) => assert!(!m.report.stopped.is_empty(), "{:?}", m.report), + Err(e) => assert!(matches!(e, crate::Error::Limit(_) | crate::Error::Invalid(_)), "{e}"), + } + } + } +} diff --git a/crates/mesh/src/vec3.rs b/crates/mesh/src/vec3.rs index fe47db7..c6c58a4 100644 --- a/crates/mesh/src/vec3.rs +++ b/crates/mesh/src/vec3.rs @@ -23,3 +23,18 @@ pub(crate) fn det(a: P, b: P, c: P) -> f64 { pub(crate) fn angle_deg(a: P, b: P) -> f64 { norm(cross(a, b)).atan2(dot(a, b)).to_degrees() } +pub(crate) fn add(a: P, b: P) -> P { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +pub(crate) fn scale(a: P, s: f64) -> P { + [a[0] * s, a[1] * s, a[2] * s] +} +pub(crate) fn dist(a: P, b: P) -> f64 { + norm(sub(a, b)) +} +pub(crate) fn lerp(a: P, b: P, t: f64) -> P { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] +} +pub(crate) fn mid(a: P, b: P) -> P { + [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])] +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 3a25193..958b516 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -58,7 +58,10 @@ pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, P pub use geometry::{Classification, Entity, GeometryRef}; pub use loads::{Distribution, Load, centrifugal_acceleration}; pub use materials::{Damage, Directional, Elastic, Erosion, Fluid, Hardening, HardeningPoint, Hyperelastic, Material, Plastic, Thermal}; -pub use mesh::{Element, ElementEdge, ElementFace, ElementKind, Node, check_coordinates}; +pub use mesh::{ + Element, ElementEdge, ElementFace, ElementKind, MIN_NORMALIZED_JACOBIAN, Node, admissible_jacobian, check_coordinates, normalized_jacobian, + tet4_jacobian, +}; pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind}; pub use sets::Set; pub use steps::{ diff --git a/crates/model/src/mesh.rs b/crates/model/src/mesh.rs index e8e7f2b..22beccb 100644 --- a/crates/model/src/mesh.rs +++ b/crates/model/src/mesh.rs @@ -227,6 +227,43 @@ pub fn check_coordinates(xyz: &[f64]) -> Result<()> { } } +/// The smallest normalized Jacobian ([`normalized_jacobian`]) of an admissible solid element. +/// +/// This is FieldCraft's one definition of an inverted or degenerate element. The solvers +/// (implicit and explicit) refuse an element whose Jacobian at one of their integration points is +/// not admissible ([`admissible_jacobian`]); the meshers' validity gate proves the same bound on +/// the whole element (`fieldcraft_mesh::quality`), so a mesh that passes the gate always passes +/// the solvers. A linear tetrahedron has one constant Jacobian ([`tet4_jacobian`]), which the gate +/// and the solvers compute from the same coordinate differences, so they agree on it bit for bit. +pub const MIN_NORMALIZED_JACOBIAN: f64 = 1e-12; + +/// The normalized Jacobian `det J / (|J₁| |J₂| |J₃|)` of a Jacobian matrix with rows +/// `J_a = ∂x/∂ξ_a`: the signed volume of the parallelepiped its rows span over that of a box with +/// the same edge lengths. It lies in `[-1, 1]` (Hadamard's inequality), does not depend on the +/// scale of each reference direction, is 1 for orthogonal rows and 0 for a flat element. A zero +/// row, or a matrix that is not finite, gives 0. +pub fn normalized_jacobian(j: &[[f64; 3]; 3]) -> f64 { + let [a, b, c] = j; + let det = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]); + let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product(); + let q = det / norms; + if q.is_finite() { q.clamp(-1.0, 1.0) } else { 0.0 } +} + +/// Whether a Jacobian matrix belongs to an admissible (not inverted, not degenerate) element: +/// its normalized Jacobian exceeds [`MIN_NORMALIZED_JACOBIAN`]. +pub fn admissible_jacobian(j: &[[f64; 3]; 3]) -> bool { + normalized_jacobian(j) > MIN_NORMALIZED_JACOBIAN +} + +/// The Jacobian matrix of a linear tetrahedron with corners `xyz` in tet4 order: its edges from +/// the first corner (`∂x/∂ξ_a` of the linear map), the form the solvers and the gate share. +pub fn tet4_jacobian(xyz: &[[f64; 3]; 4]) -> [[f64; 3]; 3] { + let [o, a, b, c] = xyz; + let e = |p: &[f64; 3]| [p[0] - o[0], p[1] - o[1], p[2] - o[2]]; + [e(a), e(b), e(c)] +} + pub(crate) fn check_label(what: &str, label: u32) -> Result<()> { if label == 0 || label > MAX_LABEL { return Err(Error::Invalid(format!("{what} label {label} is outside 1..={MAX_LABEL}"))); diff --git a/crates/solver/Cargo.toml b/crates/solver/Cargo.toml index 860f48c..07738db 100644 --- a/crates/solver/Cargo.toml +++ b/crates/solver/Cargo.toml @@ -14,3 +14,7 @@ fieldcraft-fem = { workspace = true } fieldcraft-parallel = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +# The validity gate, to prove the solvers and the meshers share one criterion. +fieldcraft-mesh = { workspace = true } diff --git a/crates/solver/tests/validity.rs b/crates/solver/tests/validity.rs new file mode 100644 index 0000000..47bdd9e --- /dev/null +++ b/crates/solver/tests/validity.rs @@ -0,0 +1,76 @@ +//! One validity criterion: the meshers' gate and both solvers decide every element alike. +//! +//! The end-to-end demo of 2026-10-13 met a raw CDT sliver that the gate accepted (its exact +//! orientation is positive) and the solver refused (its Jacobian was below the solver's +//! tolerance). Both now use `fieldcraft_model::admissible_jacobian`: the gate on the whole +//! element, the implicit solver at its integration points, the explicit tetrahedron on its one +//! Jacobian. + +use fieldcraft_fem::uniform::UniformStrain; +use fieldcraft_fem::{Formulation, Solid, material::isotropic}; +use fieldcraft_mesh::quality::{Validity, element_validity}; +use fieldcraft_model::{ElementKind, MIN_NORMALIZED_JACOBIAN, normalized_jacobian, tet4_jacobian}; + +fn implicit_accepts(kind: ElementKind, xyz: &[[f64; 3]]) -> bool { + let d = isotropic(210_000.0, 0.3).unwrap(); + Solid::new(kind, xyz, Formulation::Full).and_then(|e| e.stiffness(&d)).is_ok() +} + +fn gate_accepts(kind: ElementKind, xyz: &[[f64; 3]]) -> bool { + element_validity(kind, xyz).map(|c| c.validity == Validity::Valid).unwrap_or(false) +} + +/// Slivers flattening through the criterion's threshold, near and far from the origin: the gate, +/// the implicit solver and the explicit tetrahedron agree on every one, and some of them are +/// exactly positive yet inadmissible (the case the demo met). +#[test] +fn gate_and_solvers_agree_on_slivers() { + let mut refused_positive = 0; + let mut accepted = 0; + for offset in [0.0, 1e3, -7.25e4] { + for k in 0..400 { + // Heights from 1e-17 to 1e-7 of the edge length, on a log scale with jitter. + let h = 10f64.powf(-17.0 + 10.0 * k as f64 / 400.0) * (1.0 + 0.37 * ((k * 7919) % 13) as f64 / 13.0); + let o = [offset, 0.5 * offset, -offset]; + let xyz = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.4, 0.35, h]].map(|p| [p[0] + o[0], p[1] + o[1], p[2] + o[2]]); + let gate = gate_accepts(ElementKind::Tet4, &xyz); + let implicit = implicit_accepts(ElementKind::Tet4, &xyz); + let explicit = UniformStrain::tet4(&xyz).is_ok(); + assert_eq!( + (gate, implicit, explicit), + (gate, gate, gate), + "h = {h:e}, offset {offset}: gate {gate}, implicit {implicit}, explicit {explicit}" + ); + let q = normalized_jacobian(&tet4_jacobian(&xyz)); + if !gate && q > 0.0 { + refused_positive += 1; + } + accepted += usize::from(gate); + assert_eq!(gate, q > MIN_NORMALIZED_JACOBIAN, "h = {h:e}"); + } + } + assert!(refused_positive > 0 && accepted > 0, "{refused_positive} refused, {accepted} accepted"); +} + +/// A curved tetrahedron the gate accepts is accepted by the implicit solver at every integration +/// point; the gate proves the criterion on the whole element. +#[test] +fn gate_accepts_only_what_the_solver_accepts_for_tet10() { + let corners = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.3, 0.3, 1.0]]; + let edges = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)]; + for k in 0..200 { + let bend = -0.6 + 1.2 * k as f64 / 200.0; + let mut xyz: Vec<[f64; 3]> = corners.to_vec(); + for (e, &(a, b)) in edges.iter().enumerate() { + let (p, q): ([f64; 3], [f64; 3]) = (corners[a], corners[b]); + let mut m = [0.5 * (p[0] + q[0]), 0.5 * (p[1] + q[1]), 0.5 * (p[2] + q[2])]; + if e == 1 { + m[2] += bend; + } + xyz.push(m); + } + if gate_accepts(ElementKind::Tet10, &xyz) { + assert!(implicit_accepts(ElementKind::Tet10, &xyz), "bend {bend}"); + } + } +} diff --git a/crates/testkit/src/runner.rs b/crates/testkit/src/runner.rs index 247cea1..40a54e4 100644 --- a/crates/testkit/src/runner.rs +++ b/crates/testkit/src/runner.rs @@ -19,6 +19,7 @@ use fieldcraft_mesh::hex::{Cell, HexOptions, Surface, hex_mesh, hex_mesh_cells}; use fieldcraft_mesh::octree::{OctreeOptions, octree_hex_mesh}; use fieldcraft_mesh::size::UniformSize; use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions}; +use fieldcraft_mesh::tet::{QualityOptions, quality_mesh}; pub const FILE: &str = "docs/meshing/corpus.md"; /// Chordal tolerance of the boundary surface, relative to the part's bounding-box diagonal. @@ -47,7 +48,7 @@ pub trait Stage { /// The stages, in column order. Meshers add theirs here. fn stages() -> Vec> { - vec![Box::new(SurfaceVolume), Box::new(BoundaryCheck), Box::new(TetCdt), Box::new(StructuredHex), Box::new(OctreeHex)] + vec![Box::new(SurfaceVolume), Box::new(BoundaryCheck), Box::new(TetCdt), Box::new(TetQualityStage), Box::new(StructuredHex), Box::new(OctreeHex)] } /// The boundary surface's enclosed volume against SolidCraft's measured volume and the closed @@ -104,18 +105,63 @@ impl Stage for TetCdt { fn run(&self, l: &Loaded) -> Result, String> { let start = Instant::now(); - let (mut tets, mut steiner, mut cavities) = (0, 0, 0); + let (mut tets, mut steiner, mut cavities, mut inadmissible) = (0, 0, 0, 0); for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) { let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() }; let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?; let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?; - m.gate(s.volume()).and_then(|r| r.into_result()).map_err(|e| format!("{}: rounded mesh: {e}", body.name))?; + let report = m.gate(s.volume()).map_err(|e| format!("{}: rounded mesh: {e}", body.name))?; + if !report.volume_matches() { + return Err(format!("{}: rounded mesh: {:?}", body.name, report.into_result().err())); + } + inadmissible += report.invalid.len() + report.undetermined.len(); tets += m.tets.len(); steiner += m.steiner.len(); cavities += m.stats.cavities; } let ms = start.elapsed().as_secs_f64() * 1e3; - Ok(vec![tets.to_string(), steiner.to_string(), cavities.to_string(), "pass".into(), format!("{ms:.0}")]) + let gate = if inadmissible == 0 { "pass".to_string() } else { format!("{inadmissible} flat") }; + Ok(vec![tets.to_string(), steiner.to_string(), cavities.to_string(), gate, format!("{ms:.0}")]) + } +} + +/// The quality stage (`fieldcraft_mesh::tet`) on each body's CDT, with the default options: the +/// mesh's tetrahedra, its extreme dihedral angles, the slivers (a dihedral angle below 10° or +/// above 170°) of the CDT and of the result, and the validity gate. +struct TetQualityStage; + +impl Stage for TetQualityStage { + fn headers(&self) -> &'static [&'static str] { + &["quality tets", "min dihedral °", "max dihedral °", "min radius ratio", "slivers CDT → mesh", "quality gate", "quality ms"] + } + + fn run(&self, l: &Loaded) -> Result, String> { + let start = Instant::now(); + let (mut tets, mut lo, mut hi, mut rr, mut before, mut after) = (0, 180.0_f64, 0.0_f64, 1.0_f64, 0, 0); + for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) { + let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() }; + let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?; + let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?; + let q = quality_mesh(&s, &m, fieldcraft_engine::analytic_surfaces(body), &QualityOptions::default()) + .map_err(|e| format!("{}: {e}", body.name))?; + let r = &q.report; + tets += r.output.tets; + lo = lo.min(r.output.min_dihedral); + hi = hi.max(r.output.max_dihedral); + rr = rr.min(r.output.min_radius_ratio); + before += r.input.slivers; + after += r.output.slivers; + } + let ms = start.elapsed().as_secs_f64() * 1e3; + Ok(vec![ + tets.to_string(), + format!("{lo:.1}"), + format!("{hi:.1}"), + format!("{rr:.3}"), + format!("{before} → {after}"), + "pass".into(), + format!("{ms:.0}"), + ]) } } diff --git a/docs/architecture.md b/docs/architecture.md index 7818c96..92a0d4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,9 +65,13 @@ Rules beyond the layer order: perturbation for degeneracies), boundary recovery by the robust constrained Delaunay method of Diazzi et al. 2023 (implicit Steiner points on indirect predicates, gift-wrapping facet recovery; `fieldcraft-mesh::cdt`, command `mesh.tet.generate`), so every boundary triangle and - its B-rep face tag survives. Then constrained Delaunay refinement to a - size field, ODT smoothing interleaved as in Tournois et al. 2009, and the Klingner–Shewchuk 2007 - improvement schedule against slivers. + its B-rep face tag survives; rounding is repaired by flips (§5.1). Then the quality stage + (`fieldcraft-mesh::tet`): a curvature-graded size field, edge-collapse coarsening, constrained + Delaunay refinement with boundary points projected onto the B-rep faces and curves (exact + surfaces from SolidCraft where analytic, the tessellation otherwise), ODT smoothing + interleaved as in Tournois et al. 2009, the Klingner–Shewchuk 2007 improvement schedule + against slivers, and tet10 with mid-edge nodes on the geometry. Every step is a checked local + replacement, so the mesh stays valid throughout. 4. **Hexahedral meshing (the core).** Grid-based all-hex first, the one family that meshes any input validly. `fieldcraft-mesh::octree` (command `mesh.hex.octree`): a 27-tree over the part, refined by curvature, thickness and narrow faces, balanced and cut by the 3-refinement @@ -107,7 +111,10 @@ Rules beyond the layer order: faces, edges and vertices become geometry tags that sets select. It meshes the analytic shapes of the verification cases. 6. **Quality.** Scaled Jacobian, aspect ratio, dihedral angles; every generated mesh is checked - for positive Jacobians and conserved volume before it reaches the model. + for valid Jacobians and conserved volume before it reaches the model. Validity is one + criterion shared with both solvers (`fieldcraft_model::admissible_jacobian`: normalized + Jacobian above 1e-12): the gate proves it on the whole element, the solvers check it at their + integration points, so a mesh the gate accepts is never refused by a solver. ## Model definition diff --git a/docs/features.md b/docs/features.md index fc8f1fc..8470c1a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -28,7 +28,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Size | Global Size — One element size for the whole part | coming soon (M2) | | | Size | Seed Edges — Element counts, sizes and bias per edge and face | coming soon (M2) | | | Size | Curvature — Refine by curvature and proximity | coming soon (M2) | | -| Tet | Tet Mesh — Constrained Delaunay tet mesh of a body: every boundary triangle kept, faces tagged (refinement comes next) | ready | `mesh.tet.generate` | +| Tet | Tet Mesh — Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged | ready | `mesh.tet.generate` | | Tet | Quadratic — Second-order elements with mid-side nodes on the geometry | coming soon (M2) | | | Tet | Delaunay Points — Delaunay tetrahedralization of a point list (debugging aid) | ready | `mesh.delaunay.points` | | 2D | 2D Mesh — Quad or triangle mesh of a planar face or polygon in the XY plane for plane stress, plane strain and axisymmetric models; edges tagged | ready | `mesh.planar` | diff --git a/docs/meshing/literature.md b/docs/meshing/literature.md index 9375b80..e1032bf 100644 --- a/docs/meshing/literature.md +++ b/docs/meshing/literature.md @@ -250,9 +250,12 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant - Reference points (§3.3) are searched among the tetrahedra whose boxes meet the missing subsegment's box (they contain the tetrahedra the subsegment crosses); on the corpus this picks the same point as searching the whole diametral ball. - - Rounding (§5.1): a Steiner point whose rounding leaves an incident tetrahedron non-positive tries the doubles - within two ulps per coordinate, nearest first; the flip-based improvement of §5.1 is not implemented. All 20 - valid corpus parts pass the validity gate after rounding. + - Rounding (§5.1): the flips of §5.1 run on the rounded coordinates (`cdt::repair`), decided by exact `orient3d` on + the doubles: every tetrahedron that rounding (or nearly cospherical input) leaves flat, inverted or below + FieldCraft's validity criterion is targeted by edge removal and multi-face removal that never touch a segment, and + a flat tetrahedron lying on one input triangle is deleted. What survives (a flat tetrahedron spanning two nearly + coplanar input triangles: 1–65 per part on 7 corpus parts) is counted by the gate and removed by the quality stage, + whose boundary is the B-rep faces rather than the input triangles. **A2.4 Diazzi and Attene 2021, convex polyhedral meshing.** **Verdict: reference.** - **Citation.** L. Diazzi, M. Attene. *Convex polyhedral meshing for robust solid modeling.* ACM TOG 40(6):259, 2021. @@ -319,6 +322,16 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant - **Complexity.** Medium, on top of A2.3. - **Code.** None read. +- **Implemented** in `fieldcraft-mesh::tet::refine`: circumcentres of tetrahedra whose radius–edge ratio exceeds B = 2 + (when their circumradius is at least half the local size) or whose circumradius exceeds 0.75 of the local size; a + circumcentre outside the domain or inside a boundary face's diametral sphere splits that face instead (at its + circumcentre when inside it, else at its longest edge's midpoint, a feature edge split when that edge is one), every + boundary point projected onto its B-rep face or curve. Small input angles are handled by bounds rather than Si's + theory: faces below half the local size are not split, no vertex closer than 0.05 of the size to another, a + tetrahedron whose point could not go in is not retried, caps on insertions and tetrahedra. Before refinement, edge + collapse coarsens the boundary recovery's dense Steiner points (X. Li, M. S. Shephard, M. W. Beall, *3D anisotropic + mesh adaptation by mesh modification*, CMAME 194(48–49):4915–4950, 2005). + **A3.2 Cheng, Dey, Shewchuk, *Delaunay Mesh Generation* (CRC, 2012).** **Verdict: reference (the textbook).** - **Links.** DOI [10.1201/b12987](https://doi.org/10.1201/b12987). - **Use.** The consolidated treatment of 3D refinement, small-angle handling and sliver theory. Buy it. @@ -356,6 +369,12 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant - **Complexity.** Moderate. - **Code.** CGAL is GPL: FORBIDDEN. This paper is the readable source of the scheme. +- **Implemented** in `fieldcraft-mesh::tet::smooth` as the density-weighted ODT update (Chen–Xu, Alliez et al.; weight + `h⁻³`), interleaved with refinement in three rounds as in A3.5; boundary vertices move in their face's tangent plane + and are projected back onto the B-rep face, curve vertices move along their curve, corners stay. A move is kept when + every tetrahedron stays positive and its star's worst quality stays above min(old worst, sin 17.5°). Tetrahedra whose + circumcentre is further than their longest edge from their centroid contribute their centroid instead. + **A3.6 Klingner and Shewchuk 2007, Aggressive Tetrahedral Mesh Improvement.** **Verdict: BUILD ON (final sliver-removal stage).** - **Citation.** B. M. Klingner, J. R. Shewchuk. IMR 16, 3–23, 2007. - **Links.** DOI [10.1007/978-3-540-75103-8_1](https://doi.org/10.1007/978-3-540-75103-8_1). @@ -376,6 +395,16 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant - **Complexity.** Moderate: edge removal requires Shewchuk's dynamic programming over the edge ring. PDF: https://people.eecs.berkeley.edu/~jrs/papers/edge.pdf, not separately verified. - **Code.** The Stellar code's licence was not verified, so it is not read. +- **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; 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 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. **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). diff --git a/docs/oracle.md b/docs/oracle.md index 03516a6..e9e5b45 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -2,7 +2,7 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. No reference value was produced with a commercial product. -**170 of 176 quantities pass** (6 known failure(s)) in 50 active cases: 41 theory, 9 published benchmark, 0 experiment; 9 pending cases wait for capabilities still being built. +**174 of 180 quantities pass** (6 known failure(s)) in 52 active cases: 43 theory, 9 published benchmark, 0 experiment; 9 pending cases wait for capabilities still being built. ## Theory: closed-form solutions, patch tests and mesh invariants @@ -28,6 +28,10 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the | [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | volume of the tetrahedra | 47000 | 47000 | < 1e-7% | 1e-12 rel. | pass | matches | | [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | elements that fail the certified Jacobian check | 0 | 0 | ≈ 0 | exact | pass | matches | | [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | elements the Jacobian check could not decide | 0 | 0 | ≈ 0 | exact | pass | matches | +| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | 213.31 | 0.144% | 3% | pass | matches | +| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches | +| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | 213.31 | 0.144% | 3% | pass | matches | +| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches | | [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | sixth frequency (the last rigid-body mode) | 0 | ≈ 0 | ≈ 0 | ± 8.1264e-5 | pass | matches | | [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | first free–free bending frequency (mode 7) | 0.081264 | 0.080618 | -0.795% | 1.5% | pass | matches | | [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | total mass | 20 | 20 | < 1e-7% | 1e-12 rel. | pass | matches | @@ -200,6 +204,8 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the - **[cantilever-tet10](../tests/oracle/cantilever-tet10/)** (theory): Cantilever under a tip load, Timoshenko deflection (tet10). Source: Timoshenko and Gere, Mechanics of Materials (beam with shear deformation: δ = PL³/3EI + PL/κGA, κ = 5/6 for a rectangle); Cowper, J. Appl. Mech. 33 (1966) 335–340 for the shear coefficient. Tolerances — tip_uz: beam theory vs 3D elasticity (a few tenths of a percent at L/h = 10); quadratic tetrahedra are converged on this mesh; reaction: equilibrium holds to rounding - **[cdt-cylinder](../tests/oracle/cdt-cylinder/)** (theory): Constrained Delaunay mesh of a cylinder: volume within the chordal bound, valid elements. Source: mesh invariants: an inscribed polygon of sagitta s loses at most 2s/R of a circle's area (elementary geometry); constrained Delaunay tetrahedralization after Diazzi et al., ACM TOG 42(6) 2023. Tolerances — volume: the tessellation inscribes the cylinder within the chordal tolerance s = 0.05: the polygon section loses between 0 and 2s/R = 0.5% of the area; the reference is the middle of that band; invalid: every element of a valid mesh has positive volume - **[cdt-stepped-block](../tests/oracle/cdt-stepped-block/)** (theory): Constrained Delaunay mesh of a stepped block: exact volume, valid elements. Source: mesh invariants: a tetrahedralization of a polyhedron fills its volume exactly (Si, TetGen paper ACM TOMS 41(2) 2015 for the definition of a CDT; Diazzi et al., ACM TOG 42(6) 2023); the volume is 60·40·10 + 40·40·10 + 20·40·10 − 10·10·10. Tolerances — volume: a planar boundary is tessellated exactly and the constrained Delaunay mesh fills it exactly: only rounding; invalid: every element of a valid mesh has positive volume; undetermined: a linear tetrahedron's sign is exact +- **[filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/)** (theory): Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, fine tet10 mesh. Source: mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium. Tolerances — fillet_peak: the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%; reaction: equilibrium holds to rounding +- **[filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/)** (theory): Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, coarse tet10 mesh. Source: mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium. Tolerances — fillet_peak: the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%; reaction: equilibrium holds to rounding - **[free-beam-frequencies](../tests/oracle/free-beam-frequencies/)** (theory): Natural frequencies of a free beam: rigid-body modes and the first bending mode. Source: Euler–Bernoulli free–free beam, β₁L = 4.7300; Timoshenko, Young and Weaver, Vibration Problems in Engineering, 4th ed., Wiley 1974, §5.3. Tolerances — rigid_6: a free body has six zero frequencies; numerical rigid-body frequencies below 10⁻³ of the first elastic one (eigenvalues below 10⁻⁶ of it) are rounding of the shifted solve; f7: Euler–Bernoulli neglects shear and rotary inertia, about 0.8% at L/h = 20 for the free–free mode; mass: the mass matrix integrates the density exactly - **[j2-bauschinger](../tests/oracle/j2-bauschinger/)** (theory): Bauschinger effect of linear kinematic hardening: tension, then back to zero strain. Source: J2 plasticity with Prager's linear kinematic hardening in uniaxial stress (W. Prager, Ing.-Arch. 1955; Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3): from σ_a = σ_y + E_t (ε_a − σ_y/E) the material unloads elastically by 2σ_y and then hardens with E_t, so at zero strain σ = σ_a − 2σ_y − E_t (ε_a − 2σ_y/E). Tolerances — residual_stress: a homogeneous small-strain state the element represents exactly, with increments that end at the load path's turn; isotropic hardening would give −281.835 (it yields again only at −σ_a); plastic_strain: (σ_a − σ_y)/H forward plus E/(E + H) (ε_a − 2σ_y/E) in reverse - **[j2-heated-bar](../tests/oracle/j2-heated-bar/)** (theory): A steel bar held at its ends and heated past yield (small strain). Source: Uniaxial J2 plasticity with linear hardening (Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3) with the additive thermal strain: the bar's total axial strain is held at zero, so its mechanical strain is −α ΔT and σ = −(σ_y + E_t (α ΔT − σ_y/E)) once α ΔT exceeds σ_y/E. Tolerances — sigma: a homogeneous uniaxial state the elements represent exactly; plastic_strain: (\|σ\| − σ_y)/H, as for the stress diff --git a/tests/oracle/cdt-cylinder/recipe.json b/tests/oracle/cdt-cylinder/recipe.json index 3b618a2..a90ad69 100644 --- a/tests/oracle/cdt-cylinder/recipe.json +++ b/tests/oracle/cdt-cylinder/recipe.json @@ -1,3 +1,3 @@ [ - {"command": "mesh.tet.generate", "params": {"body": "Cylinder", "tolerance": 0.05}, "id": "tet"} + {"command": "mesh.tet.generate", "params": {"body": "Cylinder", "tolerance": 0.05, "quality": false}, "id": "tet"} ] diff --git a/tests/oracle/cdt-stepped-block/recipe.json b/tests/oracle/cdt-stepped-block/recipe.json index 064fa2e..87d2995 100644 --- a/tests/oracle/cdt-stepped-block/recipe.json +++ b/tests/oracle/cdt-stepped-block/recipe.json @@ -1,3 +1,3 @@ [ - {"command": "mesh.tet.generate", "params": {"body": "Steps", "tolerance": 0.05}, "id": "tet"} + {"command": "mesh.tet.generate", "params": {"body": "Steps", "tolerance": 0.05, "quality": false}, "id": "tet"} ] diff --git a/tests/oracle/filleted-bracket-fillet-stress-coarse/README.md b/tests/oracle/filleted-bracket-fillet-stress-coarse/README.md new file mode 100644 index 0000000..c3be6f4 --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress-coarse/README.md @@ -0,0 +1,12 @@ +# Filleted L-bracket: peak stress on the fillet (coarse mesh) + +The corpus part `filleted-bracket` (an 8 mm L-bracket with an R6 inside fillet and a hole through +each leg), built with SolidCraft from `design.json`. The foot face `F2:side:l1` is clamped and a +2 kN load in +x acts on the top face `F2:side:l5` (steel, E = 210 GPa, ν = 0.3). The leg bends +about its root, where the fillet concentrates the stress; beam theory gives about 197 MPa nominal +at the root (6FL/(bt²) with L = 42 mm, b = 40 mm, t = 8 mm). + +`mesh.tet.generate` makes a quality tet10 mesh (size 16 mm, 90° per element along curved +faces), with the boundary and mid-edge nodes on the exact fillet cylinder. The peak nodal von +Mises stress on the fillet over a four-level study (sizes 16, 11.3, 8, 5.66 mm with 90°, 64°, +45°, 32°): 213.3, 210.6, 214.3, 213.3 MPa. The coarse and the fine level are the two cases. diff --git a/tests/oracle/filleted-bracket-fillet-stress-coarse/design.json b/tests/oracle/filleted-bracket-fillet-stress-coarse/design.json new file mode 100644 index 0000000..8135dfd --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress-coarse/design.json @@ -0,0 +1,89 @@ +{ + "commands": [ + { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Profile" + } + }, + { + "command": "sketch.line", + "params": { + "points": [ + [ + 0, + 0 + ], + [ + 60, + 0 + ], + [ + 60, + 8 + ], + [ + 8, + 8 + ], + [ + 8, + 50 + ], + [ + 0, + 50 + ] + ], + "closed": true + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "distance": 40, + "body_name": "Bracket" + } + }, + { + "command": "solid.fillet", + "params": { + "edges": [ + [ + 8, + 8, + 20 + ] + ], + "radius": 6 + } + }, + { + "command": "solid.hole", + "params": { + "position": [ + 40, + 8, + 20 + ], + "diameter": 8 + } + }, + { + "command": "solid.hole", + "params": { + "position": [ + 8, + 32, + 20 + ], + "diameter": 8 + } + } + ] +} diff --git a/tests/oracle/filleted-bracket-fillet-stress-coarse/expected.json b/tests/oracle/filleted-bracket-fillet-stress-coarse/expected.json new file mode 100644 index 0000000..1bdd036 --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress-coarse/expected.json @@ -0,0 +1,38 @@ +{ + "title": "Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, coarse tet10 mesh", + "kind": "theory", + "source": "mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium", + "slow": true, + "quantities": [ + { + "id": "fillet_peak", + "quantity": "largest nodal von Mises stress on the fillet face (MPa)", + "measure": { + "command": "solve.probe", + "params": { + "step": "Load", + "set": "fillet" + }, + "pointer": "/max_von_mises/value" + }, + "value": 213.0, + "tolerance": { + "relative": 0.03 + }, + "reason": "the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%" + }, + { + "id": "reaction", + "quantity": "total reaction x (N)", + "measure": { + "from": "solve", + "pointer": "/reaction_total/0" + }, + "value": -2000.0, + "tolerance": { + "relative": 1e-09 + }, + "reason": "equilibrium holds to rounding" + } + ] +} diff --git a/tests/oracle/filleted-bracket-fillet-stress-coarse/fixture.json b/tests/oracle/filleted-bracket-fillet-stress-coarse/fixture.json new file mode 100644 index 0000000..5be5987 --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress-coarse/fixture.json @@ -0,0 +1,13 @@ +{ + "note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.", + "values": { + "fillet_peak": { + "band": 2.1330697455903942e-7, + "value": 213.30697455903942 + }, + "reaction": { + "band": 2.000000000000858e-6, + "value": -2000.0000000008579 + } + } +} diff --git a/tests/oracle/filleted-bracket-fillet-stress-coarse/recipe.json b/tests/oracle/filleted-bracket-fillet-stress-coarse/recipe.json new file mode 100644 index 0000000..a627128 --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress-coarse/recipe.json @@ -0,0 +1,13 @@ +[ + {"command": "mesh.tet.generate", "params": {"body": "Bracket", "tolerance": 0.088, "size": 16.0, "curvature_angle": 90.0, "order": "quadratic"}, "id": "tet"}, + {"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-09}}, + {"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Bracket"}]}}, + {"command": "model.set.create", "params": {"name": "foot", "geometry": [{"body": "Bracket", "face": "F2:side:l1"}]}}, + {"command": "model.set.create", "params": {"name": "top", "geometry": [{"body": "Bracket", "face": "F2:side:l5"}]}}, + {"command": "model.set.create", "params": {"name": "fillet", "geometry": [{"body": "Bracket", "face": "F3:blend:F2:side:l3|F2:side:l4"}]}}, + {"command": "model.section.create", "params": {"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}}}, + {"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "foot"}}}, + {"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "top"}, "vector": [2000, 0, 0]}}, + {"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}}, + {"command": "solve.static", "params": {}, "id": "solve"} +] diff --git a/tests/oracle/filleted-bracket-fillet-stress/README.md b/tests/oracle/filleted-bracket-fillet-stress/README.md new file mode 100644 index 0000000..9f8683f --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress/README.md @@ -0,0 +1,12 @@ +# Filleted L-bracket: peak stress on the fillet (fine mesh) + +The corpus part `filleted-bracket` (an 8 mm L-bracket with an R6 inside fillet and a hole through +each leg), built with SolidCraft from `design.json`. The foot face `F2:side:l1` is clamped and a +2 kN load in +x acts on the top face `F2:side:l5` (steel, E = 210 GPa, ν = 0.3). The leg bends +about its root, where the fillet concentrates the stress; beam theory gives about 197 MPa nominal +at the root (6FL/(bt²) with L = 42 mm, b = 40 mm, t = 8 mm). + +`mesh.tet.generate` makes a quality tet10 mesh (size 5.66 mm, 31.8° per element along curved +faces), with the boundary and mid-edge nodes on the exact fillet cylinder. The peak nodal von +Mises stress on the fillet over a four-level study (sizes 16, 11.3, 8, 5.66 mm with 90°, 64°, +45°, 32°): 213.3, 210.6, 214.3, 213.3 MPa. The coarse and the fine level are the two cases. diff --git a/tests/oracle/filleted-bracket-fillet-stress/design.json b/tests/oracle/filleted-bracket-fillet-stress/design.json new file mode 100644 index 0000000..8135dfd --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress/design.json @@ -0,0 +1,89 @@ +{ + "commands": [ + { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Profile" + } + }, + { + "command": "sketch.line", + "params": { + "points": [ + [ + 0, + 0 + ], + [ + 60, + 0 + ], + [ + 60, + 8 + ], + [ + 8, + 8 + ], + [ + 8, + 50 + ], + [ + 0, + 50 + ] + ], + "closed": true + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "distance": 40, + "body_name": "Bracket" + } + }, + { + "command": "solid.fillet", + "params": { + "edges": [ + [ + 8, + 8, + 20 + ] + ], + "radius": 6 + } + }, + { + "command": "solid.hole", + "params": { + "position": [ + 40, + 8, + 20 + ], + "diameter": 8 + } + }, + { + "command": "solid.hole", + "params": { + "position": [ + 8, + 32, + 20 + ], + "diameter": 8 + } + } + ] +} diff --git a/tests/oracle/filleted-bracket-fillet-stress/expected.json b/tests/oracle/filleted-bracket-fillet-stress/expected.json new file mode 100644 index 0000000..620cfa1 --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress/expected.json @@ -0,0 +1,38 @@ +{ + "title": "Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, fine tet10 mesh", + "kind": "theory", + "source": "mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium", + "slow": true, + "quantities": [ + { + "id": "fillet_peak", + "quantity": "largest nodal von Mises stress on the fillet face (MPa)", + "measure": { + "command": "solve.probe", + "params": { + "step": "Load", + "set": "fillet" + }, + "pointer": "/max_von_mises/value" + }, + "value": 213.0, + "tolerance": { + "relative": 0.03 + }, + "reason": "the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%" + }, + { + "id": "reaction", + "quantity": "total reaction x (N)", + "measure": { + "from": "solve", + "pointer": "/reaction_total/0" + }, + "value": -2000.0, + "tolerance": { + "relative": 1e-09 + }, + "reason": "equilibrium holds to rounding" + } + ] +} diff --git a/tests/oracle/filleted-bracket-fillet-stress/fixture.json b/tests/oracle/filleted-bracket-fillet-stress/fixture.json new file mode 100644 index 0000000..f94a9f4 --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress/fixture.json @@ -0,0 +1,13 @@ +{ + "note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.", + "values": { + "fillet_peak": { + "band": 2.1330693756948646e-7, + "value": 213.30693756948645 + }, + "reaction": { + "band": 2.0000000000001007e-6, + "value": -2000.0000000001007 + } + } +} diff --git a/tests/oracle/filleted-bracket-fillet-stress/recipe.json b/tests/oracle/filleted-bracket-fillet-stress/recipe.json new file mode 100644 index 0000000..b914cae --- /dev/null +++ b/tests/oracle/filleted-bracket-fillet-stress/recipe.json @@ -0,0 +1,13 @@ +[ + {"command": "mesh.tet.generate", "params": {"body": "Bracket", "tolerance": 0.088, "size": 5.65685424949238, "curvature_angle": 31.819805153394636, "order": "quadratic"}, "id": "tet"}, + {"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-09}}, + {"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Bracket"}]}}, + {"command": "model.set.create", "params": {"name": "foot", "geometry": [{"body": "Bracket", "face": "F2:side:l1"}]}}, + {"command": "model.set.create", "params": {"name": "top", "geometry": [{"body": "Bracket", "face": "F2:side:l5"}]}}, + {"command": "model.set.create", "params": {"name": "fillet", "geometry": [{"body": "Bracket", "face": "F3:blend:F2:side:l3|F2:side:l4"}]}}, + {"command": "model.section.create", "params": {"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}}}, + {"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "foot"}}}, + {"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "top"}, "vector": [2000, 0, 0]}}, + {"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}}, + {"command": "solve.static", "params": {}, "id": "solve"} +]