Configurations: a configuration table of rows (parameter expressions and feature suppressions per row) that switch the design (config.activate: undoable, all or nothing), kept through parameter renames and feature deletes; FusionStartDesignConfigModeCmd, FusionShowDesignConfigPanelCmd (with whether the design still matches the active row), config.column, config.row

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 10a67ef259
commit b78cb886b8
7 files changed
+385

No files matched your search

+1
View File
@@ -52,6 +52,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (FusionStartDesignConfigModeCmd, FusionShowDesignConfigPanelCmd, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Known limitations
+117
View File
@@ -0,0 +1,117 @@
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
//! those values in the design, which then rebuilds as usual.
use serde::{Deserialize, Serialize};
use crate::{DocError, Document, Result};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Column {
/// A parameter's expression.
Param(String),
/// A feature's suppression (by feature id).
Suppress(u64),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfigRow {
pub name: String,
/// One cell per column: an expression, or "true"/"false" for suppression.
pub cells: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ConfigTable {
pub columns: Vec<Column>,
pub rows: Vec<ConfigRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
}
pub const MAX_COLUMNS: usize = 500;
pub const MAX_ROWS: usize = 2000;
impl ConfigTable {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.rows.is_empty()
}
}
impl Document {
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option<String> {
match c {
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
}
}
/// A column's display key: `param:<name>` or `suppress:<feature name>`.
pub fn config_key(&self, c: &Column) -> String {
match c {
Column::Param(n) => format!("param:{n}"),
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
}
}
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
let mut next = self.clone();
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
match c {
Column::Param(n) => {
if next.config_current(c).as_deref() != Some(v.as_str()) {
next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
}
}
Column::Suppress(id) => {
let on = match v.trim() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
};
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
}
}
next.configs.active = Some(row.to_string());
*self = next;
Ok(())
}
/// The active row's values all hold in the design.
pub fn configuration_matches(&self) -> bool {
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_switch_parameters() {
let mut d = Document::new("A");
d.set_param("w", "10 mm", None, None).unwrap();
d.configs.columns.push(Column::Param("w".into()));
d.configs.rows.push(ConfigRow { name: "Small".into(), cells: vec!["10 mm".into()] });
d.configs.rows.push(ConfigRow { name: "Large".into(), cells: vec!["40 mm".into()] });
d.apply_configuration("Large").unwrap();
assert_eq!(d.config_current(&Column::Param("w".into())).as_deref(), Some("40 mm"));
assert!(d.configuration_matches());
d.set_param("w", "12 mm", None, None).unwrap();
assert!(!d.configuration_matches());
d.configs.rows.push(ConfigRow { name: "Bad".into(), cells: vec!["1/".into()] });
let before = d.clone();
assert!(d.apply_configuration("Bad").is_err());
assert_eq!(d, before, "all or nothing");
}
}
+14
View File
@@ -1057,6 +1057,9 @@ pub struct Document {
/// Plastic rules.
#[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")]
pub plastic: crate::plastic::PlasticSettings,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
/// Favourite parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub favorites: std::collections::BTreeSet<String>,
@@ -1140,6 +1143,7 @@ impl Document {
assembly: Default::default(),
sheet: Default::default(),
plastic: Default::default(),
configs: Default::default(),
body_components: Default::default(),
materials: Default::default(),
appearances: Default::default(),
@@ -1296,6 +1300,16 @@ impl Document {
}
}
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
if keep.contains(&false) {
let mut k = keep.iter();
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
for r in &mut self.configs.rows {
let mut k = keep.iter();
r.cells.retain(|_| k.next().copied().unwrap_or(true));
}
}
if let Some(m) = self.marker
&& m > idx
{
+1
View File
@@ -9,6 +9,7 @@ pub mod appearance;
mod assembly;
pub mod canvas;
mod clipboard;
pub mod config;
mod document;
mod eval;
pub mod expr;
+11
View File
@@ -650,6 +650,17 @@ impl Document {
if self.favorites.remove(old) {
self.favorites.insert(new.to_string());
}
for c in &mut self.configs.columns {
if *c == crate::config::Column::Param(old.to_string()) {
*c = crate::config::Column::Param(new.to_string());
}
}
// Configuration cells refer to parameters too.
for r in &mut self.configs.rows {
for cell in &mut r.cells {
*cell = expr::rename_reference(cell, old, new);
}
}
if let Some(c) = self.param_comments.remove(old) {
self.param_comments.insert(new.to_string(), c);
}
+239
View File
@@ -0,0 +1,239 @@
//! Configurations: the design's configuration table (rows of parameter values and feature
//! suppressions) and switching between its rows.
use serde_json::{Value, json};
use solvecraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionStartDesignConfigModeCmd", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("FusionShowDesignConfigPanelCmd", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
CommandSpec::new("config.row", "Configuration Row", row).params(
"name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
),
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
];
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
if let Some(n) = str_(p, "param") {
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
return Err(bad(cmd, format!("no parameter `{n}`")));
}
return Ok(Column::Param(n.to_string()));
}
let f = str_(p, "feature").ok_or_else(|| bad(cmd, "give `param` or `feature`"))?;
let id = s.doc.find_feature(f).map(|x| x.id).ok_or_else(|| bad(cmd, format!("no feature `{f}`")))?;
Ok(Column::Suppress(id))
}
/// Add a column, filling existing rows with the design's current value.
fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
if s.doc.configs.columns.contains(&c) {
return Ok(());
}
if s.doc.configs.columns.len() >= MAX_COLUMNS {
return Err(bad(cmd, "too many columns"));
}
let cur = s.doc.config_current(&c).unwrap_or_default();
let t = &mut s.doc_mut().configs;
t.columns.push(c);
for r in &mut t.rows {
r.cells.push(cur.clone());
}
Ok(())
}
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionStartDesignConfigModeCmd";
for n in string_list(p, "params") {
let c = column_of(s, &json!({ "param": n }), cmd)?;
add_column(s, c, cmd)?;
}
for n in string_list(p, "features") {
let c = column_of(s, &json!({ "feature": n }), cmd)?;
add_column(s, c, cmd)?;
}
if s.doc.configs.rows.is_empty() {
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
let t = &mut s.doc_mut().configs;
t.rows.push(ConfigRow { name: "Default".into(), cells });
t.active = Some("Default".into());
}
show(s, p)
}
fn show(s: &mut Session, _p: &Value) -> Result<Value> {
let t = &s.doc.configs;
let keys: Vec<String> = t.columns.iter().map(|c| s.doc.config_key(c)).collect();
let rows: Vec<Value> = t
.rows
.iter()
.map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>()}))
.collect();
Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()}))
}
fn column(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.column";
let c = column_of(s, p, cmd)?;
if bool_(p, "delete").unwrap_or(false) {
let t = &mut s.doc_mut().configs;
let i = t.columns.iter().position(|x| *x == c).ok_or_else(|| bad(cmd, "not a column of the table"))?;
t.columns.remove(i);
for r in &mut t.rows {
if i < r.cells.len() {
r.cells.remove(i);
}
}
} else {
add_column(s, c, cmd)?;
}
show(s, p)
}
fn row(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.row";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
if bool_(p, "delete").unwrap_or(false) {
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
let t = &mut s.doc_mut().configs;
t.rows.remove(i);
if t.active.as_deref() == Some(name.as_str()) {
t.active = None;
}
return show(s, p);
}
let mut cells: Vec<String> = match (idx, str_(p, "from")) {
(_, Some(f)) => s.doc.configs.rows.iter().find(|r| r.name == f).map(|r| r.cells.clone()).ok_or_else(|| bad(cmd, format!("no row `{f}`")))?,
(Some(i), None) => s.doc.configs.rows.get(i).map(|r| r.cells.clone()).unwrap_or_default(),
(None, None) => s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect(),
};
if let Some(vals) = p.get("values") {
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
for (k, v) in vals {
let text = match v {
Value::String(t) => t.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
};
if text.len() > 4096 {
return Err(bad(cmd, "value too long"));
}
let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
let col = s
.doc
.configs
.columns
.iter()
.position(|c| match c {
Column::Param(n) => n == key,
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
})
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
if let Some(cell) = cells.get_mut(col) {
*cell = text;
}
}
}
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
let t = &mut s.doc_mut().configs;
match idx {
Some(i) => {
if let Some(r) = t.rows.get_mut(i) {
r.cells = cells;
if let Some(n) = rename {
if t.active.as_deref() == Some(r.name.as_str()) {
t.active = Some(n.clone());
}
r.name = n;
}
}
}
None => {
if t.rows.len() >= MAX_ROWS {
return Err(bad(cmd, "too many rows"));
}
t.rows.push(ConfigRow { name, cells });
}
}
show(s, p)
}
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.activate";
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?;
s.refresh();
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
let mut out = show(s, p)?;
out["errors"] = json!(errors);
Ok(out)
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn configurations_switch_sizes_and_suppressions() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "PrimitiveBox", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 5]], "radius": 3}));
let fillet = s.doc.features.last().map(|f| f.name.clone()).unwrap_or_default();
let t = run(&mut s, "FusionStartDesignConfigModeCmd", json!({"params": ["len"], "features": [fillet]}));
assert_eq!(t["rows"][0]["name"], "Default");
run(&mut s, "config.row", json!({"name": "Long", "values": {"len": "80 mm"}}));
run(&mut s, "config.row", json!({"name": "Sharp", "from": "Long", "values": {fillet.clone(): true}}));
let filleted = 8000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0;
assert!((volume(&mut s) - filleted).abs() < 0.5);
run(&mut s, "config.activate", json!({"row": "Long"}));
assert!((volume(&mut s) - (16000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0)).abs() < 0.5, "{}", volume(&mut s));
run(&mut s, "config.activate", json!({"row": "Sharp"}));
assert!((volume(&mut s) - 16000.0).abs() < 1e-6);
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
assert_eq!((t["active"].as_str(), t["matches"].as_bool()), (Some("Sharp"), Some(true)), "{t}");
// Undo goes back to the previous configuration's values.
run(&mut s, "UndoCommand", json!({}));
assert!(volume(&mut s) > 15000.0 && volume(&mut s) < 16000.0);
// Renaming the parameter keeps the column; editing it by hand breaks the match.
run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
assert_eq!(t["columns"][0], "param:length", "{t}");
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["matches"], false);
// Bad input.
assert!(s.execute("config.row", &json!({"name": "X", "values": {"nope": 1}})).is_err());
assert!(s.execute("config.activate", &json!({"row": "Missing"})).is_err());
run(&mut s, "config.row", json!({"name": "Broken", "values": {"length": "1/"}}));
assert!(s.execute("config.activate", &json!({"row": "Broken"})).is_err());
run(&mut s, "config.row", json!({"name": "Broken", "delete": true}));
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
}
+2
View File
@@ -11,6 +11,7 @@ mod face;
mod features;
mod features_more;
pub use features::auto_operation;
mod config;
mod file;
mod inspect;
pub(crate) mod joints;
@@ -144,6 +145,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());