model: rename a definition and every reference to it (model.rename, #290)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 10:17:28 -04:00
1 parent 587cbc3b01
commit a0ae8b2111
7 files changed
+458 -2

No files matched your search

+2 -1
View File
@@ -583,7 +583,8 @@ pub static CATALOGUE: &[Capability] = &[
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
// ---- Tools ----
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
// Renaming starts from a definition's item menu (Rename…); the search finds it here too.
ready_more("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary", &["model.rename"]),
ready("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model"),
ready("file.sample", "Samples", Tools, "File", "Open a sample model, ready to solve"),
ready("file.save", "Save", Tools, "File", "Save the model to its .fieldcraft file"),
+78
View File
@@ -13,6 +13,9 @@ use fieldcraft_model::{
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use fieldcraft_io::{Item, ItemKind};
use fieldcraft_model::rename::Kind as RenameKind;
use super::{name, opt, req};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
@@ -356,6 +359,60 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
run: step_activate,
};
pub const RENAME: CommandSpec = CommandSpec {
id: "model.rename",
label: "Rename",
params: r#"{"kind": "set" | "material" | "section" | "step" | "boundary_condition" | "load" | "reference_point" | "coupling" | "contact" | "tie" | "point_mass" | "spring" | "fluid_domain" | "datum", "from": "end", "to": "Tip"} — renames the definition and every reference to it (sets in sections, couplings, contacts, ties, discrete elements, boundary conditions, loads and explicit steps; materials in sections and fluid domains; reference points; boundary conditions and loads in the steps they are active in; steps in imperfections; fluid domains in fluid steps; datum planes in partitions). A name already taken is refused; the viewport's hidden items follow the new name"#,
form: Some(super::forms::RENAME),
kind: CommandKind::Model,
requires: &[],
run: rename,
};
/// `model.rename`'s kinds, as the command takes them.
pub const RENAME_KINDS: &[(&str, RenameKind)] = &[
("set", RenameKind::Set),
("material", RenameKind::Material),
("section", RenameKind::Section),
("step", RenameKind::Step),
("boundary_condition", RenameKind::BoundaryCondition),
("load", RenameKind::Load),
("reference_point", RenameKind::ReferencePoint),
("coupling", RenameKind::Coupling),
("contact", RenameKind::Contact),
("tie", RenameKind::Tie),
("point_mass", RenameKind::PointMass),
("spring", RenameKind::Spring),
("fluid_domain", RenameKind::FluidDomain),
("datum", RenameKind::Datum),
];
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let kind: &str = req(p, "kind")?;
let from: &str = req(p, "from")?;
let to: &str = req(p, "to")?;
let Some((_, k)) = RENAME_KINDS.iter().find(|(id, _)| *id == kind) else {
let kinds: Vec<&str> = RENAME_KINDS.iter().map(|(id, _)| *id).collect();
return Err(EngineError::Params(format!("`kind` is one of {}", kinds.join(", "))));
};
s.model.rename(*k, from, to)?;
// What the viewport hides follows the new name.
let item = match k {
RenameKind::Datum => Some(ItemKind::Datum),
RenameKind::BoundaryCondition => Some(ItemKind::BoundaryCondition),
RenameKind::Load => Some(ItemKind::Load),
RenameKind::ReferencePoint => Some(ItemKind::ReferencePoint),
RenameKind::Coupling => Some(ItemKind::Coupling),
_ => None,
};
if let Some(kind) = item
&& s.view.hidden.remove(&Item { kind, name: from.to_string() })
{
s.view.hidden.insert(Item { kind, name: to.to_string() });
}
Ok(json!({"kind": kind, "from": from, "name": to}))
}
pub const ACTIVITY: CommandSpec = CommandSpec {
id: "model.activity",
label: "Step Table",
@@ -473,6 +530,27 @@ pub(crate) mod tests {
s.run_script(&script).unwrap();
}
/// A rename rewrites the references, checks clean, is undone in one step, follows the hidden
/// state and refuses a name in use (#290).
#[test]
fn rename_keeps_the_model_whole_and_undoes() {
let mut s = Session::default();
defined_bar(&mut s);
s.execute("view.hide", &json!({"items": [{"kind": "load", "name": "push"}]})).unwrap();
s.execute("model.rename", &json!({"kind": "set", "from": "end", "to": "Tip"})).unwrap();
s.execute("model.rename", &json!({"kind": "load", "from": "push", "to": "Push"})).unwrap();
s.execute("model.rename", &json!({"kind": "step", "from": "Bend", "to": "Load"})).unwrap();
assert!(s.model.sets().get("end").is_none() && !s.model.set_users("Tip").is_empty());
assert!(s.model.step("Load").is_some_and(|st| st.loads.contains("Push")));
assert!(s.view.is_hidden(fieldcraft_io::ItemKind::Load, "Push") && !s.view.is_hidden(fieldcraft_io::ItemKind::Load, "push"));
assert_eq!(s.execute("model.check", &json!({})).unwrap()["ready"], true);
let e = s.execute("model.rename", &json!({"kind": "set", "from": "Tip", "to": "root"})).unwrap_err().to_string();
assert!(e.contains("already exists"), "{e}");
assert!(s.execute("model.rename", &json!({"kind": "partition", "from": "a", "to": "b"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.model.step("Bend").is_some() && s.model.step("Load").is_none());
}
#[test]
fn a_script_defines_a_model_that_checks_clean() {
let mut s = Session::default();
+27
View File
@@ -834,6 +834,33 @@ pub const STEP_ACTIVATE: &[Field] = &[
flag("active", "Active").help("On: they act in the step. Off: they are switched off from it on.").def("true"),
];
pub const RENAME: &[Field] = &[
choice(
"kind",
"Kind",
&[
("set", "Set"),
("material", "Material"),
("section", "Section"),
("step", "Step"),
("boundary_condition", "Boundary condition"),
("load", "Load"),
("reference_point", "Reference point"),
("coupling", "Coupling"),
("contact", "Contact"),
("tie", "Tie"),
("point_mass", "Point mass"),
("spring", "Spring"),
("fluid_domain", "Fluid domain"),
("datum", "Datum"),
],
)
.help("What kind of definition to rename.")
.req(),
text("from", "Name").help("Its current name.").req(),
text("to", "New name").help("Every definition that refers to it is changed to the new name.").req(),
];
pub const UNITS: &[Field] = &[choice(
"system",
"Unit system",
+1
View File
@@ -131,6 +131,7 @@ pub static ALL: &[CommandSpec] = &[
define::STEP_EDIT,
define::STEP_DELETE,
define::STEP_ACTIVATE,
define::RENAME,
define::ACTIVITY,
file::OPEN,
file::SAVE,
+1
View File
@@ -44,6 +44,7 @@ pub mod materials;
mod mesh;
pub mod nodal;
pub mod progress;
pub mod rename;
pub mod sections;
pub mod seeds;
pub mod sets;
+348
View File
@@ -0,0 +1,348 @@
//! Renaming a definition and every reference to it, so a name can change while other
//! definitions use it (deleting and recreating is refused then).
//!
//! What refers to what: sets are named by sections, couplings, contacts, ties, point masses and
//! springs, boundary conditions, loads and explicit steps (history nodes, initial velocities,
//! rigid walls, mass scaling); materials by sections and fluid domains; reference points by
//! couplings, boundary conditions, loads, point masses and springs; boundary conditions and
//! loads by the steps they are active in; steps by the nonlinear steps whose imperfection is one
//! of their buckling modes; fluid domains by fluid steps; datum planes by the partitions that
//! cut by them. Partitions are not renamed: their cut faces are named after them.
use std::collections::BTreeMap;
use crate::{Error, Model, Procedure, Result, Target, check_name};
/// A kind of definition [`Model::rename`] renames.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Set,
Material,
Section,
Step,
BoundaryCondition,
Load,
ReferencePoint,
Coupling,
Contact,
Tie,
PointMass,
Spring,
FluidDomain,
Datum,
}
impl Kind {
/// The kind in words, as errors name it.
pub fn word(self) -> &'static str {
match self {
Kind::Set => "set",
Kind::Material => "material",
Kind::Section => "section",
Kind::Step => "step",
Kind::BoundaryCondition => "boundary condition",
Kind::Load => "load",
Kind::ReferencePoint => "reference point",
Kind::Coupling => "coupling",
Kind::Contact => "contact",
Kind::Tie => "tie",
Kind::PointMass => "point mass",
Kind::Spring => "spring",
Kind::FluidDomain => "fluid domain",
Kind::Datum => "datum",
}
}
}
/// Move `map`'s entry `from` to `to`: `from` must exist and `to` must be free.
fn rekey<T>(map: &mut BTreeMap<String, T>, what: &str, from: &str, to: &str) -> Result<()> {
if map.contains_key(to) {
return Err(Error::Invalid(format!("{what} `{to}` already exists")));
}
let value = map.remove(from).ok_or_else(|| Error::NotFound(format!("{what} `{from}`")))?;
map.insert(to.to_string(), value);
Ok(())
}
/// `name` becomes `to` where it is `from`.
fn swap(name: &mut String, from: &str, to: &str) {
if name == from {
*name = to.to_string();
}
}
fn swap_opt(name: &mut Option<String>, from: &str, to: &str) {
if let Some(n) = name {
swap(n, from, to);
}
}
fn swap_target(target: &mut Target, kind: Kind, from: &str, to: &str) {
match (target, kind) {
(Target::Set(s), Kind::Set) | (Target::ReferencePoint(s), Kind::ReferencePoint) => swap(s, from, to),
_ => {}
}
}
impl Model {
/// Rename definition `from` of `kind` to `to`, rewriting every reference to it. A name in use
/// by another definition of the kind, or an invalid one, is refused and nothing changes;
/// renaming to the same name does nothing.
pub fn rename(&mut self, kind: Kind, from: &str, to: &str) -> Result<()> {
let what = kind.word();
check_name(what, to)?;
if from == to {
return self.exists(kind, from);
}
// The definition moves first: it fails before anything else changes.
match kind {
Kind::Set => rekey(&mut self.sets, what, from, to)?,
Kind::Material => rekey(&mut self.materials, what, from, to)?,
Kind::Section => rekey(&mut self.sections, what, from, to)?,
Kind::BoundaryCondition => rekey(&mut self.boundary_conditions, what, from, to)?,
Kind::Load => rekey(&mut self.loads, what, from, to)?,
Kind::ReferencePoint => rekey(&mut self.reference_points, what, from, to)?,
Kind::Coupling => rekey(&mut self.couplings, what, from, to)?,
Kind::Contact => rekey(&mut self.contacts, what, from, to)?,
Kind::Tie => rekey(&mut self.ties, what, from, to)?,
Kind::PointMass => rekey(&mut self.masses, what, from, to)?,
Kind::Spring => rekey(&mut self.springs, what, from, to)?,
Kind::FluidDomain => rekey(&mut self.fluid_domains, what, from, to)?,
Kind::Datum => rekey(&mut self.datums, what, from, to)?,
Kind::Step => {
if self.steps.iter().any(|s| s.name == to) {
return Err(Error::Invalid(format!("step `{to}` already exists")));
}
let step = self.steps.iter_mut().find(|s| s.name == from).ok_or_else(|| Error::NotFound(format!("step `{from}`")))?;
step.name = to.to_string();
}
}
self.rewrite_references(kind, from, to);
Ok(())
}
fn exists(&self, kind: Kind, name: &str) -> Result<()> {
let found = match kind {
Kind::Set => self.sets.contains_key(name),
Kind::Material => self.materials.contains_key(name),
Kind::Section => self.sections.contains_key(name),
Kind::BoundaryCondition => self.boundary_conditions.contains_key(name),
Kind::Load => self.loads.contains_key(name),
Kind::ReferencePoint => self.reference_points.contains_key(name),
Kind::Coupling => self.couplings.contains_key(name),
Kind::Contact => self.contacts.contains_key(name),
Kind::Tie => self.ties.contains_key(name),
Kind::PointMass => self.masses.contains_key(name),
Kind::Spring => self.springs.contains_key(name),
Kind::FluidDomain => self.fluid_domains.contains_key(name),
Kind::Datum => self.datums.contains_key(name),
Kind::Step => self.steps.iter().any(|s| s.name == name),
};
if found { Ok(()) } else { Err(Error::NotFound(format!("{} `{name}`", kind.word()))) }
}
/// Every reference to `from` of `kind` now names `to`.
fn rewrite_references(&mut self, kind: Kind, from: &str, to: &str) {
match kind {
Kind::Set | Kind::ReferencePoint => {
let set = kind == Kind::Set;
for c in self.couplings.values_mut() {
swap(if set { &mut c.set } else { &mut c.reference_point }, from, to);
}
for b in self.boundary_conditions.values_mut() {
use crate::BoundaryCondition as B;
let (B::Fixed { target } | B::Displacement { target, .. } | B::Symmetry { target, .. } | B::Temperature { target, .. }) = b;
swap_target(target, kind, from, to);
}
for l in self.loads.values_mut() {
use crate::Load as L;
match l {
L::Force { target, .. } | L::Moment { target, .. } | L::Temperature { target, .. } => swap_target(target, kind, from, to),
L::Traction { set: s, .. }
| L::Pressure { set: s, .. }
| L::BodyForce { set: s, .. }
| L::SurfaceFlux { set: s, .. }
| L::Convection { set: s, .. }
| L::HeatSource { set: s, .. } => {
if set {
swap(s, from, to);
}
}
L::Gravity { set: s, .. } | L::Centrifugal { set: s, .. } => {
if set {
swap_opt(s, from, to);
}
}
}
}
for m in self.masses.values_mut() {
swap_target(&mut m.target, kind, from, to);
}
for s in self.springs.values_mut() {
use crate::Spring as S;
let (S::Spring { first, second, .. } | S::Dashpot { first, second, .. }) = s;
swap_target(first, kind, from, to);
if let Some(t) = second {
swap_target(t, kind, from, to);
}
}
if set {
self.rewrite_set_only(from, to);
}
}
Kind::Material => {
for s in self.sections.values_mut() {
swap(&mut s.material, from, to);
}
for d in self.fluid_domains.values_mut() {
swap(&mut d.material, from, to);
}
}
Kind::BoundaryCondition | Kind::Load => {
for step in &mut self.steps {
let active = if kind == Kind::Load { &mut step.loads } else { &mut step.boundary_conditions };
if active.remove(from) {
active.insert(to.to_string());
}
}
}
Kind::Step => {
for step in &mut self.steps {
if let Procedure::NonlinearStatic(n) = &mut step.procedure
&& let Some(i) = &mut n.imperfection
{
swap(&mut i.step, from, to);
}
}
}
Kind::FluidDomain => {
for step in &mut self.steps {
if let Procedure::Fluid { domain, .. } = &mut step.procedure {
swap(domain, from, to);
}
}
}
Kind::Datum => {
for p in &mut self.partitions {
swap(&mut p.datum, from, to);
}
}
Kind::Section | Kind::Coupling | Kind::Contact | Kind::Tie | Kind::PointMass | Kind::Spring => {}
}
}
/// The references only a set has: sections, contacts, ties and explicit steps.
fn rewrite_set_only(&mut self, from: &str, to: &str) {
for s in self.sections.values_mut() {
swap(&mut s.set, from, to);
}
for c in self.contacts.values_mut() {
match &mut c.surfaces {
crate::ContactSurfaces::Pair { main, secondary } => {
swap(main, from, to);
swap(secondary, from, to);
}
crate::ContactSurfaces::General { surface } => swap_opt(surface, from, to),
}
}
for t in self.ties.values_mut() {
swap(&mut t.main, from, to);
swap(&mut t.secondary, from, to);
}
for step in &mut self.steps {
if let Procedure::ExplicitDynamic(e) = &mut step.procedure {
swap_opt(&mut e.history_nodes, from, to);
for v in &mut e.initial_velocities {
swap(&mut v.set, from, to);
}
for w in &mut e.rigid_walls {
swap(&mut w.set, from, to);
}
if let Some(m) = &mut e.mass_scaling {
swap_opt(&mut m.set, from, to);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BoundaryCondition, Coupling, CouplingKind, Datum, Dofs, Load, Material, Partition, ReferencePoint, Section, Set, Step, Tie};
/// A model whose definitions refer to each other: set `s` in a section, a coupling, a tie, a
/// boundary condition and a load; material `m` in the section; reference point `rp` in the
/// coupling and a load; both loads and the condition active in step `go`; datum `d` cut by.
fn model() -> Model {
let mut m = Model::default();
for s in ["s", "t"] {
m.add_set(s, Set { nodes: vec![1], ..Set::default() }).unwrap();
}
m.add_material("m", Material { elastic: Some(crate::Elastic::Isotropic { young: 1.0, poisson: 0.3 }), ..Material::default() }).unwrap();
m.add_section("sec", Section { set: "s".into(), material: "m".into(), kind: crate::SectionKind::Solid {}, formulation: Default::default() })
.unwrap();
m.add_reference_point("rp", ReferencePoint { xyz: [0.0; 3], label: 0 }).unwrap();
m.add_coupling("c", Coupling { reference_point: "rp".into(), set: "s".into(), kind: CouplingKind::Kinematic, dofs: Dofs::all() }).unwrap();
m.add_tie("tie", Tie::new("s", "t")).unwrap();
m.add_boundary_condition("fix", BoundaryCondition::Fixed { target: Target::Set("s".into()) }).unwrap();
m.add_load("p", Load::Pressure { set: "s".into(), magnitude: 1.0 }).unwrap();
m.add_load("f", Load::Force { target: Target::ReferencePoint("rp".into()), vector: [1.0, 0.0, 0.0], distribution: Default::default() })
.unwrap();
m.add_step(Step::new("go", Procedure::Static {}), None).unwrap();
m.set_boundary_condition_active("go", "fix", true).unwrap();
m.set_load_active("go", "p", true).unwrap();
m.set_load_active("go", "f", true).unwrap();
m.add_datum("d", Datum::Plane { point: [0.0; 3], normal: [0.0, 0.0, 1.0] }).unwrap();
m.add_partition(Partition { name: "half".into(), datum: "d".into(), bodies: Vec::new() }).unwrap();
m
}
/// Whatever is renamed, every reference follows: the old name has no users left, the new
/// one has all of them, and the model reads back from its file.
#[test]
fn renaming_rewrites_every_reference() {
let mut m = model();
let users = m.set_users("s");
assert_eq!(users.len(), 5, "{users:?}");
m.rename(Kind::Set, "s", "S2").unwrap();
assert!(m.set_users("s").is_empty() && m.sets().get("s").is_none());
assert_eq!(m.set_users("S2"), users);
let users = m.material_users("m");
m.rename(Kind::Material, "m", "steel").unwrap();
assert_eq!(m.material_users("steel"), users);
assert_eq!(m.section("sec").map(|s| s.material.as_str()), Some("steel"));
let users = m.reference_point_users("rp");
m.rename(Kind::ReferencePoint, "rp", "RP-1").unwrap();
assert!(m.reference_point_users("rp").is_empty());
assert_eq!(m.reference_point_users("RP-1"), users);
m.rename(Kind::BoundaryCondition, "fix", "clamp").unwrap();
m.rename(Kind::Load, "f", "push").unwrap();
m.rename(Kind::Step, "go", "Load").unwrap();
let step = m.steps().iter().find(|s| s.name == "Load").unwrap();
assert!(step.boundary_conditions.contains("clamp") && step.loads.contains("push") && step.loads.contains("p"));
m.rename(Kind::Datum, "d", "Mid").unwrap();
assert_eq!(m.datum_users("Mid"), ["partition `half`"]);
let json = serde_json::to_value(&m).unwrap();
let back: Model = serde_json::from_value(json).unwrap();
assert_eq!(back, m);
}
/// A taken, invalid or missing name changes nothing; the same name is a no-op.
#[test]
fn a_rename_that_cannot_be_done_changes_nothing() {
let mut m = model();
let before = m.clone();
assert!(m.rename(Kind::Set, "s", "t").unwrap_err().to_string().contains("already exists"));
assert!(m.rename(Kind::Set, "nope", "x").is_err());
assert!(m.rename(Kind::Set, "s", "").is_err());
assert!(m.rename(Kind::Step, "go", "go").is_ok());
assert!(m.rename(Kind::Step, "gone", "gone").is_err());
assert_eq!(m, before);
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Panel | Capability | Status | Command |
|---|---|---|---|
| Model | Model Info — Node and element counts and the model summary | ready | `model.info` |
| Model | Model Info — Node and element counts and the model summary | ready | `model.info`, `model.rename` |
| File | Open Model — Open a .fieldcraft model | ready | `file.open` |
| File | Samples — Open a sample model, ready to solve | ready | `file.sample` |
| File | Save — Save the model to its .fieldcraft file | ready | `file.save` |