forked from 1b-agent/fieldcraft-upstream
Exact 3D Delaunay tetrahedralization (Bowyer–Watson, ghost hull, BRIO/Hilbert, verifier) (#14)
* mesh: exact Bowyer-Watson Delaunay tetrahedralization with ghost tets, BRIO/Hilbert order and a verifier Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mesh: Delaunay benchmark, hash-paired cavity faces, mesh.delaunay.points debug command Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mesh: simplify Delaunay helpers, share predicates and parsers, halve verifier work Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
13 files changed
+1508
-17
No files matched your search
@@ -1,8 +1,9 @@
|
||||
use modelcraft_mesh::delaunay::Triangulation;
|
||||
use modelcraft_mesh::quality::{self, QualityReport};
|
||||
use modelcraft_model::{ElementKind, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{label, labels, xyz};
|
||||
use super::{label, labels, points, xyz};
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
@@ -32,6 +33,30 @@ pub const QUALITY: CommandSpec = CommandSpec {
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
id: "mesh.delaunay.points",
|
||||
label: "Delaunay of Points",
|
||||
params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#,
|
||||
run: delaunay_points,
|
||||
};
|
||||
|
||||
/// The most points `mesh.delaunay.points` takes (its report lists every tetrahedron).
|
||||
const DELAUNAY_MAX_POINTS: usize = 20_000;
|
||||
|
||||
fn delaunay_points(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let points = points(p, "points", DELAUNAY_MAX_POINTS)?;
|
||||
let tri = Triangulation::new(&points)?;
|
||||
tri.verify()?;
|
||||
let duplicates: Vec<[u32; 2]> = (0..points.len() as u32).filter_map(|v| tri.duplicate_of(v).map(|d| [v, d])).collect();
|
||||
let tets: Vec<[u32; 4]> = tri.finite_tets().map(|(_, vs)| vs).collect();
|
||||
Ok(json!({
|
||||
"dimension": tri.dimension(),
|
||||
"vertices": points.len() - duplicates.len(),
|
||||
"duplicates": duplicates,
|
||||
"tets": tets,
|
||||
}))
|
||||
}
|
||||
|
||||
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let l = label(p, "label")?;
|
||||
s.model.set_node(l, xyz(p, "xyz")?)?;
|
||||
@@ -127,6 +152,32 @@ mod tests {
|
||||
s.execute("mesh.element.add", &json!({"label": label, "kind": "hex8", "nodes": nodes})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_of_points_reports_tets_and_duplicates() {
|
||||
let mut s = Session::default();
|
||||
let cube = json!({"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], [1, 1, 1]]});
|
||||
let r = s.execute("mesh.delaunay.points", &cube).unwrap();
|
||||
assert_eq!(r["dimension"], 3);
|
||||
assert_eq!(r["vertices"], 8);
|
||||
assert_eq!(r["duplicates"], json!([[8, 7]]));
|
||||
let tets = r["tets"].as_array().unwrap();
|
||||
assert!((5..=6).contains(&tets.len()), "{tets:?}");
|
||||
assert!(s.journal.is_empty(), "a report does not change the model");
|
||||
|
||||
let flat = s.execute("mesh.delaunay.points", &json!({"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]]})).unwrap();
|
||||
assert_eq!((flat["dimension"].clone(), flat["tets"].clone()), (json!(2), json!([])));
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"points": [[0, 0]]}),
|
||||
json!({"points": [[0, 0, "x"]]}),
|
||||
json!({"points": [[f64::MAX, 0, 0]]}),
|
||||
json!({"points": [[1e-300, 0, 0]]}),
|
||||
json!({"points": vec![[0, 0, 0]; 20_001]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.delaunay.points", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_quality_and_inverted_elements() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::ELEMENT_ADD,
|
||||
mesh::ELEMENT_REMOVE,
|
||||
mesh::QUALITY,
|
||||
mesh::DELAUNAY_POINTS,
|
||||
];
|
||||
|
||||
/// A required label parameter (1..=u32::MAX; the model checks its own range).
|
||||
@@ -32,13 +33,22 @@ pub(crate) fn label(p: &Value, key: &str) -> Result<u32> {
|
||||
|
||||
/// A required `[x, y, z]` parameter.
|
||||
pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
|
||||
let bad = || EngineError::Params(format!("`{key}` must be [x, y, z]"));
|
||||
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(bad)?;
|
||||
let mut out = [0.0; 3];
|
||||
for (o, v) in out.iter_mut().zip(a) {
|
||||
*o = v.as_f64().ok_or_else(bad)?;
|
||||
p.get(key).and_then(xyz_value).ok_or_else(|| EngineError::Params(format!("`{key}` must be [x, y, z]")))
|
||||
}
|
||||
|
||||
/// A JSON `[x, y, z]`.
|
||||
fn xyz_value(v: &Value) -> Option<[f64; 3]> {
|
||||
match v.as_array()?.as_slice() {
|
||||
[x, y, z] => Some([x.as_f64()?, y.as_f64()?, z.as_f64()?]),
|
||||
_ => None,
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A required list of `[x, y, z]` points, at most `max_len` long.
|
||||
pub(crate) fn points(p: &Value, key: &str, max_len: usize) -> Result<Vec<[f64; 3]>> {
|
||||
let bad = |what: String| EngineError::Params(format!("`{key}` must be a list of at most {max_len} [x, y, z]{what}"));
|
||||
let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len).ok_or_else(|| bad(String::new()))?;
|
||||
raw.iter().enumerate().map(|(i, v)| xyz_value(v).ok_or_else(|| bad(format!(" (entry {i} is not)")))).collect()
|
||||
}
|
||||
|
||||
/// A required list of labels (each in 1..=MAX_LABEL), at most `max_len` long.
|
||||
|
||||
@@ -179,6 +179,11 @@ mod tests {
|
||||
json!({"elements": [u32::MAX, -1, 1.5]}),
|
||||
json!({"elements": {}, "bins": -5}),
|
||||
json!({"elements": [1], "bins": 1e300}),
|
||||
json!({"points": null}),
|
||||
json!({"points": [[1, 2]]}),
|
||||
json!({"points": [[1e308, -1e308, 0], [0, 0, 0]]}),
|
||||
json!({"points": [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]}),
|
||||
json!({"points": vec![[1, 1, 1]; 30_000]}),
|
||||
];
|
||||
for spec in commands() {
|
||||
for p in &hostile {
|
||||
|
||||
Reference in new issue
Block a user