forked from 1b-agent/fieldcraft-upstream
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
318 lines
13 KiB
Rust
318 lines
13 KiB
Rust
//! 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,
|
|
/// One block topology laid over the whole body (a grid with O-grids round its circles and a
|
|
/// blend layer along its fillets, chamfers and shell walls) projected onto its faces
|
|
/// (projection blocking, `mesh.hex.project`): no partitions, one conforming mesh.
|
|
Project,
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|