diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a57b7ef..24c62a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -41,6 +41,7 @@ is committed and listed here with its exports. | crates/ui-egui/thumbnails/heated-bracket.png | FieldCraft contributors | Original: the heated-bracket sample as docs/media/examples/heated-bracket.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the heated-bracket sample's thumbnail on the Start card | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/rubber-seal.png | FieldCraft contributors | Original: the rubber-seal sample as docs/media/examples/rubber-seal.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the rubber-seal sample's thumbnail on the Start card | MIT OR Apache-2.0 | | crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 | +| crates/engine/tests/designs/pi5-enclosure.json | FieldCraft contributors | Original SolidCraft recipe (solid.* commands) of an enclosure for a single-board computer, for thermal analysis: base, lid, heat sink, board, chips, connectors and the air inside | MIT OR Apache-2.0 | | crates/geometry/tests/designs/hollow-box.solidcraft, crates/geometry/tests/designs/hollow-box-post.solidcraft, crates/geometry/tests/designs/open-tray-post.solidcraft | FieldCraft contributors | Built with `solidcraft-cli run` from the recipes beside them (crates/geometry/tests/designs/*.json) | MIT OR Apache-2.0 | | assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 2c49449..eb09214 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec { pub const PARTITION: CommandSpec = CommandSpec { id: "geometry.partition", label: "Partition", - params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, + params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, form: Some(super::forms::PARTITION), kind: CommandKind::Model, requires: &[Requirement::Design, Requirement::DatumPlane], @@ -506,21 +506,44 @@ fn partition(s: &mut Session, p: &Value) -> Result { } let cells: Vec = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect(); let (faces, bodies): (usize, Vec) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect()); + let mut warnings: Vec = report.map(|r| r.warnings.clone()).unwrap_or_default(); + for cell in &cells { + if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) { + warnings.push(format!( + "partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)" + )); + } + } // The cut shows as edges on the cells, so the plane steps out of the way (view state, #295): // selecting it in the browser or its eye shows it again. if s.view.hidden.len() < MAX_HIDDEN { s.view.hidden.insert(Item { kind: ItemKind::Datum, name: datum }); } - let reply = json!({ + let mut reply = json!({ "name": name, "split": split, "cells": cells, "cut_faces": faces, "bodies": bodies, }); + if !warnings.is_empty() + && let Some(o) = reply.as_object_mut() + { + o.insert("warnings".into(), json!(warnings)); + } Ok(cleared.report_if_any(s, reply)) } +/// Whether a cell's boundary, tessellated as the meshers take it by default, bounds a volume mesh: +/// the defects (open edges, edges on three triangles) when it does not. +fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> { + let input = super::hex::shape_input(shape, None)?; + let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() }; + fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options) + .map(|_| ()) + .map_err(|e| EngineError::Params(e.to_string())) +} + fn datum_suppress(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?; let suppressed = super::opt(p, "suppressed")?.unwrap_or(true); @@ -913,4 +936,50 @@ pub(crate) mod tests { assert!(s.execute("mesh.clear", &json!({"bodies": ["Body1"]})).is_err(), "nothing to clear"); let _ = std::fs::remove_dir_all(&dir); } + + /// The Raspberry Pi 5 enclosure analysis design (base, lid, heat sink, board, chips and the + /// air inside), its SolidCraft recipe. + pub(crate) fn pi5() -> (crate::tests::TempDir, Session) { + scripted(serde_json::from_str(include_str!("../../tests/designs/pi5-enclosure.json")).unwrap()) + } + + /// #384: cuts stay on their plane (the kernel's own split put them 1.8 µm off it on the air + /// of the enclosure, so the cut faces were not even recognized), and a plane along an + /// existing face or through a hole's axis is a warning naming the plane, the body and where. + #[test] + fn cuts_stay_on_the_plane_and_risky_ones_are_named() { + let (_dir, mut s) = pi5(); + let plane = |s: &mut Session, name: &str, x: f64| { + s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": "yz", "offset": x}})).unwrap(); + s.execute("geometry.partition", &json!({"name": format!("P{name}"), "datum": name, "bodies": ["Air"]})).unwrap() + }; + // Beside the board's mounting holes: clean cells, cut faces on the plane. + let r = plane(&mut s, "X", 16.5); + assert!(r.get("warnings").is_none(), "{r}"); + let design = s.design.clone().unwrap(); + for cell in ["Air/a", "Air/b"] { + let shape = design.body(cell).unwrap(); + let cut: Vec = shape + .faces() + .iter() + .zip(shape.face_surfaces().unwrap()) + .filter(|(f, _)| f.name.starts_with("partition:PX:")) + .filter_map(|(_, srf)| match srf { + Some(fieldcraft_geometry::FaceSurface::Plane { point, .. }) => Some(point[0]), + _ => None, + }) + .collect(); + assert!(!cut.is_empty() && cut.iter().all(|x| (x - 16.5).abs() < 1e-9), "{cell}: {cut:?}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + // Along the side of the heat sink's pedestal: named. + let r = plane(&mut s, "Side", 34.0); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("partition `PSide`: the plane lies on face `F45:-x` of `Air`"), "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + // Through a standoff's hole: the cell whose boundary breaks at the hole's edge is named. + let r = plane(&mut s, "Axis", 74.5); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("partition `PAxis`: cell `Air/") && warnings.contains("boundary no mesher takes"), "{r}"); + } } diff --git a/crates/engine/tests/designs/pi5-enclosure.json b/crates/engine/tests/designs/pi5-enclosure.json new file mode 100644 index 0000000..7fdae00 --- /dev/null +++ b/crates/engine/tests/designs/pi5-enclosure.json @@ -0,0 +1 @@ +[{"command": "solid.box", "params": {"length": 112, "width": 82, "height": 36, "corner": [0, 0, 0], "body_name": "Base"}}, {"command": "solid.fillet", "params": {"edges": [[0, 0, 18.0], [112, 0, 18.0], [0, 82, 18.0], [112, 82, 18.0]], "radius": 5}}, {"command": "solid.shell", "params": {"faces": [[56.0, 41.0, 36]], "thickness": 3, "direction": "inside", "body": "Base"}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [-1, -1, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [113, -1, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [-1, 83, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [113, 83, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.hole", "params": {"position": [-1, -1, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [113, -1, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [-1, 83, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [113, 83, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [16.5, 16.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [74.5, 16.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [16.5, 65.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [74.5, 65.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Nut1"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [34, 17], "radius": 11, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut1", "profiles": "all", "distance": 6, "operation": "new", "body_name": "Gland1"}}, {"command": "solid.cylinder", "params": {"diameter": 17, "height": 8, "base": [34, -6, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland1"]}}, {"command": "solid.cylinder", "params": {"diameter": 14, "height": 6, "base": [34, -14, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland1"]}}, {"command": "solid.fillet", "params": {"edges": [[34, -20, 24]], "radius": 2.5, "body": "Gland1"}}, {"command": "solid.cylinder", "params": {"diameter": 6.5, "height": 30, "base": [34, -20, 17], "axis": [0, -1, 0], "operation": "new", "body_name": "Cable1"}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Nut2"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [66, 17], "radius": 11, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut2", "profiles": "all", "distance": 6, "operation": "new", "body_name": "Gland2"}}, {"command": "solid.cylinder", "params": {"diameter": 17, "height": 8, "base": [66, -6, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland2"]}}, {"command": "solid.cylinder", "params": {"diameter": 14, "height": 6, "base": [66, -14, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland2"]}}, {"command": "solid.fillet", "params": {"edges": [[66, -20, 24]], "radius": 2.5, "body": "Gland2"}}, {"command": "solid.cylinder", "params": {"diameter": 6.5, "height": 30, "base": [66, -20, 17], "axis": [0, -1, 0], "operation": "new", "body_name": "Cable2"}}, {"command": "sketch.create", "params": {"plane": {"origin": [112, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Nut3"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [41.0, 18], "radius": 14, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut3", "profiles": "all", "distance": 5, "operation": "new", "body_name": "Gland3"}}, {"command": "solid.cylinder", "params": {"diameter": 24, "height": 17, "base": [117, 41.0, 18], "axis": [1, 0, 0], "operation": "join", "targets": ["Gland3"]}}, {"command": "solid.hole", "params": {"position": [134, 41.0, 18], "diameter": 14, "depth": 15}}, {"command": "solid.fillet", "params": {"edges": [[134, 41.0, 30]], "radius": 2, "body": "Gland3"}}, {"command": "solid.box", "params": {"length": 110.8, "width": 80.8, "height": 1.6, "corner": [0.6, 0.6, 36], "operation": "new", "body_name": "ORing"}}, {"command": "solid.box", "params": {"length": 107.2, "width": 77.2, "height": 1.6, "corner": [2.4, 2.4, 36], "operation": "cut", "targets": ["ORing"]}}, {"command": "solid.box", "params": {"length": 128, "width": 98, "height": 4, "corner": [-8, -8, 37.6], "operation": "new", "body_name": "Lid"}}, {"command": "solid.fillet", "params": {"edges": [[-8, -8, 39.6], [120, -8, 39.6], [-8, 90, 39.6], [120, 90, 39.6]], "radius": 8}}, {"command": "solid.hole", "params": {"position": [-1, -1, 41.6], "diameter": 3.5, "depth": 4}}, {"command": "solid.hole", "params": {"position": [113, -1, 41.6], "diameter": 3.5, "depth": 4}}, {"command": "solid.hole", "params": {"position": [-1, 83, 41.6], "diameter": 3.5, "depth": 4}}, {"command": "solid.hole", "params": {"position": [113, 83, 41.6], "diameter": 3.5, "depth": 4}}, {"command": "solid.box", "params": {"length": 22, "width": 22, "height": 4, "center": [44, 43, 39.6], "operation": "new", "body_name": "LidCut"}}, {"command": "solid.combine", "params": {"target": "Lid", "tools": ["LidCut"], "operation": "cut"}}, {"command": "sketch.create", "params": {"plane": {"origin": [8.0, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Comb"}}, {"command": "sketch.line", "params": {"points": [[7.0, 41.6], [75.0, 41.6], [75.0, 46.6], [75.0, 66.6], [73.7, 66.6], [73.7, 46.6], [71.29444444444444, 46.6], [71.29444444444444, 66.6], [69.99444444444444, 66.6], [69.99444444444444, 46.6], [67.58888888888889, 46.6], [67.58888888888889, 66.6], [66.28888888888889, 66.6], [66.28888888888889, 46.6], [63.88333333333333, 46.6], [63.88333333333333, 66.6], [62.583333333333336, 66.6], [62.583333333333336, 46.6], [60.17777777777778, 46.6], [60.17777777777778, 66.6], [58.87777777777778, 66.6], [58.87777777777778, 46.6], [56.47222222222222, 46.6], [56.47222222222222, 66.6], [55.172222222222224, 66.6], [55.172222222222224, 46.6], [52.766666666666666, 46.6], [52.766666666666666, 66.6], [51.46666666666667, 66.6], [51.46666666666667, 46.6], [49.06111111111111, 46.6], [49.06111111111111, 66.6], [47.76111111111111, 66.6], [47.76111111111111, 46.6], [45.355555555555554, 46.6], [45.355555555555554, 66.6], [44.05555555555556, 66.6], [44.05555555555556, 46.6], [41.65, 46.6], [41.65, 66.6], [40.35, 66.6], [40.35, 46.6], [37.94444444444444, 46.6], [37.94444444444444, 66.6], [36.644444444444446, 66.6], [36.644444444444446, 46.6], [34.23888888888889, 46.6], [34.23888888888889, 66.6], [32.93888888888889, 66.6], [32.93888888888889, 46.6], [30.533333333333335, 46.6], [30.533333333333335, 66.6], [29.233333333333334, 66.6], [29.233333333333334, 46.6], [26.82777777777778, 46.6], [26.82777777777778, 66.6], [25.52777777777778, 66.6], [25.52777777777778, 46.6], [23.122222222222224, 46.6], [23.122222222222224, 66.6], [21.822222222222223, 66.6], [21.822222222222223, 46.6], [19.416666666666668, 46.6], [19.416666666666668, 66.6], [18.116666666666667, 66.6], [18.116666666666667, 46.6], [15.711111111111112, 46.6], [15.711111111111112, 66.6], [14.411111111111111, 66.6], [14.411111111111111, 46.6], [12.005555555555556, 46.6], [12.005555555555556, 66.6], [10.705555555555556, 66.6], [10.705555555555556, 46.6], [8.3, 46.6], [8.3, 66.6], [7.0, 66.6], [7.0, 46.6]], "closed": true}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Comb", "profiles": "all", "distance": 96, "operation": "new", "body_name": "Heatsink"}}, {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 27.6, "corner": [34, 33, 14.0], "operation": "join", "targets": ["Heatsink"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 3.5, "base": [-1, -1, 41.6], "operation": "new", "body_name": "Screw1"}}, {"command": "solid.cylinder", "params": {"diameter": 3, "height": 16, "base": [-1, -1, 25.6], "operation": "join", "targets": ["Screw1"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 45.1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Sock1"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [-1, -1], "radius": 1.45, "sides": 6}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Sock1", "profiles": "all", "distance": 2, "direction": "negative", "operation": "cut", "targets": ["Screw1"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 3.5, "base": [113, -1, 41.6], "operation": "new", "body_name": "Screw2"}}, {"command": "solid.cylinder", "params": {"diameter": 3, "height": 16, "base": [113, -1, 25.6], "operation": "join", "targets": ["Screw2"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 45.1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Sock2"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [113, -1], "radius": 1.45, "sides": 6}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Sock2", "profiles": "all", "distance": 2, "direction": "negative", "operation": "cut", "targets": ["Screw2"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 3.5, "base": [-1, 83, 41.6], "operation": "new", "body_name": "Screw3"}}, {"command": "solid.cylinder", "params": {"diameter": 3, "height": 16, "base": [-1, 83, 25.6], "operation": "join", "targets": ["Screw3"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 45.1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Sock3"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [-1, 83], "radius": 1.45, "sides": 6}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Sock3", "profiles": "all", "distance": 2, "direction": "negative", "operation": "cut", "targets": ["Screw3"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 3.5, "base": [113, 83, 41.6], "operation": "new", "body_name": "Screw4"}}, {"command": "solid.cylinder", "params": {"diameter": 3, "height": 16, "base": [113, 83, 25.6], "operation": "join", "targets": ["Screw4"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 45.1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Sock4"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [113, 83], "radius": 1.45, "sides": 6}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Sock4", "profiles": "all", "distance": 2, "direction": "negative", "operation": "cut", "targets": ["Screw4"]}}, {"command": "solid.box", "params": {"length": 85, "width": 56, "height": 1.6, "corner": [13, 13, 9], "operation": "new", "body_name": "PCB"}}, {"command": "solid.hole", "params": {"position": [16.5, 16.5, 10.6], "diameter": 2.7, "depth": 1.6}}, {"command": "solid.hole", "params": {"position": [74.5, 16.5, 10.6], "diameter": 2.7, "depth": 1.6}}, {"command": "solid.hole", "params": {"position": [16.5, 65.5, 10.6], "diameter": 2.7, "depth": 1.6}}, {"command": "solid.hole", "params": {"position": [74.5, 65.5, 10.6], "diameter": 2.7, "depth": 1.6}}, {"command": "solid.fillet", "params": {"edges": [[13, 13, 9.8], [98, 13, 9.8], [13, 69, 9.8], [98, 69, 9.8]], "radius": 3}}, {"command": "solid.box", "params": {"length": 17, "width": 17, "height": 2.4, "corner": [35.5, 34.5, 10.6], "operation": "new", "body_name": "SoC"}}, {"command": "solid.box", "params": {"length": 17, "width": 17, "height": 1.0, "corner": [35.5, 34.5, 13.0], "operation": "new", "body_name": "Pad"}}, {"command": "solid.box", "params": {"length": 10, "width": 14, "height": 1.2, "corner": [22.0, 36.0, 10.6], "operation": "new", "body_name": "RAM"}}, {"command": "solid.box", "params": {"length": 7, "width": 7, "height": 1.0, "corner": [61.5, 39.5, 10.6], "operation": "new", "body_name": "RP1"}}, {"command": "solid.box", "params": {"length": 6, "width": 6, "height": 0.9, "corner": [20.0, 24.0, 10.6], "operation": "new", "body_name": "PMIC"}}, {"command": "solid.box", "params": {"length": 21, "width": 16, "height": 13.5, "corner": [79.0, 15.25, 10.6], "operation": "new", "body_name": "Ethernet"}}, {"command": "solid.box", "params": {"length": 17.5, "width": 14.5, "height": 16, "corner": [81.75, 34.75, 10.6], "operation": "new", "body_name": "USB1"}}, {"command": "solid.box", "params": {"length": 17.5, "width": 14.5, "height": 16, "corner": [81.75, 52.75, 10.6], "operation": "new", "body_name": "USB2"}}, {"command": "solid.box", "params": {"length": 9, "width": 7.5, "height": 3.3, "corner": [19.7, 12.649999999999999, 10.6], "operation": "new", "body_name": "USBC"}}, {"command": "solid.box", "params": {"length": 7.5, "width": 7, "height": 3.5, "corner": [35.05, 13.100000000000001, 10.6], "operation": "new", "body_name": "HDMI0"}}, {"command": "solid.box", "params": {"length": 7.5, "width": 7, "height": 3.5, "corner": [48.45, 13.100000000000001, 10.6], "operation": "new", "body_name": "HDMI1"}}, {"command": "solid.box", "params": {"length": 3, "width": 16, "height": 4, "corner": [57.0, 21.0, 10.6], "operation": "new", "body_name": "FFC1"}}, {"command": "solid.box", "params": {"length": 3, "width": 16, "height": 4, "corner": [68.5, 21.0, 10.6], "operation": "new", "body_name": "FFC2"}}, {"command": "solid.box", "params": {"length": 51, "width": 5, "height": 2.5, "corner": [20.0, 63.0, 10.6], "operation": "new", "body_name": "Header"}}, {"command": "solid.box", "params": {"length": 0.64, "width": 0.64, "height": 8.5, "corner": [21.05, 63.91, 10.6], "operation": "new", "body_name": "Pins"}}, {"command": "solid.box", "params": {"length": 0.64, "width": 0.64, "height": 8.5, "corner": [21.05, 66.45, 10.6], "operation": "join", "targets": ["Pins"]}}, {"command": "solid.pattern.rectangular", "params": {"bodies": ["Pins"], "dir1": [1, 0, 0], "count1": 20, "spacing1": 2.54}}, {"command": "solid.box", "params": {"length": 106, "width": 76, "height": 34.6, "corner": [3, 3, 3], "operation": "new", "body_name": "Air"}}, {"command": "solid.combine", "params": {"target": "Air", "tools": ["Base", "PCB", "SoC", "Pad", "Heatsink"], "operation": "cut", "keep_tools": true}}] \ No newline at end of file diff --git a/crates/geometry/src/partition.rs b/crates/geometry/src/partition.rs index b3bc563..e5aa9e1 100644 --- a/crates/geometry/src/partition.rs +++ b/crates/geometry/src/partition.rs @@ -1,8 +1,8 @@ //! Partitions: a design's bodies cut into cells by planes, for meshing cell by cell. //! -//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel -//! (`split_by_plane`, the operation behind SolidCraft's split body), in memory: the design file is -//! never changed. The partitions live in the FieldCraft model and are applied again, in order, +//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel (the body's +//! intersections with a box on each side of the plane, as SolidCraft's split body does), in +//! memory: the design file is never changed. The partitions live in the FieldCraft model and are applied again, in order, //! whenever the design is opened, so an edit of the design followed by a re-open replays them. //! //! Names are FieldCraft's own, so they do not depend on the order of the design's timeline: @@ -52,6 +52,8 @@ pub struct CutReport { pub split: Vec, /// How many cut faces it named. pub faces: usize, + /// What may make its cells hard to mesh: the plane lying on a face of a body it cut. + pub warnings: Vec, } fn vec3(p: [f64; 3]) -> Vec3 { @@ -107,7 +109,7 @@ impl Design { errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut"), bodies: vec![b.clone()] }); } } - let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0 }; + let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0, warnings: Vec::new() }; let mut next = Vec::with_capacity(bodies.len() + 1); for body in bodies { let listed = cut.bodies.iter().any(|b| b == &body.name); @@ -116,9 +118,10 @@ impl Design { continue; } match split(&body, plane, *n, cut)? { - Split::Cells(cells, faces) => { + Split::Cells(cells, faces, warnings) => { report.split.push(body.name.clone()); report.faces += faces; + report.warnings.extend(warnings); next.extend(cells); } Split::Misses => { @@ -153,10 +156,63 @@ impl Design { } } +/// The parts of `body` on either side of `plane` (only the non-empty ones): its intersections +/// with two boxes, one on each side, whose faces in the plane are squares as wide as the body's +/// bounding-box diagonal and that reach that far from it. SolidCraft's `split_by_plane` uses boxes +/// several times the body's size, and its boolean then puts the cut up to a few micrometres off +/// the plane (1.8 µm on a 140 mm body), off the faces it should meet (#384); boxes of the body's own +/// size keep the cut on the plane. +fn halves(body: &Shape, plane: &Plane) -> solidcraft_kernel::Result> { + let bbox = solidcraft_kernel::measure(&body.body)?.bbox; + let reach = bbox.diagonal().max(LINEAR_REACH); + let c = plane.to_local(bbox.center()); + let square = solidcraft_geom::Region2 { + outer: solidcraft_geom::Loop2::polygon(&[ + solidcraft_geom::Vec2::new(c.x - reach, c.y - reach), + solidcraft_geom::Vec2::new(c.x + reach, c.y - reach), + solidcraft_geom::Vec2::new(c.x + reach, c.y + reach), + solidcraft_geom::Vec2::new(c.x - reach, c.y + reach), + ]), + holes: vec![], + }; + let mut out = Vec::with_capacity(2); + for (lo, hi) in [(0.0, reach), (-reach, 0.0)] { + let Some(side) = solidcraft_kernel::extrude(plane, std::slice::from_ref(&square), lo, hi)?.pop() else { continue }; + out.extend(solidcraft_kernel::boolean(&body.body, &side, solidcraft_kernel::BoolOp::Intersect)?); + } + Ok(out) +} + +/// Planar faces of `body` the cut's plane lies on (within [`COINCIDENT`] of the body's size), +/// as warnings: the kernel may leave micrometre slivers where a cut runs along a face (#384). +fn coincident(body: &Shape, parent: &Faces, cut: &Cut, n: [f64; 3], size: f64) -> Vec { + let reach = COINCIDENT * size.max(1.0); + let mut out = Vec::new(); + for (i, surface) in parent.surfaces.iter().enumerate() { + let Some(FaceSurface::Plane { normal, point }) = surface else { continue }; + let along = dot(*normal, n).abs(); + let distance = dot(sub(*point, cut.point), n).abs(); + if (along - 1.0).abs() < 1e-9 && distance <= reach { + let name = parent.names.get(i).map_or_else(|| i.to_string(), |f| f.name.clone()); + out.push(format!( + "partition `{}`: the plane lies on face `{name}` of `{}` ({distance:.2e} mm from it); a cut along an existing face can leave micrometre slivers that no mesher takes: move the plane off the face (by a fraction of the element size)", + cut.name, body.name + )); + } + } + out +} + +/// A plane within this fraction of a body's size of one of its faces lies on it. +const COINCIDENT: f64 = 1e-5; + +/// The least reach of the boxes [`halves`] cuts with (mm), for bodies with no extent. +const LINEAR_REACH: f64 = 1.0; + /// What cutting one body gave. enum Split { - /// Its cells, named, and how many cut faces they have. - Cells(Vec, usize), + /// Its cells, named, how many cut faces they have, and warnings. + Cells(Vec, usize, Vec), /// The plane does not divide it. Misses, /// The kernel failed to cut it: its message. @@ -165,7 +221,7 @@ enum Split { /// Cut one body. fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result { - let sides = match guard("partition", || solidcraft_kernel::split_by_plane(&body.body, plane)) { + let sides = match guard("partition", || halves(body, plane)) { Ok(p) if p.len() >= 2 => p, Ok(_) => return Ok(Split::Misses), Err(e) => return Ok(Split::Failed(e.to_string())), @@ -209,7 +265,7 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result { faces += name_faces(&mut shape, &parent, cut, n, size, tol)?; out.push(shape); } - Ok(Split::Cells(out, faces)) + Ok(Split::Cells(out, faces, coincident(body, &parent, cut, n, size))) } /// A body's faces as matching needs them: each face's analytic surface, name and triangles. @@ -437,7 +493,7 @@ mod tests { assert_eq!(p.bodies.len(), 2, "{:?}", p.bodies.iter().map(|b| &b.name).collect::>()); let volume: f64 = p.bodies.iter().map(|b| b.measure().unwrap().volume).sum(); assert!((volume - whole).abs() < 1e-6 * whole, "{volume} vs {whole}"); - assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4 }]); + assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4, warnings: Vec::new() }]); let mut sides = Vec::new(); for b in &p.bodies { let m = b.measure().unwrap();