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 <noreply@anthropic.com> 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
@@ -65,6 +65,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/geometry.open.svg, assets/icons/geometry.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.connector.svg, assets/icons/interaction.connector.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.connector.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>geometry.face.absorb</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 823 B |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -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.
|
||||
|
||||
@@ -37,6 +37,9 @@ pub struct Merge {
|
||||
pub body: String,
|
||||
/// The faces, by persistent name (a name also selects the pieces a partition cut it into).
|
||||
pub faces: Vec<String>,
|
||||
/// Absorb: the merged face keeps the name of this one of `faces` instead of `merge:<name>`
|
||||
/// (a tiny face absorbed into a neighbour leaves the neighbour, by its own name).
|
||||
pub into: Option<String>,
|
||||
}
|
||||
|
||||
/// The name of the face merge `name` makes.
|
||||
@@ -65,7 +68,13 @@ impl Design {
|
||||
self.errors.push(FeatureError { feature, message: format!("no body `{}`", m.body), bodies: vec![m.body.clone()] });
|
||||
continue;
|
||||
};
|
||||
match body.merge_faces(&merged_face_name(&m.name), &m.faces) {
|
||||
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
|
||||
if m.into.as_ref().is_some_and(|i| !m.faces.contains(i)) {
|
||||
let message = format!("the face it keeps, `{name}`, is not one it merges");
|
||||
self.errors.push(FeatureError { feature, message, bodies: vec![m.body.clone()] });
|
||||
continue;
|
||||
}
|
||||
match body.merge_faces(&name, &m.faces) {
|
||||
Ok(merged) => *body = merged,
|
||||
Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![m.body.clone()] }),
|
||||
}
|
||||
@@ -105,14 +114,11 @@ impl Shape {
|
||||
}
|
||||
|
||||
/// This shape with the faces named `faces` (two or more, touching along edges) made one face
|
||||
/// named `name`, in place of the first of them.
|
||||
/// named `name` (a new name, or the name of one of them), in place of the first of them.
|
||||
pub fn merge_faces(&self, name: &str, faces: &[String]) -> Result<Shape> {
|
||||
if faces.len() > MAX_MERGE_FACES {
|
||||
return Err(Error::Invalid(format!("a merge takes at most {MAX_MERGE_FACES} faces")));
|
||||
}
|
||||
if self.faces.iter().any(|f| f.name == name) {
|
||||
return Err(Error::Invalid(format!("body `{}` already has a face `{name}`", self.name)));
|
||||
}
|
||||
let mut members = BTreeSet::new();
|
||||
for f in faces {
|
||||
let found = self.faces_named(f);
|
||||
@@ -121,6 +127,9 @@ impl Shape {
|
||||
}
|
||||
members.extend(found);
|
||||
}
|
||||
if self.faces.iter().zip(0u32..).any(|(f, i)| f.name == name && !members.contains(&i)) {
|
||||
return Err(Error::Invalid(format!("body `{}` already has a face `{name}`", self.name)));
|
||||
}
|
||||
if members.len() < 2 {
|
||||
return Err(Error::Invalid("a merge needs two or more faces".into()));
|
||||
}
|
||||
@@ -156,6 +165,28 @@ impl Shape {
|
||||
Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map })
|
||||
}
|
||||
|
||||
/// The neighbour face `face` shares the longest edges with (by name): where a tiny or sliver
|
||||
/// face is best absorbed. An error for a face the body lacks or one with no neighbour.
|
||||
pub fn absorbing_neighbour(&self, face: &str) -> Result<String> {
|
||||
let own = self.faces_named(face);
|
||||
let [f] = own.as_slice() else {
|
||||
return Err(Error::Invalid(format!("body `{}` has no face `{face}` (or several pieces of it: name one)", self.name)));
|
||||
};
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
let mut shared: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
|
||||
for (points, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
|
||||
if !faces.contains(f) {
|
||||
continue;
|
||||
}
|
||||
let length: f64 = points.windows(2).map(|w| (0..3).map(|k| (w[1][k] - w[0][k]).powi(2)).sum::<f64>().sqrt()).sum();
|
||||
for g in faces.iter().filter(|g| *g != f) {
|
||||
*shared.entry(*g).or_default() += length;
|
||||
}
|
||||
}
|
||||
let best = shared.into_iter().max_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))).map(|(g, _)| g);
|
||||
best.map(|g| self.face_label(g)).ok_or_else(|| Error::Invalid(format!("face `{face}` of body `{}` has no neighbour to absorb it", self.name)))
|
||||
}
|
||||
|
||||
/// An error unless `members` touch one another along edges, all in one piece.
|
||||
fn connected(&self, members: &BTreeSet<u32>) -> Result<()> {
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
@@ -335,7 +366,7 @@ mod tests {
|
||||
assert_eq!(hole.len(), 2, "{hole:?}");
|
||||
let before = d.bodies[0].tessellate(0.01).unwrap();
|
||||
let body = d.bodies[0].name.clone();
|
||||
let d = d.merged(&[Merge { name: "Hole".into(), body, faces: hole.clone() }]).unwrap();
|
||||
let d = d.merged(&[Merge { name: "Hole".into(), body, faces: hole.clone(), into: None }]).unwrap();
|
||||
assert!(d.errors.is_empty(), "{:?}", d.errors);
|
||||
let body = &d.bodies[0];
|
||||
assert_eq!(body.faces().len(), 7);
|
||||
@@ -375,7 +406,7 @@ mod tests {
|
||||
.map(|(n, _)| n)
|
||||
.collect();
|
||||
assert_eq!(planes.len(), 2, "top and bottom");
|
||||
let m = |name: &str, body: &str, faces: Vec<String>| Merge { name: name.into(), body: body.into(), faces };
|
||||
let m = |name: &str, body: &str, faces: Vec<String>| Merge { name: name.into(), body: body.into(), faces, into: None };
|
||||
let merged = d
|
||||
.clone()
|
||||
.merged(&[
|
||||
@@ -400,13 +431,13 @@ mod tests {
|
||||
let d = plate();
|
||||
let body = d.bodies[0].name.clone();
|
||||
let hole = halves(&d);
|
||||
let d = d.merged(&[Merge { name: "Hole".into(), body: body.clone(), faces: hole }]).unwrap();
|
||||
let d = d.merged(&[Merge { name: "Hole".into(), body: body.clone(), faces: hole, into: None }]).unwrap();
|
||||
let b = &d.bodies[0];
|
||||
let f = b.face_index("merge:Hole").unwrap();
|
||||
let mesh = b.tessellate(0.01).unwrap();
|
||||
let next = mesh.edge_faces.iter().find(|fs| fs.contains(&f)).and_then(|fs| fs.iter().find(|&&g| g != f)).copied().unwrap();
|
||||
let next = b.faces()[next as usize].name.clone();
|
||||
let d = d.merged(&[Merge { name: "More".into(), body, faces: vec!["merge:Hole".into(), next] }]).unwrap();
|
||||
let d = d.merged(&[Merge { name: "More".into(), body, faces: vec!["merge:Hole".into(), next], into: None }]).unwrap();
|
||||
assert!(d.errors.is_empty(), "{:?}", d.errors);
|
||||
let b = &d.bodies[0];
|
||||
let f = b.face_index("merge:More").unwrap();
|
||||
@@ -414,4 +445,21 @@ mod tests {
|
||||
assert_eq!(b.merged_faces(), vec![(f, 3)]);
|
||||
assert_eq!(b.face_surfaces().unwrap()[f as usize], None);
|
||||
}
|
||||
|
||||
/// The neighbour a half of the hole is best absorbed into is the other half (two 10 mm seams,
|
||||
/// against a 15.7 mm rim arc with each cap); absorbing it keeps the other half's name.
|
||||
#[test]
|
||||
fn an_absorbed_face_leaves_its_neighbour_by_name() {
|
||||
let d = plate();
|
||||
let body = d.bodies[0].name.clone();
|
||||
let hole = halves(&d);
|
||||
let best = d.bodies[0].absorbing_neighbour(&hole[0]).unwrap();
|
||||
assert_eq!(best, hole[1]);
|
||||
let d = d.merged(&[Merge { name: "A".into(), body, faces: vec![hole[0].clone(), hole[1].clone()], into: Some(hole[1].clone()) }]).unwrap();
|
||||
assert!(d.errors.is_empty(), "{:?}", d.errors);
|
||||
let b = &d.bodies[0];
|
||||
assert_eq!(b.faces().len(), 7);
|
||||
assert!(b.face_index(&hole[1]).is_some() && b.face_index(&hole[0]).is_none());
|
||||
assert!(b.absorbing_neighbour("nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ pub struct FaceMerge {
|
||||
/// Two or more faces by persistent name (a name also stands for the pieces a partition cut
|
||||
/// it into).
|
||||
pub faces: Vec<String>,
|
||||
/// Absorb (`geometry.face.absorb`): the merged face keeps this one's name (one of `faces`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub into: Option<String>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
@@ -59,6 +62,9 @@ impl Model {
|
||||
return Err(invalid(format!("face `{f}` is listed twice")));
|
||||
}
|
||||
}
|
||||
if merge.into.as_ref().is_some_and(|i| !merge.faces.contains(i)) {
|
||||
return Err(invalid("the face it keeps is not one of its faces".into()));
|
||||
}
|
||||
self.face_merges.push(merge);
|
||||
Ok(())
|
||||
}
|
||||
@@ -74,7 +80,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn merge(name: &str, faces: &[&str]) -> FaceMerge {
|
||||
FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect() }
|
||||
FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect(), into: None }
|
||||
}
|
||||
|
||||
/// Merges keep their order, are saved and read back, and bad ones are refused.
|
||||
@@ -89,10 +95,12 @@ mod tests {
|
||||
assert!(m.add_face_merge(merge("Blank", &["x", " "])).is_err());
|
||||
assert!(m.add_face_merge(merge("9bad", &["x", "y"])).is_err());
|
||||
assert!(m.add_face_merge(FaceMerge { body: String::new(), ..merge("NoBody", &["x", "y"]) }).is_err());
|
||||
assert!(m.add_face_merge(FaceMerge { into: Some("z".into()), ..merge("Into", &["x", "y"]) }).is_err());
|
||||
m.add_face_merge(FaceMerge { into: Some("y".into()), ..merge("Absorb", &["x", "y"]) }).unwrap();
|
||||
let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
|
||||
assert_eq!(back.face_merges(), m.face_merges());
|
||||
assert_eq!(m.remove_face_merge("Fillet").unwrap().name, "Fillet");
|
||||
assert!(m.remove_face_merge("Fillet").is_err());
|
||||
assert_eq!(m.face_merges().len(), 1);
|
||||
assert_eq!(m.face_merges().len(), 2);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
|
||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
|
||||
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||
|
||||
79 ready, 38 coming soon.
|
||||
80 ready, 38 coming soon.
|
||||
|
||||
## Geometry
|
||||
|
||||
@@ -23,6 +23,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||
| Prepare | Datum Point — A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane | ready | `geometry.datum.point`, (`geometry.datum.point.edit`) |
|
||||
| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`), (`geometry.partition.suppress`) |
|
||||
| Prepare | Merge Faces — Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged | ready | `geometry.faces.merge`, (`geometry.faces.merge.delete`) |
|
||||
| Prepare | Absorb Faces — Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found | ready | `geometry.face.absorb` |
|
||||
| Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |
|
||||
|
||||
## Mesh
|
||||
|
||||
Reference in new issue
Block a user