feat: preserve modeling intent across sketches, assemblies, and fabrication

Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands.

Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
bherbruck committed 2026-10-11 01:28:01 -04:00
1 parent d71bdb78ec
commit 07932d0f42
65 files changed
+6145 -484

No files matched your search

+135 -15
View File
@@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize};
use crate::{DocError, Document, Result};
#[path = "config_rules.rs"]
mod rules;
pub use rules::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Column {
@@ -32,14 +36,24 @@ pub struct ConfigTable {
pub rows: Vec<ConfigRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ConfigRule>,
}
pub const MAX_COLUMNS: usize = 500;
pub const MAX_ROWS: usize = 2000;
pub fn suppression_cell(value: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(DocError::Invalid("suppression must be true or false".into())),
}
}
impl ConfigTable {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.rows.is_empty()
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
}
}
@@ -75,10 +89,46 @@ pub fn material_cell(value: &str) -> Result<Option<String>> {
}
impl Document {
/// Freeze design-unit-sensitive configuration literals when changing display units.
/// Explicit units and nonliteral expressions retain their source text.
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
let feature_lengths: Vec<String> = self
.features
.iter()
.flat_map(|feature| {
feature
.param_names
.iter()
.zip(feature.kind.inputs())
.filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length)
.map(|(name, _)| name.clone())
})
.collect();
let lengths: std::collections::BTreeSet<String> = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect();
for row in &mut self.configs.rows {
for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) {
if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::<f64>().is_ok() {
*cell = format!("{} {previous_unit}", cell.trim());
}
}
}
for rule in &mut self.configs.rules {
rule.preserve_length_units(&lengths, previous_unit);
}
}
/// 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::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| {
// Feature literals are stored in base units, unlike newly typed bare
// row cells. Capture their unit explicitly before editing in an inch design.
if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::<f64>().is_ok() {
format!("{} mm", e.trim())
} else {
e
}
}),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
@@ -97,27 +147,34 @@ impl Document {
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
self.apply_configuration_with_trace(row).map(|_| ())
}
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
}
let mut next = self.clone();
let changes: Vec<(String, String)> = self
.configs
.columns
.iter()
.zip(&r.cells)
.filter_map(|(c, v)| match c {
Column::Param(name) => Some((name.clone(), v.clone())),
_ => None,
})
.collect();
next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?;
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
if v.len() > 4096 {
return Err(DocError::Invalid("configuration value too long".into()));
}
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::Param(_) => {}
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 on = suppression_cell(v)?;
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
@@ -140,8 +197,9 @@ impl Document {
}
}
next.configs.active = Some(row.to_string());
let trace = next.evaluate_configuration_rules()?;
*self = next;
Ok(())
Ok(trace)
}
/// The active row's values all hold in the design.
@@ -150,9 +208,24 @@ impl Document {
if r.cells.len() != self.configs.columns.len() {
return false;
}
if !self.configs.rules.is_empty() {
let mut expected = self.clone();
if expected.apply_configuration(&r.name).is_err() {
return false;
}
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
}
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(' ', "")),
Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)),
Column::Param(name) => {
let kind = self
.features
.iter()
.find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2)));
let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind));
self.config_current(c)
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
}
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
})
@@ -163,6 +236,53 @@ impl Document {
mod tests {
use super::*;
#[test]
fn rows_use_final_graph_and_missing_targets_never_reappear() {
let mut d = Document::new("A");
d.set_param("a", "1 mm", None, None).unwrap();
d.set_param("b", "a", None, None).unwrap();
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
d.apply_configuration("Reordered").unwrap();
assert!(d.configuration_matches());
assert_eq!(d.param_values().0["a"].v, 2.0);
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
let before = d.clone();
assert!(d.apply_configuration("Cycle").is_err());
assert_eq!(d, before);
d.params.retain(|p| p.name != "a");
let before = d.clone();
assert!(d.apply_configuration("Reordered").is_err());
assert_eq!(d, before);
}
#[test]
fn bulk_changes_reject_new_errors_in_unchanged_dependents() {
let mut d = Document::new("A");
d.set_param("length", "2 mm", None, None).unwrap();
d.set_param("twice", "length * 2", None, None).unwrap();
let before = d.clone();
assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err());
assert_eq!(d, before);
assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err());
assert_eq!(d, before);
}
#[test]
fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() {
let mut d = Document::new("Existing broken design");
d.set_param("width", "10 mm", None, None).unwrap();
d.params[0].expr = "1/".into();
d.configs.columns = vec![Column::Param("width".into())];
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
d.apply_configuration("Default").unwrap();
assert!(d.configuration_matches());
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
let before = d.clone();
assert!(d.apply_configuration("Different error").is_err());
assert_eq!(d, before);
}
#[test]
fn rows_switch_parameters() {
let mut d = Document::new("A");