Files
OneCAD-Simulation/crates/model/src/datums.rs
T
jyvikoandClaude Opus 5.5 750f5f5a61 model: geometry names up to 64 KiB everywhere, so long persistent face names mesh
Persistent names grow with a design's split history; opening accepted them but mesh tags, sets
and loads capped geometry names at 256 characters. One cap (64 KiB, against hostile input)
now holds for geometry references, partitions and saved views.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:09:04 -04:00

240 lines
10 KiB
Rust

//! Datum planes, axes and points, 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), an axis (a point and its unit direction) or a point. Partitions, mirrors and symmetry
//! conditions name planes; points are inputs for planes and axes (their coordinates are taken
//! when those are made). 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::geometry::MAX_GEOMETRY_NAME_LEN;
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;
/// A datum plane, axis or point.
#[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] },
/// A point.
Point { point: [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)? },
Datum::Point { point: p } => Datum::Point { point: point(p)? },
})
}
/// The plane's point and unit normal; `None` for an axis or a point.
pub fn plane(&self) -> Option<([f64; 3], [f64; 3])> {
match *self {
Datum::Plane { point, normal } => Some((point, normal)),
Datum::Axis { .. } | Datum::Point { .. } => None,
}
}
/// The axis's point and unit direction; `None` for a plane or a point.
pub fn axis(&self) -> Option<([f64; 3], [f64; 3])> {
match *self {
Datum::Axis { point, direction } => Some((point, direction)),
Datum::Plane { .. } | Datum::Point { .. } => None,
}
}
/// The point's coordinates; `None` for a plane or an axis.
pub fn point(&self) -> Option<[f64; 3]> {
match *self {
Datum::Point { point } => Some(point),
Datum::Plane { .. } | Datum::Axis { .. } => None,
}
}
/// `plane`, `axis` or `point`.
pub fn kind(&self) -> &'static str {
match self {
Datum::Plane { .. } => "plane",
Datum::Axis { .. } => "axis",
Datum::Point { .. } => "point",
}
}
}
/// 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(|_| ())
}
/// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\``
/// strings.
pub 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 a datum {}; a partition cuts by a plane", partition.datum, d.kind())));
}
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_GEOMETRY_NAME_LEN {
return Err(invalid(format!("body names are 1 to {MAX_GEOMETRY_NAME_LEN} bytes")));
}
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());
m.add_datum("P", Datum::Point { point: [1.0, 2.0, 3.0] }).unwrap();
assert_eq!((m.datum("P").and_then(Datum::point), m.datum("P").map(Datum::kind)), (Some([1.0, 2.0, 3.0]), Some("point")));
assert!(m.add_partition(p("Bad", "P")).is_err());
assert!(m.add_datum("Q", Datum::Point { point: [f64::NAN, 0.0, 0.0] }).is_err());
assert_eq!(m.datum_users("Mid"), ["partition `Half`"]);
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());
}
}