forked from 1b-agent/fieldcraft-upstream
* Model, fem and decks: point masses, rotary inertia, springs, dashpots and rod3 trusses Model: PointMass and Spring definitions (spring | dashpot; to ground or two-point; along a degree of freedom, a vector or the line of the ends) on a set or reference point, resolved per node; ElementKind::Rod3. fem: rod2/rod3 truss kernel on Basis::Line2/Line3 declaring ElementDofs (translations). Decks: Abaqus T3D3, MASS, ROTARYI, SPRING1/2/A, DASHPOT1/2/A; Nastran CONM1, CELAS2, CDAMP2; LS-DYNA ELEMENT_MASS/INERTIA and ELEMENT_DISCRETE; what a format cannot take is reported. Commands model.mass.* and model.spring.*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Implicit solver: trusses, point masses, rotary inertia and springs on the DofLayout Trusses declare their translations like solids; point masses, inertia and springs ask the layout for the kinds they need at their nodes (translations, rotations), and reference points they use get six degrees of freedom. The matrix pattern takes rows for those points; dashpots are ignored with a warning. Existing results are bit-identical (hash-checked). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Explicit solver: trusses, point masses, springs and dashpots on the node-major layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: fem.md on trusses and discrete elements in the layout; regenerate features.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: trusses in plane models and corotational explicit trusses 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>
532 lines
28 KiB
Rust
532 lines
28 KiB
Rust
//! Model definition commands: create, edit and delete sets, materials, sections, reference
|
||
//! points, couplings, point masses, springs and dashpots, boundary conditions, loads, fluid
|
||
//! domains and steps, and the step activity table.
|
||
//!
|
||
//! Each definition's parameters are its JSON form in the model (the same form a `.modelcraft`
|
||
//! file stores), plus its `name`. `create` fails when the name is taken, `edit` replaces an
|
||
//! existing definition, `delete` refuses while another definition refers to it.
|
||
|
||
use modelcraft_model::{
|
||
BoundaryCondition, Coupling, FaceCondition, FluidBoundary, FluidDomain, Load, Material, Model, PointMass, ReferencePoint, Section, Set, Spring,
|
||
Step,
|
||
};
|
||
use serde::de::DeserializeOwned;
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{name, opt, req};
|
||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||
|
||
/// The parameters other than `name`, read as a definition's JSON form.
|
||
fn fields<T: DeserializeOwned>(p: &Value, what: &str) -> Result<T> {
|
||
let mut obj = p.as_object().cloned().ok_or_else(|| EngineError::Params(format!("the parameters of a {what} are a JSON object")))?;
|
||
obj.remove("name");
|
||
serde_json::from_value(Value::Object(obj)).map_err(|e| EngineError::Params(format!("{what}: {e}")))
|
||
}
|
||
|
||
/// Create, edit and delete commands for one kind of definition.
|
||
macro_rules! definition_commands {
|
||
($what:literal, $ty:ty, $id:literal, $label:literal, $doc:literal, $add:ident, $replace:ident, $remove:ident,
|
||
$CREATE:ident, $EDIT:ident, $DELETE:ident) => {
|
||
pub const $CREATE: CommandSpec = CommandSpec {
|
||
id: concat!("model.", $id, ".create"),
|
||
label: concat!("New ", $label),
|
||
params: concat!($doc, " — fails if the name is taken"),
|
||
kind: CommandKind::Model,
|
||
run: |s: &mut Session, p: &Value| {
|
||
let n = name(p)?;
|
||
s.model.$add(n, fields::<$ty>(p, $what)?)?;
|
||
Ok(json!({"name": n}))
|
||
},
|
||
};
|
||
pub const $EDIT: CommandSpec = CommandSpec {
|
||
id: concat!("model.", $id, ".edit"),
|
||
label: concat!("Edit ", $label),
|
||
params: concat!($doc, " — replaces the existing definition of that name"),
|
||
kind: CommandKind::Model,
|
||
run: |s: &mut Session, p: &Value| {
|
||
let n = name(p)?;
|
||
s.model.$replace(n, fields::<$ty>(p, $what)?)?;
|
||
Ok(json!({"name": n}))
|
||
},
|
||
};
|
||
pub const $DELETE: CommandSpec = CommandSpec {
|
||
id: concat!("model.", $id, ".delete"),
|
||
label: concat!("Delete ", $label),
|
||
params: r#"{"name": "…"} — refused while another definition refers to it"#,
|
||
kind: CommandKind::Model,
|
||
run: |s: &mut Session, p: &Value| {
|
||
s.model.$remove(name(p)?)?;
|
||
Ok(Value::Null)
|
||
},
|
||
};
|
||
};
|
||
}
|
||
|
||
definition_commands!(
|
||
"set",
|
||
Set,
|
||
"set",
|
||
"Set",
|
||
r#"{"name": "root", "geometry": [{"body": "Body1", "face": "F2:top"}, {"body": "Body1", "edge": "…"}, {"body": "Body1", "vertex": "…"}, {"body": "Body1"}], "nodes": [labels], "elements": [labels], "faces": [{"element": 1, "face": 3}], "edges": [{"element": 1, "edge": 2}]} — any of the member lists (element edges: where a 2D model's surface loads act); geometry members resolve against the mesh's geometry tags after every remesh (a face name also selects its split pieces `name#0`, `name#1`, …)"#,
|
||
add_set,
|
||
replace_set,
|
||
remove_set,
|
||
SET_CREATE,
|
||
SET_EDIT,
|
||
SET_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"material",
|
||
Material,
|
||
"material",
|
||
"Material",
|
||
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage)} — in the model's unit system (model.units), never converted"#,
|
||
add_material,
|
||
replace_material,
|
||
remove_material,
|
||
MATERIAL_CREATE,
|
||
MATERIAL_EDIT,
|
||
MATERIAL_DELETE
|
||
);
|
||
|
||
/// A copy of material `name` with `change` applied, put back through the model's validation.
|
||
fn change_material(s: &mut Session, p: &Value, change: impl FnOnce(&mut Material, &Value) -> Result<()>) -> Result<Value> {
|
||
let n = name(p)?;
|
||
let mut m = s.model.material(n).cloned().ok_or_else(|| EngineError::Params(format!("material `{n}` does not exist")))?;
|
||
change(&mut m, p)?;
|
||
s.model.replace_material(n, m)?;
|
||
Ok(json!({"name": n}))
|
||
}
|
||
|
||
pub const MATERIAL_PLASTICITY: CommandSpec = CommandSpec {
|
||
id: "model.material.plasticity",
|
||
label: "Plasticity",
|
||
params: r#"{"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000, "hardening": "isotropic|kinematic"} | {"type": "table", "points": [{"strain": 0, "stress": 250}, {"strain": 0.1, "stress": 300}]} | {"type": "johnson_cook", "a": 792, "b": 510, "n": 0.26, "c": 0.014, "m": 1.03, "reference_strain_rate": 1, "room_temperature": 294, "melt_temperature": 1793, "specific_heat": 4.77e8, "taylor_quinney": 0.9} | null} — von Mises plasticity of an isotropic material (null removes it); explicit dynamics uses it, the linear solver the elastic constants alone"#,
|
||
kind: CommandKind::Model,
|
||
run: |s: &mut Session, p: &Value| {
|
||
change_material(s, p, |m, p| {
|
||
m.plastic = req(p, "plastic")?;
|
||
Ok(())
|
||
})
|
||
},
|
||
};
|
||
|
||
pub const MATERIAL_DAMAGE: CommandSpec = CommandSpec {
|
||
id: "model.material.damage",
|
||
label: "Damage",
|
||
params: r#"{"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61} | null, "erosion": {"plastic_strain": 1.0} | null} — damage to failure and element erosion of a plastic material (each key optional; null removes it); a failed element is eroded in explicit dynamics"#,
|
||
kind: CommandKind::Model,
|
||
run: |s: &mut Session, p: &Value| {
|
||
change_material(s, p, |m, p| {
|
||
// Absent leaves a field as it is; null removes it.
|
||
let (damage, erosion) = (opt(p, "damage")?, opt(p, "erosion")?);
|
||
if damage.is_none() && erosion.is_none() {
|
||
return Err(EngineError::Params("give `damage`, `erosion` or both".into()));
|
||
}
|
||
if let Some(d) = damage {
|
||
m.damage = d;
|
||
}
|
||
if let Some(e) = erosion {
|
||
m.erosion = e;
|
||
}
|
||
Ok(())
|
||
})
|
||
},
|
||
};
|
||
|
||
definition_commands!(
|
||
"section",
|
||
Section,
|
||
"section",
|
||
"Section",
|
||
r#"{"name": "web", "set": "all", "material": "Steel", "kind": {"type": "solid"} | {"type": "shell", "thickness": 2, "offset": 0 (-0.5..=0.5 of the thickness), "points": 5} | {"type": "beam", "profile": {"type": "rectangle", "width", "height"} | {"type": "circle", "radius"} | {"type": "pipe", "radius", "thickness"} | {"type": "i", "height", "width", "web", "flange"} | {"type": "box", "width", "height", "thickness"}, "orientation": [x, y, z]} | {"type": "rod", "area": 10} | {"type": "plane_stress", "thickness": 1} | {"type": "plane_strain", "thickness": 1 (default)} | {"type": "axisymmetric"} (2D: elements in the XY plane; axisymmetric about Y with x the radius, forces totals over the circumference), "formulation": {"integration": "full|reduced", "incompatible_modes": false, "hybrid": false}}"#,
|
||
add_section,
|
||
replace_section,
|
||
remove_section,
|
||
SECTION_CREATE,
|
||
SECTION_EDIT,
|
||
SECTION_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"reference point",
|
||
ReferencePoint,
|
||
"reference_point",
|
||
"Reference Point",
|
||
r#"{"name": "RP", "xyz": [x, y, z], "label": 0} — label: its node label in decks (default: the highest free label, kept through remeshes)"#,
|
||
add_reference_point,
|
||
replace_reference_point,
|
||
remove_reference_point,
|
||
REFERENCE_POINT_CREATE,
|
||
REFERENCE_POINT_EDIT,
|
||
REFERENCE_POINT_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"coupling",
|
||
Coupling,
|
||
"coupling",
|
||
"Coupling",
|
||
r#"{"name": "pad", "reference_point": "RP", "set": "hole", "kind": "kinematic|distributing", "dofs": [1, 2, 3, 4, 5, 6] (default all)}"#,
|
||
add_coupling,
|
||
replace_coupling,
|
||
remove_coupling,
|
||
COUPLING_CREATE,
|
||
COUPLING_EDIT,
|
||
COUPLING_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"point mass",
|
||
PointMass,
|
||
"mass",
|
||
"Point Mass",
|
||
r#"{"name": "motor", "target": {"set": "…"} | {"reference_point": "…"}, "mass": 2.5 | [mx, my, mz] (per global direction), "inertia": [I11, I22, I33, I12, I13, I23] (tensor about the node in global axes; needs rotations: a reference point)} — at every node of the target, each with the full value"#,
|
||
add_mass,
|
||
replace_mass,
|
||
remove_mass,
|
||
MASS_CREATE,
|
||
MASS_EDIT,
|
||
MASS_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"spring",
|
||
Spring,
|
||
"spring",
|
||
"Spring or Dashpot",
|
||
r#"{"name": "mount", "type": "spring", "stiffness": 1000 | "type": "dashpot", "coefficient": 5, "first": {"set": "…"} | {"reference_point": "…"}, "second": (optional; without it each node of `first` connects to the ground; with it each end is one node), "direction": {"dof": 1..6} (4–6: torsional) | {"vector": [x, y, z]} | "line" (along the two ends, two-point only)} — dashpots act in dynamic steps; static and frequency steps ignore them"#,
|
||
add_spring,
|
||
replace_spring,
|
||
remove_spring,
|
||
SPRING_CREATE,
|
||
SPRING_EDIT,
|
||
SPRING_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"boundary condition",
|
||
BoundaryCondition,
|
||
"boundary_condition",
|
||
"Boundary Condition",
|
||
r#"{"name": "clamp", "type": "fixed", "target": {"set": "root"} | {"reference_point": "RP"}} | {"name", "type": "displacement", "target", "values": [u1, u2, u3, ur1, ur2, ur3] (null leaves a degree of freedom free)} | {"name", "type": "symmetry", "target", "plane": "x|y|z" (the plane's normal)} — active in the steps that list it (model.step.activate)"#,
|
||
add_boundary_condition,
|
||
replace_boundary_condition,
|
||
remove_boundary_condition,
|
||
BOUNDARY_CONDITION_CREATE,
|
||
BOUNDARY_CONDITION_EDIT,
|
||
BOUNDARY_CONDITION_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"load",
|
||
Load,
|
||
"load",
|
||
"Load",
|
||
r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} | {"name", "type": "centrifugal", "angular_velocity" (rad per time unit), "point": [x, y, z] (on the axis), "axis": [x, y, z], "set" (optional)} — 2D models: in-plane vectors, surface loads on element edges; active in the steps that list it (model.step.activate)"#,
|
||
add_load,
|
||
replace_load,
|
||
remove_load,
|
||
LOAD_CREATE,
|
||
LOAD_EDIT,
|
||
LOAD_DELETE
|
||
);
|
||
|
||
definition_commands!(
|
||
"fluid domain",
|
||
FluidDomain,
|
||
"fluid_domain",
|
||
"Fluid Domain",
|
||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing)}"#,
|
||
add_fluid_domain,
|
||
replace_fluid_domain,
|
||
remove_fluid_domain,
|
||
FLUID_DOMAIN_CREATE,
|
||
FLUID_DOMAIN_EDIT,
|
||
FLUID_DOMAIN_DELETE
|
||
);
|
||
|
||
pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
|
||
id: "model.fluid_domain.boundary",
|
||
label: "Inlet, Outlet or Wall",
|
||
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"} | {"type": "pressure", "pressure": 0} | {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w]} | {"type": "slip"} | {"type": "periodic"}} — sets one boundary's condition (obstacle faces take walls and moving walls)"#,
|
||
kind: CommandKind::Model,
|
||
run: fluid_boundary,
|
||
};
|
||
|
||
fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let domain: &str = req(p, "domain")?;
|
||
let at: FluidBoundary = req(p, "at")?;
|
||
let condition: FaceCondition = req(p, "condition")?;
|
||
s.model.set_fluid_boundary(domain, at, condition)?;
|
||
Ok(json!({"domain": domain}))
|
||
}
|
||
|
||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||
id: "model.step.create",
|
||
label: "New Step",
|
||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||
kind: CommandKind::Model,
|
||
run: step_create,
|
||
};
|
||
|
||
pub const STEP_EDIT: CommandSpec = CommandSpec {
|
||
id: "model.step.edit",
|
||
label: "Edit Step",
|
||
params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#,
|
||
kind: CommandKind::Model,
|
||
run: step_edit,
|
||
};
|
||
|
||
pub const STEP_DELETE: CommandSpec = CommandSpec {
|
||
id: "model.step.delete",
|
||
label: "Delete Step",
|
||
params: r#"{"name": "Bend"}"#,
|
||
kind: CommandKind::Model,
|
||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||
};
|
||
|
||
pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||
id: "model.step.activate",
|
||
label: "Activate in Step",
|
||
params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#,
|
||
kind: CommandKind::Model,
|
||
run: step_activate,
|
||
};
|
||
|
||
pub const ACTIVITY: CommandSpec =
|
||
CommandSpec { id: "model.activity", label: "Step Table", params: "{}", kind: CommandKind::Model, run: |s, _| Ok(activity(&s.model)) };
|
||
|
||
fn step_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let n = name(p)?;
|
||
let mut step = Step::new(n, req(p, "procedure")?);
|
||
if let Some(o) = opt(p, "output")? {
|
||
step.output = o;
|
||
}
|
||
step.boundary_conditions = opt(p, "boundary_conditions")?.unwrap_or_default();
|
||
step.loads = opt(p, "loads")?.unwrap_or_default();
|
||
s.model.add_step(step, opt(p, "before")?)?;
|
||
Ok(json!({"name": n}))
|
||
}
|
||
|
||
fn step_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let n = name(p)?;
|
||
let mut step = s.model.step(n).cloned().ok_or_else(|| EngineError::Params(format!("no step `{n}`")))?;
|
||
if let Some(procedure) = opt(p, "procedure")? {
|
||
step.set_procedure(procedure);
|
||
}
|
||
if let Some(o) = opt(p, "output")? {
|
||
step.output = o;
|
||
}
|
||
if let Some(b) = opt(p, "boundary_conditions")? {
|
||
step.boundary_conditions = b;
|
||
}
|
||
if let Some(l) = opt(p, "loads")? {
|
||
step.loads = l;
|
||
}
|
||
s.model.replace_step(step)?;
|
||
Ok(json!({"name": n}))
|
||
}
|
||
|
||
fn step_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let step: &str = req(p, "step")?;
|
||
let active = opt(p, "active")?.unwrap_or(true);
|
||
let bcs: Vec<&str> = opt(p, "boundary_conditions")?.unwrap_or_default();
|
||
let loads: Vec<&str> = opt(p, "loads")?.unwrap_or_default();
|
||
if bcs.is_empty() && loads.is_empty() {
|
||
return Err(EngineError::Params("list `boundary_conditions` or `loads` to switch".into()));
|
||
}
|
||
for b in &bcs {
|
||
s.model.set_boundary_condition_active(step, b, active)?;
|
||
}
|
||
for l in &loads {
|
||
s.model.set_load_active(step, l, active)?;
|
||
}
|
||
Ok(activity(&s.model))
|
||
}
|
||
|
||
/// The step × boundary-condition/load table: the steps in order, and for each item the steps it
|
||
/// is active in.
|
||
fn activity(m: &Model) -> Value {
|
||
let row =
|
||
|active: &dyn Fn(&Step) -> bool| -> Value { m.steps().iter().filter(|s| active(s)).map(|s| s.name.as_str()).collect::<Vec<_>>().into() };
|
||
let bcs: serde_json::Map<String, Value> =
|
||
m.boundary_conditions().keys().map(|n| (n.clone(), row(&|s| s.boundary_conditions.contains(n)))).collect();
|
||
let loads: serde_json::Map<String, Value> = m.loads().keys().map(|n| (n.clone(), row(&|s| s.loads.contains(n)))).collect();
|
||
json!({
|
||
"steps": m.steps().iter().map(|s| json!({"name": s.name, "procedure": s.procedure.name()})).collect::<Vec<_>>(),
|
||
"boundary_conditions": bcs,
|
||
"loads": loads,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) mod tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
/// A two-element bar tagged with body `Bar` and faces `left` and `right`.
|
||
pub(crate) fn tagged_bar(s: &mut Session) {
|
||
for (i, x) in [0.0, 1.0, 2.0].into_iter().enumerate() {
|
||
for (j, (y, z)) in [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)].into_iter().enumerate() {
|
||
s.execute("mesh.node.add", &json!({"label": 1 + i * 4 + j, "xyz": [x, y, z]})).unwrap();
|
||
}
|
||
}
|
||
// Node (i, y, z) = 1 + 4i + 2y + z.
|
||
let n = |i: u32, y: u32, z: u32| 1 + 4 * i + 2 * y + z;
|
||
for e in 0..2 {
|
||
let nodes = [n(e, 0, 0), n(e + 1, 0, 0), n(e + 1, 1, 0), n(e, 1, 0), n(e, 0, 1), n(e + 1, 0, 1), n(e + 1, 1, 1), n(e, 1, 1)];
|
||
s.execute("mesh.element.add", &json!({"label": e + 1, "kind": "hex8", "nodes": nodes})).unwrap();
|
||
}
|
||
s.execute("mesh.tag", &json!({"geometry": {"body": "Bar"}, "elements": [1, 2]})).unwrap();
|
||
s.execute("mesh.tag", &json!({"geometry": {"body": "Bar", "face": "left"}, "faces": [{"element": 1, "face": 6}]})).unwrap();
|
||
s.execute("mesh.tag", &json!({"geometry": {"body": "Bar", "face": "right"}, "faces": [{"element": 2, "face": 4}]})).unwrap();
|
||
}
|
||
|
||
pub(crate) fn defined_bar(s: &mut Session) {
|
||
tagged_bar(s);
|
||
let script = json!([
|
||
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
||
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Bar"}]}},
|
||
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "Bar", "face": "left"}]}},
|
||
{"command": "model.set.create", "params": {"name": "end", "geometry": [{"body": "Bar", "face": "right"}]}},
|
||
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}},
|
||
{"command": "model.section.create", "params": {"name": "bar", "set": "all", "material": "Steel", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": true}}},
|
||
{"command": "model.reference_point.create", "params": {"name": "RP", "xyz": [3, 0.5, 0.5]}},
|
||
{"command": "model.coupling.create", "params": {"name": "pad", "reference_point": "RP", "set": "end", "kind": "kinematic"}},
|
||
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
|
||
{"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"reference_point": "RP"}, "vector": [0, 0, -100]}},
|
||
{"command": "model.load.create", "params": {"name": "press", "type": "pressure", "set": "end", "magnitude": 2}},
|
||
{"command": "model.step.create", "params": {"name": "Bend", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
|
||
{"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 6}, "boundary_conditions": ["clamp"]}},
|
||
]);
|
||
s.run_script(&script).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn a_script_defines_a_model_that_checks_clean() {
|
||
let mut s = Session::default();
|
||
defined_bar(&mut s);
|
||
let check = s.execute("model.check", &json!({})).unwrap();
|
||
assert_eq!(check["ready"], true, "{check}");
|
||
// `press` is defined but in no step: a warning, not an error.
|
||
assert_eq!(check["warnings"], 1, "{check}");
|
||
assert!(check["diagnostics"][0]["subject"] == "load `press`", "{check}");
|
||
|
||
let r = s.execute("model.set.resolve", &json!({"name": "end"})).unwrap();
|
||
assert_eq!(r["nodes"], json!({"count": 4, "labels": [9, 10, 11, 12]}));
|
||
assert_eq!(r["faces"]["labels"], json!([{"element": 2, "face": 4}]));
|
||
|
||
let table = s.execute("model.step.activate", &json!({"step": "Bend", "loads": ["press"]})).unwrap();
|
||
assert_eq!(table["loads"]["press"], json!(["Bend"]));
|
||
assert_eq!(table["boundary_conditions"]["clamp"], json!(["Bend", "Modes"]));
|
||
assert_eq!(s.execute("model.check", &json!({})).unwrap()["warnings"], 0);
|
||
let off = s.execute("model.step.activate", &json!({"step": "Bend", "loads": ["press", "push"], "active": false})).unwrap();
|
||
assert_eq!(off["loads"], json!({"press": [], "push": []}));
|
||
|
||
let info = s.execute("model.info", &json!({})).unwrap();
|
||
assert_eq!(info["units"], "mm-t-s-N-MPa");
|
||
assert_eq!(info["definitions"]["sets"], 3);
|
||
assert_eq!(info["definitions"]["steps"], 2);
|
||
}
|
||
|
||
#[test]
|
||
fn edits_and_deletes_keep_the_model_consistent() {
|
||
let mut s = Session::default();
|
||
defined_bar(&mut s);
|
||
let journal = s.journal.len();
|
||
|
||
// Refused: a taken name, a missing reference, a referenced item, a typo in a field.
|
||
assert!(s.execute("model.set.create", &json!({"name": "all", "nodes": [1]})).is_err());
|
||
assert!(s.execute("model.section.create", &json!({"name": "x", "set": "nope", "material": "Steel", "kind": {"type": "solid"}})).is_err());
|
||
let e = s.execute("model.set.delete", &json!({"name": "end"})).unwrap_err().to_string();
|
||
assert!(e.contains("coupling `pad`") && e.contains("load `press`"), "{e}");
|
||
assert!(
|
||
s.execute("model.material.create", &json!({"name": "Al", "elastic": {"type": "isotropic", "young": 70000, "poison": 0.33}})).is_err()
|
||
);
|
||
assert!(s.execute("model.step.edit", &json!({"name": "Bend", "loads": ["nope"]})).is_err());
|
||
assert!(s.execute("model.step.create", &json!({"name": "Late", "procedure": {"type": "static"}, "before": "nope"})).is_err());
|
||
assert_eq!(s.journal.len(), journal, "refusals change nothing");
|
||
|
||
// Edits replace; the step keeps its table entries unless told otherwise.
|
||
s.execute(
|
||
"model.material.edit",
|
||
&json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 200000, "poisson": 0.29}, "density": 7.8e-9}),
|
||
)
|
||
.unwrap();
|
||
s.execute("model.step.edit", &json!({"name": "Modes", "procedure": {"type": "frequency", "modes": 12, "max_hz": 1000}})).unwrap();
|
||
assert!(s.model.step("Modes").unwrap().boundary_conditions.contains("clamp"));
|
||
s.execute(
|
||
"model.step.create",
|
||
&json!({"name": "Pre", "procedure": {"type": "static"}, "before": "Bend", "boundary_conditions": ["clamp"], "loads": ["press"]}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(s.model.steps().iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Pre", "Bend", "Modes"]);
|
||
|
||
// A load listed by a step is not deleted until no step lists it.
|
||
assert!(s.execute("model.load.delete", &json!({"name": "press"})).unwrap_err().to_string().contains("step `Pre`"));
|
||
s.execute("model.step.activate", &json!({"step": "Pre", "loads": ["press"], "active": false})).unwrap();
|
||
s.execute("model.load.delete", &json!({"name": "press"})).unwrap();
|
||
s.execute("model.coupling.delete", &json!({"name": "pad"})).unwrap();
|
||
s.execute("model.set.delete", &json!({"name": "end"})).unwrap();
|
||
s.execute("model.step.delete", &json!({"name": "Pre"})).unwrap();
|
||
assert!(s.execute("model.step.delete", &json!({"name": "Pre"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn the_check_names_the_bad_items_and_sets_follow_a_retag() {
|
||
let mut s = Session::default();
|
||
defined_bar(&mut s);
|
||
// Point the section at a face set: it has no elements, and the elements lose their section.
|
||
s.execute("model.section.edit", &json!({"name": "bar", "set": "root", "material": "Steel", "kind": {"type": "solid"}})).unwrap();
|
||
let check = s.execute("model.check", &json!({})).unwrap();
|
||
assert_eq!(check["ready"], false);
|
||
let messages: Vec<String> = check["diagnostics"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|d| format!("{}: {}", d["subject"].as_str().unwrap(), d["message"].as_str().unwrap()))
|
||
.collect();
|
||
assert!(messages.contains(&"section `bar`: set `root` has no elements".to_string()), "{messages:#?}");
|
||
assert!(messages.contains(&"model: elements 1, 2 have no section".to_string()), "{messages:#?}");
|
||
|
||
// Clearing the tags leaves geometry sets unresolved, and says so.
|
||
s.execute("mesh.tag.clear", &json!({})).unwrap();
|
||
let e = s.execute("model.set.resolve", &json!({"name": "root"})).unwrap_err().to_string();
|
||
assert!(e.contains("face left of body Bar is not on the mesh (the mesh carries no geometry tags)"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn plasticity_and_damage_edit_a_material() {
|
||
let mut s = Session::default();
|
||
defined_bar(&mut s);
|
||
let bilinear = json!({"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000});
|
||
s.execute("model.material.plasticity", &json!({"name": "Steel", "plastic": bilinear})).unwrap();
|
||
assert!(s.model.material("Steel").unwrap().plastic.is_some());
|
||
// Damage needs plasticity; D4 and D5 need Johnson–Cook: refused, the model unchanged.
|
||
let before = s.model.clone();
|
||
let jc_damage = json!({"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61});
|
||
assert!(s.execute("model.material.damage", &json!({"name": "Steel", "damage": jc_damage})).is_err());
|
||
assert_eq!(s.model, before);
|
||
s.execute(
|
||
"model.material.damage",
|
||
&json!({"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.3, "d2": 0, "d3": 0}, "erosion": {"plastic_strain": 0.5}}),
|
||
)
|
||
.unwrap();
|
||
let m = s.model.material("Steel").unwrap();
|
||
assert!(m.damage.is_some() && m.erosion.is_some());
|
||
s.execute("model.material.damage", &json!({"name": "Steel", "erosion": null})).unwrap();
|
||
assert!(s.model.material("Steel").unwrap().erosion.is_none() && s.model.material("Steel").unwrap().damage.is_some());
|
||
// Removing plasticity under damage is refused.
|
||
assert!(s.execute("model.material.plasticity", &json!({"name": "Steel", "plastic": null})).is_err());
|
||
for bad in
|
||
[json!({"name": "Steel"}), json!({"name": "Nope", "plastic": null}), json!({"name": "Steel", "plastic": {"type": "table", "points": []}})]
|
||
{
|
||
assert!(s.execute("model.material.plasticity", &bad).is_err(), "{bad}");
|
||
}
|
||
assert!(s.execute("model.material.damage", &json!({"name": "Steel"})).is_err());
|
||
}
|
||
}
|