feat: user-defined physical materials stored in the design (#532) (#606)

A design can define its own physical materials (material.create, material.edit, material.delete): name, density and an optional look. They work everywhere built-in materials do: assignment, mass properties, colours and configuration cells. Stored in user_materials, an additive field under format 16. Un-ignores the #554 regression test. Fixes #532.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-13 04:47:23 -04:00
1 parent a2f31f683d
commit 14635e5d3a
13 files changed
+492 -37

No files matched your search

@@ -132,7 +132,6 @@ fn excessively_nested_assembly_refuses_partial_totals() {
/// rubber, leather). A user-defined material with its density is assigned like a built-in one
/// and sets the body's mass: a 10 mm walnut cube at 0.65 g/cm³ weighs 0.65 g.
#[test]
#[ignore = "fails until #532 is fixed"]
fn user_defined_material_sets_the_body_mass() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
@@ -143,3 +142,160 @@ fn user_defined_material_sets_the_body_mass() {
close(&props["bodies"][0]["density_g_cm3"], 0.65);
close(&props["total"]["mass_g"], 0.65);
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-user-material-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("design.solidcraft").to_string_lossy().to_string();
(dir, path)
}
fn mass(s: &mut Session) -> Value {
run(s, "component.properties", json!({"component": "root"}))["total"]["mass_g"].clone()
}
/// #532: a design's own material is listed after the library, gives its bodies its look, and
/// can be edited (density and name: the bodies follow) and deleted; each step is undoable.
#[test]
fn user_defined_material_is_edited_renamed_and_deleted() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Pad"}));
assert!(s.execute("material.edit", &json!({"material": "Rubber", "density_g_cm3": 1.2})).is_err(), "nothing to edit yet");
let created = run(&mut s, "material.create", json!({"name": "Rubber", "density_g_cm3": 1.5, "color": "#1c1c1c"}));
assert_eq!(created["appearance"]["color"], "#1c1c1c", "{created}");
let list = run(&mut s, "material.list", json!({}));
let materials = list["materials"].as_array().unwrap();
assert_eq!(materials.len(), solidcraft_doc::MATERIALS.len() + 1);
let rubber = materials.last().unwrap();
assert_eq!((rubber["name"].as_str(), rubber["user"].as_bool()), (Some("Rubber"), Some(true)));
// Assigned (in any case), it sets the mass and the body's colour.
run(&mut s, "material.assign", json!({"bodies": ["Pad"], "material": "rubber"}));
close(&mass(&mut s), 1.5);
let pad = s.model.state().body("Pad").unwrap().clone();
let grey = 28.0 / 255.0;
assert_eq!(s.doc.body_color(&pad), Some([grey, grey, grey]));
// A new density changes the mass; a new name carries the body with it.
run(&mut s, "material.edit", json!({"material": "Rubber", "density_g_cm3": 1.2, "name": "Foot Rubber"}));
close(&mass(&mut s), 1.2);
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Foot Rubber");
run(&mut s, "edit.undo", json!({}));
close(&mass(&mut s), 1.5);
assert_eq!(s.doc.materials.get("Pad").map(String::as_str), Some("Rubber"));
run(&mut s, "edit.redo", json!({}));
// A material in use is not deleted unless its bodies get another one.
let refused = s.execute("material.delete", &json!({"material": "Foot Rubber"})).unwrap_err().to_string();
assert!(refused.contains("body `Pad`"), "{refused}");
run(&mut s, "material.delete", json!({"material": "Foot Rubber", "replace": "Oak"}));
close(&mass(&mut s), 0.75);
assert!(s.doc.user_materials.is_empty());
assert!(s.execute("material.assign", &json!({"bodies": ["Pad"], "material": "Foot Rubber"})).is_err());
run(&mut s, "edit.undo", json!({}));
close(&mass(&mut s), 1.2);
}
/// #532: names and densities are checked before anything changes.
#[test]
fn user_defined_material_refuses_bad_names_and_densities() {
let mut s = Session::default();
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
let doc = s.doc.clone();
for p in [
json!({"name": "Steel", "density_g_cm3": 1.0}),
json!({"name": "walnut", "density_g_cm3": 1.0}),
json!({"name": " ", "density_g_cm3": 1.0}),
json!({"name": "x".repeat(129), "density_g_cm3": 1.0}),
json!({"name": "Leather", "density_g_cm3": 0}),
json!({"name": "Leather", "density_g_cm3": -1}),
json!({"name": "Leather", "density_g_cm3": 1e9}),
json!({"name": "Leather", "density_g_cm3": "heavy"}),
json!({"name": "Leather"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "color": "#zzzzzz"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "appearance": "No Such Look"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "opacity": 0.5}),
] {
assert!(s.execute("material.create", &p).is_err(), "{p}");
}
for p in [
json!({"material": "Steel", "density_g_cm3": 1.0}),
json!({"material": "Walnut", "name": "Oak"}),
json!({"material": "Walnut", "density_g_cm3": 0}),
] {
assert!(s.execute("material.edit", &p).is_err(), "{p}");
}
assert_eq!(*s.doc, *doc);
}
/// #532: a design's materials are saved with it: after reopening, the bodies keep their
/// material, its density and its look.
#[test]
fn user_defined_material_survives_save_and_reopen() {
let (dir, path) = temp_design("saved");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Tab"}));
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
run(&mut s, "file.save", json!({"path": path}));
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Leather", "{props}");
close(&props["total"]["mass_g"], 0.86);
assert_eq!(s.doc.material_look("Leather").map(|l| l.name), Some("Rubber - Black".to_string()));
run(&mut s, "material.edit", json!({"material": "Leather", "density_g_cm3": 0.9}));
close(&mass(&mut s), 0.9);
let _ = std::fs::remove_dir_all(&dir);
}
/// #532: designs from before user materials open unchanged: built-in assignments keep their
/// densities and a design without its own materials saves no `user_materials`. A damaged
/// file's unusable density falls back to the default instead of producing nonsense masses.
#[test]
fn designs_without_user_materials_open_unchanged() {
let (dir, path) = temp_design("legacy");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "Steel"}));
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
assert!(legacy.get("user_materials").is_none());
legacy["format"] = json!("solidcraft/16");
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), 7.85);
assert!(s.doc.user_materials.is_empty());
assert!(s.execute("material.delete", &json!({"material": "Steel"})).is_err(), "no design materials: nothing to delete");
legacy["user_materials"] = json!([{"name": "Broken", "density": -3.0, "appearance": {"name": "Broken", "color": [1, 2, 3], "opacity": 9.0}}]);
legacy["materials"] = json!({"Plate": "Broken"});
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
let _ = std::fs::remove_dir_all(&dir);
}
/// #532: configuration material cells take a design's own material and follow its rename.
#[test]
fn configurations_switch_to_a_user_defined_material() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
run(&mut s, "config.configure", json!({"materials": ["Slat"]}));
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
close(&mass(&mut s), 0.65);
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
run(&mut s, "config.activate", json!({"row": "Default"}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
assert_eq!(s.doc.materials.get("Slat").map(String::as_str), Some("Black Walnut"));
run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"}));
assert!(!s.doc.materials.contains_key("Slat"));
}
+1 -1
View File
@@ -265,7 +265,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
match s.doc.configs.columns.get(col) {
Some(Column::Material(_)) => {
text = solidcraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
}
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
+14 -11
View File
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
@@ -34,7 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
CommandSpec::new("material.list", "List Materials", material_list)
.noundo()
.doc_only()
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -283,11 +286,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.assign";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solidcraft_doc::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
.map(|(n, _)| n.to_string())
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let canonical = s.doc.material_name(material).ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let st = s.model.state();
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
return Err(bad(cmd, "`bodies` must list existing bodies"));
@@ -303,7 +302,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
fn color_param(v: &Value) -> Option<[u8; 3]> {
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
if let Some(t) = v.as_str() {
let h = t.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
@@ -455,7 +454,7 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
c.dist(p)
}
fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
}
@@ -524,6 +523,10 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"materials": solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
let user = s.doc.user_materials.iter().map(
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
);
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
}
+214
View File
@@ -0,0 +1,214 @@
//! Physical materials defined in a design (`material.create`, `material.edit`,
//! `material.delete`): a name, a density and, optionally, a look. They are assigned like the
//! built-in library's (`material.assign`), set masses (`component.properties`) and fill
//! configuration material cells. The design keeps them, so they are saved and undone like
//! features.
use serde_json::{Value, json};
use solidcraft_doc::appearance::{Look, library};
use solidcraft_doc::config::Column;
use solidcraft_doc::{MATERIALS, UserMaterial, valid_density};
use super::CommandSpec;
use super::inspect::{color_param, look_json};
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
),
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
),
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
),
];
/// Most materials a design defines.
const MAX_USER_MATERIALS: usize = 1000;
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
}
/// A new material name: 1–128 characters, not a built-in material's or another design
/// material's (`except`: the one being renamed).
fn new_name(s: &Session, cmd: &str, name: &str, except: Option<&str>) -> Result<String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 128 {
return Err(bad(cmd, "`name` must have 1–128 characters"));
}
if MATERIALS.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
return Err(bad(cmd, format!("`{name}` is a built-in material (material.list)")));
}
let taken = |m: &&UserMaterial| m.name.eq_ignore_ascii_case(name) && except.is_none_or(|e| !m.name.eq_ignore_ascii_case(e));
if s.doc.user_materials.iter().any(|m| taken(&m)) {
return Err(bad(cmd, format!("the design already has a material `{name}`")));
}
Ok(name.to_string())
}
fn density(cmd: &str, p: &Value) -> Result<f64> {
p.get("density_g_cm3")
.and_then(Value::as_f64)
.filter(|d| valid_density(*d))
.ok_or_else(|| bad(cmd, "`density_g_cm3` must be a number above 0 and at most 1000"))
}
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
/// to clear it (`color: null`).
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
let opacity = match p.get("opacity") {
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
None => None,
};
let mut l = match (str_(p, "appearance"), p.get("color")) {
(Some(_), Some(c)) if !c.is_null() => return Err(bad(cmd, "give `appearance` or `color`, not both")),
(Some(n), _) => library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?,
(None, Some(Value::Null)) => return Ok(Some(None)),
(None, Some(c)) => {
let mut l = Look::custom(color_param(c).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?, 1.0);
l.name = material.to_string();
l
}
(None, None) if opacity.is_some() => return Err(bad(cmd, "`opacity` needs `appearance` or `color`")),
(None, None) => return Ok(None),
};
if let Some(o) = opacity {
l.opacity = o;
}
Ok(Some(Some(l)))
}
fn material_json(m: &UserMaterial) -> Value {
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
}
/// The index of the design material `material` names.
fn existing(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
let name = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
s.doc
.user_materials
.iter()
.position(|m| m.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| bad(cmd, format!("the design defines no material `{name}` (built-in materials cannot be changed)")))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.create";
let name = new_name(s, cmd, str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?, None)?;
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
}
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
let out = material_json(&material);
s.doc_mut().user_materials.push(material);
Ok(out)
}
/// Which configuration columns hold materials.
fn material_columns(s: &Session) -> Vec<bool> {
s.doc.configs.columns.iter().map(|c| matches!(c, Column::Material(_))).collect()
}
/// Where material `name` is used: bodies, and configuration rows with a cell naming it.
fn uses(s: &Session, name: &str) -> (Vec<String>, Vec<String>) {
let bodies = s.doc.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(name)).map(|(b, _)| b.clone()).collect();
let columns = material_columns(s);
let rows = s
.doc
.configs
.rows
.iter()
.filter(|r| r.cells.iter().zip(&columns).any(|(cell, material)| *material && cell.trim().eq_ignore_ascii_case(name)))
.map(|r| r.name.clone())
.collect();
(bodies, rows)
}
/// Point every use of material `from` (body assignments, configuration cells) at `to`
/// ("Default" removes the body assignments).
fn move_uses(s: &mut Session, from: &str, to: &str) {
let columns = material_columns(s);
let d = s.doc_mut();
let bodies: Vec<String> = d.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(from)).map(|(b, _)| b.clone()).collect();
for b in bodies {
if to == "Default" {
d.materials.remove(&b);
} else {
d.materials.insert(b, to.to_string());
}
}
for row in &mut d.configs.rows {
for (cell, material) in row.cells.iter_mut().zip(&columns) {
if *material && cell.trim().eq_ignore_ascii_case(from) {
*cell = to.to_string();
}
}
}
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.edit";
let i = existing(s, cmd, p)?;
let old = s.doc.user_materials.get(i).cloned().ok_or_else(|| bad(cmd, "no such material"))?;
let name = match str_(p, "name") {
Some(n) => new_name(s, cmd, n, Some(&old.name))?,
None => old.name.clone(),
};
let density = if p.get("density_g_cm3").is_some() { density(cmd, p)? } else { old.density };
let appearance = match look(cmd, p, &name)? {
Some(l) => l,
// A colour named after the material keeps following its name.
None => old.appearance.clone().map(|mut l| {
if l.name == old.name {
l.name = name.clone();
}
l
}),
};
let material = UserMaterial { name: name.clone(), density, appearance };
let mut out = material_json(&material);
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
*m = material;
}
if name != old.name {
move_uses(s, &old.name, &name);
}
out["bodies"] = json!(uses(s, &name).0);
Ok(out)
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.delete";
let i = existing(s, cmd, p)?;
let name = s.doc.user_materials.get(i).map(|m| m.name.clone()).ok_or_else(|| bad(cmd, "no such material"))?;
let (bodies, rows) = uses(s, &name);
let replace = match str_(p, "replace") {
Some(r) => {
let r = s.doc.material_name(r).ok_or_else(|| bad(cmd, format!("unknown material `{r}` (see material.list)")))?;
if r.eq_ignore_ascii_case(&name) {
return Err(bad(cmd, "`replace` must be another material"));
}
Some(r)
}
None if bodies.is_empty() && rows.is_empty() => None,
None => {
let mut used: Vec<String> =
bodies.iter().map(|b| format!("body `{b}`")).chain(rows.iter().map(|r| format!("configuration `{r}`"))).collect();
if used.len() > 10 {
let more = used.len() - 10;
used.truncate(10);
used.push(format!("{more} more"));
}
return Err(bad(cmd, format!("`{name}` is used by {}: give `replace` (a material for them)", used.join(", "))));
}
};
if let Some(r) = &replace {
move_uses(s, &name, r);
}
s.doc_mut().user_materials.remove(i);
Ok(json!({"deleted": name, "replaced_by": replace, "bodies": bodies}))
}
+2
View File
@@ -26,6 +26,7 @@ mod config;
mod file;
mod inspect;
pub(crate) mod joints;
mod materials;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
@@ -214,6 +215,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(parameters::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v