diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 3693a0e..ac16b6a 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -42,6 +42,7 @@ pub mod geometry; pub mod loads; pub mod materials; mod mesh; +pub mod mesh_plans; pub mod nodal; pub mod progress; pub mod relaxations; @@ -71,6 +72,7 @@ pub use mesh::{ Element, ElementEdge, ElementFace, ElementKind, MIN_NORMALIZED_JACOBIAN, Node, admissible_jacobian, check_coordinates, normalized_jacobian, tet4_jacobian, }; +pub use mesh_plans::{CutRole, MeshPlan, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind}; pub use relaxations::{Relaxation, RelaxationCell}; pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind}; pub use seeds::{Bias, BiasToward, Divisions, Seed, SeedConstraint}; @@ -167,6 +169,9 @@ pub struct Model { /// Relaxation fields (`mesh.node.relax`), by name. #[serde(skip_serializing_if = "BTreeMap::is_empty")] relaxations: BTreeMap, + /// Mesh plans (`mesh.plan`), by name. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + mesh_plans: BTreeMap, } /// A [`Model`] as stored, before it is checked. @@ -217,6 +222,8 @@ struct ModelData { seeds: Vec, #[serde(default)] relaxations: BTreeMap, + #[serde(default)] + mesh_plans: BTreeMap, } impl TryFrom for Model { @@ -303,6 +310,9 @@ impl TryFrom for Model { for (n, v) in d.relaxations { m.set_relaxation(&n, v)?; } + for (n, v) in d.mesh_plans { + m.set_mesh_plan(&n, v)?; + } Ok(m) } } @@ -434,6 +444,7 @@ impl Model { suppressed_datums: self.suppressed_datums.clone(), seeds: self.seeds.clone(), relaxations: self.relaxations.clone(), + mesh_plans: self.mesh_plans.clone(), } } @@ -460,6 +471,29 @@ impl Model { remove_existing(&mut self.relaxations, "relaxation", name) } + /// The mesh plans, by name. + pub fn mesh_plans(&self) -> &BTreeMap { + &self.mesh_plans + } + + pub fn mesh_plan(&self, name: &str) -> Option<&MeshPlan> { + self.mesh_plans.get(name) + } + + /// Add a mesh plan, or replace the one of that name. + pub fn set_mesh_plan(&mut self, name: &str, plan: MeshPlan) -> Result<()> { + plan.check().map_err(|e| context("mesh plan", name, e))?; + if self.mesh_plans.contains_key(name) { + replace_existing(&mut self.mesh_plans, "mesh plan", name, plan) + } else { + insert_new(&mut self.mesh_plans, "mesh plan", name, plan) + } + } + + pub fn remove_mesh_plan(&mut self, name: &str) -> Result { + remove_existing(&mut self.mesh_plans, "mesh plan", name) + } + /// Counts of each kind of definition, for summaries. pub fn definition_counts(&self) -> [(&'static str, usize); 16] { [ diff --git a/crates/model/src/mesh_plans.rs b/crates/model/src/mesh_plans.rs new file mode 100644 index 0000000..a17dbf8 --- /dev/null +++ b/crates/model/src/mesh_plans.rs @@ -0,0 +1,313 @@ +//! Mesh plans: how a body of an opened design is to be hex meshed, derived from the features +//! that built it (`mesh.plan`), kept so it can be inspected, edited and applied again +//! (`mesh.plan.apply`) after the design changes. +//! +//! A plan names the body, the element size and the sweep direction, lists the features it maps +//! and the template each one takes (a sweep of the profile, an O-grid round a hole, layers +//! through a shell, an imprint of a joined tool, a copy of a pattern instance), the partition cuts +//! its templates need (each with the feature it is for and why), and the features it does not +//! map yet with the reason. Plans are definitions of the model: journalled with the command that +//! made them and saved in the `.fieldcraft` file. + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result, check_coordinates, check_name}; + +/// Most entries in each list of a plan. +pub const MAX_PLAN_ITEMS: usize = 10_000; + +/// What a plan meshes with today. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanMesher { + /// The whole body swept in one piece (`mesh.hex.generate`, swept methods). + #[default] + Sweep, + /// The body cut by the plan's partitions, its cells swept together (`mesh.cells`). + Cells, +} + +/// A feature the plan maps, and the template it takes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanFeature { + /// The feature's id (`F` in persistent names). + pub id: u64, + pub name: String, + /// The feature's type (`extrude`, `box`, `hole`, …). + #[serde(rename = "type")] + pub kind: String, + /// `sweep`, `block`, `butterfly`, `o_grid`, `layers`, `imprint`, `partition` or `copy`. + pub template: String, + /// What the template does with this feature, in a sentence. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, + /// The body's faces the feature made (persistent names): the surfaces its blocks' boundary + /// nodes project onto (the projection blocking mesher, #399). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub faces: Vec, +} + +/// The kind of ring a circle of the body takes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RingKind { + /// Material inside the circle (a cylinder, a boss): a square core and four blocks round it. + Butterfly, + /// Material outside the circle (a hole, a circular cut): four blocks between the circle and + /// a square frame round it. + OGrid, +} + +/// A circle of the body, square to the sweep, with the ring of blocks that goes round it: the +/// block topology the projection mesher (#399) builds, its corners at 45° on the circle and its +/// radial edges projected onto `faces`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanRing { + /// The feature the circle comes from. + pub feature: u64, + pub kind: RingKind, + /// The circle's centre (a point on its axis), mm. + pub center: [f64; 3], + /// Its axis (unit, along the sweep). + pub axis: [f64; 3], + pub radius: f64, + /// Half the side of the square frame (an O-grid's outer boundary, a butterfly's core is + /// `radius / 2`), mm. + pub frame: f64, + /// The cylindrical faces the ring's inner boundary projects onto (persistent names). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub faces: Vec, +} + +/// What a cut is for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CutRole { + /// Square to the sweep, where a joined or cut tool's section starts or ends. + Level, + /// Along the sweep: the frame of an O-grid or butterfly, or of a cut opening. + #[default] + Frame, +} + +/// A partition cut the plan needs: a plane through `point` with normal `normal`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanCut { + pub point: [f64; 3], + pub normal: [f64; 3], + #[serde(default)] + pub role: CutRole, + /// The feature whose template needs it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub feature: Option, + pub reason: String, +} + +/// A feature the plan does not map, and why. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanUnmapped { + pub id: u64, + pub name: String, + #[serde(rename = "type")] + pub kind: String, + pub reason: String, +} + +/// A mesh plan of one body. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MeshPlan { + /// The body of the design as opened (not a partition cell). + pub body: String, + /// Element size, mm. + pub size: f64, + /// The chordal tolerance the body and its cells are tessellated with, mm (small enough for + /// its smallest curve); `None`: the meshers' default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tolerance: Option, + /// Elements through the thinnest wall the features give (a shell's thickness, a plate's). + pub layers: u32, + #[serde(default)] + pub mesher: PlanMesher, + /// The sweep direction (unit), when the mapped features are prismatic along one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option<[f64; 3]>, + /// Every direction all mapped features are prismatic along (`direction` first). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub directions: Vec<[f64; 3]>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub features: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cuts: Vec, + /// The rings round the body's circles (O-grids at holes, butterflies in bosses). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rings: Vec, + /// The other bodies that are copies of this one (a pattern's or a mirror's), meshed with + /// plans of their own when this plan is applied. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub copies: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unmapped: Vec, + /// What the planner decided and why (the size it took, a cut it left out). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, +} + +fn unit_length(v: [f64; 3]) -> f64 { + (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() +} + +impl MeshPlan { + pub fn check(&self) -> Result<()> { + if self.body.trim().is_empty() || self.body.len() > 1024 { + return Err(Error::Invalid("a mesh plan names its body".into())); + } + if !(self.size.is_finite() && self.size > 0.0) { + return Err(Error::Invalid(format!("the element size must be positive, got {}", self.size))); + } + if self.tolerance.is_some_and(|t| !(t.is_finite() && t > 0.0)) { + return Err(Error::Invalid("the tolerance must be positive".into())); + } + if !(1..=100).contains(&self.layers) { + return Err(Error::Invalid(format!("the layers through a wall must be 1 to 100, got {}", self.layers))); + } + if self.features.len() > MAX_PLAN_ITEMS + || self.cuts.len() > MAX_PLAN_ITEMS + || self.unmapped.len() > MAX_PLAN_ITEMS + || self.notes.len() > MAX_PLAN_ITEMS + || self.rings.len() > MAX_PLAN_ITEMS + || self.copies.len() > MAX_PLAN_ITEMS + || self.features.iter().any(|f| f.faces.len() > MAX_PLAN_ITEMS) + || self.directions.len() > 3 + { + return Err(Error::Limit(format!("a mesh plan lists at most {MAX_PLAN_ITEMS} features, cuts and unmapped features, and 3 directions"))); + } + for d in self.direction.iter().chain(&self.directions) { + check_coordinates(d)?; + if (unit_length(*d) - 1.0).abs() > 1e-6 { + return Err(Error::Invalid(format!("a sweep direction is a unit vector, got {d:?}"))); + } + } + for c in &self.cuts { + check_coordinates(&c.point)?; + check_coordinates(&c.normal)?; + if !(unit_length(c.normal) > 1e-9) { + return Err(Error::Invalid("a cut's normal must not be zero".into())); + } + } + for r in &self.rings { + check_coordinates(&r.center)?; + check_coordinates(&r.axis)?; + if (unit_length(r.axis) - 1.0).abs() > 1e-6 { + return Err(Error::Invalid(format!("a ring's axis is a unit vector, got {:?}", r.axis))); + } + if !(r.radius.is_finite() && r.radius > 0.0 && r.frame.is_finite() && r.frame > 0.0) { + return Err(Error::Invalid("a ring's radius and frame must be positive".into())); + } + if r.faces.len() > MAX_PLAN_ITEMS { + return Err(Error::Limit(format!("a ring lists at most {MAX_PLAN_ITEMS} faces"))); + } + } + if self.copies.iter().any(|c| c.trim().is_empty() || c == &self.body) { + return Err(Error::Invalid("a plan's copies are other bodies, by name".into())); + } + Ok(()) + } + + /// The name a plan of `body` gets by default: `Plan-` with every character a + /// definition name does not take as `_`. + pub fn default_name(body: &str) -> String { + let clean: String = body.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect(); + let name: String = format!("Plan-{clean}").chars().take(crate::MAX_NAME_LEN).collect(); + if check_name("mesh plan", &name).is_ok() { name } else { "Plan".into() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plan() -> MeshPlan { + MeshPlan { + body: "Base".into(), + size: 2.0, + tolerance: Some(0.01), + layers: 2, + mesher: PlanMesher::Cells, + direction: Some([0.0, 0.0, 1.0]), + directions: vec![[0.0, 0.0, 1.0]], + features: vec![PlanFeature { + id: 1, + name: "Box1".into(), + kind: "box".into(), + template: "block".into(), + note: String::new(), + faces: vec!["F1:+z".into()], + }], + cuts: vec![PlanCut { point: [1.0, 0.0, 0.0], normal: [1.0, 0.0, 0.0], role: CutRole::Frame, feature: Some(1), reason: "frame".into() }], + rings: vec![PlanRing { + feature: 1, + kind: RingKind::OGrid, + center: [0.0; 3], + axis: [0.0, 0.0, 1.0], + radius: 1.0, + frame: 2.0, + faces: Vec::new(), + }], + copies: vec!["Base.2".into()], + unmapped: Vec::new(), + notes: Vec::new(), + } + } + + #[test] + fn a_plan_round_trips_and_checks() { + let p = plan(); + p.check().unwrap(); + let back: MeshPlan = serde_json::from_value(serde_json::to_value(&p).unwrap()).unwrap(); + assert_eq!(back, p); + } + + #[test] + fn hostile_plans_are_refused() { + let bad = [ + MeshPlan { size: f64::NAN, ..plan() }, + MeshPlan { size: 0.0, ..plan() }, + MeshPlan { layers: 0, ..plan() }, + MeshPlan { tolerance: Some(-1.0), ..plan() }, + MeshPlan { body: String::new(), ..plan() }, + MeshPlan { direction: Some([0.0, 0.0, 2.0]), ..plan() }, + MeshPlan { rings: vec![PlanRing { radius: -1.0, ..plan().rings[0].clone() }], ..plan() }, + MeshPlan { rings: vec![PlanRing { axis: [0.0, 0.0, 0.0], ..plan().rings[0].clone() }], ..plan() }, + MeshPlan { copies: vec!["Base".into()], ..plan() }, + MeshPlan { + cuts: vec![PlanCut { point: [0.0; 3], normal: [0.0; 3], role: CutRole::Level, feature: None, reason: String::new() }], + ..plan() + }, + MeshPlan { + cuts: vec![PlanCut { + point: [f64::INFINITY, 0.0, 0.0], + normal: [1.0, 0.0, 0.0], + role: CutRole::Frame, + feature: None, + reason: String::new(), + }], + ..plan() + }, + ]; + for p in bad { + assert!(p.check().is_err(), "{p:?}"); + } + } + + #[test] + fn default_names_are_definition_names() { + assert_eq!(MeshPlan::default_name("Base"), "Plan-Base"); + assert_eq!(MeshPlan::default_name("Pins.3"), "Plan-Pins_3"); + assert!(check_name("mesh plan", &MeshPlan::default_name(&"x".repeat(500))).is_ok()); + } +}