Record ownership transfers in the timeline so earlier Join/Cut membership, world placement, machining data and styles survive component moves, edits, independent copying, undo and reopening. Preserve pending positions in history snapshots and keep constructor transfers ordered during feature redefinition. Retain shallow sheet pockets and exact planar intersection islands through unfold/refold, with depth-aware flat previews and DXF export. Store explicit 3D wire coordinate spaces and migrate known legacy provenance without moving saved points or analytic fits. Infer unique eligible cylindrical Emboss targets, align Revolve manipulators with actual extent geometry, and discard obsolete preview errors after input correction. Certify constructed and immutable imported face styles using exact source metadata while preserving legacy face names. Upgrade designs to format10 with bounded validation and geometry-preserving legacy migration.
371 lines
17 KiB
Rust
371 lines
17 KiB
Rust
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
|
|
//! expression, feature suppression, body material or body appearance. Activating a row sets
|
|
//! those values in the design atomically, which then rebuilds as usual.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{DocError, Document, Result};
|
|
|
|
#[path = "config_rules.rs"]
|
|
mod rules;
|
|
pub use rules::*;
|
|
#[path = "config_styles.rs"]
|
|
mod styles;
|
|
pub use styles::*;
|
|
#[path = "config_certificates.rs"]
|
|
mod certificates;
|
|
pub use certificates::*;
|
|
#[path = "config_clone.rs"]
|
|
mod clone;
|
|
pub use clone::*;
|
|
|
|
#[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),
|
|
/// A body's physical material (library name, or Default to clear).
|
|
Material(String),
|
|
/// A body's appearance (library name, serialized Look, or empty to clear).
|
|
Appearance(String),
|
|
/// Shared component definition's direct appearance.
|
|
ComponentAppearance(u64),
|
|
/// A certified semantic face lineage in a shared definition.
|
|
FaceAppearance(ConfigFaceTarget),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ConfigRow {
|
|
pub name: String,
|
|
/// One cell per column: expression, suppression, material name or appearance look.
|
|
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>,
|
|
#[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.rules.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
|
|
/// and JSON Look objects support both convenient editing and lossless custom colours.
|
|
pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
|
let value = value.trim();
|
|
if value.is_empty() || value.eq_ignore_ascii_case("default") {
|
|
return Ok(None);
|
|
}
|
|
if value.len() > 4096 {
|
|
return Err(DocError::Invalid("appearance value too long".into()));
|
|
}
|
|
if let Some(look) = crate::appearance::library(value) {
|
|
return Ok(Some(look));
|
|
}
|
|
let look: crate::appearance::Look =
|
|
serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
|
|
if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
|
|
return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
|
|
}
|
|
Ok(Some(look))
|
|
}
|
|
|
|
/// Parse a physical material cell, returning None for the default material.
|
|
pub fn material_cell(value: &str) -> Result<Option<String>> {
|
|
let name = crate::MATERIALS
|
|
.iter()
|
|
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
|
|
.map(|(n, _)| *n)
|
|
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
|
Ok((name != "Default").then(|| name.to_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, 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()),
|
|
Column::ComponentAppearance(id) => {
|
|
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
|
|
}
|
|
Column::FaceAppearance(target) => {
|
|
Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A column's display key, prefixed by its kind to distinguish targets with equal names.
|
|
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())),
|
|
Column::Material(body) => format!("material:{body}"),
|
|
Column::Appearance(body) => format!("appearance:{body}"),
|
|
Column::ComponentAppearance(id) => format!("component_appearance:{id}"),
|
|
Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()),
|
|
}
|
|
}
|
|
|
|
/// 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(_) => {}
|
|
Column::Suppress(id) => {
|
|
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;
|
|
}
|
|
Column::Material(body) => match material_cell(v)? {
|
|
Some(material) => {
|
|
next.materials.insert(body.clone(), material);
|
|
}
|
|
None => {
|
|
next.materials.remove(body);
|
|
}
|
|
},
|
|
Column::Appearance(body) => match appearance_cell(v)? {
|
|
Some(look) => {
|
|
next.appearances.bodies.insert(body.clone(), look);
|
|
}
|
|
None => {
|
|
next.appearances.bodies.remove(body);
|
|
}
|
|
},
|
|
Column::ComponentAppearance(id) => {
|
|
if *id == 0 || !next.components.iter().any(|component| component.id == *id) {
|
|
return Err(DocError::Unknown(format!("component {id} is gone")));
|
|
}
|
|
match appearance_cell(v)? {
|
|
Some(look) => {
|
|
next.appearances.components.insert(*id, look);
|
|
}
|
|
None => {
|
|
next.appearances.components.remove(id);
|
|
}
|
|
}
|
|
}
|
|
Column::FaceAppearance(target) => {
|
|
next.certify_config_face(target)?;
|
|
next.appearances.faces.retain(|face| !target.matches(face));
|
|
if let Some(look) = appearance_cell(v)? {
|
|
next.appearances.faces.push(crate::appearance::FaceLook {
|
|
body: target.body.clone(),
|
|
point: target.point,
|
|
look,
|
|
name: Some(target.name.clone()),
|
|
strict_name: true,
|
|
certificate: target.certificate.clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
next.configs.active = Some(row.to_string());
|
|
let trace = next.evaluate_configuration_rules()?;
|
|
*self = next;
|
|
Ok(trace)
|
|
}
|
|
|
|
/// 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 };
|
|
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(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()),
|
|
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
|
|
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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");
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_saved_appearance_rolls_back_material_and_parameters() {
|
|
let mut d = Document::new("A");
|
|
d.set_param("width", "10 mm", None, None).unwrap();
|
|
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
|
|
d.configs.rows.push(ConfigRow {
|
|
name: "Broken".into(),
|
|
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
|
|
});
|
|
let before = d.clone();
|
|
assert!(d.apply_configuration("Broken").is_err());
|
|
assert_eq!(d, before);
|
|
assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
|
|
assert!(appearance_cell(&"a".repeat(4097)).is_err());
|
|
assert!(appearance_cell("default").unwrap().is_none());
|
|
}
|
|
}
|