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

+385
View File
@@ -0,0 +1,385 @@
//! Model definition commands: create, edit and delete sets, materials, sections, reference
//! points, couplings, boundary conditions, loads 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, Load, Material, Model, ReferencePoint, Section, Set, Step};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use super::{name, opt, req};
use crate::{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"),
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"),
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"#,
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}]} — any of the member lists; 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} — in the model's unit system (model.units), never converted"#,
add_material,
replace_material,
remove_material,
MATERIAL_CREATE,
MATERIAL_EDIT,
MATERIAL_DELETE
);
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}, "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!(
"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)} — active in the steps that list it (model.step.activate)"#,
add_load,
replace_load,
remove_load,
LOAD_CREATE,
LOAD_EDIT,
LOAD_DELETE
);
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}, "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)}"#,
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"#,
run: step_edit,
};
pub const STEP_DELETE: CommandSpec = CommandSpec {
id: "model.step.delete",
label: "Delete Step",
params: r#"{"name": "Bend"}"#,
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"#,
run: step_activate,
};
pub const ACTIVITY: CommandSpec = CommandSpec { id: "model.activity", label: "Step Table", params: "{}", 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.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}");
}
}
+1 -8
View File
@@ -7,7 +7,7 @@ use modelcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOpti
use modelcraft_model::{ElementKind, MAX_ELEMENTS};
use serde_json::{Value, json};
use super::{label, labels, optional_number, points, xyz};
use super::{label, labels, listed, optional_number, points, xyz};
use crate::{CommandSpec, EngineError, Result, Session};
pub const NODE_ADD: CommandSpec = CommandSpec {
@@ -96,18 +96,11 @@ fn element_remove(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Labels listed in a report: the first ones, and how many there are.
const LISTED: usize = 100;
/// A JSON number, or a string for ±∞ (JSON has no infinity).
fn number(v: f64) -> Value {
if v.is_finite() { json!(v) } else { json!(if v > 0.0 { "inf" } else { "-inf" }) }
}
fn listed(v: &[u32]) -> Value {
json!({"count": v.len(), "labels": v.iter().take(LISTED).collect::<Vec<_>>()})
}
fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
let bins = match p.get("bins") {
None => 10,
+57 -1
View File
@@ -1,11 +1,14 @@
//! The command registry. Each area has its own module; [`ALL`] lists every command.
pub(crate) mod define;
mod geometry;
mod mesh;
mod model;
mod tags;
use modelcraft_model::MAX_LABEL;
use serde_json::Value;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{CommandSpec, EngineError, Result};
@@ -22,6 +25,37 @@ pub static ALL: &[CommandSpec] = &[
mesh::QUALITY,
mesh::DELAUNAY_POINTS,
mesh::SURFACE_CHECK,
tags::TAG,
tags::TAG_CLEAR,
model::UNITS,
model::CHECK,
model::SET_RESOLVE,
define::SET_CREATE,
define::SET_EDIT,
define::SET_DELETE,
define::MATERIAL_CREATE,
define::MATERIAL_EDIT,
define::MATERIAL_DELETE,
define::SECTION_CREATE,
define::SECTION_EDIT,
define::SECTION_DELETE,
define::REFERENCE_POINT_CREATE,
define::REFERENCE_POINT_EDIT,
define::REFERENCE_POINT_DELETE,
define::COUPLING_CREATE,
define::COUPLING_EDIT,
define::COUPLING_DELETE,
define::BOUNDARY_CONDITION_CREATE,
define::BOUNDARY_CONDITION_EDIT,
define::BOUNDARY_CONDITION_DELETE,
define::LOAD_CREATE,
define::LOAD_EDIT,
define::LOAD_DELETE,
define::STEP_CREATE,
define::STEP_EDIT,
define::STEP_DELETE,
define::STEP_ACTIVATE,
define::ACTIVITY,
];
/// A required label parameter (1..=u32::MAX; the model checks its own range).
@@ -72,3 +106,25 @@ pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64>
Some(v) => v.as_f64().ok_or_else(|| EngineError::Params(format!("`{key}` must be a number"))),
}
}
/// The `name` parameter.
pub(crate) fn name(p: &Value) -> Result<&str> {
p.get("name").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`name` must be a name".into()))
}
/// An optional parameter read as `T`'s JSON form; `None` when it is absent.
pub(crate) fn opt<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::Params(format!("`{key}`: {e}")))).transpose()
}
/// A required parameter read as `T`'s JSON form.
pub(crate) fn req<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result<T> {
opt(p, key)?.ok_or_else(|| EngineError::Params(format!("`{key}` is required")))
}
/// Entries listed in a report: the first ones, and how many there are.
pub(crate) const LISTED: usize = 100;
pub(crate) fn listed<T: serde::Serialize>(v: &[T]) -> Value {
json!({"count": v.len(), "labels": v.get(..LISTED).unwrap_or(v)})
}
+54 -2
View File
@@ -1,10 +1,62 @@
use modelcraft_model::{Severity, UnitSystem};
use serde_json::{Value, json};
use crate::{CommandSpec, Result, Session};
use super::{listed, name};
use crate::{CommandSpec, EngineError, Result, Session};
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", run: info };
pub const UNITS: CommandSpec = CommandSpec {
id: "model.units",
label: "Unit System",
params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#,
run: units,
};
pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", run: check };
pub const SET_RESOLVE: CommandSpec = CommandSpec {
id: "model.set.resolve",
label: "Resolve Set",
params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#,
run: set_resolve,
};
fn info(s: &mut Session, _: &Value) -> Result<Value> {
let counts: serde_json::Map<String, Value> = s.model.element_counts().into_iter().map(|(k, n)| (k.name().to_string(), json!(n))).collect();
Ok(json!({"nodes": s.model.nodes().len(), "elements": counts}))
let definitions: serde_json::Map<String, Value> = s.model.definition_counts().into_iter().map(|(k, n)| (k.to_string(), json!(n))).collect();
Ok(json!({"nodes": s.model.nodes().len(), "elements": counts, "units": s.model.units().label(), "definitions": definitions}))
}
fn units(s: &mut Session, p: &Value) -> Result<Value> {
let label = p.get("system").and_then(Value::as_str).unwrap_or_default();
let system = UnitSystem::from_label(label).ok_or_else(|| {
let known: Vec<&str> = UnitSystem::ALL.iter().map(|u| u.label()).collect();
EngineError::Params(format!("`system` must be one of {}", known.join(", ")))
})?;
s.model.set_units(system);
// SolidCraft designs are in millimetres.
let warning = match system.length() {
Some(length) if length != "mm" && s.design.is_some() => {
Some(format!("the open design is in millimetres but this system's length unit is {length}"))
}
_ => None,
};
Ok(json!({"system": system.label(), "warning": warning}))
}
fn check(s: &mut Session, _: &Value) -> Result<Value> {
let diagnostics = s.model.check();
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count();
Ok(json!({
"ready": errors == 0,
"errors": errors,
"warnings": diagnostics.len() - errors,
"diagnostics": diagnostics,
}))
}
fn set_resolve(s: &mut Session, p: &Value) -> Result<Value> {
let r = s.model.resolve_set(name(p)?)?;
Ok(json!({"nodes": listed(&r.nodes), "elements": listed(&r.elements), "faces": listed(&r.faces)}))
}
+63
View File
@@ -0,0 +1,63 @@
//! Geometry tags on the mesh: which body, face, edge or vertex each mesh entity lies on. Meshers
//! write them; these commands set them by hand (for a mesh from elsewhere) and clear them.
use modelcraft_model::{ElementFace, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
use super::{opt, req};
use crate::{CommandSpec, EngineError, Result, Session};
pub const TAG: CommandSpec = CommandSpec {
id: "mesh.tag",
label: "Tag Mesh with Geometry",
params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#,
run: tag,
};
pub const TAG_CLEAR: CommandSpec = CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", run: tag_clear };
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ElementEdge {
element: u32,
edge: u8,
}
/// An optional list parameter of at most [`MAX_ELEMENTS`] entries.
fn list<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Vec<T>> {
if p.get(key).and_then(Value::as_array).is_some_and(|a| a.len() > MAX_ELEMENTS) {
return Err(EngineError::Params(format!("`{key}` must be a list of at most {MAX_ELEMENTS} entries")));
}
Ok(opt(p, key)?.unwrap_or_default())
}
fn tag(s: &mut Session, p: &Value) -> Result<Value> {
let geometry: GeometryRef = req(p, "geometry")?;
let elements: Vec<u32> = list(p, "elements")?;
let nodes: Vec<u32> = list(p, "nodes")?;
let faces: Vec<ElementFace> = list(p, "faces")?;
let edges: Vec<ElementEdge> = list(p, "edges")?;
let count = elements.len() + nodes.len() + faces.len() + edges.len();
if count == 0 {
return Err(EngineError::Params("list `elements`, `faces`, `edges` or `nodes` to tag".into()));
}
let t = s.model.geometry_tag(&geometry)?;
for e in elements {
s.model.tag_element(e, t)?;
}
for f in faces {
s.model.tag_element_face(f.element, f.face, t)?;
}
for e in edges {
s.model.tag_element_edge(e.element, e.edge, t)?;
}
for n in nodes {
s.model.tag_node(n, t)?;
}
Ok(json!({"geometry": geometry, "tagged": count}))
}
fn tag_clear(s: &mut Session, _: &Value) -> Result<Value> {
s.model.clear_geometry_tags();
Ok(Value::Null)
}