From b3a7f12472faed4d206f98bde577b4ece3c07f4b Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:49:52 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/virtual_faces.rs | 42 ++++++++++++++++++++------ crates/mesh/src/hex/layered.rs | 4 +++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/virtual_faces.rs b/crates/engine/src/cmd/virtual_faces.rs index 3197585..adfe940 100644 --- a/crates/engine/src/cmd/virtual_faces.rs +++ b/crates/engine/src/cmd/virtual_faces.rs @@ -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> = 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> = 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 diff --git a/crates/mesh/src/hex/layered.rs b/crates/mesh/src/hex/layered.rs index a43b72b..167bb41 100644 --- a/crates/mesh/src/hex/layered.rs +++ b/crates/mesh/src/hex/layered.rs @@ -431,9 +431,13 @@ pub(crate) fn prepare(ctxs: &[Ctx], setup: Setup) -> Result { } } } + // 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),