//! Provenance-driven mesh plans (#402): the features that built a body (`geometry.features`) //! become its hex-meshing plan, and the plan is carried out with today's meshers. //! //! `mesh.plan` reads the body's feature graph and maps each feature to a template: //! - an extrude of a sketch: the sweep, along its stored direction, of the profile's cross-section //! (circles butterflied, circular holes O-gridded); //! - a box: a block (or a part of the swept cross-section); a cylinder: a butterfly, an O-grid //! when it cuts; //! - a hole: an O-grid at its stored diameter and axis; //! - a shell: layers through its stored thickness (the element size takes the layers through //! the thinnest wall); //! - a join or cut (and a combine): the tool's cross-section imprinted on the swept one; //! - a pattern or mirror: the instance meshed and repeated; //! - a split body: its partition cuts. //! //! The sweep direction is the direction most mapped features are prismatic along (the first new //! body's stored direction on a tie); a feature that is not prismatic along it is reported as not //! mapped. Fillets, chamfers, revolves, sweeps and lofts are reported as not mapped (their blend, //! revolve and sweep templates are step 3 of #402); nothing is faked. //! //! The butterflies and O-grids need a frame round each circle: the plan cuts the body by planes //! square to the sweep on either side of every circle that is not the body's own outline //! ([`frame_cuts`]), placed so the frame is square (as far from the circle as the body's nearest //! side, when that is close) and never on or near a face of the body, through another circle, or //! next to another cut. `mesh.plan.apply` adds those cuts as partitions of the body (only those), //! sweeps its cells together (`mesh.cells`), or sweeps the whole body when the plan has no cuts //! (`mesh.hex.generate`), and reports the quality and whether the cells joined node to node. //! //! The plan is a definition of the model ([`MeshPlan`]): saved with it, inspectable, editable //! (apply an edited plan object) and applied again after the design changes. The projection //! blocking mesher (#399) consumes the same plan: its direction, templates and cuts. use fieldcraft_geometry::{Curve, Design, FaceSurface, FeatureNode, Shape}; use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind}; use serde_json::{Value, json}; mod blocks; use crate::requirement::Requirement; use crate::schema::ProblemKind; use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; /// Elements a plan aims at most for one body unless told otherwise. pub const DEFAULT_MAX_ELEMENTS: usize = 300_000; /// The most a plan may aim at. const MAX_ELEMENTS: usize = 5_000_000; /// Element size by default, as a fraction of the body's bounding-box diagonal (as the meshers). const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0; /// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take). const MIN_RELATIVE_TOLERANCE: f64 = 1e-5; pub const PLAN: CommandSpec = CommandSpec { id: "mesh.plan", label: "Mesh Plan", params: r#"{"body": "Base", "name"?: "Plan-Base", "size"?: mm (largest element size; default 1/25 of the body's diagonal), "layers"?: 2 (elements through the thinnest wall: a shell's thickness, a plate's; 1..=10), "max_elements"?: 300000 (the size grows past the walls' when more would be needed)} — the body's hex-meshing plan derived from the features that built it (geometry.features), saved in the model under `name` (replacing a plan of that name); nothing is meshed (mesh.plan.apply). The plan: the sweep `direction` (and every direction the mapped features are prismatic along), the element `size`, `tolerance` and `layers`; per mapped feature its `template` (sweep of an extrude's profile along its stored direction, block for a box, butterfly for a cylinder or a circular boss, o_grid for a hole or a circular cut, layers through a shell's thickness, imprint of a join, cut or combine tool's cross-section, copy for a pattern or mirror, partition for a split body) and the `faces` it made (the projection targets); the `rings` round the body's circles (o_grid or butterfly: centre, axis, radius, half-side of the square `frame`, the cylindrical `faces` they project onto); the partition `cuts` the frames and imprints need (each with its feature and reason, never on a face of the body); the `mesher` (cells when there are cuts, else sweep); the `copies` (the other bodies a pattern or mirror made from it); the features it does not map with the reason (`unmapped`: fillets, chamfers, revolves, sweeps and lofts are step 3; a feature not prismatic along the sweep needs projection blocking, #399) and `notes`"#, form: Some(super::forms::MESH_PLAN), kind: CommandKind::Model, requires: &[Requirement::Design], run: plan, }; pub const APPLY: CommandSpec = CommandSpec { id: "mesh.plan.apply", label: "Apply Mesh Plan", params: r#"{"plan": "Plan-Base" (a saved plan) | {plan object, as mesh.plan replies it; edited}, "name"?: "Plan-Base" (the name an object is saved under; default Plan-), "symmetry"?: true (a projection plan on a body mirror symmetric across planes through its middle square to the sweep meshes one cell and mirrors it: false meshes the whole body)} — carries the plan out with today's meshers: replaces the plan's earlier partitions (`-cut`) with its `cuts` (datum planes and partitions of the plan's body only; a cut the kernel cannot make, or that leaves a cell no mesher takes, is left out and reported in `skipped_cuts`), then meshes the body: its cells swept together with structured hex (mesh.cells, `default` hex) when there are cuts, else the body swept (mesh.hex.generate, swept), at the plan's size; each of its `copies` is meshed the same way by a plan of its own (`Plan-`, saved). No fallback: a cell that does not mesh all-hex is the error. Replies the cuts made and skipped, the cells, the element count, the smallest scaled Jacobian, `all_hex`, whether the cells joined node to node into one mesh (`conforming`), the mapped features, what today's meshers cannot build yet (`pending`: the O-grid and butterfly rings, which need the projection blocking mesher, #399), the `unmapped` features, the copies and the mesher's reply (`mesh`); `replaced` names the meshes it replaced. A plan object is checked and saved first"#, form: Some(super::forms::MESH_PLAN_APPLY), kind: CommandKind::Model, requires: &[Requirement::Design], run: apply, }; type V3 = [f64; 3]; fn dot(a: V3, b: V3) -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } fn cross(a: V3, b: V3) -> V3 { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } fn unit(v: V3) -> Option { let n = dot(v, v).sqrt(); (n.is_finite() && n > 1e-12).then(|| v.map(|c| c / n)) } /// Parallel or opposite, within 1e-6 of a radian. fn parallel(a: V3, b: V3) -> bool { match (unit(a), unit(b)) { (Some(a), Some(b)) => dot(a, b).abs() > 1.0 - 1e-6, _ => false, } } fn perpendicular(a: V3, b: V3) -> bool { match (unit(a), unit(b)) { (Some(a), Some(b)) => dot(a, b).abs() < 1e-6, _ => false, } } /// A direction up to its sign: the first non-zero component positive. fn canonical(d: V3) -> Option { let d = unit(d)?; let first = d.iter().copied().find(|c| c.abs() > 1e-9).unwrap_or(1.0); Some(if first < 0.0 { d.map(|c| if c == 0.0 { 0.0 } else { -c }) } else { d }) } const AXES: [V3; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; /// Whether the faces `faces` (persistent names on `shape`) are all prismatic along `d`: planes /// along or across it, cylinders about an axis along it. fn faces_prismatic(shape: &Shape, surfaces: &[Option], faces: &[String], d: V3) -> bool { faces.iter().all(|name| { let Some(i) = shape.face_index(name) else { return true }; match surfaces.get(i as usize).copied().flatten() { Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d), Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d), _ => false, } }) } /// The kinds of feature the plan has a template for. fn template_of(f: &FeatureNode) -> Option<&'static str> { let op = f.operation.as_deref().unwrap_or("new_body"); Some(match (f.kind.as_str(), op) { ("extrude", "new_body") => "sweep", ("extrude", "join" | "cut" | "intersect") => "imprint", ("box", "new_body") => "block", ("box", _) => "imprint", ("cylinder", "cut") => "o_grid", ("cylinder", _) => "butterfly", ("hole", _) => "o_grid", ("shell", _) => "layers", ("combine", _) => "imprint", ("pattern" | "mirror", _) => "copy", ("split", _) => "partition", ("fillet" | "chamfer", _) => "blend", _ => return None, }) } /// Why a feature with no template is not mapped. fn unmapped_reason(f: &FeatureNode) -> String { match f.kind.as_str() { "revolve" => "revolving the profile mesh about the stored axis is step 3 of the provenance plan (#402); no template yet".into(), "sweep" | "loft" => { "sweeping the profile along the stored path or between the sections is step 3 of the provenance plan (#402); no template yet".into() } other => format!("no template for `{other}` features yet"), } } /// The directions a feature builds along, or is prismatic along by its nature. fn feature_directions(f: &FeatureNode) -> Vec { match f.kind.as_str() { "box" => AXES.to_vec(), _ => f.direction.into_iter().collect(), } } /// Whether `f` keeps to a sweep along `d`. fn compatible(f: &FeatureNode, d: V3, shape: &Shape, surfaces: &[Option]) -> bool { match f.kind.as_str() { "extrude" | "cylinder" | "hole" if f.direction.is_some_and(|x| parallel(x, d)) => true, "box" => AXES.iter().any(|a| parallel(*a, d)), // Copies and combines bring the features they repeat or join, which are judged themselves. "pattern" | "mirror" | "combine" | "split" => true, // A blend is a layer of blocks along the body's sides, whatever its edges' direction. "fillet" | "chamfer" => true, _ => faces_prismatic(shape, surfaces, &f.faces, d), } } /// A circle a butterfly or O-grid goes round: in the plane square to the sweep. #[derive(Debug, Clone, Copy)] struct Circle { center: V3, radius: f64, feature: u64, kind: RingKind, /// Whether the circle needs a frame cut round it (not the body's own outline). framed: bool, } /// The circles of `f` along `d`: a cylinder's or hole's, an extrude's profile circles (in world /// coordinates). A circle that is the body's whole outline (a lone cylinder) needs no frame. fn circles(f: &FeatureNode, d: V3) -> Vec { let mut out = Vec::new(); let cut = f.operation.as_deref() == Some("cut"); match f.kind.as_str() { "cylinder" | "hole" if f.direction.is_some_and(|x| parallel(x, d)) => { let r = f.values.get("radius").copied().or_else(|| f.values.get("diameter").map(|v| v / 2.0)); if let (Some(c), Some(r)) = (f.placement, r) { let kind = if cut { RingKind::OGrid } else { RingKind::Butterfly }; out.push(Circle { center: c, radius: r, feature: f.id, kind, framed: f.operation.as_deref() != Some("new_body") }); } } "extrude" if f.direction.is_some_and(|x| parallel(x, d)) => { let Some(sk) = &f.sketch else { return out }; let world = |p: [f64; 2]| -> V3 { std::array::from_fn(|k| sk.origin[k] + p[0] * sk.x_dir[k] + p[1] * sk.y_dir[k]) }; for p in &sk.profiles { // A profile whose outline is one circle is the body's own when the extrude makes // a new body; a joined boss's or a cut's otherwise. let outline_is_body = f.operation.as_deref() == Some("new_body"); // An outline is material inside (unless the extrude cuts), a hole material outside. let loops = std::iter::once((&p.outer, outline_is_body, cut)).chain(p.holes.iter().map(|h| (h, false, !cut))); for (lp, own, hollow) in loops { if let [Curve::Circle { center, radius }] = lp.as_slice() { let kind = if hollow { RingKind::OGrid } else { RingKind::Butterfly }; out.push(Circle { center: world(*center), radius: *radius, feature: f.id, kind, framed: !own }); } } } } _ => {} } out } /// The circles of `f` where the body has them: a feature's stored placement is the original's, /// and a pattern or mirror copy has its circles elsewhere. Each stored circle takes the centre of /// a cylindrical face of the feature on this body of its radius, about an axis along `d`, each /// face once; a stored circle with no such face keeps its place. fn placed(stored: Vec, f: &FeatureNode, d: V3, shape: &Shape, surfaces: &[Option]) -> Vec { let mut axes: Vec<(V3, f64)> = Vec::new(); for name in &f.faces { let Some(i) = shape.face_index(name) else { continue }; if let Some(FaceSurface::Cylinder { origin, axis, radius }) = surfaces.get(i as usize).copied().flatten() && parallel(axis, d) { // The axis's point in the plane through the origin square to `d`. let along = dot(origin, d); let p: V3 = std::array::from_fn(|k| origin[k] - along * d[k]); let same = |(q, r): &(V3, f64)| (r - radius).abs() < 1e-6 * radius.max(1.0) && (0..3).all(|k| (q[k] - p[k]).abs() < 1e-6 * radius.max(1.0)); if !axes.iter().any(same) { axes.push((p, radius)); } } } let mut out = Vec::with_capacity(stored.len()); for mut c in stored { let near = |(p, r): &(V3, f64)| { let off: V3 = std::array::from_fn(|k| c.center[k] - p[k]); let along = dot(off, d); ((r - c.radius).abs() < 1e-6 * c.radius.max(1.0), dot(off, off) - along * along) }; let best = axes.iter().enumerate().filter(|(_, a)| near(a).0).min_by(|a, b| near(a.1).1.total_cmp(&near(b.1).1)).map(|(k, _)| k); if let Some(k) = best { let (p, _) = axes.swap_remove(k); // Keep the stored point's level along the sweep. let along = dot(c.center, d); c.center = std::array::from_fn(|i| p[i] + along * d[i]); } out.push(c); } out } /// What the cuts must keep clear of: the body's extent, its planar faces (normal, offset, name) /// and the points of its curved faces, and the smallest distance a cut keeps. struct Clearance<'a> { corners: Vec, planes: Vec<(V3, f64, &'a str)>, curved: Vec>, gap: f64, } impl Clearance<'_> { /// The body's extent along `n`. fn extent(&self, n: V3) -> (f64, f64) { self.corners.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), c| (l.min(dot(*c, n)), h.max(dot(*c, n)))) } /// Whether the plane `n·x = t` lies inside the body, `gap` from its extent. fn inside(&self, n: V3, t: f64) -> bool { let (lo, hi) = self.extent(n); t > lo + self.gap && t < hi - self.gap } /// Whether the plane passes through a curved face (between its points' extremes along `n`, /// by more than `gap`): the partitions' tessellation does not close along such a cut. fn crosses_curved(&self, n: V3, t: f64) -> bool { self.curved.iter().any(|pts| { let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(dot(*p, n)), h.max(dot(*p, n)))); t > lo + 1e-6 && t < hi - 1e-6 }) } /// The planar faces square to `n` within `gap` of the plane `n·x = t`, with their distance. fn faces_near(&self, n: V3, t: f64) -> impl Iterator + '_ { self.planes .iter() .filter(move |(m, _, _)| parallel(*m, n)) .map(move |(m, off, name)| ((t - off * dot(*m, n).signum()).abs(), *name)) .filter(|(x, _)| *x < self.gap) } } /// A joined or cutting tool's place along the sweep and, for a cut, its sides across it: the /// levels and the frame of its imprint. struct Imprint { feature: u64, cut: bool, /// Where its cross-section starts and ends along the sweep direction. extent: Option<(f64, f64)>, /// A cut's side planes along the sweep (normal, offset): a rectangle's four. sides: Vec<(V3, f64)>, /// The middle of the cut's rectangle: which way is out of the opening from each side. center: Option, } /// The imprint of `f` (a join or cut, `cut` saying which) along `d`. fn imprint_of(f: &FeatureNode, d: V3, cut: bool) -> Option { let v = |k: &str| f.values.get(k).copied(); let mut sides = Vec::new(); let mut center = None; let extent = match f.kind.as_str() { "box" => { let p = f.placement?; let dims = [v("length")?, v("width")?, v("height")?]; let k = AXES.iter().position(|a| parallel(*a, d))?; for j in (0..3).filter(|&j| j != k) { sides.push((AXES[j], p[j])); sides.push((AXES[j], p[j] + dims[j])); } center = Some(std::array::from_fn(|i| p[i] + 0.5 * dims[i])); Some((p[k], p[k] + dims[k])) } "cylinder" => { let (p, a) = (f.placement?, f.direction?); parallel(a, d).then(|| (dot(p, d), dot(p, d) + v("height").unwrap_or_default() * dot(a, d))) } "hole" => { let (p, a) = (f.placement?, f.direction?); v("depth").filter(|_| parallel(a, d)).map(|depth| (dot(p, d), dot(p, d) + depth * dot(a, d))) } "extrude" => { let (sk, a) = (f.sketch.as_ref()?, f.direction?); if !parallel(a, d) || f.two_sided { return None; } // A rectangle's sides (four lines, each along a sketch axis). for p in &sk.profiles { let lines: Vec<([f64; 2], [f64; 2])> = p.outer.iter().filter_map(|c| if let Curve::Line { a, b } = c { Some((*a, *b)) } else { None }).collect(); let square = lines.len() == 4 && p.outer.len() == 4 && p.holes.is_empty() && lines.iter().all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 || (a[1] - b[1]).abs() < 1e-9); if square { let world = |q: [f64; 2]| -> V3 { std::array::from_fn(|k| sk.origin[k] + q[0] * sk.x_dir[k] + q[1] * sk.y_dir[k]) }; let mid = lines.iter().fold([0.0; 2], |m, (a, _)| [m[0] + a[0] / 4.0, m[1] + a[1] / 4.0]); center = Some(world(mid)); for (a, b) in lines { let (wa, wb) = (world(a), world(b)); if let Some(n) = unit(cross(std::array::from_fn(|k| wb[k] - wa[k]), d)) { sides.push((n, dot(n, wa))); } } } } let t0 = dot(sk.origin, d); Some((t0, t0 + v("distance")? * dot(a, d))) } _ => None, }; Some(Imprint { feature: f.id, cut, extent, sides: if cut { sides } else { Vec::new() }, center }) } /// The cuts that imprint the tools: a plane square to the sweep where each tool's cross-section /// starts or ends inside the body (a level), and for a cutting tool with a rectangular section, /// its four sides extended across the body (the frame of the opening). A plane goes in only /// inside the body (`gap` from its extent), not through a curved face, not near but off a face, not on a face a feature /// other than the tools made (a level exactly on a tool's own face is that face carried on: no /// sliver), and not next to another cut. A frame side on a face of the body (an opening's end /// wall) is moved one element (twice `gap`) out of the opening: the kernel cannot cut along a /// face (#427, #455), and a strip one element wide is left between the cut and the face. fn imprint_cuts(tools: &[Imprint], d: V3, body: &Clearance, cuts: &mut Vec) { let tool_ids: Vec = tools.iter().map(|t| t.feature).collect(); // `off`: the distance from `t` of the face a frame side was moved off, allowed exactly. let mut add = |n: V3, t: f64, reason: String, feature: u64, off: Option| { if !body.inside(n, t) || body.crosses_curved(n, t) { return; } // Near a face but off it, or on a face some other feature made (a floor a boss stands // on): a sliver, or a cut along a face that the kernel splits badly. let near_face = body.faces_near(n, t).any(|(x, name)| { if let Some(o) = off { return (x - o).abs() > 1e-6; } x > 1e-6 || !fieldcraft_geometry::features::feature_ids(name).any(|id| tool_ids.contains(&id)) }); if near_face || cuts.iter().any(|k| parallel(k.normal, n) && (dot(k.point, n) - t).abs() < body.gap) { return; } let role = if parallel(n, d) { CutRole::Level } else { CutRole::Frame }; cuts.push(PlanCut { point: n.map(|x| x * t), normal: n, role, feature: Some(feature), reason }); }; for tool in tools { let what = if tool.cut { "cut" } else { "joined" }; if let Some((a, b)) = tool.extent { for (t, end) in [(a.min(b), "starts"), (a.max(b), "ends")] { add(d, t, format!("level: the {what} section of F{} {end} here", tool.feature), tool.feature, None); } } for (n, off) in &tool.sides { let why = format!("frame: a side of the opening F{} cuts, carried across the body", tool.feature); let on_face = body.faces_near(*n, *off).any(|(x, _)| x <= 1e-6); match tool.center.filter(|_| on_face) { Some(c) => { let out = (off - dot(*n, c)).signum(); let shift = 2.0 * body.gap; add(*n, off + out * shift, format!("{why}, {shift:.4} mm out of the opening off its end face"), tool.feature, Some(shift)); } None => add(*n, *off, why, tool.feature, None), } } } } /// Most profile segments [`profile_wall`] compares pairwise. const MAX_WALL_SEGMENTS: usize = 2000; /// The thinnest wall of a profile: the smallest distance between two parallel lines of its /// loops with material between them (the point halfway between, over their overlap, inside the /// outline and outside its holes). Arcs count only as the polygon their ends make. fn profile_wall(p: &fieldcraft_geometry::ProfileGeometry) -> Option { let polygon = |lp: &[Curve]| -> Vec<[f64; 2]> { let mut pts = Vec::new(); for c in lp { match c { Curve::Line { a, .. } => pts.push(*a), Curve::Arc { center, radius, start, sweep } => { for k in 0..8 { let t = start + sweep * f64::from(k) / 8.0; pts.push([center[0] + radius * t.cos(), center[1] + radius * t.sin()]); } } Curve::Circle { center, radius } => { for k in 0..16 { let t = std::f64::consts::TAU * f64::from(k) / 16.0; pts.push([center[0] + radius * t.cos(), center[1] + radius * t.sin()]); } } Curve::Other { .. } => {} } } pts }; let inside = |poly: &[[f64; 2]], q: [f64; 2]| { let mut odd = false; for (i, a) in poly.iter().enumerate() { let b = poly[(i + 1) % poly.len()]; if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < a[0] + (q[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) { odd = !odd; } } odd }; let outer = polygon(&p.outer); let holes: Vec> = p.holes.iter().map(|h| polygon(h)).collect(); let material = |q: [f64; 2]| inside(&outer, q) && !holes.iter().any(|h| inside(h, q)); let lines: Vec<([f64; 2], [f64; 2])> = std::iter::once(&p.outer) .chain(&p.holes) .flatten() .filter_map(|c| if let Curve::Line { a, b } = c { Some((*a, *b)) } else { None }) .take(MAX_WALL_SEGMENTS) .collect(); let mut best: Option = None; for (i, (a, b)) in lines.iter().enumerate() { let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let len = (dx * dx + dy * dy).sqrt(); if !(len > 1e-12) { continue; } let (ux, uy) = (dx / len, dy / len); for (c, e) in lines.iter().skip(i + 1) { let (ex, ey) = (e[0] - c[0], e[1] - c[1]); if (ux * ey - uy * ex).abs() > 1e-9 * (ex * ex + ey * ey).sqrt().max(1e-12) { continue; } let gap = ux * (c[1] - a[1]) - uy * (c[0] - a[0]); if gap.abs() < 1e-9 { continue; } // The overlap of the two along the direction. let along = |q: [f64; 2]| (q[0] - a[0]) * ux + (q[1] - a[1]) * uy; let (lo, hi) = (along(*c).min(along(*e)).max(0.0), along(*c).max(along(*e)).min(len)); if hi - lo <= 1e-9 { continue; } let m = 0.5 * (lo + hi); let q = [a[0] + ux * m - uy * gap / 2.0, a[1] + uy * m + ux * gap / 2.0]; if material(q) && best.is_none_or(|x| gap.abs() < x) { best = Some(gap.abs()); } } } best } /// Two in-plane axes square to `d`: the other two principal axes when `d` is one. fn in_plane_axes(d: V3) -> [V3; 2] { if let Some(k) = AXES.iter().position(|a| parallel(*a, d)) { let others: Vec = AXES.iter().enumerate().filter(|(i, _)| *i != k).map(|(_, a)| *a).collect(); if let [u, v] = others.as_slice() { return [*u, *v]; } } let least = AXES.iter().copied().min_by(|a, b| dot(*a, d).abs().total_cmp(&dot(*b, d).abs())).unwrap_or([1.0, 0.0, 0.0]); let u = unit(cross(d, least)).unwrap_or([1.0, 0.0, 0.0]); [u, unit(cross(d, u)).unwrap_or([0.0, 1.0, 0.0])] } /// The frame cuts round the circles: for each circle (concentric ones together, the largest /// framing them) and each in-plane axis, a plane on either side, square to the axis, at a /// distance `h` from the centre; none on a side where the body's side is within 4 radii (it is /// the frame there). `h` is first the distance to the body's side on the other side (a square /// frame, when that is 1.4 to 4 radii), else 2, 1.75, 2.5, 1.5 or 3 radii, the first /// that keeps the plane inside the body, off its curved faces, at least `gap` off every face of /// the body square to the axis, clear of the other circles (1.3 of their radius), and not next to another cut (one /// within 0.6 of the radius serves instead). A side with no such place gets no cut, and a note. fn frame_cuts(circles: &[Circle], d: V3, body: &Clearance, cuts: &mut Vec) -> Vec { let mut groups: Vec = Vec::new(); for c in circles.iter().filter(|c| c.framed) { let same_axis = |g: &Circle| { let off = std::array::from_fn::(|k| c.center[k] - g.center[k]); let along = dot(off, d); dot(off, off) - along * along < 1e-12 }; match groups.iter_mut().find(|g| same_axis(g)) { Some(g) if c.radius > g.radius => *g = *c, Some(_) => {} None => groups.push(*c), } } let gap = body.gap; let mut notes = Vec::new(); for g in &groups { let r = g.radius; for a in in_plane_axes(d) { let (amin, amax) = body.extent(a); let c = dot(g.center, a); for sign in [1.0, -1.0] { let opposite = if sign > 0.0 { c - amin } else { amax - c }; let same = if sign > 0.0 { amax - c } else { c - amin }; // The body's side close by on this side frames the circle itself. if same <= 4.0 * r { continue; } let mut hs: Vec = [opposite].into_iter().filter(|h| (1.4 * r..=4.0 * r).contains(h)).collect(); hs.extend([2.0, 1.75, 2.5, 1.5, 3.0].map(|k| k * r)); let mut placed = false; for h in hs { let t = c + sign * h; if !body.inside(a, t) || body.crosses_curved(a, t) || body.faces_near(a, t).next().is_some() { continue; } if groups.iter().any(|o| !std::ptr::eq(o, g) && (t - dot(o.center, a)).abs() < 1.3 * o.radius) { continue; } if cuts.iter().any(|k| parallel(k.normal, a) && (dot(k.point, a) - t).abs() < (0.6 * r).max(gap)) { placed = true; break; } cuts.push(PlanCut { point: a.map(|x| x * t), normal: a, role: CutRole::Frame, feature: Some(g.feature), reason: format!("frame of the O-grid round the circle of radius {r:.4} at F{}: {h:.4} mm from its centre", g.feature), }); placed = true; break; } if !placed { notes.push(format!( "F{}: no frame cut on the {} side along {:?} (the body's side or a face is the frame there)", g.feature, if sign > 0.0 { "positive" } else { "negative" }, a )); } } } } notes } /// The rings round the circles: each circle once (concentric ones the largest), with the /// half-side of its square frame (an O-grid's: the nearest frame cut or side of the body square /// to an in-plane axis; a butterfly's core: half the radius) and the cylindrical faces of the /// feature it projects onto. fn plan_rings( circles: &[Circle], d: V3, body: &Clearance, cuts: &[PlanCut], shape: &Shape, surfaces: &[Option], features: &[FeatureNode], ) -> Vec { let mut out: Vec = Vec::new(); for c in circles { let same = |r: &PlanRing| { let off: V3 = std::array::from_fn(|k| c.center[k] - r.center[k]); let along = dot(off, d); dot(off, off) - along * along < 1e-12 }; // Concentric circles each get a ring (a boss and its hole: squares inside squares). if out.iter().any(|r| same(r) && (r.radius - c.radius).abs() < 1e-9 * c.radius.max(1.0)) { continue; } let frame = match c.kind { RingKind::Butterfly => 0.5 * c.radius, RingKind::OGrid => in_plane_axes(d) .iter() .flat_map(|&a| { let (lo, hi) = body.extent(a); let t = dot(c.center, a); let planes = cuts.iter().filter(move |k| parallel(k.normal, a)).map(move |k| (dot(k.point, a) - t).abs()); [t - lo, hi - t].into_iter().chain(planes) }) .filter(|x| *x > c.radius) .fold(f64::INFINITY, f64::min), }; let faces = features .iter() .filter(|f| f.id == c.feature) .flat_map(|f| &f.faces) .filter(|name| { let Some(i) = shape.face_index(name) else { return false }; match surfaces.get(i as usize).copied().flatten() { Some(FaceSurface::Cylinder { origin, axis, radius }) => { let off: V3 = std::array::from_fn(|k| c.center[k] - origin[k]); let along = dot(off, d); parallel(axis, d) && (radius - c.radius).abs() < 1e-6 * c.radius.max(1.0) && dot(off, off) - along * along < 1e-9 * c.radius.max(1.0) } _ => false, } }) .cloned() .collect(); if let Some(axis) = canonical(d) { out.push(PlanRing { feature: c.feature, kind: c.kind, center: c.center, axis, radius: c.radius, frame: if frame.is_finite() { frame } else { 2.0 * c.radius }, faces, }); } } out } /// The other bodies of the design the plan's copy features (a pattern, a mirror) made from the /// same timeline: each lists that feature too. fn copies_of(design: &Design, body: &str, features: &[PlanFeature]) -> Result> { let copy_ids: Vec = features.iter().filter(|f| f.template == "copy").map(|f| f.id).collect(); if copy_ids.is_empty() { return Ok(Vec::new()); } let mut out = Vec::new(); for graph in design.features(None)? { if graph.body != body && graph.features.iter().any(|f| copy_ids.contains(&f.id)) { out.push(graph.body); } } Ok(out) } /// The shape of `body`: a body of the design as opened, or a cell partitions cut from one. fn plan_shape<'a>(design: &'a Design, body: &str) -> Option<&'a Shape> { design.source_body(body).or_else(|| design.body(body)) } /// Build the plan of `body`: a body of the design as opened, or a cell of one (planned from the /// features that built the body it was cut from, with the rings the cell holds). pub(crate) fn build(design: &Design, body: &str, size: Option, layers: u32, max_elements: usize) -> Result { let shape = plan_shape(design, body).ok_or_else(|| EngineError::Params(format!("no body `{body}` in the design")))?; let source = design.source_of(body).unwrap_or(body); let is_cell = source != body; let graph = design.features(Some(source))?; let features: Vec = graph.into_iter().next().map(|b| b.features).unwrap_or_default(); let surfaces = shape.face_surfaces()?; let m = shape.measure()?; let (lo, hi) = (m.bbox_min, m.bbox_max); let diagonal = dot(std::array::from_fn(|k| hi[k] - lo[k]), std::array::from_fn(|k| hi[k] - lo[k])).sqrt(); if !(diagonal.is_finite() && diagonal > 0.0) { return Err(EngineError::Params(format!("body `{body}` has no extent"))); } // The sweep direction: the candidate most mapped features keep to, the first new body's own // on a tie (candidates in the order the features give them). let mapped: Vec<&FeatureNode> = features.iter().filter(|f| template_of(f).is_some() && f.error.is_none()).collect(); let primary = mapped.iter().find(|f| f.operation.as_deref() == Some("new_body")).and_then(|f| feature_directions(f).first().copied()); let mut candidates: Vec = Vec::new(); for d in primary.into_iter().chain(mapped.iter().flat_map(|f| feature_directions(f))).filter_map(canonical) { if !candidates.iter().any(|c| parallel(*c, d)) { candidates.push(d); } } let score = |d: V3| mapped.iter().filter(|f| compatible(f, d, shape, &surfaces)).count(); let best = candidates.iter().map(|d| score(*d)).max().unwrap_or(0); let directions: Vec = candidates.iter().copied().filter(|d| score(*d) == best && best > 0).collect(); let direction = directions.first().copied(); let mut plan_features = Vec::new(); let mut unmapped = Vec::new(); let mut notes = Vec::new(); let mut circle_list = Vec::new(); let mut imprints = Vec::new(); // The thinnest wall the features give, with what gives it. let mut thin: Option<(f64, String)> = None; let mut thinner = |t: f64, why: String| { if t.is_finite() && t > 0.0 && thin.as_ref().is_none_or(|(x, _)| t < *x) { thin = Some((t, why)); } }; for f in &features { let unmap = |reason: String| PlanUnmapped { id: f.id, name: f.name.clone(), kind: f.kind.clone(), reason }; if let Some(e) = &f.error { unmapped.push(unmap(format!("failed to rebuild: {e}"))); continue; } let Some(template) = template_of(f) else { let mut reason = unmapped_reason(f); if let Some(d) = direction && !f.faces.is_empty() && faces_prismatic(shape, &surfaces, &f.faces, d) { reason.push_str("; its faces are prismatic along the sweep, so the sweep meshes them as part of the cross-section"); } unmapped.push(unmap(reason)); continue; }; let Some(d) = direction else { unmapped.push(unmap("no sweep direction keeps to the body's features".into())); continue; }; if !compatible(f, d, shape, &surfaces) { unmapped.push(unmap(format!("not prismatic along the sweep direction {d:?}: it needs blocks of its own (projection blocking, #399)"))); continue; } let v = |k: &str| f.values.get(k).copied(); let note = match (f.kind.as_str(), template) { ("extrude", "sweep") => { if let Some(t) = v("distance").filter(|_| f.direction.is_some_and(|x| parallel(x, d))) { thinner(t, format!("{} extent", f.name)); } for p in f.sketch.iter().flat_map(|sk| &sk.profiles) { if let Some(t) = profile_wall(p) { thinner(t, format!("{} profile's thinnest wall", f.name)); } } let along = if f.direction.is_some_and(|x| parallel(x, d)) { "along its stored direction" } else { "across its extrude direction (its profile is prismatic along the sweep)" }; format!("the profile's cross-section swept {along}; circles butterflied, circular holes O-gridded") } ("extrude", _) => "its profile imprinted on the swept cross-section between the levels of its extent".into(), ("box", t) => { let dims: Vec = ["length", "width", "height"].iter().filter_map(|k| v(k)).collect(); if let Some(min) = dims.iter().copied().reduce(f64::min) { thinner(min, format!("{} thinnest side", f.name)); } if t == "block" { "a mapped block (part of the swept cross-section)".into() } else { "its rectangle imprinted on the swept cross-section".into() } } ("cylinder", "butterfly") => format!("a butterfly round its circle (radius {:.4})", v("radius").unwrap_or_default()), ("cylinder", _) => format!("an O-grid round its circle (radius {:.4})", v("radius").unwrap_or_default()), ("hole", _) => format!("an O-grid round the hole (diameter {:.4}) along its stored axis", v("diameter").unwrap_or_default()), ("shell", _) => { let t = v("thickness").unwrap_or_default(); thinner(t, format!("{} thickness", f.name)); format!("{layers} layers through the {t:.4} mm wall") } ("combine", _) => format!("the cross-sections of {} imprinted on the target", f.tools.join(", ")), ("pattern" | "mirror", _) => format!("the instance meshed and repeated by {} transforms", f.transforms.len()), ("split", _) => "the pieces meshed as cells of the split".into(), ("fillet", _) => { format!("a blend layer of blocks along its edges, {:.4} mm deep, projected onto the fillet faces", v("radius").unwrap_or_default()) } ("chamfer", _) => { format!("a blend layer of blocks along its edges, {:.4} mm deep, projected onto the chamfer faces", v("distance").unwrap_or_default()) } _ => String::new(), }; circle_list.extend(placed(circles(f, d), f, d, shape, &surfaces)); // A tool: joined or cut by its own operation, or the tool body of a combine. let role = match f.operation.as_deref() { Some("join") => Some(false), Some("cut") => Some(true), _ => features.iter().find(|c| c.kind == "combine" && c.tools.iter().any(|t| f.creates.contains(t))).and_then(|c| { match c.operation.as_deref() { Some("join") => Some(false), Some("cut") => Some(true), _ => None, } }), }; let template = if role.is_some() && template == "block" { "imprint" } else { template }; if let Some(cut) = role { imprints.extend(imprint_of(f, d, cut)); } plan_features.push(PlanFeature { id: f.id, name: f.name.clone(), kind: f.kind.clone(), template: template.into(), note, faces: f.faces.clone(), }); } // The element size: at most the one asked for (or the meshers' default), small enough for // `layers` through the thinnest wall, but not so small the body takes more than // `max_elements`. let asked = size.unwrap_or(DEFAULT_RELATIVE_SIZE * diagonal); let mut h = asked; if let Some((t, why)) = &thin { let through = t / f64::from(layers); if through < h { notes.push(format!("size {through:.4} mm: {layers} layers through the {t:.4} mm wall ({why})")); h = through; } } let floor = (m.volume.abs() / max_elements as f64).cbrt(); if h < floor { notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)")); h = floor; } let mut cuts = Vec::new(); let mut rings = Vec::new(); // Where the frame cuts could not go: the swept cells' business only. let mut frame_notes = Vec::new(); if let Some(d) = direction { let planes = surfaces .iter() .zip(shape.faces()) .filter_map(|(s, f)| match s { Some(FaceSurface::Plane { normal, point }) => Some((*normal, dot(*normal, *point), f.name.as_str())), _ => None, }) .collect(); // The curved faces' points, from a tessellation at the meshers' default tolerance. let mesh = shape.tessellate(1e-3 * diagonal)?; let mut curved: Vec> = vec![Vec::new(); surfaces.len()]; for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) { if !matches!(surfaces.get(f as usize), Some(Some(FaceSurface::Plane { .. }))) && let Some(pts) = curved.get_mut(f as usize) { pts.extend(t.iter().filter_map(|&i| mesh.positions.get(i as usize).copied())); } } curved.retain(|p| !p.is_empty()); let corners = (0..8) .map(|i| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }]) .collect(); let body = Clearance { corners, planes, curved, gap: 0.5 * h }; imprint_cuts(&imprints, d, &body, &mut cuts); frame_notes = frame_cuts(&circle_list, d, &body, &mut cuts); rings = plan_rings(&circle_list, d, &body, &cuts, shape, &surfaces, &features); // A cell holds the rings whose axis runs through it. if is_cell { rings.retain(|r| { (0..3).all(|k| { (r.center[k] - d[k] * dot(r.center, d)) >= lo[k] - d[k] * dot(lo, d) - 1e-9 && (r.center[k] - d[k] * dot(r.center, d)) <= hi[k] - d[k] * dot(hi, d) + 1e-9 }) }); } } // The tessellation: the meshers' default, finer where the body has small curves (a hundredth // of the smallest radius), so short blend edges stay edges. let radius = surfaces .iter() .filter_map(|s| match s { Some(FaceSurface::Cylinder { radius, .. }) | Some(FaceSurface::Sphere { radius, .. }) => Some(*radius), Some(FaceSurface::Torus { minor, .. }) => Some(*minor), _ => None, }) .fold(f64::INFINITY, f64::min); let tolerance = (1e-3 * diagonal).min(0.01 * radius).max(MIN_RELATIVE_TOLERANCE * diagonal); if tolerance < 1e-3 * diagonal { notes.push(format!("tolerance {tolerance:.4} mm: a hundredth of the smallest radius ({radius:.4} mm)")); } let mesher = if cuts.is_empty() { PlanMesher::Sweep } else { PlanMesher::Cells }; // A cell's copies are its body's (meshed with it), not its own. let copies = if !is_cell { copies_of(design, body, &plan_features)? } else { Vec::new() }; let mut plan = MeshPlan { body: body.into(), size: h, tolerance: Some(tolerance), layers, mesher, direction, directions, features: plan_features, cuts, rings, copies, unmapped, notes, }; plan.check()?; // Rings and blends: one block topology over the whole body when it fits (no cuts needed); // else the frame cuts and swept cells, with the reason. match layout_of(design, &plan)? { Some(Ok(layout)) => { plan.mesher = PlanMesher::Project; plan.cuts.clear(); // The rings' frames are the layout's. if let Some(d) = plan.direction { for r in &mut plan.rings { if let Some(half) = layout.frame_of(r.center, d).filter(|_| r.kind == RingKind::OGrid) { r.frame = half; } } } plan.notes.push(layout.summary()); } Some(Err(why)) => { plan.notes.extend(frame_notes); plan.notes.push(format!("no projection layout ({why}): the frame cuts box the rings and the cells are swept")); } None => plan.notes.extend(frame_notes), } plan.check()?; Ok(plan) } /// The block layout of `plan` for the projection mesher, when the plan has rings or blends /// (`None` when it has neither: the sweep meshes it); `Err` inside says why the body does not /// take one. fn layout_of(design: &Design, plan: &MeshPlan) -> Result>> { let blends: Vec<&PlanFeature> = plan.features.iter().filter(|f| f.template == "blend").collect(); if plan.rings.is_empty() && blends.is_empty() { return Ok(None); } let Some(direction) = plan.direction else { return Ok(Some(Err("no sweep direction".into()))) }; let shape = plan_shape(design, &plan.body).ok_or_else(|| EngineError::Params(format!("no body `{}` in the design", plan.body)))?; let surfaces = shape.face_surfaces()?; let m = shape.measure()?; let diagonal = (0..3).map(|k| (m.bbox_max[k] - m.bbox_min[k]).powi(2)).sum::().sqrt(); let mesh = shape.tessellate(plan.tolerance.unwrap_or(1e-3 * diagonal).max(1e-5 * diagonal))?; let blend_faces = blends.iter().flat_map(|f| &f.faces).filter_map(|n| shape.face_index(n)).collect(); let shell = if plan.features.iter().any(|f| f.kind == "shell") { design .features(Some(design.source_of(&plan.body).unwrap_or(&plan.body)))? .into_iter() .flat_map(|b| b.features) .filter(|f| f.kind == "shell") .find_map(|f| f.values.get("thickness").copied()) } else { None }; let input = blocks::Input { shape, surfaces: &surfaces, mesh: &mesh, direction, rings: &plan.rings, blends: blend_faces, shell, size: plan.size }; Ok(Some(blocks::layout(&input))) } fn plan(s: &mut Session, p: &Value) -> Result { let body: String = super::req(p, "body")?; let size: Option = super::opt(p, "size")?; if size.is_some_and(|v| !(v.is_finite() && v > 0.0)) { return Err(EngineError::param("size", ProblemKind::NotPositive)); } let layers: u32 = super::opt(p, "layers")?.unwrap_or(2); if !(1..=10).contains(&layers) { return Err(EngineError::Params("`layers` must be 1 to 10".into())); } let max_elements: usize = super::opt(p, "max_elements")?.unwrap_or(DEFAULT_MAX_ELEMENTS); if !(1..=MAX_ELEMENTS).contains(&max_elements) { return Err(EngineError::Params(format!("`max_elements` must be 1 to {MAX_ELEMENTS}"))); } let name: String = super::opt(p, "name")?.unwrap_or_else(|| MeshPlan::default_name(&body)); let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let plan = build(design, &body, size, layers, max_elements)?; s.model.set_mesh_plan(&name, plan.clone())?; let mut reply = serde_json::to_value(&plan).map_err(|e| EngineError::Params(e.to_string()))?; if let Some(o) = reply.as_object_mut() { o.insert("name".into(), json!(name)); } Ok(reply) } /// The partitions (and their datums) an earlier apply of plan `name` made. fn plan_cut_names(s: &Session, name: &str) -> Vec { let prefix = format!("{name}-cut"); s.model .partitions() .iter() .map(|p| p.name.clone()) .filter(|n| n.strip_prefix(&prefix).is_some_and(|k| !k.is_empty() && k.bytes().all(|b| b.is_ascii_digit()))) .collect() } fn apply(s: &mut Session, p: &Value) -> Result { let (name, plan) = match p.get("plan") { Some(Value::String(n)) => (n.clone(), s.model.mesh_plan(n).cloned().ok_or_else(|| EngineError::Params(format!("no mesh plan `{n}`")))?), Some(v @ Value::Object(_)) => { let plan: MeshPlan = serde_json::from_value(v.clone()).map_err(|e| EngineError::Params(format!("`plan`: {e}")))?; let name: String = super::opt(p, "name")?.unwrap_or_else(|| MeshPlan::default_name(&plan.body)); s.model.set_mesh_plan(&name, plan.clone())?; (name, plan) } _ => return Err(EngineError::Params("`plan` must be a saved plan's name or a plan object".into())), }; let symmetry: bool = super::opt(p, "symmetry")?.unwrap_or(true); let symmetric = if symmetry { apply_symmetric(s, &name, &plan)? } else { None }; let mut reply = match symmetric { Some(r) => r, None => apply_one(s, &name, &plan)?, }; // The copies: each meshed by a plan of its own at this plan's size, saved beside it. let mut copies = Vec::with_capacity(plan.copies.len()); for copy in &plan.copies { let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; if design.source_body(copy).is_none() { return Err(EngineError::Params(format!("mesh plan `{name}`: no copy `{copy}` in the design"))); } let mut theirs = build(design, copy, Some(plan.size), plan.layers, MAX_ELEMENTS)?; // Its copies are this body and the others: they are meshed here, not again. theirs.copies.clear(); theirs.size = plan.size; let copy_name = MeshPlan::default_name(copy); s.model.set_mesh_plan(©_name, theirs.clone())?; let r = apply_one(s, ©_name, &theirs)?; take_replaced(&mut reply, &r); copies.push(json!({"body": copy, "plan": copy_name, "cells": r["cells"], "elements": r["elements"], "min_scaled_jacobian": r["min_scaled_jacobian"], "conforming": r["conforming"]})); } let mins: Vec = std::iter::once(&reply).chain(&copies).filter_map(|r| r["min_scaled_jacobian"].as_f64()).collect(); if let Some(o) = reply.as_object_mut() { if !copies.is_empty() { o.insert("copies".into(), json!(copies)); o.insert("min_scaled_jacobian".into(), json!(mins.iter().copied().reduce(f64::min))); } let results: Vec<&str> = s.solved_steps().collect(); if o.get("replaced").is_some_and(|r| r.as_array().is_some_and(|a| !a.is_empty())) && !results.is_empty() { o.insert("results_cleared".into(), json!(results)); } } Ok(reply) } /// The bodies `mesh.plan.apply` fills (replace.rs): the cells of the plan's body and of its /// copies, as the design has them now (the body itself when it is not cut). pub(crate) fn plan_bodies(s: &Session, p: &Value) -> Result> { let plan = match p.get("plan") { Some(Value::String(n)) => s.model.mesh_plan(n).cloned().ok_or_else(|| EngineError::Params(format!("no mesh plan `{n}`")))?, Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).map_err(|e| EngineError::Params(format!("`plan`: {e}")))?, _ => return Err(EngineError::Params("`plan` must be a saved plan's name or a plan object".into())), }; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let sources: Vec<&str> = std::iter::once(plan.body.as_str()).chain(plan.copies.iter().map(String::as_str)).collect(); Ok(design.bodies.iter().filter(|b| design.source_of(&b.name).is_some_and(|x| sources.contains(&x))).map(|b| b.name.clone()).collect()) } /// Move a mesher's `replaced` and `stale_sets` (replace.rs) into `into`, which reports them once /// for the whole command. fn take_replaced(into: &mut Value, from: &Value) { for key in ["replaced", "stale_sets"] { let Some(items) = from.get(key).and_then(Value::as_array).filter(|a| !a.is_empty()) else { continue }; if let Some(o) = into.as_object_mut() { let list = o.entry(key).or_insert_with(|| json!([])); if let Some(list) = list.as_array_mut() { for item in items { if !list.contains(item) { list.push(item.clone()); } } } } } } /// Carry out one plan: its cuts, then the mesh of the cells (or of the body when it has none). fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result { let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let source = plan_shape(design, &plan.body) .ok_or_else(|| EngineError::Params(format!("the plan is of body `{}`, which the design does not have", plan.body)))?; // The chordal tolerance the cells are tessellated with, from the body's extent (as mesh.cells // takes it from the cells'), for the check of the cells and the mesher alike. let tolerance = match plan.tolerance { Some(t) => t, None => { let b = source.measure()?; 1e-3 * (0..3).map(|k| (b.bbox_max[k] - b.bbox_min[k]).powi(2)).sum::().sqrt() } }; if plan.mesher == PlanMesher::Project { return apply_projection(s, name, plan, tolerance); } let cuts: Vec<&PlanCut> = plan.cuts.iter().collect(); let (cleared, made, skipped) = cut(s, name, &plan.body, &cuts, tolerance)?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let cells: Vec = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())).map(|b| b.name.clone()).collect(); // No fallback to a coarser plan: a cell the hex meshers cannot take is the error, with the // cuts that made it, so the plan is edited rather than quietly paved. let mesh = if cells.len() > 1 { (super::cells::CELLS.run)(s, &json!({"bodies": cells, "size": plan.size, "default": "hex", "tolerance": tolerance})) } else { let body = cells.first().cloned().unwrap_or_else(|| plan.body.clone()); (super::hex::HEX_GENERATE.run)(s, &json!({"body": body, "size": plan.size, "method": "swept", "tolerance": tolerance})) } .map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}")))?; let summary = summary(&mesh, &cells); // What today's meshers cannot express yet: the rings round circles are built by the // projection mesher (#399); until then the frame cuts box the circle and the sweep meshes the // cell's cross-section. let pending: Vec = plan .rings .iter() .map(|r| { let what = match r.kind { RingKind::OGrid => "O-grid ring round the hole", RingKind::Butterfly => "butterfly round the circle", }; json!({"feature": r.feature, "ring": r.kind, "radius": r.radius, "needs": format!("the {what} is built by the projection blocking mesher (#399); today the frame cuts box it and the swept cell's cross-section is meshed by the sweep's quad mesher")}) }) .collect(); let mut reply = json!({ "name": name, "body": plan.body, "cuts": made, "skipped_cuts": skipped.iter().map(|(n, why)| json!({"name": n, "reason": why})).collect::>(), "cells": cells, "size": plan.size, "tolerance": tolerance, "elements": summary.elements, "min_scaled_jacobian": summary.min_scaled_jacobian, "all_hex": summary.all_hex, "conforming": summary.conforming, "mapped": plan.features.iter().map(|f| json!({"id": f.id, "name": f.name, "type": f.kind, "template": f.template})).collect::>(), "pending": pending, "unmapped": plan.unmapped, "mesh": mesh, }); reply = cleared.report_if_any(s, reply); if let Some(m) = reply.get_mut("mesh").and_then(Value::as_object_mut) { let theirs = json!({"replaced": m.remove("replaced"), "stale_sets": m.remove("stale_sets")}); m.remove("results_cleared"); take_replaced(&mut reply, &theirs); } if let Some(o) = reply.as_object_mut() { // Reported once for the whole command, by apply. o.remove("results_cleared"); } Ok(reply) } /// Most surface points the symmetry test checks. const SYMMETRY_SAMPLES: usize = 3000; /// The planes the body of `plan` is mirror symmetric about, square to the in-plane principal axes /// of the sweep `d` through the middle of its extent (normal, offset), none through a ring: every /// point of its surface (the middles of its tessellation's triangles, a little inside and a little /// outside along the triangle's normal) has its mirror image inside and outside alike (the /// generalized winding number of the tessellation). fn symmetry_planes(shape: &Shape, d: V3, rings: &[PlanRing]) -> Result> { let m = shape.measure()?; let (lo, hi) = (m.bbox_min, m.bbox_max); let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::().sqrt(); if !(diagonal.is_finite() && diagonal > 0.0) { return Ok(Vec::new()); } // Fine enough that the chords lie well within `delta` of the surface. let mesh = shape.tessellate(2e-4 * diagonal)?; // Each triangle's middle and normal: a point of a face, away from its edges. let samples: Vec<(V3, V3)> = mesh .triangles .iter() .filter_map(|t| { let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied().unwrap_or_default()); let n = unit(cross(std::array::from_fn(|k| b[k] - a[k]), std::array::from_fn(|k| c[k] - a[k])))?; Some((std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0), n)) }) .collect(); let step = (samples.len() / SYMMETRY_SAMPLES).max(1); let delta = 2e-3 * diagonal; let mut out = Vec::new(); for a in in_plane_axes(d).into_iter().filter(|a| AXES.iter().any(|x| parallel(*x, *a))) { let c = 0.5 * (dot(lo, a) + dot(hi, a)); if rings.iter().any(|r| (dot(r.center, a) - c).abs() < 1.01 * r.radius) { continue; } let mirror = |x: V3| -> V3 { std::array::from_fn(|k| x[k] - 2.0 * (dot(x, a) - c) * a[k]) }; let symmetric = samples.iter().step_by(step).all(|(p, n)| { let inside = mirror(std::array::from_fn(|k| p[k] - delta * n[k])); let outside = mirror(std::array::from_fn(|k| p[k] + delta * n[k])); blocks::winding(&mesh, inside) > 0.5 && blocks::winding(&mesh, outside) < 0.5 }); if symmetric { out.push((a, c)); } } Ok(out) } /// Carry out a projection plan on a mirror-symmetric body by its symmetry: the body cut by its /// planes of symmetry (`-cut`), the cell on the negative side of every plane planned /// and meshed (`Plan-`), and its mesh mirrored across each plane in turn onto the other /// cells, joined node to node on the planes — so mirror-image regions mesh alike. `None` when /// the body has no symmetry, or its cell does not take a projection layout (the whole body is /// meshed then). fn apply_symmetric(s: &mut Session, name: &str, plan: &MeshPlan) -> Result> { if plan.mesher != PlanMesher::Project || !plan.copies.is_empty() { return Ok(None); } let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let (Some(shape), Some(d)) = (design.source_body(&plan.body), plan.direction) else { return Ok(None) }; // A body other partitions have cut already is its cells (the whole body's plan is refused). if plan_cut_names(s, name).is_empty() && design.body(&plan.body).is_none() { return Ok(None); } let planes = symmetry_planes(shape, d, &plan.rings)?; if planes.is_empty() { return Ok(None); } let tolerance = match plan.tolerance { Some(t) => t, None => { let b = shape.measure()?; 1e-3 * (0..3).map(|k| (b.bbox_max[k] - b.bbox_min[k]).powi(2)).sum::().sqrt() } }; let cuts: Vec = planes .iter() .map(|(a, c)| PlanCut { point: a.map(|x| x * c), normal: *a, role: CutRole::Frame, feature: None, reason: "symmetry: the body is its own mirror image across this plane".into(), }) .collect(); let refs: Vec<&PlanCut> = cuts.iter().collect(); let (cleared, made, skipped) = cut(s, name, &plan.body, &refs, tolerance)?; let undo = |s: &mut Session| -> Result<()> { cut(s, name, &plan.body, &[], tolerance).map(|_| ()) }; if !skipped.is_empty() { undo(s)?; return Ok(None); } let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let cells: Vec<(String, V3)> = design .bodies .iter() .filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())) .filter_map(|b| b.measure().ok().map(|m| (b.name.clone(), std::array::from_fn(|k| 0.5 * (m.bbox_min[k] + m.bbox_max[k]))))) .collect(); let fundamental = cells.iter().find(|(_, mid)| planes.iter().all(|(a, c)| dot(*mid, *a) < *c)).map(|(n, _)| n.clone()); let Some(cell) = fundamental else { undo(s)?; return Ok(None); }; let mut cell_plan = build(design, &cell, Some(plan.size), plan.layers, MAX_ELEMENTS)?; cell_plan.size = plan.size; cell_plan.tolerance = Some(tolerance); if cell_plan.mesher != PlanMesher::Project { undo(s)?; return Ok(None); } let cell_name = MeshPlan::default_name(&cell); s.model.set_mesh_plan(&cell_name, cell_plan.clone())?; let r = apply_projection(s, &cell_name, &cell_plan, tolerance)?; let mut meshed = vec![cell.clone()]; for (a, c) in &planes { (super::mirror::MIRROR.run)(s, &json!({"plane": {"point": a.map(|x| x * c), "normal": a}, "bodies": meshed}))?; meshed = cells.iter().map(|(n, _)| n.clone()).filter(|n| !s.model.body_elements(n).is_empty()).collect(); } if let Some((missing, _)) = cells.iter().find(|(n, _)| s.model.body_elements(n).is_empty()) { return Err(EngineError::Params(format!("mesh plan `{name}`: the mirror images do not reach cell `{missing}`"))); } let elements: usize = cells.iter().map(|(n, _)| s.model.body_elements(n).len()).sum(); let mut reply = json!({ "name": name, "body": plan.body, "mesher": "project", "symmetry": planes.iter().map(|(a, c)| json!({"normal": a, "offset": c})).collect::>(), "layout": r["layout"], "blend_layers": r["blend_layers"], "cuts": made, "skipped_cuts": [], "cells": cells.iter().map(|(n, _)| n.clone()).collect::>(), "meshed": cell, "size": plan.size, "tolerance": tolerance, "elements": elements, "min_scaled_jacobian": r["min_scaled_jacobian"], "all_hex": true, // One cell's mesh and its mirror images, joined node to node on the planes. "conforming": true, "mapped": plan.features.iter().map(|f| json!({"id": f.id, "name": f.name, "type": f.kind, "template": f.template})).collect::>(), "pending": [], "unmapped": plan.unmapped, "mesh": r["mesh"], }); reply = cleared.report_if_any(s, reply); take_replaced(&mut reply, &r); if let Some(o) = reply.as_object_mut() { o.remove("results_cleared"); } Ok(Some(reply)) } /// Carry out a plan whose mesher is projection blocking: its earlier partitions removed, one block /// layout over the whole body (rings, blend layer) projected and smoothed, the body's mesh /// replaced. No fallback: a layout that does not fit, or a mesh below the projection's quality /// floor, is the error. fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64) -> Result { let (cleared, _, _) = cut(s, name, &plan.body, &[], tolerance)?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let layout = match layout_of(design, plan)? { Some(Ok(l)) => l, Some(Err(why)) => return Err(EngineError::Params(format!("mesh plan `{name}`: no projection layout for body `{}`: {why}", plan.body))), None => return Err(EngineError::Params(format!("mesh plan `{name}`: projection blocking needs rings or blends; set the mesher to sweep"))), }; // Partitions of the user's that cut the body leave cells, each meshed on its own. if design.body(&plan.body).is_none() { let cells: Vec<&str> = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())).map(|b| b.name.as_str()).collect(); return Err(EngineError::Params(format!( "mesh plan `{name}`: body `{}` is cut into the cells {cells:?} by partitions; plan and apply a cell (mesh.plan {{\"body\": \"{}\"}}), or delete the partitions", plan.body, cells.first().copied().unwrap_or_default() ))); } let body = super::project::open_body(s, &plan.body, Some(tolerance), &json!({}))?; let merge = super::project::merge_distance(None, body.diagonal())?; let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, ..Default::default() }; let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge) .map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?; if let Some(m) = mesh.as_object_mut() { // The block listing is long; the plan reports the layout instead. m.remove("topology"); } let mut reply = json!({ "name": name, "body": plan.body, "mesher": "project", "layout": layout.summary(), "blend_layers": layout.layers.iter().map(|(side, depth, what)| json!({"side": side, "depth": depth, "for": what})).collect::>(), "cuts": [], "skipped_cuts": [], "cells": [plan.body], "size": plan.size, "tolerance": tolerance, "blocks": mesh["blocks"], "elements": mesh["elements"], "min_scaled_jacobian": mesh["min_scaled_jacobian"], "all_hex": true, // One block topology, one mesh: every block face shared node to node. "conforming": true, "mapped": plan.features.iter().map(|f| json!({"id": f.id, "name": f.name, "type": f.kind, "template": f.template})).collect::>(), "pending": [], "unmapped": plan.unmapped, "mesh": mesh, }); reply = cleared.report_if_any(s, reply); if let Some(m) = reply.get_mut("mesh").and_then(Value::as_object_mut) { let theirs = json!({"replaced": m.remove("replaced"), "stale_sets": m.remove("stale_sets")}); m.remove("results_cleared"); take_replaced(&mut reply, &theirs); } if let Some(o) = reply.as_object_mut() { o.remove("results_cleared"); } Ok(reply) } /// Replace plan `name`'s partitions of `body` with `cuts` (named `-cut` after their /// place in the plan). A cut the kernel cannot make (or that divides nothing left), or that /// leaves a cell whose boundary no mesher takes at `tolerance` (a plane the tessellation does /// not close along), is left out: the latest such cut first, one at a time. Returns the meshes /// the first recut removed, the cuts made and those left out with why. #[allow(clippy::type_complexity)] fn cut( s: &mut Session, name: &str, body: &str, cuts: &[&PlanCut], tolerance: f64, ) -> Result<(super::replace::Cleared, Vec, Vec<(String, String)>)> { for old in plan_cut_names(s, name) { s.model.remove_partition(&old)?; if s.model.datum_users(&old).is_empty() && s.model.datum(&old).is_some() { s.model.remove_datum(&old)?; } } let mut names = Vec::with_capacity(cuts.len()); for (k, c) in cuts.iter().enumerate() { let n = format!("{name}-cut{}", k + 1); if s.model.datum(&n).is_some() { return Err(EngineError::Params(format!("datum `{n}` exists and is not the plan's: rename it or the plan"))); } s.model.add_datum(&n, Datum::Plane { point: c.point, normal: c.normal })?; s.model.add_partition(Partition { name: n.clone(), datum: n.clone(), bodies: vec![body.to_string()], suppressed: false })?; names.push(n); } let cleared = super::datum::recut(s)?; let mut skipped: Vec<(String, String)> = Vec::new(); for _ in 0..=names.len() { let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let live: Vec<&String> = names.iter().filter(|n| !skipped.iter().any(|(k, _)| k == *n)).collect(); let mut failed: Vec<(String, String)> = live .iter() .filter_map(|n| { let feature = format!("partition {n}"); let messages: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect(); (!messages.is_empty()).then(|| ((*n).clone(), messages.join("; "))) }) .collect(); if failed.is_empty() { let bad = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(body)).find_map(|cell| { let why = super::datum::unmeshable(cell, Some(tolerance)).err()?; // The latest of the plan's cuts that bound the cell. let latest = live.iter().rev().find(|n| cell.faces().iter().any(|f| f.name.starts_with(&format!("partition:{n}:"))))?; Some(((*latest).clone(), format!("cell `{}` it bounds has a boundary no mesher takes ({why})", cell.name))) }); failed.extend(bad); } if failed.is_empty() { break; } for (n, _) in &failed { s.model.remove_partition(n)?; s.model.remove_datum(n)?; } skipped.extend(failed); // The meshes this recut removes are those of cells the first one made, which had none. super::datum::recut(s)?; } let made = names.into_iter().filter(|n| !skipped.iter().any(|(f, _)| f == n)).collect(); Ok((cleared, made, skipped)) } /// What a mesher's reply says of the mesh a plan made. struct Summary { min_scaled_jacobian: Option, elements: usize, /// Every element a hexahedron. all_hex: bool, /// The cells joined node to node into one mesh: every interface merged (none tied), and the /// merged interfaces connect every cell. conforming: bool, } /// The [`Summary`] of a mesher's reply (`mesh.hex.generate`'s `bodies`, or `mesh.cells`' /// `cells` and `interfaces`) over `cells`. fn summary(mesh: &Value, cells: &[String]) -> Summary { let mut min: Option = None; let (mut elements, mut all_hex) = (0usize, true); let rows = mesh.get("bodies").or_else(|| mesh.get("cells")).and_then(Value::as_array).into_iter().flatten(); for b in rows { if let Some(v) = b.get("quality").and_then(|q| q.get("min_scaled_jacobian")).and_then(Value::as_f64) { min = Some(min.map_or(v, |m: f64| m.min(v))); } elements += b.get("elements").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)); all_hex &= b.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("hex")); } // The cells the merged interfaces join, grown from the first. let interfaces: Vec<&Value> = mesh.get("interfaces").and_then(Value::as_array).into_iter().flatten().collect(); let merged = |i: &Value| i.get("joined").and_then(Value::as_str) == Some("merged"); let mut joined: Vec<&str> = cells.first().map(String::as_str).into_iter().collect(); loop { let before = joined.len(); for i in interfaces.iter().copied().filter(|i| merged(i)) { let pair: Vec<&str> = i.get("cells").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect(); if let [a, b] = pair.as_slice() { match (joined.contains(a), joined.contains(b)) { (true, false) => joined.push(b), (false, true) => joined.push(a), _ => {} } } } if joined.len() == before { break; } } let conforming = interfaces.iter().all(|i| merged(i)) && cells.iter().all(|c| joined.contains(&c.as_str())); Summary { min_scaled_jacobian: min, elements, all_hex, conforming } } #[cfg(test)] mod tests { use serde_json::{Value, json}; use crate::Session; fn open_script(script: &str) -> (crate::tests::TempDir, Session) { let bytes = fieldcraft_geometry::script_design(script).unwrap(); let (dir, path) = crate::tests::design_file(&bytes); let mut s = Session::default(); s.execute("geometry.open", &json!({"path": path})).unwrap(); (dir, s) } fn open(commands: Value) -> (crate::tests::TempDir, Session) { open_script(&json!({"commands": commands}).to_string()) } fn templates(plan: &Value) -> Vec<(String, String)> { plan["features"].as_array().unwrap().iter().map(|f| (f["type"].as_str().unwrap().into(), f["template"].as_str().unwrap().into())).collect() } fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> { list.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() } /// Plan, apply (the whole body, not by its symmetry), and check the mesh is all-hex, /// conforming and fills the body. fn apply(s: &mut Session, plan: &Value, min_sj: f64) -> Value { let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"], "symmetry": false})).unwrap(); assert_eq!((r["all_hex"].as_bool(), r["conforming"].as_bool()), (Some(true), Some(true)), "{r}"); let sj = r["min_scaled_jacobian"].as_f64().unwrap(); assert!(sj >= min_sj, "min scaled Jacobian {sj} < {min_sj}: {r}"); r } /// The mesh's volume against the bodies'. fn volume_matches(s: &mut Session) { let q = s.execute("mesh.quality", &json!({})).unwrap(); let bodies = s.execute("geometry.bodies", &json!({})).unwrap(); let geometry: f64 = bodies.as_array().unwrap().iter().map(|b| b["volume"].as_f64().unwrap()).sum(); let mesh = q["volume"].as_f64().unwrap_or_else(|| q["metrics"]["volume"]["sum"].as_f64().unwrap_or_default()); if mesh > 0.0 { assert!((mesh - geometry).abs() < 0.02 * geometry, "mesh volume {mesh} vs {geometry}"); } } #[test] fn a_plate_with_holes_is_one_block_layout_with_an_o_grid_round_each_hole() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 6, "body_name": "Plate"}}, {"command": "solid.hole", "params": {"position": [15, 20, 6], "diameter": 8, "depth": 6}}, {"command": "solid.hole", "params": {"position": [45, 20, 6], "diameter": 8, "depth": 6}}, ])); let plan = s.execute("mesh.plan", &json!({"body": "Plate"})).unwrap(); assert_eq!(templates(&plan), pairs(&[("box", "block"), ("hole", "o_grid"), ("hole", "o_grid")])); assert_eq!(plan["direction"], json!([0.0, 0.0, 1.0])); // Two O-grid rings, each projecting onto its hole's walls, framed short of the other hole. let rings = plan["rings"].as_array().unwrap(); assert_eq!(rings.len(), 2, "{plan}"); for r in rings { assert_eq!((r["kind"].as_str(), r["radius"].as_f64()), (Some("o_grid"), Some(4.0))); assert!(!r["faces"].as_array().unwrap().is_empty(), "{r}"); assert!(r["frame"].as_f64().unwrap() > 4.0); } // One block layout over the whole plate: no partition cuts. assert_eq!((plan["mesher"].as_str(), plan.get("cuts")), (Some("project"), None), "{plan}"); let r = apply(&mut s, &plan, 0.5); assert_eq!(r["cells"], json!(["Plate"]), "{r}"); assert_eq!(r["pending"], json!([]), "{r}"); // The ring nodes lie on the hole walls: the holes are round. assert!(r["mesh"]["max_gap"].as_f64().unwrap() < 1e-6, "{r}"); volume_matches(&mut s); // Saved: applied again by name it replaces its mesh. let again = s.execute("mesh.plan.apply", &json!({"plan": "Plan-Plate", "symmetry": false})).unwrap(); assert_eq!(again["elements"], r["elements"]); assert!(s.model.partitions().is_empty()); assert!(!again["replaced"].as_array().unwrap().is_empty(), "{again}"); } #[test] fn a_sketched_plate_sweeps_its_profile_and_o_grids_its_circle() { let bytes = fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap(); let (_d, path) = crate::tests::design_file(&bytes); let mut s = Session::default(); s.execute("geometry.open", &json!({"path": path})).unwrap(); let body = s.execute("geometry.bodies", &json!({})).unwrap()[0]["name"].as_str().unwrap().to_string(); let plan = s.execute("mesh.plan", &json!({"body": body})).unwrap(); assert_eq!(templates(&plan), pairs(&[("extrude", "sweep"), ("extrude", "imprint")])); assert_eq!(plan["rings"].as_array().unwrap().len(), 1, "{plan}"); assert_eq!(plan["rings"][0]["kind"], "o_grid"); apply(&mut s, &plan, 0.5); volume_matches(&mut s); } #[test] fn a_cylinder_is_a_butterfly_swept_along_its_axis() { let (_d, mut s) = open(json!([{"command": "solid.cylinder", "params": {"diameter": 20, "height": 30, "body_name": "Cyl"}}])); let plan = s.execute("mesh.plan", &json!({"body": "Cyl"})).unwrap(); assert_eq!(templates(&plan), pairs(&[("cylinder", "butterfly")])); assert_eq!((plan["mesher"].as_str(), plan.get("cuts")), (Some("project"), None)); assert_eq!(plan["rings"][0]["kind"], "butterfly"); assert_eq!(plan["rings"][0]["frame"], 5.0); apply(&mut s, &plan, 0.7); volume_matches(&mut s); } #[test] fn a_joined_boss_is_a_butterfly_on_the_block() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 40, "width": 40, "height": 10, "body_name": "Base"}}, {"command": "solid.cylinder", "params": {"diameter": 12, "height": 8, "base": [20, 20, 10], "operation": "join", "targets": ["Base"]}}, ])); let plan = s.execute("mesh.plan", &json!({"body": "Base"})).unwrap(); assert_eq!(templates(&plan), pairs(&[("box", "block"), ("cylinder", "butterfly")])); assert_eq!(plan["rings"][0]["kind"], "butterfly"); apply(&mut s, &plan, 0.5); volume_matches(&mut s); } #[test] fn a_shelled_box_takes_layers_through_its_wall() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Case"}}, {"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "body": "Case"}}, ])); let plan = s.execute("mesh.plan", &json!({"body": "Case", "layers": 2})).unwrap(); assert_eq!(templates(&plan), pairs(&[("box", "block"), ("shell", "layers")])); // Two elements through the 2 mm wall. assert_eq!(plan["size"], 1.0); apply(&mut s, &plan, 0.9); volume_matches(&mut s); } #[test] fn a_patterned_and_mirrored_part_meshes_every_copy() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 4, "body_name": "Tab"}}, {"command": "solid.hole", "params": {"position": [5, 5, 4], "diameter": 4, "depth": 4}}, {"command": "solid.pattern.rectangular", "params": {"bodies": ["Tab"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 15}}, {"command": "solid.mirror", "params": {"bodies": ["Tab"], "plane": {"origin": [0, 0, 0], "normal": [0, 1, 0]}}}, ])); let plan = s.execute("mesh.plan", &json!({"body": "Tab.1"})).unwrap(); assert_eq!(templates(&plan), pairs(&[("box", "block"), ("hole", "o_grid"), ("pattern", "copy"), ("mirror", "copy")])); let copies = plan["copies"].as_array().unwrap(); assert_eq!(copies.len(), 3, "{plan}"); let r = apply(&mut s, &plan, 0.5); assert_eq!(r["copies"].as_array().unwrap().len(), 3, "{r}"); for c in r["copies"].as_array().unwrap() { assert_eq!(c["conforming"], true, "{c}"); assert_eq!(c["elements"], r["elements"], "a copy meshes as the instance: {c}"); } // Every body meshed. for b in s.execute("geometry.bodies", &json!({})).unwrap().as_array().unwrap() { assert!(!s.model.body_elements(b["name"].as_str().unwrap()).is_empty(), "{b}"); } } #[test] fn a_fillet_is_a_blend_layer_and_hostile_plans_are_refused() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "body_name": "B"}}, {"command": "solid.fillet", "params": {"edges": [[20, 10, 20]], "radius": 2}}, ])); let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap(); assert_eq!(templates(&plan), pairs(&[("box", "block"), ("fillet", "blend")])); assert_eq!(plan["mesher"], "project", "{plan}"); let r = apply(&mut s, &plan, 0.5); // The layer along the filleted edge, on the two sides it runs between, at its radius. let layers = r["blend_layers"].as_array().unwrap(); assert_eq!(layers.len(), 2, "{r}"); assert!(layers.iter().all(|l| (l["depth"].as_f64().unwrap() - 2.0).abs() < 1e-6), "{r}"); volume_matches(&mut s); let mut bad = plan.clone(); bad["size"] = json!(-1.0); assert!(s.execute("mesh.plan.apply", &json!({"plan": bad})).is_err()); let mut bad = plan.clone(); bad["rings"] = json!([{"feature": 1, "kind": "o_grid", "center": [0, 0, 0], "axis": [0, 0, 0], "radius": 1, "frame": 2}]); assert!(s.execute("mesh.plan.apply", &json!({"plan": bad})).is_err()); assert!(s.execute("mesh.plan", &json!({"body": "Nope"})).is_err()); assert!(s.execute("mesh.plan", &json!({"body": "B", "layers": 0})).is_err()); assert!(s.execute("mesh.plan", &json!({"body": "B", "size": f64::MAX})).is_ok()); assert!(s.execute("mesh.plan.apply", &json!({"plan": "Nope"})).is_err()); } /// A body partitions have cut is planned whole, and a cell of it on its own (from the features /// that built the body, with the rings the cell holds) (#473). #[test] fn a_partitioned_body_and_its_cells_can_be_planned() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 6, "body_name": "Plate"}}, {"command": "solid.hole", "params": {"position": [15, 20, 6], "diameter": 8, "depth": 6}}, {"command": "solid.hole", "params": {"position": [45, 20, 6], "diameter": 8, "depth": 6}}, ])); s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "point_normal", "point": [30, 0, 0], "normal": [1, 0, 0]}})).unwrap(); s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap(); let whole = s.execute("mesh.plan", &json!({"body": "Plate"})).unwrap(); assert_eq!(whole["rings"].as_array().map(Vec::len), Some(2), "{whole}"); let cell = s.execute("mesh.plan", &json!({"body": "Plate/a"})).unwrap(); assert_eq!(cell["rings"].as_array().map(Vec::len), Some(1), "{cell}"); assert_eq!(cell["mesher"], "project", "{cell}"); let r = s.execute("mesh.plan.apply", &json!({"plan": cell["name"]})).unwrap(); assert_eq!(r["all_hex"], true, "{r}"); assert!(r["min_scaled_jacobian"].as_f64().unwrap() >= 0.5, "{r}"); // The whole body's plan cannot mesh a body partitions have cut: it says to plan a cell. let e = s.execute("mesh.plan.apply", &json!({"plan": whole["name"]})).unwrap_err().to_string(); assert!(e.contains("cut into the cells") && e.contains("Plate/a"), "{e}"); } /// The same from a saved model opened again (the design it refers to rebuilt on opening). #[test] fn a_saved_model_partitioned_after_opening_plans_its_body_and_cells() { let bytes = fieldcraft_geometry::script_design( &json!({"commands": [ {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 6, "body_name": "Plate"}}, {"command": "solid.hole", "params": {"position": [15, 20, 6], "diameter": 8, "depth": 6}}, ]}) .to_string(), ) .unwrap(); let (_d, path) = crate::tests::design_file(&bytes); let model = std::path::Path::new(&path).with_file_name("model.fieldcraft").to_string_lossy().into_owned(); let mut s = Session::default(); s.execute("geometry.open", &json!({"path": path})).unwrap(); s.execute("file.save_as", &json!({"path": model})).unwrap(); let mut s = Session::default(); s.execute("file.open", &json!({"path": model})).unwrap(); s.execute("geometry.datum", &json!({"name": "SymX", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap(); s.execute("geometry.partition", &json!({"name": "HalfX", "datum": "SymX", "bodies": ["Plate"]})).unwrap(); let cell = s.execute("mesh.plan", &json!({"body": "Plate/a"})).unwrap(); assert_eq!(cell["rings"].as_array().map(Vec::len), Some(1), "{cell}"); let whole = s.execute("mesh.plan", &json!({"body": "Plate"})).unwrap(); assert_eq!(whole["body"], "Plate", "{whole}"); // The plate is symmetric too, but the user's partition has cut it: refused, not mirrored. let e = s.execute("mesh.plan.apply", &json!({"plan": whole["name"]})).unwrap_err().to_string(); assert!(e.contains("cut into the cells"), "{e}"); s.execute("mesh.plan.apply", &json!({"plan": cell["name"]})).unwrap(); } /// The feature graph of every corpus design and the crank arm: every body lists the features /// that built it, and a plan builds for every body (fillets, revolves and lofts unmapped). #[test] fn every_corpus_design_has_a_feature_graph_and_a_plan() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/corpus"); let mut scripts: Vec<(String, String)> = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| { let path = e.ok()?.path().join("recipe.json"); Some((path.parent()?.file_name()?.to_string_lossy().into_owned(), std::fs::read_to_string(path).ok()?)) }) .collect(); scripts.push(("crank-arm".into(), crate::sample::CRANK_ARM.into())); scripts.sort(); assert!(scripts.len() > 10); for (name, script) in scripts { let (_d, mut s) = open_script(&script); let graph = s.execute("geometry.features", &json!({})).unwrap(); for b in graph["bodies"].as_array().unwrap() { let features = b["features"].as_array().unwrap(); assert!(!features.is_empty(), "{name}: {b}"); // Every feature's faces are faces of the body. let body = b["body"].as_str().unwrap(); let plan = s.execute("mesh.plan", &json!({"body": body})); let plan = plan.unwrap_or_else(|e| panic!("{name}/{body}: {e}")); let mapped = plan["features"].as_array().map_or(0, Vec::len); let unmapped = plan["unmapped"].as_array().map_or(0, Vec::len); assert_eq!(mapped + unmapped, features.len(), "{name}/{body}: {plan}"); } } } } /// Acceptance of the projection layouts (#402 step 3): parts built the way enclosures are, each /// meshed all-hex from its plan alone, conforming, holes round. #[cfg(test)] mod acceptance { use serde_json::{Value, json}; use super::tests_support::*; use crate::Session; #[test] fn a_plate_with_four_holes_has_round_o_grid_rings() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 80, "width": 60, "height": 8, "body_name": "Plate"}}, {"command": "solid.hole", "params": {"position": [15, 15, 8], "diameter": 8, "depth": 8}}, {"command": "solid.hole", "params": {"position": [65, 15, 8], "diameter": 8, "depth": 8}}, {"command": "solid.hole", "params": {"position": [15, 45, 8], "diameter": 8, "depth": 8}}, {"command": "solid.hole", "params": {"position": [65, 45, 8], "diameter": 8, "depth": 8}}, ])); let r = plan_and_apply(&mut s, "Plate"); assert!(r["layout"].as_str().unwrap().contains("4 circle(s) ringed"), "{r}"); accept(&mut s, &r, 0.5, 10.0); } /// An enclosure lid: a plate with rounded corners, a screw hole in each corner close to the /// fillet and a square opening. Each hole's ring reaches the plate's sides and takes the /// corner fillet in (no blend layer, no fan into the hole), its frame centred on the hole. #[test] fn a_lid_with_screw_holes_in_its_rounded_corners_rings_each_hole_to_the_fillet() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 128, "width": 98, "height": 4, "body_name": "Lid"}}, {"command": "solid.fillet", "params": {"edges": [[0, 0, 2], [128, 0, 2], [128, 98, 2], [0, 98, 2]], "radius": 8}}, {"command": "solid.hole", "params": {"position": [7, 7, 4], "diameter": 3.5, "depth": 4}}, {"command": "solid.hole", "params": {"position": [121, 7, 4], "diameter": 3.5, "depth": 4}}, {"command": "solid.hole", "params": {"position": [7, 91, 4], "diameter": 3.5, "depth": 4}}, {"command": "solid.hole", "params": {"position": [121, 91, 4], "diameter": 3.5, "depth": 4}}, {"command": "solid.box", "params": {"length": 22, "width": 22, "height": 4, "corner": [41, 40, 0], "operation": "cut"}}, ])); let plan = s.execute("mesh.plan", &json!({"body": "Lid", "size": 1.5, "layers": 2})).unwrap(); assert_eq!(plan["mesher"], "project", "{plan}"); // Every frame is the same square, centred on its hole and reaching the plate's sides. let rings = plan["rings"].as_array().unwrap(); assert_eq!(rings.len(), 4, "{plan}"); for r in rings { assert!((r["frame"].as_f64().unwrap() - 7.0).abs() < 1e-6, "{r}"); } let notes = plan["notes"].as_array().unwrap(); assert!(notes.iter().all(|n| !n.as_str().unwrap().contains("frame cut")), "{plan}"); let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap(); assert!(r["layout"].as_str().unwrap().contains("4 circle(s) ringed"), "{r}"); assert_eq!(r["blend_layers"], json!([]), "the rings take the corner fillets in: {r}"); accept(&mut s, &r, 0.6, 6.0); } /// The enclosure base of the test below without its standoffs: symmetric across x = 28 and /// y = 21, so the plan meshes one quarter and mirrors it: the four corners mesh exactly /// alike, the smallest scaled Jacobian round each corner boss well above 0.5. #[test] fn an_enclosure_base_meshes_its_four_corners_alike() { let mut commands = vec![ json!({"command": "solid.box", "params": {"length": 56, "width": 42, "height": 16, "body_name": "Base"}}), json!({"command": "solid.fillet", "params": {"edges": [[0, 0, 8], [56, 0, 8], [56, 42, 8], [0, 42, 8]], "radius": 5}}), json!({"command": "solid.shell", "params": {"faces": [[28, 21, 16]], "thickness": 3, "body": "Base"}}), ]; let corners = [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)]; for (x, y) in corners { commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 13, "height": 16, "base": [x, y, 0], "operation": "join", "targets": ["Base"]}})); } for (x, y) in corners { commands.push(json!({"command": "solid.hole", "params": {"position": [x, y, 16], "diameter": 3.3, "depth": 8}})); } let (_d, mut s) = open(json!(commands)); let plan = s.execute("mesh.plan", &json!({"body": "Base", "size": 1.5, "layers": 2})).unwrap(); let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap(); accept(&mut s, &r, 0.5, 10.0); // Every element's scaled Jacobian, by the corner (quadrant) its centroid lies in. let m = s.execute("mesh.quality.mark", &json!({"limits": {"scaled_jacobian": 1.01, "min_dihedral": null, "max_dihedral": null, "aspect_ratio": null, "min_angle": null, "max_angle": null}})).unwrap(); let mut worst = [f64::INFINITY; 4]; for e in m["elements"].as_array().unwrap() { let at = &e["at"]; let q = usize::from(at[0].as_f64().unwrap() > 28.0) + 2 * usize::from(at[1].as_f64().unwrap() > 21.0); for f in e["failed"].as_array().unwrap() { if f["metric"] == "scaled_jacobian" { worst[q] = worst[q].min(f["value"].as_f64().unwrap()); } } } // Meshed by its symmetry: one quarter, mirrored across both planes. assert_eq!(r["symmetry"].as_array().map(Vec::len), Some(2), "{r}"); assert_eq!(r["cells"].as_array().map(Vec::len), Some(4), "{r}"); let (lo, hi) = worst.iter().fold((f64::INFINITY, 0.0f64), |(l, h), w| (l.min(*w), h.max(*w))); assert!(hi - lo < 1e-9, "per corner min scaled Jacobian {worst:?}: {r}"); assert!(lo > 0.65, "per corner min scaled Jacobian {worst:?}"); } /// An enclosure base: a box with rounded vertical edges, shelled open at the top, a screw /// boss joined at each corner (centred outside it, nearly touching the inner fillet) with a /// blind hole, and standoffs on the floor. Each boss's ring frames the whole circle, /// subdivided where the walls join it; the inner fillet is taken in by the ring's corner and /// the boss's near touch with it leaves no sliver. #[test] fn an_enclosure_base_rings_each_corner_boss_where_the_walls_join_it() { let mut commands = vec![ json!({"command": "solid.box", "params": {"length": 56, "width": 42, "height": 16, "body_name": "Base"}}), json!({"command": "solid.fillet", "params": {"edges": [[0, 0, 8], [56, 0, 8], [56, 42, 8], [0, 42, 8]], "radius": 5}}), json!({"command": "solid.shell", "params": {"faces": [[28, 21, 16]], "thickness": 3, "body": "Base"}}), ]; for (x, y) in [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)] { commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 13, "height": 16, "base": [x, y, 0], "operation": "join", "targets": ["Base"]}})); } for (x, y) in [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)] { commands.push(json!({"command": "solid.hole", "params": {"position": [x, y, 16], "diameter": 3.3, "depth": 8}})); } for (x, y) in [(16.5, 16.5), (39.5, 16.5)] { commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 6, "height": 4, "base": [x, y, 3], "operation": "join", "targets": ["Base"]}})); } let (_d, mut s) = open(json!(commands)); let plan = s.execute("mesh.plan", &json!({"body": "Base", "size": 1.5, "layers": 2})).unwrap(); assert_eq!(plan["mesher"], "project", "{plan}"); let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap(); // Symmetric across x = 28 only (the standoffs stand on one side of y = 21): one half // meshed (two bosses, their holes and a standoff ringed) and mirrored. assert_eq!(r["symmetry"].as_array().map(Vec::len), Some(1), "{r}"); assert!(r["layout"].as_str().unwrap().contains("5 circle(s) ringed"), "{r}"); accept(&mut s, &r, 0.5, 10.0); } /// The crank arm sample, whole: the back pocket's frame cuts lie on its end faces (x = 48 and /// x = 130), which the kernel cannot cut along; the plan moves them one element out of the /// pocket, so both are made and the cells between the bosses mesh structured (#455). #[test] fn the_crank_arm_meshes_from_its_plan_with_its_pocket_framed() { for size in [3.0, 4.0] { let mut s = Session::default(); s.execute("file.sample", &json!({"name": "crank-arm"})).unwrap(); s.execute("mesh.clear", &json!({})).unwrap(); s.execute("geometry.partition.delete", &json!({"name": "Half"})).unwrap(); let plan = s.execute("mesh.plan", &json!({"body": "CrankArm", "size": size})).unwrap(); let frames: Vec = plan["cuts"].as_array().unwrap().iter().filter(|c| c["feature"] == 6).map(|c| c["point"][0].as_f64().unwrap()).collect(); assert_eq!(frames.len(), 2, "{plan}"); assert!(frames.iter().all(|x| (x - (48.0 - size)).abs() < 1e-9 || (x - (130.0 + size)).abs() < 1e-9), "{frames:?}"); let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap(); assert_eq!(r["skipped_cuts"], json!([]), "{r}"); assert_eq!((r["all_hex"].as_bool(), r["conforming"].as_bool()), (Some(true), Some(true)), "{r}"); let q = s.execute("mesh.quality", &json!({})).unwrap(); let (sj, er) = (q["metrics"]["scaled_jacobian"]["min"].as_f64().unwrap(), q["metrics"]["edge_ratio"]["max"].as_f64().unwrap()); assert!(sj >= 0.45 && er <= 10.0, "size {size}: min scaled Jacobian {sj}, max edge ratio {er}"); let geometry: f64 = s.execute("geometry.bodies", &json!({})).unwrap().as_array().unwrap().iter().map(|b| b["volume"].as_f64().unwrap()).sum(); assert!((q["volume"].as_f64().unwrap() - geometry).abs() < 0.01 * geometry, "{q}"); } } #[test] fn a_filleted_box_meshes_structured_round_its_corners() { let (_d, mut s) = open(json!([ {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Box"}}, {"command": "solid.fillet", "params": {"edges": [[0, 0, 10], [40, 0, 10], [40, 30, 10], [0, 30, 10]], "radius": 4}}, ])); let r = plan_and_apply(&mut s, "Box"); assert_eq!(r["blend_layers"].as_array().unwrap().len(), 4, "{r}"); accept(&mut s, &r, 0.5, 10.0); } #[test] fn a_box_filleted_on_every_edge_takes_a_blend_layer_on_every_side() { let recipe: Value = serde_json::from_str( &std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/corpus/rounded-box/recipe.json")).unwrap(), ) .unwrap(); let (_d, mut s) = open(recipe["commands"].clone()); let r = plan_and_apply(&mut s, "Box"); assert_eq!(r["blend_layers"].as_array().unwrap().len(), 6, "{r}"); accept(&mut s, &r, 0.5, 10.0); } } /// What the plan tests share. #[cfg(test)] mod tests_support { use serde_json::{Value, json}; use crate::Session; pub(super) fn open(commands: Value) -> (crate::tests::TempDir, Session) { let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap(); let (dir, path) = crate::tests::design_file(&bytes); let mut s = Session::default(); s.execute("geometry.open", &json!({"path": path})).unwrap(); (dir, s) } /// Plan `body` (it must take the projection layout) and apply the plan to the whole body. pub(super) fn plan_and_apply(s: &mut Session, body: &str) -> Value { let plan = s.execute("mesh.plan", &json!({"body": body})).unwrap(); assert_eq!(plan["mesher"], "project", "{plan}"); assert!(plan["unmapped"].as_array().is_none_or(Vec::is_empty), "{plan}"); s.execute("mesh.plan.apply", &json!({"plan": plan["name"], "symmetry": false})).unwrap() } /// All hex, one conforming mesh on the geometry, the volume the body's, no element below /// `min_sj` or above `max_edge_ratio`, no free faces inside. pub(super) fn accept(s: &mut Session, r: &Value, min_sj: f64, max_edge_ratio: f64) { assert_eq!((r["all_hex"].as_bool(), r["conforming"].as_bool()), (Some(true), Some(true)), "{r}"); let q = s.execute("mesh.quality", &json!({})).unwrap(); let sj = q["metrics"]["scaled_jacobian"]["min"].as_f64().unwrap(); let er = q["metrics"]["edge_ratio"]["max"].as_f64().unwrap(); assert!(sj >= min_sj, "min scaled Jacobian {sj}: {q}"); assert!(er <= max_edge_ratio, "max edge ratio {er}: {q}"); assert!(r["mesh"]["max_gap"].as_f64().unwrap() < 0.05, "boundary nodes off the geometry: {r}"); let bodies = s.execute("geometry.bodies", &json!({})).unwrap(); let geometry: f64 = bodies.as_array().unwrap().iter().map(|b| b["volume"].as_f64().unwrap()).sum(); let mesh = q["volume"].as_f64().unwrap(); assert!((mesh - geometry).abs() < 0.01 * geometry, "mesh volume {mesh} vs {geometry}"); let dup = s.execute("mesh.check.duplicates", &json!({})).unwrap(); assert_eq!((dup["counts"]["coincident"].as_u64(), dup["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{dup}"); } }