diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 4159383..d384a55 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -64,6 +64,7 @@ is committed and listed here with its exports.
| assets/icons/geometry.midsurface.svg, assets/icons/geometry.midsurface.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.midsurface.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| 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.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 |
diff --git a/assets/icons/geometry.faces.merge.svg b/assets/icons/geometry.faces.merge.svg
new file mode 100644
index 0000000..c388365
--- /dev/null
+++ b/assets/icons/geometry.faces.merge.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/assets/icons/geometry.faces.merge.vectorcraft b/assets/icons/geometry.faces.merge.vectorcraft
new file mode 100644
index 0000000..10f6634
Binary files /dev/null and b/assets/icons/geometry.faces.merge.vectorcraft differ
diff --git a/assets/icons/src/geometry.faces.merge.json b/assets/icons/src/geometry.faces.merge.json
new file mode 100644
index 0000000..3f7e376
--- /dev/null
+++ b/assets/icons/src/geometry.faces.merge.json
@@ -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"}, {"line": [7.5, 6, 16.5, 10], "stroke": "warm", "width": 1.25, "dash": [1.5, 2]}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index da3399e..51b5e82 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -294,6 +294,14 @@ pub static CATALOGUE: &[Capability] = &[
"Cut bodies into cells by a datum plane; the two faces of each cut are paired by name",
&["geometry.partition.delete", "geometry.partition.suppress"],
),
+ covering(
+ "geometry.faces.merge",
+ "Merge Faces",
+ Geometry,
+ "Prepare",
+ "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"],
+ ),
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"),
diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs
index a4a337a..04ea972 100644
--- a/crates/engine/src/cmd/datum.rs
+++ b/crates/engine/src/cmd/datum.rs
@@ -12,7 +12,7 @@
use crate::schema::ProblemKind;
use std::sync::Arc;
-use fieldcraft_geometry::{Cut, Design};
+use fieldcraft_geometry::{Cut, Design, Merge};
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
use fieldcraft_model::{Datum, Model, Partition};
use serde::Deserialize;
@@ -470,12 +470,15 @@ fn cuts(model: &Model) -> Vec {
.collect()
}
-/// `design` cut by `model`'s partitions (the design as opened when the model has none).
+/// `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 {
- Ok(design.partitioned(&cuts(model))?)
+ let merges: Vec =
+ model.face_merges().iter().map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone() }).collect();
+ Ok(design.partitioned(&cuts(model))?.merged(&merges)?)
}
-/// Cut the session's design by its model's partitions again. The bodies that are gone (cells
+/// Cut the session's design by its model's partitions again (and merge its faces again). The bodies that are gone (cells
/// whose partition went, a body a new partition cut) take their meshes with them (#385): what
/// went is in the returned [`super::replace::Cleared`], which the command reports.
pub(super) fn recut(s: &mut Session) -> Result {
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 669d84a..8f1b6f3 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1710,6 +1710,14 @@ pub const PARTITION: &[Field] = &[
references("bodies", "Bodies", C::Body).help("The bodies to divide; every body the plane cuts when empty.").hint("every body the plane divides"),
];
pub const PARTITION_DELETE: &[Field] = existing!("Partition", C::Partition);
+pub const FACES_MERGE: &[Field] = &[
+ NAME,
+ face_rows("faces", "Faces")
+ .help("Two or more adjacent faces of one body, meshed as one face. Shift-click faces in the view, then Use selection.")
+ .req()
+ .hint("Shift-click faces"),
+];
+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(),
text("name", "Name").help("The name the plan is saved under.").hint("Plan-"),
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index db5a0af..2f87a38 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -35,6 +35,7 @@ mod symmetry;
mod tags;
pub mod update;
pub(crate) mod view;
+mod virtual_faces;
pub mod xy;
use crate::schema::ProblemKind;
@@ -70,6 +71,8 @@ pub static ALL: &[CommandSpec] = &[
datum::DATUM_SUPPRESS,
datum::PARTITION_SUPPRESS,
datum::PARTITION_DELETE,
+ virtual_faces::FACES_MERGE,
+ virtual_faces::FACES_MERGE_DELETE,
mesh::NODE_ADD,
mesh::NODE_REMOVE,
mesh::ELEMENT_ADD,
diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs
index 81f9591..a4f1a78 100644
--- a/crates/engine/src/cmd/plan.rs
+++ b/crates/engine/src/cmd/plan.rs
@@ -113,14 +113,19 @@ fn canonical(d: V3) -> Option {
const AXES: [V3; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
/// Whether the faces `faces` (persistent names on `shape`) are all prismatic along `d`: planes
-/// along or across it, cylinders about an axis along it.
+/// along or across it, cylinders about an axis along it; a merged face on no one surface, when
+/// each of its B-rep faces is.
fn faces_prismatic(shape: &Shape, surfaces: &[Option], faces: &[String], d: V3) -> bool {
+ let prismatic = |s: Option| match s {
+ Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d),
+ Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d),
+ _ => false,
+ };
faces.iter().all(|name| {
let Some(i) = shape.face_index(name) else { return true };
match surfaces.get(i as usize).copied().flatten() {
- Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d),
- Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d),
- _ => false,
+ Some(s) => prismatic(Some(s)),
+ None => shape.member_surfaces(i).is_ok_and(|m| m.len() > 1 && m.into_iter().all(prismatic)),
}
})
}
diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs
index e8537fb..cd01843 100644
--- a/crates/engine/src/cmd/replace.rs
+++ b/crates/engine/src/cmd/replace.rs
@@ -46,6 +46,17 @@ pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator Value {
if self.bodies.is_empty() { reply } else { self.report(s, reply) }
diff --git a/crates/engine/src/cmd/virtual_faces.rs b/crates/engine/src/cmd/virtual_faces.rs
new file mode 100644
index 0000000..5a12b4d
--- /dev/null
+++ b/crates/engine/src/cmd/virtual_faces.rs
@@ -0,0 +1,323 @@
+//! Virtual topology: faces merged for meshing only (`geometry.faces.merge`).
+//!
+//! 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
+//! partitions, whenever the design is opened or cut again ([`super::datum::partitioned`]), so the
+//! meshers, the viewport and the checks all see the merged face. The B-rep is not changed.
+
+use fieldcraft_geometry::virtual_faces::merged_face_name;
+use fieldcraft_model::FaceMerge;
+use fieldcraft_model::geometry::{Entity, GeometryRef};
+use serde_json::{Value, json};
+
+use crate::requirement::Requirement;
+use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
+
+pub const FACES_MERGE: CommandSpec = CommandSpec {
+ id: "geometry.faces.merge",
+ label: "Merge Faces",
+ params: r#"{"name": "Fillet", "faces": [{"body": "Base", "face": "F3:blend#0"}, {"body": "Base", "face": "F3:blend#1"}]} — makes two or more adjacent faces of one body one face for meshing only (virtual topology): the meshers, the checks and the viewport take them as one face `merge:`, without the edges between them (an edge they leave in pieces along a neighbour is one edge), on the surface they share (none when they lie on different surfaces: the meshers keep to the tessellation). The design's B-rep is not changed. A face name also selects the pieces a partition cut it into, and may be a face an earlier merge made. The model keeps the merge and applies it again, by persistent face names, whenever the design is opened (after the partitions); one that no longer applies (a face gone, faces that no longer touch) is listed with the design's errors. The body loses its mesh (`replaced`; remesh it); sets naming the merged faces no longer resolve (`stale_sets`: name `merge:` instead). Faces on several bodies, faces that do not touch along an edge, or a face the body lacks are an error. One undo step"#,
+ form: Some(super::forms::FACES_MERGE),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Design],
+ run: merge,
+};
+
+pub const FACES_MERGE_DELETE: CommandSpec = CommandSpec {
+ id: "geometry.faces.merge.delete",
+ label: "Delete Face Merge",
+ params: r#"{"name": "Fillet"} — removes a face merge: its faces are separate faces again (a later merge that names `merge:` no longer applies and is listed with the design's errors); the body loses its mesh (`replaced`)"#,
+ form: Some(super::forms::FACES_MERGE_DELETE),
+ kind: CommandKind::Model,
+ requires: &[Requirement::FaceMerge],
+ run: delete,
+};
+
+/// Most faces one merge lists.
+const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES;
+
+/// The body and the face names of `faces` (`[{body, face}]`, all on one body).
+fn faces_of(p: &Value) -> Result<(String, Vec)> {
+ let refs: Vec = super::req(p, "faces")?;
+ if !(2..=MAX_FACES).contains(&refs.len()) {
+ return Err(EngineError::invalid("faces", format!("2 to {MAX_FACES} faces of one body")));
+ }
+ let body = refs.first().map(|r| r.body.clone()).unwrap_or_default();
+ let mut names = Vec::with_capacity(refs.len());
+ for r in &refs {
+ match &r.entity {
+ Entity::Face(f) if r.body == body => names.push(f.clone()),
+ Entity::Face(_) => return Err(EngineError::invalid("faces", "faces of one body: a merge makes one face of one body")),
+ _ => {
+ return Err(EngineError::invalid("faces", format!("`{}`: give {{\"body\", \"face\"}}", r.body.chars().take(80).collect::())));
+ }
+ }
+ }
+ Ok((body, names))
+}
+
+/// The open design's sets that name a face of `body` it no longer has.
+fn stale_sets(s: &Session, body: &str) -> Vec {
+ let Some(shape) = s.design.as_deref().and_then(|d| d.body(body)) else { return Vec::new() };
+ let gone = |r: &GeometryRef| r.body == body && matches!(&r.entity, Entity::Face(f) if shape.faces_named(f).is_empty());
+ 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 {
+ 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();
+ if !errors.is_empty() {
+ return Err(EngineError::Params(format!("{feature}: {}", errors.join("; "))));
+ }
+ cleared.extend(super::replace::clear(&mut s.model, [body]));
+ let stale = stale_sets(s, body);
+ let mut reply = cleared.report_if_any(s, reply);
+ if !stale.is_empty()
+ && let Some(o) = reply.as_object_mut()
+ {
+ let mut all: Vec = o.get("stale_sets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
+ all.extend(stale);
+ all.sort();
+ all.dedup();
+ o.insert("stale_sets".into(), json!(all));
+ }
+ Ok(reply)
+}
+
+fn merge(s: &mut Session, p: &Value) -> Result {
+ let name = super::name(p)?.to_string();
+ let (body, faces) = faces_of(p)?;
+ 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::())));
+ }
+ s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone() })?;
+ 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 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() {
+ o.insert("brep_faces".into(), json!(brep));
+ }
+ Ok(reply)
+}
+
+fn delete(s: &mut Session, p: &Value) -> Result {
+ let name = super::name(p)?;
+ let removed = s.model.remove_face_merge(name)?;
+ let mut cleared = super::datum::recut(s)?;
+ cleared.extend(super::replace::clear(&mut s.model, [removed.body.as_str()]));
+ Ok(cleared.report_if_any(s, json!({"deleted": name, "body": removed.body})))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeSet;
+
+ use fieldcraft_geometry::FaceSurface;
+ use serde_json::json;
+
+ use crate::Session;
+
+ /// The plate's hole halves (two half-cylinders), by name.
+ fn halves(s: &Session) -> Vec {
+ let b = &s.design.as_ref().unwrap().bodies[0];
+ b.faces()
+ .iter()
+ .zip(b.face_surfaces().unwrap())
+ .filter(|(_, x)| matches!(x, Some(FaceSurface::Cylinder { .. })))
+ .map(|(f, _)| f.name.clone())
+ .collect()
+ }
+
+ fn merge_hole(s: &mut Session) -> serde_json::Value {
+ let body = s.design.as_ref().unwrap().bodies[0].name.clone();
+ let faces: Vec<_> = halves(s).into_iter().map(|f| json!({"body": body, "face": f})).collect();
+ s.execute("geometry.faces.merge", &json!({"name": "Hole", "faces": faces})).unwrap()
+ }
+
+ fn faces(s: &Session) -> Vec {
+ s.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect()
+ }
+
+ /// A merge makes the hole one face of the design; undo and redo take it out and put it back;
+ /// it is saved with the model and applied again when the model and its design are opened.
+ #[test]
+ fn a_merge_is_one_face_undone_saved_and_replayed() {
+ let (mut s, dir) = super::super::datum::tests::plate();
+ let before = faces(&s);
+ assert_eq!(before.len(), 8);
+ let r = merge_hole(&mut s);
+ assert_eq!((r["face"].as_str(), r["brep_faces"].as_u64()), (Some("merge:Hole"), Some(2)), "{r}");
+ assert_eq!(faces(&s).len(), 7);
+ assert!(faces(&s).contains(&"merge:Hole".to_string()));
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(faces(&s), before);
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert_eq!(faces(&s).len(), 7);
+ // Saved, and replayed on open.
+ let file = dir.join("merged.fieldcraft");
+ s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
+ let mut t = Session::default();
+ t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
+ assert_eq!(t.model.face_merges().len(), 1);
+ assert_eq!(faces(&t), faces(&s));
+ // Opening the design again (after an edit of it) replays the merge.
+ let design = s.sources.design.as_ref().unwrap().path().to_string();
+ s.execute("geometry.open", &json!({"path": design})).unwrap();
+ assert_eq!(faces(&s).len(), 7);
+ // Deleting it brings the halves back.
+ s.execute("geometry.faces.merge.delete", &json!({"name": "Hole"})).unwrap();
+ assert_eq!(faces(&s), before);
+ std::fs::remove_dir_all(dir).ok();
+ }
+
+ /// The mesh nodes on the seams between the plate's hole halves (the B-rep edges between them).
+ fn nodes_on_seams(s: &Session, seams: &[Vec<[f64; 3]>]) -> usize {
+ let on = |p: [f64; 3]| {
+ seams.iter().any(|line| {
+ line.windows(2).any(|w| {
+ let (a, b) = (w[0], w[1]);
+ let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
+ let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
+ let l2 = ab.iter().map(|x| x * x).sum::();
+ let t = (ab.iter().zip(&ap).map(|(x, y)| x * y).sum::() / l2).clamp(0.0, 1.0);
+ (0..3).map(|k| (ap[k] - t * ab[k]).powi(2)).sum::().sqrt() < 1e-6
+ })
+ })
+ };
+ s.model.nodes().values().filter(|n| on(n.xyz)).count()
+ }
+
+ /// A mesher takes the merged face as one: its boundary element faces are tagged with it, the
+ /// halves are gone from the tags, and the seams between the halves (no longer edges) carry at
+ /// most `left` nodes.
+ fn mesher_meshes_a_merged_face_as_one(command: &str, params: serde_json::Value, left: impl Fn(usize) -> usize) {
+ let (mut s, dir) = super::super::datum::tests::plate();
+ let hole = halves(&s);
+ let b = &s.design.as_ref().unwrap().bodies[0];
+ let seams: Vec> = b
+ .edges()
+ .unwrap()
+ .into_iter()
+ .filter(|e| e.faces.len() == 2 && e.faces[0] != e.faces[1] && e.faces.iter().all(|&f| hole.contains(&b.faces()[f as usize].name)))
+ .map(|e| e.points)
+ .collect();
+ assert_eq!(seams.len(), 2);
+ s.execute(command, ¶ms).unwrap();
+ let before = nodes_on_seams(&s, &seams);
+ assert!(before >= 6, "{command}: a node row on each seam before the merge: {before}");
+ let r = merge_hole(&mut s);
+ assert!(r["replaced"].as_array().is_some_and(|a| !a.is_empty()), "the mesh goes: {r}");
+ assert!(s.model.elements().is_empty());
+ s.execute(command, ¶ms).unwrap();
+ // The face, edge and vertex names the elements and nodes are tagged with.
+ let c = s.model.classification();
+ let used = c.elements().values().flat_map(|t| t.faces.values().chain(t.edges.values())).chain(c.nodes().values());
+ let tagged: BTreeSet = used.filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect();
+ assert!(tagged.contains("merge:Hole"), "{tagged:?}");
+ assert!(!tagged.iter().any(|n| hole.iter().any(|h| n.split(['|', '~']).any(|p| p == h))), "{tagged:?}");
+ let after = nodes_on_seams(&s, &seams);
+ assert!(after <= left(before), "{command}: nodes on the seams after the merge: {after} (before {before})");
+ std::fs::remove_dir_all(dir).ok();
+ }
+
+ #[test]
+ fn the_tet_mesher_meshes_a_merged_face_as_one() {
+ // Before: the surface mesh keeps the seams as feature lines (21 nodes); after: none.
+ mesher_meshes_a_merged_face_as_one("mesh.tet.generate", json!({"size": 4}), |_| 0);
+ }
+
+ #[test]
+ fn the_hex_mesher_meshes_a_merged_face_as_one() {
+ // The swept O-grid round the hole had a node column on each seam; after, the rim is one
+ // closed edge and only the column where its ring starts is left.
+ mesher_meshes_a_merged_face_as_one("mesh.hex.generate", json!({"size": 2.5}), |before| before / 2);
+ }
+
+ /// The hex-meshing plan sees the merged hole as one face: the hole's feature and its ring
+ /// project onto `merge:Hole` (still a cylinder), not the halves.
+ #[test]
+ fn the_plan_sees_a_merged_face_as_one() {
+ let (mut s, dir) = super::super::datum::tests::plate();
+ let body = s.design.as_ref().unwrap().bodies[0].name.clone();
+ let hole = halves(&s);
+ let before = s.execute("mesh.plan", &json!({"body": body})).unwrap();
+ let ring = |r: &serde_json::Value| r["rings"][0]["faces"].clone();
+ let mut halves_sorted = hole.clone();
+ halves_sorted.sort();
+ let mut got: Vec = serde_json::from_value(ring(&before)).unwrap();
+ got.sort();
+ assert_eq!(got, halves_sorted, "{before}");
+ merge_hole(&mut s);
+ let after = s.execute("mesh.plan", &json!({"body": body})).unwrap();
+ assert_eq!(ring(&after), json!(["merge:Hole"]), "{after}");
+ let features = after["features"].as_array().unwrap();
+ assert!(features.iter().any(|f| f["faces"] == json!(["merge:Hole"])), "{after}");
+ assert!(!after.to_string().contains(&hole[0]), "{after}");
+ std::fs::remove_dir_all(dir).ok();
+ }
+
+ /// The viewport draws a merged face in one colour: the hole's triangles, and only those.
+ #[test]
+ fn a_merged_face_is_drawn_in_one_colour() {
+ let (mut s, dir) = super::super::datum::tests::plate();
+ let p = crate::view::Palette::LIGHT;
+ let merged = |s: &Session| crate::view::scene(s, &p).0.tris.into_iter().filter(|t| t.color == p.merged).collect::>();
+ assert!(merged(&s).is_empty());
+ merge_hole(&mut s);
+ let tris = merged(&s);
+ assert!(!tris.is_empty());
+ // The hole: Ø10 about (15, 15).
+ for t in &tris {
+ assert!(t.p.iter().all(|v| ((v.x - 15.0).hypot(v.y - 15.0) - 5.0).abs() < 1e-3), "{t:?}");
+ }
+ std::fs::remove_dir_all(dir).ok();
+ }
+
+ /// Sets naming a merged face go stale (and are named); bad merges change nothing.
+ #[test]
+ fn bad_merges_change_nothing() {
+ let (mut s, dir) = super::super::datum::tests::plate();
+ let body = s.design.as_ref().unwrap().bodies[0].name.clone();
+ let names = faces(&s);
+ let hole = halves(&s);
+ s.execute("model.set.create", &json!({"name": "Bore", "geometry": [{"body": body, "face": hole[0]}]})).unwrap();
+ let journal = s.journal.len();
+ let f = |n: &str| json!({"body": body, "face": n});
+ for p in [
+ json!({}),
+ json!({"name": "M"}),
+ json!({"name": "M", "faces": [f(&hole[0])]}),
+ json!({"name": "M", "faces": [f(&hole[0]), {"body": "Other", "face": hole[1]}]}),
+ json!({"name": "M", "faces": [{"body": "Other", "face": "a"}, {"body": "Other", "face": "b"}]}),
+ json!({"name": "M", "faces": [f(&hole[0]), f("nope")]}),
+ json!({"name": "M", "faces": [f(&hole[0]), {"body": body}]}),
+ json!({"name": "M", "faces": [f(&hole[0]), f(&hole[0])]}),
+ json!({"name": "1M", "faces": [f(&hole[0]), f(&hole[1])]}),
+ json!({"name": "M", "faces": "x"}),
+ ] {
+ assert!(s.execute("geometry.faces.merge", &p).is_err(), "{p}");
+ }
+ // Top and bottom do not touch.
+ let b = &s.design.as_ref().unwrap().bodies[0];
+ let caps: Vec = b
+ .faces()
+ .iter()
+ .zip(b.face_surfaces().unwrap())
+ .filter(|(_, x)| matches!(x, Some(FaceSurface::Plane { normal, .. }) if normal[2].abs() > 0.5))
+ .map(|(f, _)| f.name.clone())
+ .collect();
+ let e = s.execute("geometry.faces.merge", &json!({"name": "Caps", "faces": [f(&caps[0]), f(&caps[1])]})).unwrap_err();
+ assert!(e.to_string().contains("does not touch"), "{e}");
+ assert!(s.execute("geometry.faces.merge.delete", &json!({"name": "Caps"})).is_err());
+ assert_eq!((s.journal.len(), faces(&s)), (journal, names));
+ assert!(s.model.face_merges().is_empty());
+ let r = merge_hole(&mut s);
+ assert_eq!(r["stale_sets"], json!(["Bore"]), "{r}");
+ std::fs::remove_dir_all(dir).ok();
+ }
+}
diff --git a/crates/engine/src/requirement.rs b/crates/engine/src/requirement.rs
index dc46ffd..09c248a 100644
--- a/crates/engine/src/requirement.rs
+++ b/crates/engine/src/requirement.rs
@@ -32,6 +32,8 @@ pub enum Requirement {
DatumPoint,
/// A partition of the design.
Partition,
+ /// A face merge.
+ FaceMerge,
/// An edge seed.
Seed,
/// At least one mesh node.
@@ -79,6 +81,7 @@ impl Requirement {
Requirement::DatumAxis => m.datums().values().any(|d| d.axis().is_some()),
Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()),
Requirement::Partition => !m.partitions().is_empty(),
+ Requirement::FaceMerge => !m.face_merges().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
@@ -119,6 +122,7 @@ impl Requirement {
Requirement::DatumAxis => "a datum axis",
Requirement::DatumPoint => "a datum point",
Requirement::Partition => "a partition",
+ Requirement::FaceMerge => "a face merge",
Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
@@ -149,6 +153,7 @@ impl Requirement {
Requirement::DatumAxis => "geometry.datum.axis",
Requirement::DatumPoint => "geometry.datum.point",
Requirement::Partition => "geometry.partition",
+ Requirement::FaceMerge => "geometry.faces.merge",
Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
@@ -206,6 +211,7 @@ impl crate::schema::Collection {
C::DatumAxis => Requirement::DatumAxis,
C::DatumPoint => Requirement::DatumPoint,
C::Partition => Requirement::Partition,
+ C::FaceMerge => Requirement::FaceMerge,
}
}
}
@@ -346,6 +352,7 @@ mod tests {
C::DatumAxis,
C::DatumPoint,
C::Partition,
+ C::FaceMerge,
];
let empty = Session::default();
for c in all {
diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs
index d621e7a..939406d 100644
--- a/crates/engine/src/schema.rs
+++ b/crates/engine/src/schema.rs
@@ -217,6 +217,8 @@ pub enum Collection {
/// A datum point.
DatumPoint,
Partition,
+ /// A face merge (`geometry.faces.merge`).
+ FaceMerge,
}
impl Collection {
@@ -242,6 +244,7 @@ impl Collection {
Collection::DatumAxis => "datum axis",
Collection::DatumPoint => "datum point",
Collection::Partition => "partition",
+ Collection::FaceMerge => "face merge",
}
}
}
@@ -271,6 +274,7 @@ impl Session {
Collection::DatumAxis => Box::new(m.datums().iter().filter(|(_, d)| d.axis().is_some()).map(|(n, _)| n.as_str())),
Collection::DatumPoint => Box::new(m.datums().iter().filter(|(_, d)| d.point().is_some()).map(|(n, _)| n.as_str())),
Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())),
+ Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs
index d710736..404c834 100644
--- a/crates/engine/src/view.rs
+++ b/crates/engine/src/view.rs
@@ -45,6 +45,8 @@ pub struct Palette {
pub contour_over: Rgb,
/// The elements `mesh.quality.mark` marked.
pub marked: Rgb,
+ /// Faces merged for meshing (`geometry.faces.merge`).
+ pub merged: Rgb,
/// The analysis setup's symbols.
pub glyphs: GlyphColors,
}
@@ -79,6 +81,7 @@ impl Palette {
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
contour_over: CONTOUR_OVER,
marked: Rgb(214, 54, 54),
+ merged: Rgb(222, 150, 30),
glyphs: GlyphColors {
constraint: Rgb(0x00, 0x8a, 0xa8),
load: Rgb(0xd0, 0x24, 0x24),
@@ -266,6 +269,27 @@ pub fn draw_geometry_marks(scene: &mut Scene, display: &DisplayMesh, bodies: &[&
}
}
+/// Draw the merged faces (`geometry.faces.merge`) of the drawn bodies (`bodies`, in the order of
+/// their surfaces) in `color`, so each shows as one face in one colour. The scene's triangles
+/// start with `display`'s surface triangles in order, as they do while no element faces are drawn.
+pub fn draw_merged_faces(scene: &mut Scene, display: &DisplayMesh, s: &Session, bodies: &[&str], color: Rgb) {
+ let Some(design) = s.design.as_deref() else { return };
+ let merged: std::collections::BTreeSet<(u32, u32)> = bodies
+ .iter()
+ .enumerate()
+ .filter_map(|(i, name)| Some((u32::try_from(i).ok()?, design.body(name)?)))
+ .flat_map(|(i, b)| b.merged_faces().into_iter().map(move |(f, _)| (i, f)))
+ .collect();
+ if merged.is_empty() {
+ return;
+ }
+ for (t, d) in scene.tris.iter_mut().zip(&display.surface_tris) {
+ if merged.contains(&(d.body, d.face)) {
+ t.color = color;
+ }
+ }
+}
+
/// What a picture shows besides the image.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shown {
@@ -354,7 +378,12 @@ fn display_parts(s: &Session, section: Option, toward: Vec3) -> (Displa
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh {
- Ok(m) => {
+ Ok(mut m) => {
+ // A merged face's B-rep faces were tessellated apart: their copies of the points
+ // they share differ by up to the weld distance, and would draw as edges.
+ if !body.merged_faces().is_empty() {
+ weld_points(&mut m.positions, m.weld_distance);
+ }
d.add_surface(&m.positions, &m.triangles, &m.tri_face);
bodies.push(body.name.clone());
}
@@ -367,6 +396,31 @@ fn display_parts(s: &Session, section: Option, toward: Vec3) -> (Displa
(d, failed, bodies)
}
+/// Move every point within `distance` of an earlier one (in x order) onto it, so copies of one
+/// point are identical.
+fn weld_points(positions: &mut [[f64; 3]], distance: f64) {
+ let mut order: Vec = (0..positions.len()).collect();
+ order.sort_by(|&a, &b| positions.get(a).map_or(0.0, |p| p[0]).total_cmp(&positions.get(b).map_or(0.0, |p| p[0])));
+ let close = |p: [f64; 3], q: [f64; 3]| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::() <= distance * distance;
+ for (k, &i) in order.iter().enumerate() {
+ let Some(p) = positions.get(i).copied() else { continue };
+ // The earliest point within reach, among those not far behind in x.
+ let mut to = p;
+ for &j in order.get(..k).unwrap_or_default().iter().rev() {
+ let Some(q) = positions.get(j).copied() else { continue };
+ if p[0] - q[0] > distance {
+ break;
+ }
+ if close(p, q) {
+ to = q;
+ }
+ }
+ if let Some(slot) = positions.get_mut(i) {
+ *slot = to;
+ }
+ }
+}
+
/// The model drawn as the viewport draws it by default (shaded with element edges).
fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
build(d, Style::ShadedEdges, Show::DEFAULT, p)
@@ -426,15 +480,13 @@ fn picture(s: &Session, p: &Palette, section: Option, toward: Vec3) ->
let Some((view, result)) = plot else {
let mut scene = model_scene(&base, p);
mark(&mut scene, &base, &marked(s), p.marked);
+ // `bodies` names the drawn surfaces in order.
+ let drawn: Vec<&str> = bodies.iter().map(String::as_str).collect();
+ if base.tris.is_empty() {
+ draw_merged_faces(&mut scene, &base, s, &drawn, p.merged);
+ }
if let Some(marks) = geometry_marks(s) {
- let bodies: Vec<&str> = s
- .design
- .iter()
- .flat_map(|d| &d.bodies)
- .filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name))
- .map(|b| b.name.as_str())
- .collect();
- draw_geometry_marks(&mut scene, &base, &bodies, marks, base.tris.is_empty(), p.marked);
+ draw_geometry_marks(&mut scene, &base, &drawn, marks, base.tris.is_empty(), p.marked);
}
let bounds = base.bounds;
let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index fb74db3..523ffcb 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -1135,11 +1135,15 @@ impl ModelApp {
let marked = fieldcraft_engine::view::marked(&self.session);
fieldcraft_engine::view::mark(&mut s, &self.display.1, &marked, theme::rgb(t.error));
}
- // What Check Geometry found, in the error colour: its faces where geometry faces are
- // drawn, short edges and a dot at each finding over everything.
+ // Faces merged for meshing, each one face in the warm colour where geometry faces are
+ // drawn; then what Check Geometry found, in the error colour: its faces, short edges
+ // and a dot at each finding over everything.
+ let faces = style != scene::Style::Wireframe && !(show.mesh && !self.display.1.tris.is_empty());
+ let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect();
+ if show.geometry && self.plot.1.is_none() && faces {
+ fieldcraft_engine::view::draw_merged_faces(&mut s, &self.display.1, &self.session, &bodies, theme::rgb(t.warm));
+ }
if let Some(marks) = fieldcraft_engine::view::geometry_marks(&self.session).filter(|_| show.geometry && self.plot.1.is_none()) {
- let faces = style != scene::Style::Wireframe && !(show.mesh && !self.display.1.tris.is_empty());
- let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect();
fieldcraft_engine::view::draw_geometry_marks(&mut s, &self.display.1, &bodies, marks, faces, theme::rgb(t.error));
}
let mut datums = Aabb3::EMPTY;
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index b7e7d1e..f30833f 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -276,6 +276,7 @@ impl Tokens {
contour: self.contour_stops(),
contour_over: rgb(self.contour_over),
marked: rgb(self.error),
+ merged: rgb(self.warm),
glyphs: self.glyph_colors(),
}
}
diff --git a/docs/features.md b/docs/features.md
index eb024e9..eeef909 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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.
-78 ready, 38 coming soon.
+79 ready, 38 coming soon.
## Geometry
@@ -22,6 +22,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Prepare | Datum Axis — A datum axis: principal axis, two points or datum points, point and direction | ready | `geometry.datum.axis`, (`geometry.datum.axis.edit`) |
| 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 | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |
## Mesh