engine: geometry.faces.merge, faces meshed as one (virtual topology, #505)

geometry.faces.merge {name, faces} records a face merge in the model;
it is applied after the partitions whenever the design is opened or cut
again, undoes through the journal and is saved with the model. Meshers,
mesh.plan (features and rings see merge:<name>; a merged face is
prismatic when its B-rep faces are), checks and the viewport see one
face, drawn in the warm colour. geometry.faces.merge.delete removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 11:09:11 -04:00
1 parent 22d709e604
commit ce02d263c7
17 files changed
+464 -22

No files matched your search

+1
View File
@@ -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.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.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.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/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.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 | | 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 |
+10
View File
@@ -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.faces.merge</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="M7.5 6 L16.5 10" fill="none" stroke="#f0b44a" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
<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: 844 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"}, {"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}]}
+8
View File
@@ -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", "Cut bodies into cells by a datum plane; the two faces of each cut are paired by name",
&["geometry.partition.delete", "geometry.partition.suppress"], &["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"), soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
// ---- Mesh ---- // ---- Mesh ----
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"), soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
+7 -4
View File
@@ -12,7 +12,7 @@
use crate::schema::ProblemKind; use crate::schema::ProblemKind;
use std::sync::Arc; use std::sync::Arc;
use fieldcraft_geometry::{Cut, Design}; use fieldcraft_geometry::{Cut, Design, Merge};
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN}; use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
use fieldcraft_model::{Datum, Model, Partition}; use fieldcraft_model::{Datum, Model, Partition};
use serde::Deserialize; use serde::Deserialize;
@@ -470,12 +470,15 @@ fn cuts(model: &Model) -> Vec<Cut> {
.collect() .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<Design> { pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
Ok(design.partitioned(&cuts(model))?) let merges: Vec<Merge> =
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 /// 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. /// went is in the returned [`super::replace::Cleared`], which the command reports.
pub(super) fn recut(s: &mut Session) -> Result<super::replace::Cleared> { pub(super) fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
+8
View File
@@ -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"), 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 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] = &[ 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(), 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-<body>"), text("name", "Name").help("The name the plan is saved under.").hint("Plan-<body>"),
+3
View File
@@ -35,6 +35,7 @@ mod symmetry;
mod tags; mod tags;
pub mod update; pub mod update;
pub(crate) mod view; pub(crate) mod view;
mod virtual_faces;
pub mod xy; pub mod xy;
use crate::schema::ProblemKind; use crate::schema::ProblemKind;
@@ -70,6 +71,8 @@ pub static ALL: &[CommandSpec] = &[
datum::DATUM_SUPPRESS, datum::DATUM_SUPPRESS,
datum::PARTITION_SUPPRESS, datum::PARTITION_SUPPRESS,
datum::PARTITION_DELETE, datum::PARTITION_DELETE,
virtual_faces::FACES_MERGE,
virtual_faces::FACES_MERGE_DELETE,
mesh::NODE_ADD, mesh::NODE_ADD,
mesh::NODE_REMOVE, mesh::NODE_REMOVE,
mesh::ELEMENT_ADD, mesh::ELEMENT_ADD,
+9 -4
View File
@@ -113,14 +113,19 @@ fn canonical(d: V3) -> Option<V3> {
const AXES: [V3; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; 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 /// 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<FaceSurface>], faces: &[String], d: V3) -> bool { fn faces_prismatic(shape: &Shape, surfaces: &[Option<FaceSurface>], faces: &[String], d: V3) -> bool {
let prismatic = |s: Option<FaceSurface>| match s {
Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d),
Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d),
_ => false,
};
faces.iter().all(|name| { faces.iter().all(|name| {
let Some(i) = shape.face_index(name) else { return true }; let Some(i) = shape.face_index(name) else { return true };
match surfaces.get(i as usize).copied().flatten() { match surfaces.get(i as usize).copied().flatten() {
Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d), Some(s) => prismatic(Some(s)),
Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d), None => shape.member_surfaces(i).is_ok_and(|m| m.len() > 1 && m.into_iter().all(prismatic)),
_ => false,
} }
}) })
} }
+11
View File
@@ -46,6 +46,17 @@ pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator<Item = &'a
} }
impl Cleared { impl Cleared {
/// Add what `other` removed (a body in both counts once, as `other` reports it).
pub(super) fn extend(&mut self, other: Cleared) {
self.bodies.retain(|(b, _, _)| !other.bodies.iter().any(|(o, _, _)| o == b));
self.bodies.extend(other.bodies);
self.removed.elements.extend(other.removed.elements);
self.removed.nodes.extend(other.removed.nodes);
self.stale_sets.extend(other.stale_sets);
self.stale_sets.sort();
self.stale_sets.dedup();
}
/// [`Cleared::report`] when anything was removed; `reply` as it is otherwise. /// [`Cleared::report`] when anything was removed; `reply` as it is otherwise.
pub(super) fn report_if_any(self, s: &Session, reply: Value) -> Value { pub(super) fn report_if_any(self, s: &Session, reply: Value) -> Value {
if self.bodies.is_empty() { reply } else { self.report(s, reply) } if self.bodies.is_empty() { reply } else { self.report(s, reply) }
+323
View File
@@ -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:<name>`, 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:<name>` 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:<name>` 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<String>)> {
let refs: Vec<GeometryRef> = 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::<String>())));
}
}
}
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<String> {
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<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();
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<String> = 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<Value> {
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::<String>())));
}
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<Value> {
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<String> {
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<String> {
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::<f64>();
let t = (ab.iter().zip(&ap).map(|(x, y)| x * y).sum::<f64>() / l2).clamp(0.0, 1.0);
(0..3).map(|k| (ap[k] - t * ab[k]).powi(2)).sum::<f64>().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<Vec<[f64; 3]>> = 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, &params).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, &params).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<String> = 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<String> = 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::<Vec<_>>();
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<String> = 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();
}
}
+7
View File
@@ -32,6 +32,8 @@ pub enum Requirement {
DatumPoint, DatumPoint,
/// A partition of the design. /// A partition of the design.
Partition, Partition,
/// A face merge.
FaceMerge,
/// An edge seed. /// An edge seed.
Seed, Seed,
/// At least one mesh node. /// At least one mesh node.
@@ -79,6 +81,7 @@ impl Requirement {
Requirement::DatumAxis => m.datums().values().any(|d| d.axis().is_some()), Requirement::DatumAxis => m.datums().values().any(|d| d.axis().is_some()),
Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()), Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()),
Requirement::Partition => !m.partitions().is_empty(), Requirement::Partition => !m.partitions().is_empty(),
Requirement::FaceMerge => !m.face_merges().is_empty(),
Requirement::Seed => !m.seeds().is_empty(), Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(), Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements, Requirement::Mesh => has_elements,
@@ -119,6 +122,7 @@ impl Requirement {
Requirement::DatumAxis => "a datum axis", Requirement::DatumAxis => "a datum axis",
Requirement::DatumPoint => "a datum point", Requirement::DatumPoint => "a datum point",
Requirement::Partition => "a partition", Requirement::Partition => "a partition",
Requirement::FaceMerge => "a face merge",
Requirement::Seed => "an edge seed", Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes", Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh", Requirement::Mesh | Requirement::Analysable => "a mesh",
@@ -149,6 +153,7 @@ impl Requirement {
Requirement::DatumAxis => "geometry.datum.axis", Requirement::DatumAxis => "geometry.datum.axis",
Requirement::DatumPoint => "geometry.datum.point", Requirement::DatumPoint => "geometry.datum.point",
Requirement::Partition => "geometry.partition", Requirement::Partition => "geometry.partition",
Requirement::FaceMerge => "geometry.faces.merge",
Requirement::Seed => "mesh.seed.edge", Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add", Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate", Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
@@ -206,6 +211,7 @@ impl crate::schema::Collection {
C::DatumAxis => Requirement::DatumAxis, C::DatumAxis => Requirement::DatumAxis,
C::DatumPoint => Requirement::DatumPoint, C::DatumPoint => Requirement::DatumPoint,
C::Partition => Requirement::Partition, C::Partition => Requirement::Partition,
C::FaceMerge => Requirement::FaceMerge,
} }
} }
} }
@@ -346,6 +352,7 @@ mod tests {
C::DatumAxis, C::DatumAxis,
C::DatumPoint, C::DatumPoint,
C::Partition, C::Partition,
C::FaceMerge,
]; ];
let empty = Session::default(); let empty = Session::default();
for c in all { for c in all {
+4
View File
@@ -217,6 +217,8 @@ pub enum Collection {
/// A datum point. /// A datum point.
DatumPoint, DatumPoint,
Partition, Partition,
/// A face merge (`geometry.faces.merge`).
FaceMerge,
} }
impl Collection { impl Collection {
@@ -242,6 +244,7 @@ impl Collection {
Collection::DatumAxis => "datum axis", Collection::DatumAxis => "datum axis",
Collection::DatumPoint => "datum point", Collection::DatumPoint => "datum point",
Collection::Partition => "partition", 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::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::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::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() names.take(MAX_NAMES).map(str::to_string).collect()
} }
+61 -9
View File
@@ -45,6 +45,8 @@ pub struct Palette {
pub contour_over: Rgb, pub contour_over: Rgb,
/// The elements `mesh.quality.mark` marked. /// The elements `mesh.quality.mark` marked.
pub marked: Rgb, pub marked: Rgb,
/// Faces merged for meshing (`geometry.faces.merge`).
pub merged: Rgb,
/// The analysis setup's symbols. /// The analysis setup's symbols.
pub glyphs: GlyphColors, 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: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
contour_over: CONTOUR_OVER, contour_over: CONTOUR_OVER,
marked: Rgb(214, 54, 54), marked: Rgb(214, 54, 54),
merged: Rgb(222, 150, 30),
glyphs: GlyphColors { glyphs: GlyphColors {
constraint: Rgb(0x00, 0x8a, 0xa8), constraint: Rgb(0x00, 0x8a, 0xa8),
load: Rgb(0xd0, 0x24, 0x24), 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. /// What a picture shows besides the image.
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct Shown { pub struct Shown {
@@ -354,7 +378,12 @@ fn display_parts(s: &Session, section: Option<Section>, toward: Vec3) -> (Displa
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) { 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() }))); let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh { 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); d.add_surface(&m.positions, &m.triangles, &m.tri_face);
bodies.push(body.name.clone()); bodies.push(body.name.clone());
} }
@@ -367,6 +396,31 @@ fn display_parts(s: &Session, section: Option<Section>, toward: Vec3) -> (Displa
(d, failed, bodies) (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<usize> = (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::<f64>() <= 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). /// The model drawn as the viewport draws it by default (shaded with element edges).
fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene { fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
build(d, Style::ShadedEdges, Show::DEFAULT, p) build(d, Style::ShadedEdges, Show::DEFAULT, p)
@@ -426,15 +480,13 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
let Some((view, result)) = plot else { let Some((view, result)) = plot else {
let mut scene = model_scene(&base, p); let mut scene = model_scene(&base, p);
mark(&mut scene, &base, &marked(s), p.marked); 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) { if let Some(marks) = geometry_marks(s) {
let bodies: Vec<&str> = s draw_geometry_marks(&mut scene, &base, &drawn, marks, base.tris.is_empty(), p.marked);
.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);
} }
let bounds = base.bounds; let bounds = base.bounds;
let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n); let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n);
+8 -4
View File
@@ -1135,11 +1135,15 @@ impl ModelApp {
let marked = fieldcraft_engine::view::marked(&self.session); let marked = fieldcraft_engine::view::marked(&self.session);
fieldcraft_engine::view::mark(&mut s, &self.display.1, &marked, theme::rgb(t.error)); 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 // Faces merged for meshing, each one face in the warm colour where geometry faces are
// drawn, short edges and a dot at each finding over everything. // 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()) { 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)); fieldcraft_engine::view::draw_geometry_marks(&mut s, &self.display.1, &bodies, marks, faces, theme::rgb(t.error));
} }
let mut datums = Aabb3::EMPTY; let mut datums = Aabb3::EMPTY;
+1
View File
@@ -276,6 +276,7 @@ impl Tokens {
contour: self.contour_stops(), contour: self.contour_stops(),
contour_over: rgb(self.contour_over), contour_over: rgb(self.contour_over),
marked: rgb(self.error), marked: rgb(self.error),
merged: rgb(self.warm),
glyphs: self.glyph_colors(), glyphs: self.glyph_colors(),
} }
} }
+2 -1
View File
@@ -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, 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. 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 ## 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 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 | 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 | 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) | | | Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |
## Mesh ## Mesh