From a0ae8b2111bb7eab1fc676a78c7f0adbb6f52e65 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:57:19 -0400 Subject: [PATCH] model: rename a definition and every reference to it (model.rename, #290) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 3 +- crates/engine/src/cmd/define.rs | 78 +++++++ crates/engine/src/cmd/forms.rs | 27 +++ crates/engine/src/cmd/mod.rs | 1 + crates/model/src/lib.rs | 1 + crates/model/src/rename.rs | 348 ++++++++++++++++++++++++++++++++ docs/features.md | 2 +- 7 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 crates/model/src/rename.rs diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 5d0b3b3..61aebba 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -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"), diff --git a/crates/engine/src/cmd/define.rs b/crates/engine/src/cmd/define.rs index 83671e3..7eb0185 100644 --- a/crates/engine/src/cmd/define.rs +++ b/crates/engine/src/cmd/define.rs @@ -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 { + 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(); diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 7584d2d..18c8416 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -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", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 99f2585..4b82655 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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, diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index d7b1758..89f38be 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -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; diff --git a/crates/model/src/rename.rs b/crates/model/src/rename.rs new file mode 100644 index 0000000..91af06b --- /dev/null +++ b/crates/model/src/rename.rs @@ -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(map: &mut BTreeMap, 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, 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); + } +} diff --git a/docs/features.md b/docs/features.md index 2c01efb..9cbf607 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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` |