forked from 1b-agent/fieldcraft-upstream
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
22d709e604
commit
ce02d263c7
17 files changed
+464
-22
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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}]}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Cut> {
|
||||
.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> {
|
||||
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
|
||||
/// went is in the returned [`super::replace::Cleared`], which the command reports.
|
||||
pub(super) fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
|
||||
|
||||
@@ -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-<body>"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]];
|
||||
|
||||
/// 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 {
|
||||
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| {
|
||||
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)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,6 +46,17 @@ pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator<Item = &'a
|
||||
}
|
||||
|
||||
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.
|
||||
pub(super) fn report_if_any(self, s: &Session, reply: Value) -> Value {
|
||||
if self.bodies.is_empty() { reply } else { self.report(s, reply) }
|
||||
|
||||
@@ -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, ¶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<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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<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)) {
|
||||
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<Section>, 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<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).
|
||||
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<Section>, 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
|
||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
|
||||
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user