forked from 1b-agent/fieldcraft-upstream
Model: datum planes and axes, and partitions by datum plane
Datums are named planes (point, unit normal) and axes (point, unit direction); a partition names the datum plane that cuts and, optionally, the bodies. A datum a partition uses cannot be deleted or turned into an axis. Both are stored in model.json, partitions in the order they cut. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
321add294b
commit
b0d0fc9900
3 files changed
+225
-2
No files matched your search
@@ -0,0 +1,202 @@
|
||||
//! Datum planes and axes, and the partitions that cut the geometry by datum planes.
|
||||
//!
|
||||
//! A datum is a construction reference in model coordinates: a plane (a point and its unit
|
||||
//! normal) or an axis (a point and its unit direction). Partitions, mirrors and symmetry
|
||||
//! conditions name them. A **partition** cuts bodies of the geometry by a datum plane into cells
|
||||
//! (the geometry layer does the cutting); the model keeps the partitions in the order they were
|
||||
//! made, because each cuts the cells the ones before it left.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Model, Result, check_name, context, insert_new, not_in_use, remove_existing, replace_existing};
|
||||
|
||||
/// Most partitions a model holds.
|
||||
pub const MAX_PARTITIONS: usize = 256;
|
||||
/// Most bodies one partition names.
|
||||
pub const MAX_PARTITION_BODIES: usize = 1000;
|
||||
/// Longest body name a partition holds.
|
||||
const MAX_BODY_NAME: usize = 256;
|
||||
|
||||
/// A datum plane or axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Datum {
|
||||
/// A point on the plane and its unit normal.
|
||||
Plane { point: [f64; 3], normal: [f64; 3] },
|
||||
/// A point on the axis and its unit direction.
|
||||
Axis { point: [f64; 3], direction: [f64; 3] },
|
||||
}
|
||||
|
||||
impl Datum {
|
||||
/// The datum with its point checked and its direction made a unit vector.
|
||||
fn checked(self) -> Result<Datum> {
|
||||
let unit = |what: &str, v: [f64; 3]| -> Result<[f64; 3]> {
|
||||
let len = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
if v.iter().all(|x| x.is_finite()) && len > 1e-12 && len.is_finite() {
|
||||
// A unit vector stays bit for bit, so a model read back equals the one written.
|
||||
Ok(if (len - 1.0).abs() <= 1e-12 { v } else { v.map(|x| x / len) })
|
||||
} else {
|
||||
Err(Error::Invalid(format!("the {what} must be a finite, non-zero vector")))
|
||||
}
|
||||
};
|
||||
let point = |p: [f64; 3]| -> Result<[f64; 3]> {
|
||||
crate::check_coordinates(&p)?;
|
||||
Ok(p)
|
||||
};
|
||||
Ok(match self {
|
||||
Datum::Plane { point: p, normal } => Datum::Plane { point: point(p)?, normal: unit("normal", normal)? },
|
||||
Datum::Axis { point: p, direction } => Datum::Axis { point: point(p)?, direction: unit("direction", direction)? },
|
||||
})
|
||||
}
|
||||
|
||||
/// The plane's point and unit normal; `None` for an axis.
|
||||
pub fn plane(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
match *self {
|
||||
Datum::Plane { point, normal } => Some((point, normal)),
|
||||
Datum::Axis { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A partition: the bodies cut by a datum plane.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Partition {
|
||||
pub name: String,
|
||||
/// The datum plane that cuts.
|
||||
pub datum: String,
|
||||
/// The bodies it cuts, as they are when it comes (cells of earlier partitions included);
|
||||
/// empty: every body the plane divides.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub bodies: Vec<String>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn datums(&self) -> &BTreeMap<String, Datum> {
|
||||
&self.datums
|
||||
}
|
||||
|
||||
pub fn datum(&self, name: &str) -> Option<&Datum> {
|
||||
self.datums.get(name)
|
||||
}
|
||||
|
||||
pub fn add_datum(&mut self, name: &str, datum: Datum) -> Result<()> {
|
||||
let datum = datum.checked().map_err(|e| context("datum", name, e))?;
|
||||
insert_new(&mut self.datums, "datum", name, datum)
|
||||
}
|
||||
|
||||
/// Replace a datum; a datum plane a partition uses stays a plane.
|
||||
pub fn replace_datum(&mut self, name: &str, datum: Datum) -> Result<()> {
|
||||
let datum = datum.checked().map_err(|e| context("datum", name, e))?;
|
||||
if datum.plane().is_none() {
|
||||
not_in_use("datum", name, self.datum_users(name))?;
|
||||
}
|
||||
replace_existing(&mut self.datums, "datum", name, datum)
|
||||
}
|
||||
|
||||
pub fn remove_datum(&mut self, name: &str) -> Result<()> {
|
||||
not_in_use("datum", name, self.datum_users(name))?;
|
||||
remove_existing(&mut self.datums, "datum", name).map(|_| ())
|
||||
}
|
||||
|
||||
fn datum_users(&self, name: &str) -> Vec<String> {
|
||||
self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name)).collect()
|
||||
}
|
||||
|
||||
/// The partitions, in the order they cut.
|
||||
pub fn partitions(&self) -> &[Partition] {
|
||||
&self.partitions
|
||||
}
|
||||
|
||||
/// Add a partition after the others.
|
||||
pub fn add_partition(&mut self, partition: Partition) -> Result<()> {
|
||||
let name = partition.name.clone();
|
||||
check_name("partition", &name)?;
|
||||
if self.partitions.iter().any(|p| p.name == name) {
|
||||
return Err(Error::Invalid(format!("partition `{name}` already exists")));
|
||||
}
|
||||
if self.partitions.len() >= MAX_PARTITIONS {
|
||||
return Err(Error::Limit(format!("a model holds at most {MAX_PARTITIONS} partitions")));
|
||||
}
|
||||
let invalid = |m: String| context("partition", &name, Error::Invalid(m));
|
||||
match self.datums.get(&partition.datum) {
|
||||
None => return Err(context("partition", &name, Error::NotFound(format!("datum `{}`", partition.datum)))),
|
||||
Some(d) if d.plane().is_none() => return Err(invalid(format!("datum `{}` is an axis; a partition cuts by a plane", partition.datum))),
|
||||
Some(_) => {}
|
||||
}
|
||||
if partition.bodies.len() > MAX_PARTITION_BODIES {
|
||||
return Err(invalid(format!("at most {MAX_PARTITION_BODIES} bodies")));
|
||||
}
|
||||
for (i, b) in partition.bodies.iter().enumerate() {
|
||||
if b.trim().is_empty() || b.len() > MAX_BODY_NAME {
|
||||
return Err(invalid(format!("body names are 1 to {MAX_BODY_NAME} characters")));
|
||||
}
|
||||
if partition.bodies.get(..i).is_some_and(|before| before.contains(b)) {
|
||||
return Err(invalid(format!("body `{b}` is listed twice")));
|
||||
}
|
||||
}
|
||||
self.partitions.push(partition);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_partition(&mut self, name: &str) -> Result<Partition> {
|
||||
let i = self.partitions.iter().position(|p| p.name == name).ok_or_else(|| Error::NotFound(format!("partition `{name}`")))?;
|
||||
Ok(self.partitions.remove(i))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plane(z: f64) -> Datum {
|
||||
Datum::Plane { point: [0.0, 0.0, z], normal: [0.0, 0.0, 2.0] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datums_are_normalised_and_partitions_refer_to_planes() {
|
||||
let mut m = Model::default();
|
||||
m.add_datum("Mid", plane(5.0)).unwrap();
|
||||
assert_eq!(m.datum("Mid"), Some(&Datum::Plane { point: [0.0, 0.0, 5.0], normal: [0.0, 0.0, 1.0] }));
|
||||
m.add_datum("Axis", Datum::Axis { point: [0.0; 3], direction: [1.0, 1.0, 0.0] }).unwrap();
|
||||
let Some(Datum::Axis { direction, .. }) = m.datum("Axis") else { panic!() };
|
||||
assert!((direction[0] - 0.5f64.sqrt()).abs() < 1e-15);
|
||||
|
||||
let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![] };
|
||||
m.add_partition(p("Half", "Mid")).unwrap();
|
||||
assert!(m.add_partition(p("Half", "Mid")).is_err());
|
||||
assert!(m.add_partition(p("Bad", "Axis")).is_err());
|
||||
assert!(m.add_partition(p("Bad", "Nope")).is_err());
|
||||
assert!(m.add_partition(Partition { bodies: vec!["A".into(), "A".into()], ..p("Bad", "Mid") }).is_err());
|
||||
assert!(matches!(m.remove_datum("Mid"), Err(Error::InUse(_))));
|
||||
assert!(matches!(m.replace_datum("Mid", Datum::Axis { point: [0.0; 3], direction: [1.0, 0.0, 0.0] }), Err(Error::InUse(_))));
|
||||
m.replace_datum("Mid", plane(3.0)).unwrap();
|
||||
assert_eq!(m.partitions().len(), 1);
|
||||
|
||||
// The model round-trips through JSON, partitions in order.
|
||||
m.add_partition(Partition { bodies: vec!["Body1/a".into()], ..p("Quarter", "Mid") }).unwrap();
|
||||
let back: Model = serde_json::from_value(serde_json::to_value(&m).unwrap()).unwrap();
|
||||
assert_eq!(back, m);
|
||||
m.remove_partition("Half").unwrap();
|
||||
m.remove_partition("Quarter").unwrap();
|
||||
m.remove_datum("Mid").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_datums_are_an_error() {
|
||||
let mut m = Model::default();
|
||||
for d in [
|
||||
Datum::Plane { point: [0.0; 3], normal: [0.0; 3] },
|
||||
Datum::Plane { point: [f64::NAN, 0.0, 0.0], normal: [0.0, 0.0, 1.0] },
|
||||
Datum::Plane { point: [1e300, 0.0, 0.0], normal: [0.0, 0.0, 1.0] },
|
||||
Datum::Axis { point: [0.0; 3], direction: [f64::INFINITY, 0.0, 0.0] },
|
||||
Datum::Axis { point: [0.0; 3], direction: [1e-300, 0.0, 0.0] },
|
||||
] {
|
||||
assert!(m.add_datum("D", d).is_err(), "{d:?}");
|
||||
}
|
||||
assert!(m.add_datum("1bad", plane(0.0)).is_err());
|
||||
assert!(m.datums().is_empty());
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -35,6 +35,7 @@ pub mod bcs;
|
||||
pub mod check;
|
||||
pub mod constraints;
|
||||
pub mod contact;
|
||||
pub mod datums;
|
||||
pub mod discrete;
|
||||
pub mod fluids;
|
||||
pub mod geometry;
|
||||
@@ -56,6 +57,7 @@ pub use bcs::{BoundaryCondition, Dofs, Symmetry, TEMPERATURE_DOF, Target};
|
||||
pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
|
||||
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
|
||||
pub use contact::{Contact, ContactSurfaces, Friction, InitialPenetration};
|
||||
pub use datums::{Datum, Partition};
|
||||
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
|
||||
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
|
||||
pub use geometry::{Classification, Entity, GeometryRef};
|
||||
@@ -141,6 +143,11 @@ pub struct Model {
|
||||
fluid_domains: BTreeMap<String, FluidDomain>,
|
||||
/// In analysis order.
|
||||
steps: Vec<Step>,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
datums: BTreeMap<String, Datum>,
|
||||
/// In the order they cut.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
partitions: Vec<Partition>,
|
||||
}
|
||||
|
||||
/// A [`Model`] as stored, before it is checked.
|
||||
@@ -181,6 +188,10 @@ struct ModelData {
|
||||
fluid_domains: BTreeMap<String, FluidDomain>,
|
||||
#[serde(default)]
|
||||
steps: Vec<Step>,
|
||||
#[serde(default)]
|
||||
datums: BTreeMap<String, Datum>,
|
||||
#[serde(default)]
|
||||
partitions: Vec<Partition>,
|
||||
}
|
||||
|
||||
impl TryFrom<ModelData> for Model {
|
||||
@@ -252,6 +263,12 @@ impl TryFrom<ModelData> for Model {
|
||||
for s in d.steps {
|
||||
m.add_step(s, None)?;
|
||||
}
|
||||
for (n, v) in d.datums {
|
||||
m.add_datum(&n, v)?;
|
||||
}
|
||||
for p in d.partitions {
|
||||
m.add_partition(p)?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
@@ -357,11 +374,13 @@ impl Model {
|
||||
loads: self.loads.clone(),
|
||||
fluid_domains: self.fluid_domains.clone(),
|
||||
steps: self.steps.clone(),
|
||||
datums: self.datums.clone(),
|
||||
partitions: self.partitions.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts of each kind of definition, for summaries.
|
||||
pub fn definition_counts(&self) -> [(&'static str, usize); 13] {
|
||||
pub fn definition_counts(&self) -> [(&'static str, usize); 15] {
|
||||
[
|
||||
("sets", self.sets.len()),
|
||||
("materials", self.materials.len()),
|
||||
@@ -376,6 +395,8 @@ impl Model {
|
||||
("loads", self.loads.len()),
|
||||
("fluid_domains", self.fluid_domains.len()),
|
||||
("steps", self.steps.len()),
|
||||
("datums", self.datums.len()),
|
||||
("partitions", self.partitions.len()),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ unzip tool reads them: `unzip -p bracket.fieldcraft model.json`):
|
||||
| Part | Content |
|
||||
|---|---|
|
||||
| `fieldcraft.json` | Manifest: `format` (`"fieldcraft/1"`), `generator` (`"FieldCraft 0.5.7"`), `geometry`, and `parts`: the byte size and SHA-256 of each other part. |
|
||||
| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads and steps. Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. |
|
||||
| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads, steps, datum planes and axes, and the partitions that cut the geometry (in order; applied again when the design is opened). Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. |
|
||||
| `mesh.bin` | Nodes, elements and geometry tags, binary (below). |
|
||||
| `journal.json` | The commands that built the model, `[{"command": id, "params": {...}}, ...]`, one per line. It is a script `fieldcraft-cli run` replays. |
|
||||
|
||||
|
||||
Reference in new issue
Block a user