forked from 1b-agent/fieldcraft-upstream
model: mesh plans, saved definitions with templates, rings, cuts and copies (#402)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
4cfb6dd3de
commit
8dd7446569
2 files changed
+347
No files matched your search
@@ -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<String, Relaxation>,
|
||||
/// Mesh plans (`mesh.plan`), by name.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
mesh_plans: BTreeMap<String, MeshPlan>,
|
||||
}
|
||||
|
||||
/// A [`Model`] as stored, before it is checked.
|
||||
@@ -217,6 +222,8 @@ struct ModelData {
|
||||
seeds: Vec<Seed>,
|
||||
#[serde(default)]
|
||||
relaxations: BTreeMap<String, Relaxation>,
|
||||
#[serde(default)]
|
||||
mesh_plans: BTreeMap<String, MeshPlan>,
|
||||
}
|
||||
|
||||
impl TryFrom<ModelData> for Model {
|
||||
@@ -303,6 +310,9 @@ impl TryFrom<ModelData> 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<String, MeshPlan> {
|
||||
&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<MeshPlan> {
|
||||
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] {
|
||||
[
|
||||
|
||||
@@ -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<id>` 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
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<f64>,
|
||||
/// 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<PlanFeature>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cuts: Vec<PlanCut>,
|
||||
/// 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<PlanRing>,
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub unmapped: Vec<PlanUnmapped>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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-<body>` 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user