mesh: a closed edge's start is no curve end of the swept cross-section (#512)

The extrude/revolve cross-section took both ends of every B-rep edge as curve ends. A closed
edge without a vertex on it (a hole's rim after geometry.faces.merge of its halves) starts
wherever its chain was picked up, the old seam vertex, so that point stayed a curve end and a
node column stayed on the merged boundary. Only open edges' ends (and the B-rep vertices) are
curve ends now; the layout and interval assignment place the rim's nodes.

The hex test runs on a square plate and asserts the seams carry exactly as many nodes as the
same lines turned a quarter round the hole (10 and 10; before 5 and 10): the corner layout
puts arc-midpoint nodes there by symmetry, so 0 is not the right target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 13:39:24 -04:00
1 parent 3a5a8213a1
commit b3a7f12472
2 files changed
+36 -10

No files matched your search

+32 -10
View File
@@ -290,11 +290,24 @@ mod tests {
s.model.nodes().values().filter(|n| on(n.xyz)).count()
}
/// The sample plate made square (30 × 30 × 10, the Ø10 hole in the middle): no layout of the
/// cross-section has a reason to cut along the seams.
fn square_plate() -> (Session, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("fieldcraft-merge-{}-{:?}", std::process::id(), std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("square.solidcraft");
let plate = fieldcraft_geometry::sample::Plate { width: 30.0, ..Default::default() };
std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&plate).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
(s, dir)
}
/// 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();
/// halves are gone from the tags, and the seams between the halves (no longer edges) carry
/// `expected(session, seams)` nodes.
fn mesher_meshes_a_merged_face_as_one(command: &str, params: serde_json::Value, expected: impl Fn(&Session, &[Vec<[f64; 3]>]) -> usize) {
let (mut s, dir) = square_plate();
let hole = halves(&s);
let b = &s.design.as_ref().unwrap().bodies[0];
let seams: Vec<Vec<[f64; 3]>> = b
@@ -319,21 +332,30 @@ mod tests {
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})");
let expected = expected(&s, &seams);
assert_eq!(after, expected, "{command}: nodes on the seams after the merge (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);
// Before: the surface mesh keeps the seams as feature lines; 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);
// Before, the swept O-grid round the hole had a node column on each seam, and after the
// merge one on the seam where the closed rim's chain started (#512). The rim's start is
// no curve end now: the seams are lines like any other round the hole. The square plate's
// layout (cuts from its corners) still puts nodes in the middle of the arcs between the
// cuts, which on this plate are where the seams lie and also a quarter turn round: the
// seams carry as many nodes as those turned lines, no more and no fewer.
let turned = |s: &Session, seams: &[Vec<[f64; 3]>]| {
let quarter: Vec<Vec<[f64; 3]>> = seams.iter().map(|l| l.iter().map(|p| [30.0 - p[1], p[0], p[2]]).collect()).collect();
nodes_on_seams(s, &quarter)
};
mesher_meshes_a_merged_face_as_one("mesh.hex.generate", json!({"size": 2.5}), turned);
}
/// The hex-meshing plan sees the merged hole as one face: the hole's feature and its ring
+4
View File
@@ -431,9 +431,13 @@ pub(crate) fn prepare(ctxs: &[Ctx], setup: Setup) -> Result<Prepared> {
}
}
}
// Curve ends: the B-rep vertices and the ends of open edges. A closed edge without a
// vertex on it (a hole's rim after its halves are merged, #512) starts wherever its chain
// was picked up: that point is no end, and the cross-section's mesher places its nodes.
vertices.extend(
brep.edges
.iter()
.filter(|e| !e.closed())
.flat_map(|e| [e.first(), e.last()])
.chain(brep.is_vertex.iter().enumerate().filter(|(_, v)| **v).map(|(i, _)| i as u32))
.map(|v| frame.coords(brep.point(v)).0),