forked from 1b-agent/fieldcraft-upstream
geometry.face.absorb: tiny and sliver faces into a neighbour (#506)
A face merge may keep one of its faces' names (Merge::into, model FaceMerge::into): absorbing a tiny face leaves the neighbour by its own name, so sets on it still select it. Shape::absorbing_neighbour finds the face a tiny one shares the longest edges with. geometry.face.absorb absorbs one face (into a named or the best neighbour) or, from_check, every small and sliver face the last geometry.check marked; check marks now carry the small face they found. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
ce02d263c7
commit
58174ba168
14 files changed
+283
-27
No files matched your search
@@ -302,6 +302,13 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged",
|
||||
&["geometry.faces.merge.delete"],
|
||||
),
|
||||
ready(
|
||||
"geometry.face.absorb",
|
||||
"Absorb Faces",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found",
|
||||
),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
|
||||
|
||||
@@ -473,8 +473,11 @@ fn cuts(model: &Model) -> Vec<Cut> {
|
||||
/// `design` cut by `model`'s partitions, then with its face merges (the design as opened when
|
||||
/// the model has neither).
|
||||
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
let merges: Vec<Merge> =
|
||||
model.face_merges().iter().map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone() }).collect();
|
||||
let merges: Vec<Merge> = model
|
||||
.face_merges()
|
||||
.iter()
|
||||
.map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() })
|
||||
.collect();
|
||||
Ok(design.partitioned(&cuts(model))?.merged(&merges)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -1717,6 +1717,12 @@ pub const FACES_MERGE: &[Field] = &[
|
||||
.req()
|
||||
.hint("Shift-click faces"),
|
||||
];
|
||||
pub const FACE_ABSORB: &[Field] = &[
|
||||
text("name", "Name").help("The absorption's name (with From check: the prefix of each one's, Name-1, Name-2, …).").hint("Absorb"),
|
||||
group("face", "Face", FACE_ROW).help("The tiny or sliver face to absorb; leave empty with From check."),
|
||||
text("into", "Into").help("The neighbour face that absorbs it, by persistent name.").hint("the neighbour sharing the longest edges"),
|
||||
flag("from_check", "From check").help("Absorb every small and sliver face the last Check Geometry found, each into its best neighbour."),
|
||||
];
|
||||
pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge);
|
||||
pub const MESH_PLAN: &[Field] = &[
|
||||
reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(),
|
||||
|
||||
@@ -444,7 +444,9 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
f.face_indices.iter().map(|&i| (f.body.clone(), i)).collect()
|
||||
};
|
||||
crate::view::GeometryMark { faces, path: f.path.clone(), at: f.at }
|
||||
let small =
|
||||
matches!(f.kind, Kind::SmallFace | Kind::SliverFace).then(|| f.faces.first().map(|n| (f.body.clone(), n.clone()))).flatten();
|
||||
crate::view::GeometryMark { faces, path: f.path.clone(), at: f.at, small }
|
||||
})
|
||||
.collect();
|
||||
Arc::new(crate::view::GeometryMarks { design: Arc::downgrade(design), marks })
|
||||
@@ -522,13 +524,13 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A session with the design the SolidCraft `commands` build open.
|
||||
fn design(commands: Value) -> (crate::tests::TempDir, Session) {
|
||||
pub(crate) fn design(commands: Value) -> (crate::tests::TempDir, Session) {
|
||||
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -73,6 +73,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
datum::PARTITION_DELETE,
|
||||
virtual_faces::FACES_MERGE,
|
||||
virtual_faces::FACES_MERGE_DELETE,
|
||||
virtual_faces::FACE_ABSORB,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Virtual topology: faces merged for meshing only (`geometry.faces.merge`).
|
||||
//! Virtual topology: faces merged for meshing only (`geometry.faces.merge`), and tiny or sliver
|
||||
//! faces absorbed into a neighbour (`geometry.face.absorb`, a merge that keeps the neighbour's
|
||||
//! name).
|
||||
//!
|
||||
//! A face merge is part of the model (`fieldcraft_model::virtual_faces`); the geometry layer
|
||||
//! applies it to the open design's bodies (`fieldcraft_geometry::virtual_faces`), after the
|
||||
@@ -33,6 +35,16 @@ pub const FACES_MERGE_DELETE: CommandSpec = CommandSpec {
|
||||
run: delete,
|
||||
};
|
||||
|
||||
pub const FACE_ABSORB: CommandSpec = CommandSpec {
|
||||
id: "geometry.face.absorb",
|
||||
label: "Absorb Faces",
|
||||
params: r#"{"name": "Sliver", "face": {"body": "Base", "face": "F4:side#2"}, "into"?: "F1:+z" (default: the neighbour it shares the longest edges with)} | {"name"?: "Absorb", "from_check": true} — absorbs a tiny or sliver face into a neighbour for meshing only (virtual topology, as geometry.faces.merge): the meshers, the checks and the viewport take the two as one face that keeps the neighbour's name, so sets on the neighbour still select it and the tiny face no longer drives the mesh. The B-rep is not changed. `from_check`: every small and sliver face the last geometry.check found (and marked) on the open design, each into its best neighbour, as `<name>-1`, `<name>-2`, … in the check's order (a face already absorbed by an earlier one is `skipped`). Kept in the model and applied again by persistent names whenever the design is opened (delete one with geometry.faces.merge.delete); the bodies lose their meshes (`replaced`); sets naming an absorbed face are `stale_sets`. Replies `absorbed` ([{name, body, face, into}]) and `skipped`. One undo step"#,
|
||||
form: Some(super::forms::FACE_ABSORB),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: absorb,
|
||||
};
|
||||
|
||||
/// Most faces one merge lists.
|
||||
const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES;
|
||||
|
||||
@@ -63,17 +75,19 @@ fn stale_sets(s: &Session, body: &str) -> Vec<String> {
|
||||
s.model.sets().iter().filter(|(_, set)| set.geometry.iter().any(gone)).map(|(n, _)| n.clone()).collect()
|
||||
}
|
||||
|
||||
/// Apply the model's partitions and merges to the design again, failing with `feature`'s error
|
||||
/// when it has one; the meshes of `body` go (its faces changed).
|
||||
fn reapply(s: &mut Session, feature: &str, body: &str, reply: Value) -> Result<Value> {
|
||||
/// Apply the model's partitions and merges to the design again, failing with the error of a merge
|
||||
/// named in `merges` when it has one; the meshes of `bodies` go (their faces changed).
|
||||
fn reapply(s: &mut Session, merges: &[String], bodies: &[String], reply: Value) -> Result<Value> {
|
||||
let mut cleared = super::datum::recut(s)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let errors: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect();
|
||||
let features: Vec<String> = merges.iter().map(|m| format!("merge {m}")).collect();
|
||||
let errors: Vec<String> =
|
||||
design.errors.iter().filter(|e| features.contains(&e.feature)).map(|e| format!("{}: {}", e.feature, e.message)).collect();
|
||||
if !errors.is_empty() {
|
||||
return Err(EngineError::Params(format!("{feature}: {}", errors.join("; "))));
|
||||
return Err(EngineError::Params(errors.join("; ")));
|
||||
}
|
||||
cleared.extend(super::replace::clear(&mut s.model, [body]));
|
||||
let stale = stale_sets(s, body);
|
||||
cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str)));
|
||||
let stale: Vec<String> = bodies.iter().flat_map(|b| stale_sets(s, b)).collect();
|
||||
let mut reply = cleared.report_if_any(s, reply);
|
||||
if !stale.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
@@ -93,10 +107,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.design.as_deref().and_then(|d| d.body(&body)).is_none() {
|
||||
return Err(EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())));
|
||||
}
|
||||
s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone() })?;
|
||||
s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone(), into: None })?;
|
||||
let face = merged_face_name(&name);
|
||||
let reply = json!({"name": name, "body": body, "face": face});
|
||||
let mut reply = reapply(s, &format!("merge {name}"), &body, reply)?;
|
||||
let mut reply = reapply(s, std::slice::from_ref(&name), std::slice::from_ref(&body), reply)?;
|
||||
let shape = s.design.as_deref().and_then(|d| d.body(&body));
|
||||
let brep = shape.and_then(|b| b.face_index(&face)).and_then(|f| shape?.merged_faces().into_iter().find(|(g, _)| *g == f)).map_or(0, |(_, n)| n);
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
@@ -105,6 +119,90 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// The faces to absorb, by body and name: the one named, or the small and sliver faces of the
|
||||
/// last geometry.check.
|
||||
fn to_absorb(s: &Session, p: &Value) -> Result<Vec<(String, String)>> {
|
||||
let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false);
|
||||
match (p.get("face"), from_check) {
|
||||
(Some(_), false) => {
|
||||
let r: GeometryRef = super::req(p, "face")?;
|
||||
match r.entity {
|
||||
Entity::Face(f) => Ok(vec![(r.body, f)]),
|
||||
_ => Err(EngineError::invalid("face", "give {\"body\", \"face\"}")),
|
||||
}
|
||||
}
|
||||
(None, true) => {
|
||||
let marks = crate::view::geometry_marks(s)
|
||||
.ok_or_else(|| EngineError::Params("`from_check`: run geometry.check (with marks) on the open design first".into()))?;
|
||||
let mut faces: Vec<(String, String)> = Vec::new();
|
||||
for f in marks.marks.iter().filter_map(|m| m.small.clone()) {
|
||||
if !faces.contains(&f) {
|
||||
faces.push(f);
|
||||
}
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(EngineError::Params("`from_check`: the last geometry.check found no small or sliver face".into()));
|
||||
}
|
||||
Ok(faces)
|
||||
}
|
||||
_ => Err(EngineError::Params("give either `face` or `from_check: true`".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let faces = to_absorb(s, p)?;
|
||||
let one = faces.len() == 1 && p.get("face").is_some();
|
||||
let name = match p.get("name") {
|
||||
None if !one => "Absorb".to_string(),
|
||||
_ => super::name(p)?.to_string(),
|
||||
};
|
||||
let into: Option<String> = super::opt(p, "into")?;
|
||||
if into.is_some() && !one {
|
||||
return Err(EngineError::invalid("into", "goes with `face`"));
|
||||
}
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
// The bodies as each absorption leaves them, to find the next one's neighbour.
|
||||
let mut shapes: Vec<fieldcraft_geometry::Shape> = Vec::new();
|
||||
let (mut absorbed, mut skipped, mut merges) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (k, (body, face)) in faces.into_iter().enumerate() {
|
||||
if !shapes.iter().any(|b| b.name == body) {
|
||||
let shape = design
|
||||
.body(&body)
|
||||
.ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())))?;
|
||||
shapes.push(shape.clone());
|
||||
}
|
||||
let Some(shape) = shapes.iter_mut().find(|b| b.name == body) else { continue };
|
||||
let target = match &into {
|
||||
Some(i) => Ok(i.clone()),
|
||||
None => shape.absorbing_neighbour(&face),
|
||||
};
|
||||
let merged = target.map_err(EngineError::from).and_then(|t| Ok((shape.merge_faces(&t, &[face.clone(), t.clone()])?, t)));
|
||||
let (next, target) = match merged {
|
||||
Ok(m) => m,
|
||||
Err(e) if !one => {
|
||||
skipped.push(json!({"body": body, "face": face, "reason": e.to_string()}));
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
*shape = next;
|
||||
let merge = if one { name.clone() } else { format!("{name}-{}", k + 1) };
|
||||
s.model.add_face_merge(FaceMerge {
|
||||
name: merge.clone(),
|
||||
body: body.clone(),
|
||||
faces: vec![face.clone(), target.clone()],
|
||||
into: Some(target.clone()),
|
||||
})?;
|
||||
absorbed.push(json!({"name": merge, "body": body, "face": face, "into": target}));
|
||||
merges.push(merge);
|
||||
}
|
||||
if merges.is_empty() {
|
||||
return Err(EngineError::Params(format!("no face could be absorbed: {}", json!(skipped))));
|
||||
}
|
||||
let bodies: Vec<String> = shapes.into_iter().map(|b| b.name).collect();
|
||||
reapply(s, &merges, &bodies, json!({"absorbed": absorbed, "skipped": skipped}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let removed = s.model.remove_face_merge(name)?;
|
||||
@@ -320,4 +418,71 @@ mod tests {
|
||||
assert_eq!(r["stale_sets"], json!(["Bore"]), "{r}");
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// A block with a 0.4 mm step on its top: the riser is a sliver at size 5.
|
||||
fn stepped() -> (crate::tests::TempDir, Session) {
|
||||
super::super::geometry::tests::design(json!([
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10.4, "operation": "join", "targets": ["Base"]}},
|
||||
]))
|
||||
}
|
||||
|
||||
fn small_faces(s: &mut Session) -> usize {
|
||||
let r = s.execute("geometry.check", &json!({"size": 5})).unwrap();
|
||||
r["findings"].as_array().unwrap().iter().filter(|f| matches!(f["kind"].as_str(), Some("small_face" | "sliver_face"))).count()
|
||||
}
|
||||
|
||||
/// `from_check` absorbs every sliver the check found into its best neighbour, which keeps its
|
||||
/// name; the check then finds none, the tet mesher tags no element face with the sliver, and
|
||||
/// undo brings it back.
|
||||
#[test]
|
||||
fn absorbing_the_check_findings_clears_them() {
|
||||
let (_dir, mut s) = stepped();
|
||||
let before = small_faces(&mut s);
|
||||
assert!(before > 0);
|
||||
let start = faces(&s);
|
||||
assert!(s.execute("geometry.face.absorb", &json!({"from_check": true, "into": "x"})).is_err());
|
||||
let r = s.execute("geometry.face.absorb", &json!({"from_check": true})).unwrap();
|
||||
let absorbed = r["absorbed"].as_array().unwrap();
|
||||
assert_eq!(absorbed.len(), before, "{r}");
|
||||
assert_eq!(absorbed[0]["name"], "Absorb-1");
|
||||
let (gone, into) = (absorbed[0]["face"].as_str().unwrap().to_string(), absorbed[0]["into"].as_str().unwrap().to_string());
|
||||
assert!(!faces(&s).contains(&gone) && faces(&s).contains(&into));
|
||||
assert_eq!(faces(&s).len(), start.len() - before);
|
||||
assert_eq!(small_faces(&mut s), 0);
|
||||
s.execute("mesh.tet.generate", &json!({"size": 2})).unwrap();
|
||||
let c = s.model.classification();
|
||||
let used: BTreeSet<String> =
|
||||
c.elements().values().flat_map(|t| t.faces.values()).filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect();
|
||||
assert!(used.contains(&into) && !used.contains(&gone), "{used:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(faces(&s), start);
|
||||
}
|
||||
|
||||
/// One face into a named neighbour, or its best one; bad requests change nothing.
|
||||
#[test]
|
||||
fn absorbing_one_face() {
|
||||
let (_dir, mut s) = stepped();
|
||||
let r = s.execute("geometry.check", &json!({"size": 5})).unwrap();
|
||||
let sliver =
|
||||
r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == "sliver_face" || f["kind"] == "small_face").unwrap()["faces"][0].clone();
|
||||
let face = json!({"body": "Base", "face": sliver});
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({"name": "A"}),
|
||||
json!({"name": "A", "face": face, "from_check": true}),
|
||||
json!({"face": face}),
|
||||
json!({"name": "A", "face": {"body": "Base", "face": "nope"}}),
|
||||
json!({"name": "A", "face": face, "into": "nope"}),
|
||||
json!({"name": "A", "face": {"body": "Nobody", "face": "x"}}),
|
||||
json!({"name": "A", "face": face, "from_check": "yes"}),
|
||||
] {
|
||||
assert!(s.execute("geometry.face.absorb", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
let r = s.execute("geometry.face.absorb", &json!({"name": "Riser", "face": face})).unwrap();
|
||||
assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}");
|
||||
assert!(s.model.face_merges()[0].into.is_some());
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,9 @@ pub struct GeometryMark {
|
||||
/// A short edge's polyline (empty for the other kinds).
|
||||
pub path: Vec<[f64; 3]>,
|
||||
pub at: [f64; 3],
|
||||
/// A small or sliver face, by body and persistent name: what `geometry.face.absorb`
|
||||
/// `from_check` absorbs.
|
||||
pub small: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// The findings of the last `geometry.check`, while the design it checked is still open.
|
||||
|
||||
Reference in new issue
Block a user