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 @@ + + + geometry.faces.merge + + + + + + + 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