diff --git a/ROADMAP.md b/ROADMAP.md index f99e8c48..040a29c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `.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 diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs new file mode 100644 index 00000000..88c3941d --- /dev/null +++ b/crates/doc/src/config.rs @@ -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, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ConfigTable { + pub columns: Vec, + pub rows: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, +} + +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 { + 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:` or `suppress:`. + 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"); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 5a0f092e..cfc46075 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, @@ -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 = 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 { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2821e15f..fac43a33 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ pub mod appearance; mod assembly; pub mod canvas; mod clipboard; +pub mod config; mod document; mod eval; pub mod expr; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 28670472..41443e15 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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); } diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs new file mode 100644 index 00000000..a29ff524 --- /dev/null +++ b/crates/engine/src/cmd/config.rs @@ -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: or suppress:), 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 { + 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 { + 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 { + let t = &s.doc.configs; + let keys: Vec = t.columns.iter().map(|c| s.doc.config_key(c)).collect(); + let rows: Vec = t + .rows + .iter() + .map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::>()})) + .collect(); + Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()})) +} + +fn column(s: &mut Session, p: &Value) -> Result { + 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 { + 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 = 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 { + 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 = 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)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6a9705ae..ed353149 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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());