From 4ac31d3988a5f6ffd098a18bae53cfb1e69702f8 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:14:45 -0400 Subject: [PATCH] engine: mesh.hex.extrude, hex layers up to a target surface (#400) The section comes from a set (a 2D mesh or element faces), the mesh already on the source faces (tagged, or a neighbouring body's boundary lying on them, whose nodes are shared so the meshes conform), or a fresh quad mesh of one planar face. Target and side faces project through the tet mesher's B-rep projection. Replaces the body's mesh like the other meshers, tags the body's own source, target and side faces, and is on the MCP mesh tool as hex_extrude. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 7 + crates/engine/src/cmd/extrude.rs | 515 +++++++++++++++++++++++++ crates/engine/src/cmd/extrude/tests.rs | 185 +++++++++ crates/engine/src/cmd/forms.rs | 27 ++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/replace.rs | 14 +- crates/mcp/src/tools.rs | 5 + docs/features.md | 4 +- 8 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/extrude.rs create mode 100644 crates/engine/src/cmd/extrude/tests.rs diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index abe0245..14e55ed 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -332,6 +332,13 @@ pub static CATALOGUE: &[Capability] = &[ "One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions", &["mesh.hex.blocks"], ), + ready( + "mesh.hex.extrude", + "Extrude to Surface", + Mesh, + "Structured Hex", + "Hex layers from a meshed or planar section up to a curved target surface, following the side faces", + ), ready_more( "mesh.plan", "Mesh Plan", diff --git a/crates/engine/src/cmd/extrude.rs b/crates/engine/src/cmd/extrude.rs new file mode 100644 index 0000000..63fdb0b --- /dev/null +++ b/crates/engine/src/cmd/extrude.rs @@ -0,0 +1,515 @@ +//! `mesh.hex.extrude`: hexahedra from a quad section up to a target surface +//! ([`fieldcraft_mesh::hex::extrude_to_surface`]). +//! +//! The section is, in this order: the quadrilaterals of a set (`source_set`: shell elements or +//! element faces); the mesh already on the source faces (element faces tagged with them, or the +//! boundary faces of a neighbouring body's mesh lying on them), whose nodes the extrusion shares +//! so it conforms with that mesh; or a fresh quad mesh of a planar source face. Target and side +//! faces are projected onto through the trimmed B-rep projection that projection blocking uses +//! ([`fieldcraft_mesh::tet::geometry::Tessellation::project_trimmed`]): exact on analytic faces, +//! the tessellation's accuracy on free-form ones. + +use std::collections::BTreeMap; + +use fieldcraft_geometry::{FaceSurface, Shape}; +use fieldcraft_mesh::hex::{ExtrudeLayers, ExtrudePath, ExtrudeRole, ExtrudeSpec, MAX_BIAS, MAX_LAYERS, extrude_to_surface}; +use fieldcraft_mesh::planar::{face_boundary, mesh_region}; +use fieldcraft_mesh::project::Geometry; +use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions}; +use fieldcraft_mesh::tet::geometry::Tessellation; +use fieldcraft_model::{ElementKind, Entity, GeometryRef, Model}; +use serde_json::{Value, json}; + +use crate::requirement::Requirement; +use crate::schema::ProblemKind; +use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; + +pub const HEX_EXTRUDE: CommandSpec = CommandSpec { + id: "mesh.hex.extrude", + label: "Extrude to Surface", + params: r#"{"source": [{"body": "Post", "face": "F1"}] (the section: faces already meshed, by this body or a neighbour, or one planar face meshed here) or "source_set": "Section" (a set of quad shell elements or element faces, such as a 2D mesh), "to_surface": [{"body": "Wall", "face": "F7"}] (the target faces the last layer lies on), "sides": [{"body": "Post", "face": "F3"}, …] (optional: the faces the section's sides follow), "direction": [0, 0, 1] or "normal": true (each node along the section's normal toward the target; the default), "layers": 8 or "size": 2.0 (layer thickness, mm; also the size of a section meshed here; default 1/25 of the paths' extent), "bias": 1.0 (last layer's thickness over the first's, 0.001 to 1000), "body": "Post" (the body the elements fill; default: the first source face's body), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of each body's diagonal)} — replaces `body`'s mesh with hex8 layers stacked on the section, each node along its own straight path, the last layer on the target surface; with `sides`, every layer's boundary nodes lie on the side faces (which need not be straight) and the interior follows them. A section taken from an existing mesh keeps its nodes, so the extrusion conforms with the neighbour across it. Boundary element faces are tagged with `body`'s source, target and side faces. Reports the layers, counts, quality, the target gap and where the section came from; a path that misses the target, or elements that would invert, is an error that says where"#, + form: Some(super::forms::HEX_EXTRUDE), + kind: CommandKind::Model, + requires: &[Requirement::Design], + run: hex_extrude, +}; + +/// Most faces in `source`, `to_surface` or `sides`. +const MAX_FACES: usize = 1000; +/// Default layer size and chordal tolerance, relative to the extent. +const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0; +const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3; +const MIN_RELATIVE_TOLERANCE: f64 = 1e-5; +/// Most quadrilaterals of a section meshed here. +const MAX_SECTION_QUADS: usize = 1_000_000; + +/// The bodies the extrusion's faces lie on, and every face it names, with one projection. +struct Faces { + bodies: Vec, + /// Per body: its name, and its faces' names. + names: Vec<(String, Vec)>, + /// Face id → (body, B-rep face). + faces: Vec<(usize, u32)>, + /// The largest chordal tolerance used. + tolerance: f64, +} + +impl Geometry for Faces { + fn project(&self, face: u32, p: [f64; 3]) -> Option<[f64; 3]> { + let &(b, f) = self.faces.get(face as usize)?; + self.bodies.get(b)?.project_trimmed(f, p) + } + fn normal(&self, face: u32, p: [f64; 3]) -> Option<[f64; 3]> { + let &(b, f) = self.faces.get(face as usize)?; + self.bodies.get(b)?.face_normal(f, p) + } +} + +impl Faces { + /// The ids of the faces `refs` names (a name selects all its pieces), loading their bodies. + fn add(&mut self, s: &Session, refs: &[GeometryRef], tolerance: Option, key: &str) -> Result> { + let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; + let mut out = Vec::new(); + for r in refs { + if !matches!(r.entity, Entity::Face(_)) { + return Err(EngineError::invalid(key, format!("{r} is not a face"))); + } + let shape = design.body(&r.body).ok_or_else(|| EngineError::invalid(key, format!("no body `{}`", r.body)))?; + let b = match self.names.iter().position(|(n, _)| *n == shape.name) { + Some(b) => b, + None => self.load(shape, tolerance)?, + }; + let names = self.names.get(b).map(|(_, f)| f.clone()).unwrap_or_default(); + let before = out.len(); + for (i, name) in names.iter().enumerate() { + if r.selects(&GeometryRef::face(shape.name.as_str(), name.as_str())?) { + let id = self.id(b, i as u32); + if !out.contains(&id) { + out.push(id); + } + } + } + if out.len() == before { + return Err(EngineError::invalid(key, format!("body `{}` has no face `{}`", r.body, r.entity.name().unwrap_or_default()))); + } + } + Ok(out) + } + + fn load(&mut self, shape: &Shape, tolerance: Option) -> Result { + let diag = shape.size()?; + let tol = tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag); + if tol < MIN_RELATIVE_TOLERANCE * diag { + return Err(EngineError::Params(format!("`tolerance` must be at least {} mm for body `{}`", MIN_RELATIVE_TOLERANCE * diag, shape.name))); + } + let mesh = shape.tessellate(tol)?; + let surface = BoundarySurface::new( + &mesh.positions, + &mesh.triangles, + &mesh.tri_face, + &SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() }, + ) + .map_err(|e| EngineError::Params(format!("body `{}`: {e}", shape.name)))?; + self.bodies.push(Tessellation::new(&surface, super::mesh::analytic_surfaces(shape))); + self.names.push((shape.name.clone(), shape.faces().iter().map(|f| f.name.clone()).collect())); + self.tolerance = self.tolerance.max(tol); + Ok(self.bodies.len() - 1) + } + + fn id(&mut self, b: usize, f: u32) -> u32 { + match self.faces.iter().position(|x| *x == (b, f)) { + Some(i) => i as u32, + None => { + self.faces.push((b, f)); + (self.faces.len() - 1) as u32 + } + } + } + + /// The body and face name of face `id`. + fn name(&self, id: u32) -> Option<(&str, &str)> { + let &(b, f) = self.faces.get(id as usize)?; + let (body, faces) = self.names.get(b)?; + Some((body.as_str(), faces.get(f as usize)?.as_str())) + } +} + +/// The section: node coordinates, the model label of each node it shares (none for a fresh +/// section), and its quadrilaterals. +struct Section { + nodes: Vec<[f64; 3]>, + labels: Vec>, + quads: Vec<[u32; 4]>, + from: &'static str, +} + +impl Section { + /// A section of model nodes: each label once, in order of first use. + fn of_labels(model: &Model, quads: &[[u32; 4]], from: &'static str) -> Result
{ + let mut index: BTreeMap = BTreeMap::new(); + let mut s = Section { nodes: Vec::new(), labels: Vec::new(), quads: Vec::with_capacity(quads.len()), from }; + for q in quads { + let mut local = [0u32; 4]; + for (slot, &l) in local.iter_mut().zip(q) { + *slot = match index.get(&l) { + Some(&i) => i, + None => { + let xyz = model.nodes().get(&l).map(|n| n.xyz).ok_or_else(|| EngineError::Params(format!("node {l} is not in the model")))?; + let i = s.nodes.len() as u32; + s.nodes.push(xyz); + s.labels.push(Some(l)); + index.insert(l, i); + i + } + }; + } + s.quads.push(local); + } + Ok(s) + } +} + +/// The four corners of element `e`'s face `face` (1-based), when it is a quadrilateral. +fn quad_face(model: &Model, e: u32, face: u8) -> Option<[u32; 4]> { + let el = model.elements().get(&e)?; + let local = el.kind.faces().get(usize::from(face).checked_sub(1)?)?; + if local.len() < 4 || (local.len() != 4 && local.len() != 8) { + return None; + } + let corner = |k: usize| local.get(k).and_then(|&i| el.nodes.get(i)).copied(); + Some([corner(0)?, corner(1)?, corner(2)?, corner(3)?]) +} + +/// The corners of a quad shell element. +fn quad_shell(model: &Model, e: u32) -> Option<[u32; 4]> { + let el = model.elements().get(&e)?; + if !matches!(el.kind, ElementKind::Quad4 | ElementKind::Quad8) { + return None; + } + Some([*el.nodes.first()?, *el.nodes.get(1)?, *el.nodes.get(2)?, *el.nodes.get(3)?]) +} + +/// The elements of `body`'s mesh. +fn body_elements(model: &Model, body: &str) -> std::collections::BTreeSet { + model.body_elements(body).into_iter().collect() +} + +/// The quadrilaterals tagged with the faces `refs` names (shell elements filling them, element +/// faces on them), leaving out `exclude`'s elements. +fn tagged_quads(model: &Model, refs: &[GeometryRef], exclude: &std::collections::BTreeSet) -> Result> { + let c = model.classification(); + let mut tags: Vec = refs.iter().flat_map(|r| c.tags_selected_by(r)).collect(); + tags.sort_unstable(); + tags.dedup(); + let mut out = Vec::new(); + for (&e, t) in c.elements() { + if exclude.contains(&e) { + continue; + } + if t.region.is_some_and(|r| tags.binary_search(&r).is_ok()) { + out.push(quad_shell(model, e).ok_or_else(|| EngineError::Params(format!("element {e} on the source face is not a quadrilateral")))?); + } + for (&f, tag) in &t.faces { + if tags.binary_search(tag).is_ok() { + out.push( + quad_face(model, e, f) + .ok_or_else(|| EngineError::Params(format!("element {e}'s face {f} on the source face is not a quadrilateral")))?, + ); + } + } + } + Ok(out) +} + +/// The boundary faces of other bodies' meshes (tagged faces) that lie on the source faces. +fn neighbour_quads(model: &Model, geometry: &Faces, source: &[u32], exclude: &std::collections::BTreeSet) -> Vec<[u32; 4]> { + let tol = 10.0 * geometry.tolerance.max(1e-9); + let on = |p: [f64; 3]| { + source.iter().any(|&f| geometry.project(f, p).is_some_and(|q| (0..3).map(|k| (q[k] - p[k]).powi(2)).sum::().sqrt() <= tol)) + }; + let xyz = |l: u32| model.nodes().get(&l).map(|n| n.xyz); + let mut out = Vec::new(); + for (&e, t) in model.classification().elements() { + if exclude.contains(&e) { + continue; + } + for &f in t.faces.keys() { + let Some(q) = quad_face(model, e, f) else { continue }; + let Some(pts) = q.iter().map(|&l| xyz(l)).collect::>>() else { continue }; + let centre = pts.iter().fold([0.0; 3], |a, p| [a[0] + p[0] / 4.0, a[1] + p[1] / 4.0, a[2] + p[2] / 4.0]); + if pts.iter().all(|p| on(*p)) && on(centre) { + out.push(q); + } + } + } + out +} + +/// A quad mesh of one planar face of `shape` of about `size`. +fn mesh_planar_face(shape: &Shape, face: u32, size: f64, tolerance: f64) -> Result
{ + let surfaces = shape.face_surfaces()?; + let Some(Some(FaceSurface::Plane { normal, point })) = surfaces.get(face as usize).copied() else { + return Err(EngineError::Params(format!( + "the source face of body `{}` is not meshed and not planar: mesh it first (a 2D mesh, or a neighbouring body's mesh)", + shape.name + ))); + }; + let n = unit(normal).ok_or_else(|| EngineError::Params("the source face has no normal".into()))?; + let t = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + let u = unit(cross(n, t)).ok_or_else(|| EngineError::Params("the source face has no normal".into()))?; + let v = cross(n, u); + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let mesh = shape.tessellate(tolerance)?; + // The tessellation in the face's frame, the face at z = 0. + let local: Vec<[f64; 3]> = mesh + .positions + .iter() + .map(|p| { + let d = [p[0] - point[0], p[1] - point[1], p[2] - point[2]]; + [dot(d, u), dot(d, v), dot(d, n)] + }) + .collect(); + let boundary = face_boundary(&local, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance.max(tolerance))?; + let opts = fieldcraft_mesh::quad::QuadOptions { max_elements: MAX_SECTION_QUADS, ..Default::default() }; + let m = mesh_region(&boundary.loops, ElementKind::Quad4, size, &opts)?; + let at = |xy: [f64; 2]| [0, 1, 2].map(|k| point[k] + xy[0] * u[k] + xy[1] * v[k]); + let nodes: Vec<[f64; 3]> = m.nodes.iter().map(|xy| at(*xy)).collect(); + let quads = m + .elements + .iter() + .map(|e| match e.as_slice() { + [a, b, c, d, ..] => Ok([*a, *b, *c, *d]), + _ => Err(EngineError::Params("the section mesher made an element that is not a quadrilateral".into())), + }) + .collect::>>()?; + Ok(Section { labels: vec![None; nodes.len()], nodes, quads, from: "meshed" }) +} + +fn unit(a: [f64; 3]) -> Option<[f64; 3]> { + let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + (l > 0.0 && l.is_finite()).then(|| a.map(|x| x / l)) +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +/// A list of face references. +fn face_refs(p: &Value, key: &str, required: bool) -> Result> { + let refs: Vec = match p.get(key) { + None if !required => return Ok(Vec::new()), + None => return Err(EngineError::param(key, ProblemKind::Required)), + Some(v) => serde_json::from_value(v.clone()).map_err(|e| EngineError::invalid(key, format!("a list of {{\"body\", \"face\"}}: {e}")))?, + }; + if refs.len() > MAX_FACES || (required && refs.is_empty()) { + return Err(EngineError::invalid(key, format!("1 to {MAX_FACES} faces"))); + } + Ok(refs) +} + +/// The body the elements fill: `body`, or the first source face's. +pub(super) fn extrude_body(_: &Session, p: &Value) -> Result> { + if let Some(b) = p.get("body") { + let name = b.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?; + GeometryRef::body(name)?; + return Ok(vec![name.to_string()]); + } + let source = face_refs(p, "source", false)?; + let first = source.first().ok_or_else(|| EngineError::Params("give `body` (the body the elements fill) with `source_set`".into()))?; + Ok(vec![first.body.clone()]) +} + +fn hex_extrude(s: &mut Session, p: &Value) -> Result { + let tolerance = super::hex::number(p, "tolerance")?; + let size = super::hex::number(p, "size")?; + let layers = match p.get("layers") { + None => None, + Some(v) => Some( + v.as_u64() + .filter(|n| (1..=MAX_LAYERS as u64).contains(n)) + .ok_or_else(|| EngineError::invalid("layers", format!("a whole number from 1 to {MAX_LAYERS}")))? as usize, + ), + }; + let bias = match p.get("bias") { + None => 1.0, + Some(v) => v + .as_f64() + .filter(|b| (1.0 / MAX_BIAS..=MAX_BIAS).contains(b)) + .ok_or_else(|| EngineError::invalid("bias", format!("a number from {} to {MAX_BIAS}", 1.0 / MAX_BIAS)))?, + }; + let path = match (p.get("direction"), p.get("normal")) { + (Some(d), None) => { + let d: [f64; 3] = serde_json::from_value(d.clone()).map_err(|_| EngineError::param("direction", ProblemKind::NotAVector { len: 3 }))?; + if !d.iter().all(|x| x.is_finite()) || unit(d).is_none() { + return Err(EngineError::invalid("direction", "a vector with a length")); + } + ExtrudePath::Direction(d) + } + (None, None) => ExtrudePath::Normal, + (None, Some(n)) => match n.as_bool() { + Some(true) => ExtrudePath::Normal, + _ => return Err(EngineError::invalid("normal", "true, or give `direction`")), + }, + (Some(_), Some(_)) => return Err(EngineError::Params("give `direction` or `normal`, not both".into())), + }; + let source = face_refs(p, "source", false)?; + let source_set = match p.get("source_set") { + None => None, + Some(v) => Some(v.as_str().ok_or_else(|| EngineError::param("source_set", ProblemKind::NotAName))?.to_string()), + }; + if source.is_empty() == source_set.is_none() { + return Err(EngineError::Params("give the section as `source` (faces) or `source_set` (a set), one of them".into())); + } + let targets = face_refs(p, "to_surface", true)?; + let sides = face_refs(p, "sides", false)?; + let body = extrude_body(s, p)?.into_iter().next().unwrap_or_default(); + let body_ref = GeometryRef::body(body.as_str())?; + let mut faces = Faces { bodies: Vec::new(), names: Vec::new(), faces: Vec::new(), tolerance: 0.0 }; + let source_ids = faces.add(s, &source, tolerance, "source")?; + let target_ids = faces.add(s, &targets, tolerance, "to_surface")?; + let side_ids = faces.add(s, &sides, tolerance, "sides")?; + if let Some(f) = target_ids.iter().find(|f| source_ids.contains(f) || side_ids.contains(f)) { + let (b, n) = faces.name(*f).unwrap_or_default(); + return Err(EngineError::Params(format!("face `{n}` of body `{b}` is both a target and a source or side face"))); + } + + // The section: a set, the mesh already on the source faces, or a fresh mesh of one face. + let own = body_elements(&s.model, &body); + let section = match &source_set { + Some(name) => { + let r = s.model.resolve_set(name)?; + let mut quads = Vec::new(); + for &e in &r.elements { + if let Some(q) = quad_shell(&s.model, e) { + quads.push(q); + } + } + for f in &r.faces { + quads.push( + quad_face(&s.model, f.element, f.face).ok_or_else(|| { + EngineError::Params(format!("set `{name}`: element {}'s face {} is not a quadrilateral", f.element, f.face)) + })?, + ); + } + if quads.is_empty() { + return Err(EngineError::Params(format!("set `{name}` has no quadrilaterals (quad shell elements or hex element faces)"))); + } + Section::of_labels(&s.model, &quads, "set")? + } + None => { + let tagged = tagged_quads(&s.model, &source, &own)?; + if !tagged.is_empty() { + Section::of_labels(&s.model, &tagged, "mesh")? + } else { + let near = neighbour_quads(&s.model, &faces, &source_ids, &own); + if !near.is_empty() { + Section::of_labels(&s.model, &near, "neighbour")? + } else { + let [id] = source_ids.as_slice() else { + return Err(EngineError::Params("the source faces are not meshed: give one face to mesh here, or mesh them first".into())); + }; + let (b, f) = faces.faces.get(*id as usize).copied().ok_or_else(|| EngineError::Params("no source face".into()))?; + let name = faces.names.get(b).map(|n| n.0.clone()).unwrap_or_default(); + let shape = s.design.as_deref().and_then(|d| d.body(&name)).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?; + let extent = shape.size()?; + let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * extent); + mesh_planar_face(shape, f, h, tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * extent))? + } + } + } + }; + + let layers = match (layers, size) { + (Some(n), _) => ExtrudeLayers::Count(n), + (None, Some(h)) => ExtrudeLayers::Size(h), + (None, None) => { + let extent = s.design.as_deref().and_then(|d| d.body(&body)).map(Shape::size).transpose()?.unwrap_or(1.0); + ExtrudeLayers::Size(DEFAULT_RELATIVE_SIZE * extent) + } + }; + let spec = ExtrudeSpec { + nodes: §ion.nodes, + quads: §ion.quads, + path, + targets: &target_ids, + sides: &side_ids, + layers, + bias, + tolerance: faces.tolerance.max(1e-9), + max_elements: fieldcraft_model::MAX_ELEMENTS / 8, + }; + let m = extrude_to_surface(&spec, &faces).map_err(|e| EngineError::of_body(&body, e))?; + + // Replace the body's mesh; the section's shared nodes stay. + let mut model = s.model.clone(); + let cleared = super::replace::clear(&mut model, [body.as_str()]); + if let Some(l) = section.labels.iter().flatten().find(|l| !model.nodes().contains_key(l)) { + return Err(EngineError::Params(format!("the section's node {l} belongs to body `{body}`'s mesh, which the extrusion replaces"))); + } + let n_src = section.nodes.len(); + let fresh = m.nodes.len() - section.labels.iter().flatten().count(); + let (first_node, first_element) = super::fresh_labels(&model, fresh, m.hexes.len())?; + let mut labels = Vec::with_capacity(m.nodes.len()); + let mut next = first_node; + for (i, xyz) in m.nodes.iter().enumerate() { + let shared = if i < n_src { section.labels.get(i).copied().flatten() } else { None }; + labels.push(match shared { + Some(l) => l, + None => { + model.set_node(next, *xyz)?; + next += 1; + next - 1 + } + }); + } + let body_tag = model.geometry_tag(&body_ref)?; + for (e, h) in m.hexes.iter().enumerate() { + let nodes: Vec = h.iter().map(|&i| labels.get(i as usize).copied().unwrap_or(0)).collect(); + let l = first_element + e as u32; + model.set_element(l, ElementKind::Hex8, nodes)?; + model.tag_element(l, body_tag)?; + } + // Boundary faces on the body's own B-rep faces get their tags. + let source_own: Vec = source_ids.iter().copied().filter(|&f| faces.name(f).is_some_and(|(b, _)| b == body)).collect(); + let mut tags: BTreeMap = BTreeMap::new(); + for b in &m.boundary { + let face = match b.role { + ExtrudeRole::Source => source_own.first().copied(), + ExtrudeRole::Target(f) | ExtrudeRole::Side(Some(f)) => Some(f), + ExtrudeRole::Side(None) => None, + }; + let Some((owner, name)) = face.and_then(|f| faces.name(f)) else { continue }; + if owner != body { + continue; + } + let key = face.unwrap_or_default(); + let tag = match tags.get(&key) { + Some(&t) => t, + None => { + let t = model.geometry_tag(&GeometryRef::face(body.as_str(), name)?)?; + tags.insert(key, t); + t + } + }; + model.tag_element_face(first_element + b.element, b.face, tag)?; + } + let (nodes, elements) = (model.nodes().len(), model.elements().len()); + s.model = model; + let reply = json!({ + "body": body, + "section": {"from": section.from, "quadrilaterals": section.quads.len(), "nodes": n_src, "shared_nodes": section.labels.iter().flatten().count()}, + "layers": m.layers, + "kind": "hex8", + "elements": m.hexes.len(), + "new_nodes": fresh, + "quality": {"min_scaled_jacobian": m.min_scaled_jacobian, "mean_scaled_jacobian": m.mean_scaled_jacobian}, + "volume": m.gate.volume, + "max_target_gap": m.max_target_gap, + "nodes": nodes, + "model_elements": elements, + }); + Ok(cleared.report(s, reply)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/extrude/tests.rs b/crates/engine/src/cmd/extrude/tests.rs new file mode 100644 index 0000000..f2fd42e --- /dev/null +++ b/crates/engine/src/cmd/extrude/tests.rs @@ -0,0 +1,185 @@ +use super::*; +use crate::tests::{TempDir, design_file}; + +fn open(commands: Value) -> (TempDir, Session) { + let bytes = fieldcraft_geometry::script_design(&json!({ "commands": commands }).to_string()).unwrap(); + let (dir, path) = design_file(&bytes); + let mut s = Session::default(); + s.execute("geometry.open", &json!({"path": path})).unwrap(); + (dir, s) +} + +/// A square post whose top is cut by a cylinder (axis X, radius 20, at z = 30), a square post +/// whose top is a sphere cap (centre z = −5, radius 25), and a block under the first post. +fn parts() -> (TempDir, Session) { + open(json!([ + {"command": "solid.box", "params": {"corner": [-4, -4, 0], "length": 8, "width": 8, "height": 30, "body_name": "Post"}}, + {"command": "solid.cylinder", "params": {"base": [-20, 0, 30], "axis": [1, 0, 0], "radius": 20, "height": 40, "body_name": "Bar", "operation": "new"}}, + {"command": "solid.combine", "params": {"target": "Post", "tools": ["Bar"], "operation": "cut"}}, + {"command": "solid.box", "params": {"corner": [-6, -6, 0], "length": 12, "width": 12, "height": 30, "body_name": "Dome", "operation": "new"}}, + {"command": "solid.sphere", "params": {"center": [0, 0, -5], "radius": 25, "body_name": "Ball", "operation": "new"}}, + {"command": "solid.combine", "params": {"target": "Dome", "tools": ["Ball"], "operation": "intersect"}}, + {"command": "solid.box", "params": {"corner": [-4, -4, -10], "length": 8, "width": 8, "height": 10, "body_name": "Base", "operation": "new"}}, + ])) +} + +/// `{"body", "face"}` of each face of `body` whose surface `pick` accepts. +fn faces(s: &Session, body: &str, pick: impl Fn(&FaceSurface) -> bool) -> Vec { + let b = s.design.as_deref().unwrap().body(body).unwrap(); + b.faces() + .iter() + .zip(b.face_surfaces().unwrap()) + .filter(|(_, f)| f.as_ref().is_some_and(&pick)) + .map(|(f, _)| json!({"body": body, "face": f.name})) + .collect() +} + +fn plane(n: [f64; 3]) -> impl Fn(&FaceSurface) -> bool { + move |f| matches!(f, FaceSurface::Plane { normal, .. } if (0..3).all(|k| (normal[k] - n[k]).abs() < 1e-9)) +} + +fn sides(s: &Session, body: &str) -> Vec { + faces(s, body, |f| matches!(f, FaceSurface::Plane { normal, .. } if normal[2].abs() < 1e-9)) +} + +/// The coordinates of the nodes on face `face` (through a set by geometry). +fn nodes_on(s: &mut Session, name: &str, face: &Value) -> Vec<[f64; 3]> { + s.execute("model.set.create", &json!({"name": name, "geometry": [face]})).unwrap(); + let r = s.model.resolve_set(name).unwrap(); + r.nodes.iter().map(|n| s.model.nodes()[n].xyz).collect() +} + +#[test] +fn a_square_post_extrudes_up_to_a_cylinder() { + let (_d, mut s) = parts(); + let bottom = faces(&s, "Post", plane([0.0, 0.0, -1.0])); + let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. })); + let r = s + .execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": top, "sides": sides(&s, "Post"), "direction": [0, 0, 1], "size": 1.0})) + .unwrap(); + assert_eq!((r["body"].as_str(), r["section"]["from"].as_str()), (Some("Post"), Some("meshed")), "{r}"); + assert_eq!(r["layers"], 10, "{r}"); + let min_sj = r["quality"]["min_scaled_jacobian"].as_f64().unwrap(); + assert!(min_sj >= 0.5, "{r}"); + assert!(r["max_target_gap"].as_f64().unwrap() < 1e-6, "{r}"); + // ∫∫ (30 − √(400 − y²)) dx dy over the 8 × 8 square. + let exact = 8.0 * (30.0 * 8.0 - (4.0 * 384.0f64.sqrt() + 400.0 * 0.2f64.asin())); + let volume = r["volume"].as_f64().unwrap(); + assert!((volume - exact).abs() < 1e-3 * exact, "{volume} vs {exact}"); + // Every node of the last layer is on the cylinder: the target faces are tagged. + let on = nodes_on(&mut s, "Top", &top[0]); + assert_eq!(on.len() as u64, r["section"]["nodes"].as_u64().unwrap(), "one node per section node"); + for p in on { + let radius = (p[1].powi(2) + (p[2] - 30.0).powi(2)).sqrt(); + assert!((radius - 20.0).abs() < 1e-6, "{p:?}: {radius}"); + } + let q = s.execute("mesh.quality", &json!({})).unwrap(); + assert_eq!(q["invalid"]["count"], 0, "{q}"); +} + +#[test] +fn a_square_post_extrudes_up_to_a_sphere_cap_along_its_normal() { + let (_d, mut s) = parts(); + let bottom = faces(&s, "Dome", plane([0.0, 0.0, -1.0])); + let cap = faces(&s, "Dome", |f| matches!(f, FaceSurface::Sphere { .. })); + let r = s + .execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": cap, "sides": sides(&s, "Dome"), "layers": 8, "bias": 0.5, "size": 1.5})) + .unwrap(); + assert_eq!(r["layers"], 8, "{r}"); + assert!(r["quality"]["min_scaled_jacobian"].as_f64().unwrap() >= 0.5, "{r}"); + let on = nodes_on(&mut s, "Cap", &cap[0]); + assert!(on.len() > 50, "{}", on.len()); + for p in on { + let radius = (p[0].powi(2) + p[1].powi(2) + (p[2] + 5.0).powi(2)).sqrt(); + assert!((radius - 25.0).abs() < 1e-6, "{p:?}: {radius}"); + } +} + +#[test] +fn the_extrusion_conforms_with_the_neighbour_it_starts_from() { + let (_d, mut s) = parts(); + s.execute("mesh.hex.generate", &json!({"body": "Base", "size": 2.0})).unwrap(); + let base_nodes = s.model.nodes().len(); + let bottom = faces(&s, "Post", plane([0.0, 0.0, -1.0])); + let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. })); + let r = s + .execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": top, "sides": sides(&s, "Post"), "direction": [0, 0, 1], "size": 2.0})) + .unwrap(); + assert_eq!(r["section"]["from"], "neighbour", "{r}"); + assert_eq!(r["section"]["shared_nodes"], 25, "the base's 5 × 5 top nodes: {r}"); + assert_eq!(s.model.nodes().len(), base_nodes + r["new_nodes"].as_u64().unwrap() as usize); + let d = s.execute("mesh.check.duplicates", &json!({})).unwrap(); + assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}"); + // The base keeps its mesh; extruding again replaces only the post's. + let base = s.model.body_elements("Base").len(); + s.execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": top, "direction": [0, 0, 1], "layers": 3})).unwrap(); + assert_eq!(s.model.body_elements("Base").len(), base); + assert_eq!(s.model.body_elements("Post").len(), 16 * 3); +} + +#[test] +fn a_2d_mesh_extrudes_from_its_set() { + let (_d, mut s) = parts(); + s.execute("mesh.planar", &json!({"polygon": [[-3, -3], [3, -3], [3, 3], [-3, 3]], "size": 1.0, "name": "Plane"})).unwrap(); + s.execute("model.set.create", &json!({"name": "Section", "geometry": [{"body": "Plane", "face": "area"}]})).unwrap(); + let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. })); + let r = s.execute("mesh.hex.extrude", &json!({"source_set": "Section", "to_surface": top, "body": "Column", "layers": 5})).unwrap(); + assert_eq!(r["section"]["from"], "set", "{r}"); + let quads = r["section"]["quadrilaterals"].as_u64().unwrap() as usize; + assert_eq!(r["section"]["shared_nodes"], r["section"]["nodes"], "{r}"); + assert_eq!(s.model.body_elements("Column").len(), quads * 5); + assert!(r["quality"]["min_scaled_jacobian"].as_f64().unwrap() >= 0.5, "{r}"); +} + +#[test] +fn hostile_params_are_errors_and_change_nothing() { + let (_d, mut s) = parts(); + let bottom = faces(&s, "Post", plane([0.0, 0.0, -1.0])); + let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. })); + let ok = json!({"source": bottom, "to_surface": top, "direction": [0, 0, 1], "layers": 2}); + let with = |k: &str, v: Value| { + let mut p = ok.clone(); + p[k] = v; + p + }; + let without = |k: &str| { + let mut p = ok.clone(); + p.as_object_mut().unwrap().remove(k); + p + }; + let cases = [ + without("to_surface"), + without("source"), + with("to_surface", json!([])), + with("to_surface", json!("F1")), + with("to_surface", json!([{"body": "Nope", "face": "F1"}])), + with("to_surface", json!([{"body": "Post", "face": "nope"}])), + with("to_surface", json!([{"body": "Post"}])), + with("to_surface", bottom.clone().into()), + with("source_set", json!("Nope")), + with("direction", json!([0, 0, 0])), + with("direction", json!([0, 0, -1])), + with("direction", json!([1, 0])), + with("direction", json!([f64::MAX, 0, 0])), + with("normal", json!(false)), + with("layers", json!(0)), + with("layers", json!(1e9)), + with("layers", json!(-3)), + with("bias", json!(0)), + with("bias", json!(1e9)), + with("bias", json!("x")), + with("size", json!(-1)), + with("tolerance", json!(1e-30)), + with("body", json!(7)), + with("sides", json!([{"body": "Dome", "face": "F4:-z"}])), + with("source", json!([{"body": "Post", "face": "F1:-x"}, {"body": "Post", "face": "F1:+x"}])), + // A curved face that is not meshed cannot be meshed here. + with("source", top.clone().into()), + ]; + let before = s.model.clone(); + for (i, p) in cases.iter().enumerate() { + assert!(s.execute("mesh.hex.extrude", p).is_err(), "case {i}: {p}"); + assert_eq!(s.model, before, "case {i} changed the model"); + } + assert!(s.execute("mesh.hex.extrude", &ok).is_ok()); +} diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 27637c8..8ede4e2 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1199,6 +1199,33 @@ pub const HEX_OCTREE: &[Field] = &[ FEATURE_ANGLE, ]; +/// A face by body and persistent name, a row of a face list. +const FACE_ROW: &[Field] = &[ + reference("body", "Body", C::Body).help("A body of the open design.").req(), + text("face", "Face").help("A persistent face name of the body (e.g. F3).").req().hint("persistent face name"), +]; +const fn face_rows(key: &'static str, label: &'static str) -> Field { + rows(key, label, FACE_ROW) +} + +pub const HEX_EXTRUDE: &[Field] = &[ + face_rows("source", "Source") + .help("The section: faces already meshed (by this body or a neighbour, whose nodes are shared), or one planar face meshed here. Pick it, then Use as source.") + .hint("pick the source face"), + reference("source_set", "Source set", C::Set).help("Instead of Source: a set of quadrilaterals (a 2D mesh, or hex element faces)."), + face_rows("to_surface", "Target surface") + .help("The faces the last layer lies on. Pick them, then Use as target surface.") + .req() + .hint("pick the target surface"), + face_rows("sides", "Side faces").help("The faces the section's sides follow (they need not be straight); straight paths when empty.").hint("straight sides"), + xyz("direction", "Direction", D::None).help("The direction every node moves along; along the section's normal toward the target when empty.").hint("the section's normal"), + int("layers", "Layers", 1, 10_000).help("The number of element layers; from Size when empty.").hint("from the size"), + positive("size", "Size", D::Length).help("Layer thickness, and the element size of a section meshed here.").hint("1/25 of the body"), + range("bias", "Bias", D::None, 0.001, 1000.0).help("The last layer's thickness over the first's: below 1 grades toward the target, above 1 toward the source.").def("1"), + reference("body", "Body", C::Body).help("The body the elements fill; its mesh is replaced.").hint("the source face's body"), + TOLERANCE, +]; + pub const BLOCK: &[Field] = &[ text("body", "Body").help("Name of the new block body.").req().def("\"Block\""), choice( diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c092c23..debfc8b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod deck; pub(crate) mod define; pub mod diagnostics; mod edit; +mod extrude; pub(crate) mod file; pub(crate) mod forms; mod geometry; @@ -84,6 +85,7 @@ pub static ALL: &[CommandSpec] = &[ hex::HEX_MAPPED, hex::HEX_BLOCKS, project::HEX_PROJECT, + extrude::HEX_EXTRUDE, cells::CELLS, plan::PLAN, plan::APPLY, diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs index 8b2ebe4..e8537fb 100644 --- a/crates/engine/src/cmd/replace.rs +++ b/crates/engine/src/cmd/replace.rs @@ -132,6 +132,7 @@ pub static MESHERS: &[(&str, Targets)] = &[ ("mesh.cells", listed_or_all), ("mesh.plan.apply", super::plan::plan_bodies), ("mesh.planar", super::mesh::planar_body), + ("mesh.hex.extrude", super::extrude::extrude_body), ("mesh.mirror", super::mirror::image_names), ]; @@ -174,7 +175,7 @@ fn made_kind(command: &str, params: &Value) -> Option { Some(if params.get("order").and_then(Value::as_str) == Some("quadratic") { ElementKind::Tet10 } else { ElementKind::Tet4 }) } "mesh.hex.project" => Some(ElementKind::Hex8), - "mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" | "mesh.plan.apply" => { + "mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" | "mesh.plan.apply" | "mesh.hex.extrude" => { Some(if params.get("order").and_then(Value::as_i64) == Some(2) { ElementKind::Hex20 } else { ElementKind::Hex8 }) } _ => None, @@ -305,6 +306,16 @@ mod tests { let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box.2", "size": 5}}); let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box.1", "size": 2.5}}); let square = json!([[0, 0], [10, 0], [10, 10], [0, 10]]); + // Box.1's bottom and top faces, for the extrusion between them. + let (_probe_dir, probe) = two_boxes(); + let face = |nz: f64| { + let b = probe.design.as_deref().and_then(|d| d.body("Box.1")).unwrap(); + let surfaces = b.face_surfaces().unwrap(); + let i = + surfaces.iter().position(|f| matches!(f, Some(fieldcraft_geometry::FaceSurface::Plane { normal, .. }) if normal[2] == nz)).unwrap(); + json!({"body": "Box.1", "face": b.faces()[i].name}) + }; + let extrude = json!({"source": [face(-1.0)], "to_surface": [face(1.0)], "direction": [0, 0, 1], "layers": 4, "size": 2.5}); let cases: Vec<(&str, Vec, Value, &str, &str)> = vec![ ("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 4}), "Box.1", "Box.2"), ("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"), @@ -321,6 +332,7 @@ mod tests { "Box.1", "Box.2", ), + ("mesh.hex.extrude", vec![tet_other.clone()], extrude, "Box.1", "Box.2"), ("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box.1"}), "Box.1", "Box.2"), // The image's tets (meshed before) are replaced by the mirrored hexahedra. ( diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1a2a87f..aa39c8f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -24,6 +24,11 @@ const MESHERS: &[(&str, &str, &str)] = &[ "mesh.cells", "one mesh of a partitioned part, each cell with its own method {methods: {cell: method}, default, size, order}; cells joined across partitions (merged or tied)", ), + ( + "hex_extrude", + "mesh.hex.extrude", + "hex layers from a section (faces, a neighbour's mesh or a 2D mesh) up to a target surface {source | source_set, to_surface, sides, direction | normal, layers | size, bias, body}", + ), ("mirror", "mesh.mirror", "reflect meshed bodies across a datum plane and join them on it {plane, bodies}"), ("block", "mesh.block", "structured hex block"), ( diff --git a/docs/features.md b/docs/features.md index a135938..4c9ed3a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -14,7 +14,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. |---|---|---|---| | Import | Open Design — Open a SolidCraft design: its bodies with persistent face names | ready | `geometry.open` | | Import | Tagged Mesh — Read a face-tagged surface mesh (3MF) | ready | `geometry.import.mesh` | -| Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre`, `geometry.features` | +| Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre` | | Inspect | Tessellate — Tessellate a body into a face-tagged boundary surface | ready | `geometry.tessellate` | | Inspect | Check Surface — Weld, closed, manifold, orientation and self-intersection checks; feature edges | ready | `mesh.surface.check` | | Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`), (`geometry.datum.suppress`) | @@ -37,7 +37,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Structured Hex | Quad Faces — All-quad meshes of faces on shared edge nodes | coming soon (M5) | | | Structured Hex | Hex Mesh — Structured hex mesh of a body: automatic, or extruded, swept, revolved or mapped (the Method drop-down) | ready | `mesh.hex.generate`, (`mesh.hex.sweep`), (`mesh.hex.mapped`) | | Structured Hex | Partitioned Mesh — One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions | ready | `mesh.cells`, (`mesh.hex.blocks`) | -| Structured Hex | Mesh Plan — A hex-meshing plan from the features that built a body (sweeps, blocks, O-grids, layers, imprints, copies); saved, editable, applied with the structured meshers | ready | `mesh.plan`, `mesh.plan.apply` | +| Structured Hex | Extrude to Surface — Hex layers from a meshed or planar section up to a curved target surface, following the side faces | ready | `mesh.hex.extrude` | | Structured Hex | Mesh Block — A mapped block of hex, wedge or quad elements between corner points; projection blocking (a block topology projected onto the part) | ready | `mesh.block`, `mesh.hex.project` | | Structured Hex | Decompose — Find sweepable and mappable regions of 3D parts (extruded cross-sections are already cut into blocks by Hex Mesh) | coming soon (M5) | | | Structured Hex | Transition — Conforming tet–hex interfaces with pyramids | coming soon (M5) | |