diff --git a/ROADMAP.md b/ROADMAP.md index c34069a..472751c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s |---|---|---| | M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done | | M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress | -| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress | +| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`|`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress | | M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | | M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started | | M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; sample `crank-mirror`; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); pyramid transitions; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started | diff --git a/crates/engine/src/cmd/cells.rs b/crates/engine/src/cmd/cells.rs index 00d036f..61de719 100644 --- a/crates/engine/src/cmd/cells.rs +++ b/crates/engine/src/cmd/cells.rs @@ -138,10 +138,11 @@ fn cells(s: &mut Session, p: &Value) -> Result { let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?; let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets }; + let seeds = super::seeds::edge_seeds(s)?; let mut meshed: Vec<(Meshed, Value)> = Vec::with_capacity(shapes.len()); for shape in shapes { let method = methods.get(&shape.name).copied().unwrap_or(default); - let input = || super::hex::shape_input(shape, Some(tolerance)); + let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }); let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> { let b = input()?; let mesh = hex_body(&hex_params, &b, m, quadratic, size)?; diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs index fc1824f..ff88254 100644 --- a/crates/engine/src/cmd/hex.rs +++ b/crates/engine/src/cmd/hex.rs @@ -15,7 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const HEX_GENERATE: CommandSpec = CommandSpec { id: "mesh.hex.generate", label: "Generate Hex Mesh", - params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#, + params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#, form: Some(super::forms::HEX_GENERATE), kind: CommandKind::Model, requires: &[Requirement::Geometry], @@ -35,9 +35,12 @@ const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3; pub(super) struct Input<'a> { pub(super) name: String, pub(super) mesh: Cow<'a, SurfaceMesh>, - surfaces: Vec>, + pub(super) surfaces: Vec>, pub(super) face_names: Vec, pub(super) diagonal: f64, + /// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its + /// edges. + pub(super) seeds: Vec, } fn analytic(s: FaceSurface) -> Surface { @@ -71,7 +74,7 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option) -> Result(s: &'a Session, p: &Value) -> Result>> { @@ -84,7 +87,7 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result>> { .as_deref() .and_then(|d| d.body(name)) .ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))?; - Ok(vec![shape_input(shape, tolerance)?]) + Ok(vec![Input { seeds: super::seeds::edge_seeds(s)?, ..shape_input(shape, tolerance)? }]) } (None, Some(i)) => { let n = s.surfaces.len(); @@ -101,14 +104,15 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result>> { let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() }; // Faces named by index, as mesh.tet.generate names them. let face_names = (0..t.faces).map(|f| f.to_string()).collect(); - Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi) }]) + Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }]) } (None, None) => { let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?; if design.bodies.is_empty() || design.bodies.len() > MAX_BODIES { return Err(EngineError::Params(format!("the design has {} bodies; meshing takes 1 to {MAX_BODIES}", design.bodies.len()))); } - design.bodies.iter().map(|b| shape_input(b, tolerance)).collect() + let seeds = super::seeds::edge_seeds(s)?; + design.bodies.iter().map(|b| Ok(Input { seeds: seeds.clone(), ..shape_input(b, tolerance)? })).collect() } _ => Err(EngineError::Params("give `body` or `surface`, not both".into())), } @@ -208,6 +212,7 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result { quadratic, chordal_tolerance: tolerance, max_elements: fieldcraft_model::MAX_ELEMENTS / 8, + seeds: super::seeds::edge_seeds(s)?, ..HexOptions::default() }; let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?; @@ -331,8 +336,14 @@ pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options) .map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?; let chord = if b.surfaces.is_empty() { 0.0 } else { number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * b.diagonal) }; - let options = - HexOptions { method, quadratic, chordal_tolerance: chord, max_elements: fieldcraft_model::MAX_ELEMENTS / 8, ..HexOptions::default() }; + let options = HexOptions { + method, + quadratic, + chordal_tolerance: chord, + max_elements: fieldcraft_model::MAX_ELEMENTS / 8, + seeds: b.seeds.clone(), + ..HexOptions::default() + }; hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name))) } diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 9a3d75c..cc938a6 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -106,7 +106,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec { pub const TET_GENERATE: CommandSpec = CommandSpec { id: "mesh.tet.generate", label: "Generate Tet Mesh", - params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#, + params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#, form: Some(super::forms::TET), kind: CommandKind::Model, requires: &[Requirement::Geometry], @@ -250,7 +250,12 @@ pub(super) fn mesh_body( }; let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?; let tets = tetrahedralize(&surface, cdt)?; - let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? }; + let mesh = if quality { + let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() }; + quality_mesh(&surface, &tets, analytic, &options)? + } else { + raw_mesh(&surface, &tets)? + }; Ok(BodyTets { name: input.name.clone(), face_names: input.face_names.clone(), @@ -301,6 +306,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result { "straightened": r.straightened, "stopped": r.stopped, }, + "seeds": r.seeds.iter().map(|(name, count, edges)| json!({"edge": name, "count": count, "mesh_edges": edges})).collect::>(), })); } let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::(); diff --git a/crates/engine/src/cmd/seeds.rs b/crates/engine/src/cmd/seeds.rs index d10853c..04b3912 100644 --- a/crates/engine/src/cmd/seeds.rs +++ b/crates/engine/src/cmd/seeds.rs @@ -65,6 +65,37 @@ pub(crate) fn seeded_edges(design: &Design, r: &GeometryRef) -> Result Ok(body.edges()?.into_iter().filter(|e| GeometryRef::new(r.body.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| r.selects(&g))).collect()) } +/// Every seed on the open design's edges, as the meshers take them. A mesher matches them to its +/// own curves by position, so a seed on an edge two partition cells share holds in both. +pub(crate) fn edge_seeds(s: &Session) -> Result> { + let Some(design) = s.design.as_deref() else { return Ok(Vec::new()) }; + let mut out = Vec::new(); + // Each body's edges, found once. + let mut edges: std::collections::BTreeMap<&str, (f64, Vec)> = std::collections::BTreeMap::new(); + for seed in s.model.seeds() { + let Some(body) = design.body(&seed.edge.body) else { continue }; + let (tolerance, all) = match edges.entry(body.name.as_str()) { + std::collections::btree_map::Entry::Occupied(e) => e.into_mut(), + std::collections::btree_map::Entry::Vacant(v) => { + v.insert(((body.size()? * fieldcraft_geometry::EDGE_TOLERANCE).max(1e-4), body.edges()?)) + } + }; + let selected = all.iter().filter(|e| GeometryRef::new(body.name.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| seed.edge.selects(&g))); + for e in selected.cloned().collect::>() { + let tolerance = *tolerance; + out.push(fieldcraft_mesh::seeds::EdgeSeed { + name: format!("{}:{}", seed.edge.body, e.name), + count: seed.count(e.length()) as usize, + points: e.points, + tolerance, + bias: seed.bias, + constraint: seed.constraint, + }); + } + } + Ok(out) +} + fn design(s: &Session) -> Result<&Design> { s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first (geometry.open)".into())) } @@ -192,6 +223,78 @@ mod tests { } } + /// The polyline of edge `name` of body `body`. + fn edge_points(s: &Session, body: &str, name: &str) -> Vec<[f64; 3]> { + s.design.as_ref().unwrap().body(body).unwrap().edges().unwrap().into_iter().find(|e| e.name == name).unwrap().points + } + + /// The model's nodes on a straight edge, by their distance from its start, sorted. + fn nodes_on(s: &Session, line: &[[f64; 3]]) -> Vec { + let (a, b) = (line[0], *line.last().unwrap()); + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + let mut out: Vec = s + .model + .nodes() + .values() + .filter_map(|n| { + let v = [n.xyz[0] - a[0], n.xyz[1] - a[1], n.xyz[2] - a[2]]; + let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / len; + let off = [v[0] - t * d[0] / len, v[1] - t * d[1] / len, v[2] - t * d[2] / len]; + let off = (off[0] * off[0] + off[1] * off[1] + off[2] * off[2]).sqrt(); + (off < 1e-6 && (-1e-6..=len + 1e-6).contains(&t)).then_some(t) + }) + .collect(); + out.sort_by(f64::total_cmp); + out + } + + #[test] + fn the_meshers_honour_seeds() { + let mut s = plate(); + let edges = thickness_edges(&mut s); + let name = edges[0]["edge"].as_str().unwrap().to_string(); + let line = edge_points(&s, "Body1", &name); + let seed = json!({"edges": [edges[0]], "count": 5, "bias": {"ratio": 3, "toward": "end"}}); + s.execute("mesh.seed.edge", &seed).unwrap(); + // Hex sweep: five layers through the thickness, graded geometrically toward the edge's end. + s.execute("mesh.hex.sweep", &json!({"body": "Body1", "size": 4})).unwrap(); + let t = nodes_on(&s, &line); + let dt: Vec = t.windows(2).map(|w| w[1] - w[0]).collect(); + assert_eq!(dt.len(), 5, "{t:?}"); + assert!((dt[0] / dt[4] - 3.0).abs() < 1e-6, "{dt:?}"); + // Tet: the edge is divided at the seed's nodes. + let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 5})).unwrap(); + assert_eq!(r["bodies"][0]["seeds"][0]["mesh_edges"], 5, "{}", r["bodies"][0]["seeds"]); + // Two exact seeds that cannot both hold are refused, naming both. + s.execute("mesh.seed.edge", &json!({"edges": [edges[1]], "count": 3})).unwrap(); + let e = s.execute("mesh.hex.sweep", &json!({"body": "Body1", "size": 4})).unwrap_err().to_string(); + assert!(e.contains(&name) && e.contains(edges[1]["edge"].as_str().unwrap()) && e.contains("conflict"), "{e}"); + } + + #[test] + fn a_seed_on_a_cut_edge_holds_in_both_cells() { + let (mut s, _dir) = super::super::datum::tests::plate(); + s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap(); + s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap(); + // An edge of the cut face, 30 mm along y, on the cell with the hole. + let edge = s + .design + .as_ref() + .unwrap() + .body("Body1/a") + .unwrap() + .edges() + .unwrap() + .into_iter() + .find(|e| e.name.contains("partition:P:a") && (e.length() - 30.0).abs() < 1e-6) + .unwrap(); + s.execute("mesh.seed.edge", &json!({"edges": [{"body": "Body1/a", "edge": edge.name}], "count": 9})).unwrap(); + let r = s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap(); + assert_eq!(r["interfaces"][0]["joined"], "merged", "{r}"); + assert_eq!(nodes_on(&s, &edge.points).len(), 10, "{r}"); + } + #[test] fn hostile_seeds_are_refused_and_change_nothing() { let mut s = plate(); diff --git a/crates/geometry/src/edges.rs b/crates/geometry/src/edges.rs index abe9c25..b68cc2f 100644 --- a/crates/geometry/src/edges.rs +++ b/crates/geometry/src/edges.rs @@ -15,7 +15,7 @@ use crate::{Result, Shape, SurfaceMesh}; /// Tessellation tolerance (a fraction of the body's size) of [`Shape::edges`]: the edge polylines /// are for display, picking and matching edges by position. -const EDGE_TOLERANCE: f64 = 1e-3; +pub const EDGE_TOLERANCE: f64 = 1e-3; /// A B-rep edge of a body. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index a4c424a..5f80d5f 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -25,7 +25,7 @@ pub mod sample; mod script; pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design}; -pub use edges::Edge; +pub use edges::{EDGE_TOLERANCE, Edge}; pub use partition::{Cut, CutReport}; pub mod tagged_mesh; #[cfg(feature = "scripts")] diff --git a/docs/mcp.md b/docs/mcp.md index 9855916..92828bd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -153,7 +153,13 @@ edges of partition cells matching counts. 2. `execute` `mesh.seed.edge {edges: [{body, edge}], count | size, bias?: {ratio, toward: start|end|both}, constraint?: exact|min|free}`. `exact` keeps the count, `min` keeps at least it, `free` is a goal. The reply gives the count each edge gets. -3. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such +3. Mesh: the hex meshers (`hex`, `hex_sweep`, `hex_mapped`, `hex_blocks`, `cells`) keep exact counts + through their interval assignment and refuse, naming the seeds, when two exact counts cannot + both hold (opposite sides of a mapped face, the layers between two levels); the tet mesher + divides the edge at the seed's nodes and grades its size field from them (its reply's + `seeds` gives the mesh edges along each). A seed on an edge two partition cells share holds + in both, so their meshes still conform. +4. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model. ## Limits