Model definition: sets by geometry name, materials, sections, couplings, BCs, loads, steps, check (#16)

* model: split nodes and elements into a mesh module (no behaviour change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* model: sets by geometry name, materials and units, sections, couplings, BCs, loads, steps and the check

Sets bind to persistent geometry names through the mesh's geometry tags
(element, element face/edge and node classification) and resolve against
the current mesh, so they survive a remesh. Steps list their active loads
and boundary conditions explicitly. Model::check names every missing or
bad item; Model::resolve gives the writers' view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: model definition commands (sets, materials, sections, RPs, couplings, BCs, loads, steps, tags, check)

Each definition has create/edit/delete with its model JSON form as
parameters; model.step.activate and model.activity drive the explicit step
table; mesh.tag records where the mesh lies on the geometry; model.check
and model.set.resolve report against the current mesh. Hostile params run
against an empty and a fully defined session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: model definition in architecture and roadmap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* model, engine: review cleanups

Validated deserialization (a file replays through the checked methods),
interned geometry tags by index, set resolution with tag bitsets and sorted
vectors, one delete policy (a load or BC listed by a step is in use),
shared helpers (context, remove_existing, step_mut, opt/req/name/listed),
dead code removed, Resolved without a per-element section map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* model: mesh-independent force distribution, tractions, stable reference point labels

Force and moment on a set default to a total, split by consistent nodal
weights over the set's faces (or equally over a node set); per_node is an
explicit opt-in. Traction is force per area. resolve() expands them to
nodal values for every writer and the solver. Reference points keep a
stored node label taken from the top of the label range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 16:19:54 -04:00
1 parent 6c36f5b2ad
commit 57b4b2f4d2
23 files changed
+4296 -268

No files matched your search

+238
View File
@@ -0,0 +1,238 @@
//! Boundary conditions: prescribed degrees of freedom on a set's nodes or a reference point.
//!
//! Degrees of freedom are numbered 1–3 for translations along X, Y, Z and 4–6 for rotations about
//! them, in the global axes. Which steps a boundary condition is active in is listed explicitly by
//! each step ([`crate::steps`]).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, remove_existing, replace_existing, require};
/// A set of degrees of freedom (1–6), written as a sorted list such as `[1, 2, 3]`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "Vec<u8>", into = "Vec<u8>")]
pub struct Dofs(u8);
impl Dofs {
pub fn all() -> Dofs {
Dofs(0b111_111)
}
pub fn none() -> Dofs {
Dofs(0)
}
pub fn contains(self, dof: u8) -> bool {
(1..=6).contains(&dof) && self.0 & (1 << (dof - 1)) != 0
}
pub fn is_empty(self) -> bool {
self.0 == 0
}
/// The degrees of freedom in order.
pub fn list(self) -> Vec<u8> {
(1..=6).filter(|d| self.contains(*d)).collect()
}
}
impl TryFrom<Vec<u8>> for Dofs {
type Error = Error;
fn try_from(v: Vec<u8>) -> Result<Dofs> {
let mut mask = 0u8;
for d in v {
if !(1..=6).contains(&d) {
return Err(Error::Invalid(format!("degree of freedom {d} is not in 1..=6")));
}
mask |= 1 << (d - 1);
}
Ok(Dofs(mask))
}
}
impl From<Dofs> for Vec<u8> {
fn from(d: Dofs) -> Vec<u8> {
d.list()
}
}
/// What a boundary condition, force or moment acts on.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Target {
/// The nodes of a set.
Set(String),
/// A reference point.
ReferencePoint(String),
}
impl Target {
pub fn set(&self) -> Option<&str> {
match self {
Target::Set(s) => Some(s),
Target::ReferencePoint(_) => None,
}
}
pub fn reference_point(&self) -> Option<&str> {
match self {
Target::ReferencePoint(r) => Some(r),
Target::Set(_) => None,
}
}
pub(crate) fn check_exists(&self, model: &Model) -> Result<()> {
match self {
Target::Set(s) => require(&model.sets, "set", s),
Target::ReferencePoint(r) => require(&model.reference_points, "reference point", r),
}
}
}
impl std::fmt::Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Target::Set(s) => write!(f, "set `{s}`"),
Target::ReferencePoint(r) => write!(f, "reference point `{r}`"),
}
}
}
/// The plane of a symmetry boundary condition, named by its normal axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Symmetry {
/// The plane normal to X: no X translation, no rotation about Y or Z.
X,
Y,
Z,
}
impl Symmetry {
/// The fixed degrees of freedom.
pub fn dofs(self) -> Dofs {
match self {
Symmetry::X => Dofs(0b110_001),
Symmetry::Y => Dofs(0b101_010),
Symmetry::Z => Dofs(0b011_100),
}
}
}
/// A boundary condition.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum BoundaryCondition {
/// Every degree of freedom held at zero.
Fixed { target: Target },
/// Prescribed values for some degrees of freedom: `values[i]` is degree of freedom `i + 1`
/// (`null` leaves it free, `0` holds it).
Displacement { target: Target, values: [Option<f64>; 6] },
/// Mirror symmetry about a plane through the nodes.
Symmetry { target: Target, plane: Symmetry },
}
impl BoundaryCondition {
pub fn target(&self) -> &Target {
match self {
BoundaryCondition::Fixed { target } | BoundaryCondition::Displacement { target, .. } | BoundaryCondition::Symmetry { target, .. } => {
target
}
}
}
/// The constrained degrees of freedom.
pub fn dofs(&self) -> Dofs {
match self {
BoundaryCondition::Fixed { .. } => Dofs::all(),
BoundaryCondition::Displacement { values, .. } => {
Dofs(values.iter().enumerate().filter(|(_, v)| v.is_some()).fold(0, |m, (i, _)| m | (1 << i)))
}
BoundaryCondition::Symmetry { plane, .. } => plane.dofs(),
}
}
/// The prescribed value of a constrained degree of freedom (1–6); `None` when it is free.
pub fn value(&self, dof: u8) -> Option<f64> {
match self {
BoundaryCondition::Displacement { values, .. } => values.get(usize::from(dof).checked_sub(1)?).copied().flatten(),
_ => self.dofs().contains(dof).then_some(0.0),
}
}
pub fn validate(&self) -> Result<()> {
if let BoundaryCondition::Displacement { values, .. } = self {
for (i, v) in values.iter().enumerate() {
if let Some(v) = v {
finite(&format!("degree of freedom {}", i + 1), *v)?;
}
}
if values.iter().all(Option::is_none) {
return Err(Error::Invalid("a displacement boundary condition needs at least one value".into()));
}
}
Ok(())
}
}
impl Model {
pub fn boundary_conditions(&self) -> &BTreeMap<String, BoundaryCondition> {
&self.boundary_conditions
}
pub fn boundary_condition(&self, name: &str) -> Option<&BoundaryCondition> {
self.boundary_conditions.get(name)
}
fn check_bc(&self, name: &str, bc: &BoundaryCondition) -> Result<()> {
bc.validate().map_err(|e| context("boundary condition", name, e))?;
bc.target().check_exists(self).map_err(|e| context("boundary condition", name, e))
}
pub fn add_boundary_condition(&mut self, name: &str, bc: BoundaryCondition) -> Result<()> {
self.check_bc(name, &bc)?;
insert_new(&mut self.boundary_conditions, "boundary condition", name, bc)
}
pub fn replace_boundary_condition(&mut self, name: &str, bc: BoundaryCondition) -> Result<()> {
self.check_bc(name, &bc)?;
replace_existing(&mut self.boundary_conditions, "boundary condition", name, bc)
}
/// Remove a boundary condition that no step lists.
pub fn remove_boundary_condition(&mut self, name: &str) -> Result<()> {
let users = self.steps.iter().filter(|s| s.boundary_conditions.contains(name)).map(|s| format!("step `{}`", s.name)).collect();
not_in_use("boundary condition", name, users)?;
remove_existing(&mut self.boundary_conditions, "boundary condition", name).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dofs_and_values() {
let d: Dofs = serde_json::from_value(serde_json::json!([3, 1, 1])).unwrap();
assert_eq!(d.list(), vec![1, 3]);
assert_eq!(serde_json::to_value(d).unwrap(), serde_json::json!([1, 3]));
assert!(serde_json::from_value::<Dofs>(serde_json::json!([0])).is_err());
assert!(serde_json::from_value::<Dofs>(serde_json::json!([7])).is_err());
assert_eq!(Symmetry::X.dofs().list(), vec![1, 5, 6]);
assert_eq!(Symmetry::Y.dofs().list(), vec![2, 4, 6]);
assert_eq!(Symmetry::Z.dofs().list(), vec![3, 4, 5]);
let t = Target::Set("s".into());
let bc = BoundaryCondition::Displacement { target: t.clone(), values: [Some(0.0), None, Some(1.5), None, None, None] };
assert_eq!(bc.dofs().list(), vec![1, 3]);
assert_eq!((bc.value(1), bc.value(2), bc.value(3), bc.value(0), bc.value(9)), (Some(0.0), None, Some(1.5), None, None));
assert_eq!(BoundaryCondition::Fixed { target: t.clone() }.value(6), Some(0.0));
assert!(BoundaryCondition::Displacement { target: t.clone(), values: [None; 6] }.validate().is_err());
assert!(BoundaryCondition::Displacement { target: t, values: [Some(f64::NAN), None, None, None, None, None] }.validate().is_err());
let json = serde_json::json!({"type": "symmetry", "target": {"reference_point": "RP"}, "plane": "z"});
let parsed: BoundaryCondition = serde_json::from_value(json.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), json);
}
}