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.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"), soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
// ---- Tools ---- // ---- 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.open", "Open Model", Tools, "File", "Open a .fieldcraft model"),
ready("file.sample", "Samples", Tools, "File", "Open a sample model, ready to solve"), 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"), 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::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use fieldcraft_io::{Item, ItemKind};
use fieldcraft_model::rename::Kind as RenameKind;
use super::{name, opt, req}; use super::{name, opt, req};
use crate::requirement::Requirement; use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
@@ -356,6 +359,60 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
run: step_activate, 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 { pub const ACTIVITY: CommandSpec = CommandSpec {
id: "model.activity", id: "model.activity",
label: "Step Table", label: "Step Table",
@@ -473,6 +530,27 @@ pub(crate) mod tests {
s.run_script(&script).unwrap(); 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] #[test]
fn a_script_defines_a_model_that_checks_clean() { fn a_script_defines_a_model_that_checks_clean() {
let mut s = Session::default(); 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"), 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( pub const UNITS: &[Field] = &[choice(
"system", "system",
"Unit system", "Unit system",
+1
View File
@@ -131,6 +131,7 @@ pub static ALL: &[CommandSpec] = &[
define::STEP_EDIT, define::STEP_EDIT,
define::STEP_DELETE, define::STEP_DELETE,
define::STEP_ACTIVATE, define::STEP_ACTIVATE,
define::RENAME,
define::ACTIVITY, define::ACTIVITY,
file::OPEN, file::OPEN,
file::SAVE, file::SAVE,
+1
View File
@@ -44,6 +44,7 @@ pub mod materials;
mod mesh; mod mesh;
pub mod nodal; pub mod nodal;
pub mod progress; pub mod progress;
pub mod rename;
pub mod sections; pub mod sections;
pub mod seeds; pub mod seeds;
pub mod sets; 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 | | 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 | Open Model — Open a .fieldcraft model | ready | `file.open` |
| File | Samples — Open a sample model, ready to solve | ready | `file.sample` | | File | Samples — Open a sample model, ready to solve | ready | `file.sample` |
| File | Save — Save the model to its .fieldcraft file | ready | `file.save` | | File | Save — Save the model to its .fieldcraft file | ready | `file.save` |