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

@@ -0,0 +1,356 @@
//! Structured configuration rule editing and non-mutating row preview.
use crate::SolveApp;
use egui::{RichText, Ui};
use serde_json::{Value, json};
use solvecraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator};
#[derive(Clone, Debug)]
pub struct Editor {
rule: ConfigRule,
row: String,
preview: Option<Value>,
preview_revision: u64,
pub(super) drafts: std::collections::BTreeMap<(String, String), String>,
pub(super) table: Option<(u64, Value)>,
}
impl Default for Editor {
fn default() -> Self {
Self {
rule: ConfigRule {
id: String::new(),
name: "New rule".into(),
enabled: true,
length_unit: None,
condition: RuleCondition::Always,
effect: RuleEffect::DriveParameter { parameter: String::new(), expression: "10 mm".into() },
},
row: String::new(),
preview: None,
preview_revision: 0,
drafts: Default::default(),
table: None,
}
}
}
fn choose(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) {
egui::ComboBox::from_id_salt(id).selected_text(if value.is_empty() { "Choose…" } else { value.as_str() }).width(130.0).show_ui(ui, |ui| {
for option in options {
ui.selectable_value(value, option.clone(), option);
}
});
}
fn expression(ui: &mut Ui, label: &str, value: &mut String) {
ui.label(label);
ui.add(egui::TextEdit::singleline(value).desired_width(160.0));
}
pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>, editor: &mut Editor) {
if editor.preview_revision != app.session.revision {
editor.preview = None;
}
let rules = app.session.doc.configs.rules.clone();
let parameters: Vec<String> =
app.session.doc.configs.columns.iter().filter_map(|c| if let Column::Param(n) = c { Some(n.clone()) } else { None }).collect();
let all_parameters: Vec<String> = app.session.doc.all_param_exprs().into_iter().map(|p| p.0).collect();
let features: Vec<(u64, String)> = app.session.doc.features.iter().map(|f| (f.id, f.name.clone())).collect();
let suppression_features: Vec<(u64, String)> =
features.iter().filter(|(id, _)| app.session.doc.configs.columns.contains(&Column::Suppress(*id))).cloned().collect();
ui.separator();
ui.label(RichText::new("Configuration rules").strong());
ui.add(egui::Label::new("Rules run from top to bottom. Matched drives change effective values.").wrap());
ui.add(egui::Label::new("Matched restrictions check the final values.").wrap());
for (index, rule) in rules.iter().enumerate() {
ui.push_id(&rule.id, |ui| {
ui.horizontal(|ui| {
let mut enabled = rule.enabled;
if ui.checkbox(&mut enabled, "").on_hover_text("Enable rule").changed() {
let mut r = rule.clone();
r.enabled = enabled;
*error = app.run("config.rule", json!({"rule":r})).err();
}
ui.add(egui::Label::new(&rule.name).truncate());
if ui.small_button("Edit").clicked() {
editor.rule = rule.clone();
}
if ui.add_enabled(index > 0, egui::Button::new("↑").small()).on_hover_text("Move earlier").clicked() {
*error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err();
}
if ui.add_enabled(index + 1 < rules.len(), egui::Button::new("↓").small()).on_hover_text("Move later").clicked() {
*error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err();
}
if ui.small_button("Delete").clicked() {
*error = app.run("config.rule", json!({"id":rule.id,"delete":true})).err();
}
});
ui.label(match &rule.effect {
RuleEffect::DriveParameter { parameter, expression } => format!("Drives {parameter} = {expression}"),
RuleEffect::DriveSuppression { feature, value } => format!(
"Drives suppression {} = {value}",
features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("missing feature")
),
RuleEffect::ParameterRange { parameter, minimum, maximum } => format!("Restricts {parameter}: {minimum} through {maximum}"),
RuleEffect::AllowParameterValues { parameter, expressions } => format!("Restricts {parameter}: {}", expressions.join(", ")),
});
});
}
ui.collapsing("Add or edit rule", |ui| {
ui.horizontal(|ui| {
expression(ui, "Rule name", &mut editor.rule.name);
ui.checkbox(&mut editor.rule.enabled, "Enabled");
if ui.small_button("New").clicked() {
editor.rule = Editor::default().rule;
}
});
let mut length_unit = editor.rule.length_unit.clone().unwrap_or_else(|| app.session.doc.units.clone());
let old_unit = length_unit.clone();
ui.horizontal(|ui| {
ui.label("Length units");
choose(ui, "rule_length_units", &mut length_unit, &["mm".into(), "cm".into(), "m".into(), "in".into(), "ft".into()]);
});
if length_unit != old_unit {
editor.rule.length_unit = Some(length_unit);
}
condition_editor(ui, &mut editor.rule.condition, &all_parameters, &features, 1);
let mut effect_kind = match editor.rule.effect {
RuleEffect::DriveParameter { .. } => "Drive parameter",
RuleEffect::DriveSuppression { .. } => "Drive suppression",
RuleEffect::ParameterRange { .. } => "Range",
RuleEffect::AllowParameterValues { .. } => "Allowed values",
}
.to_string();
let prior = effect_kind.clone();
ui.horizontal(|ui| {
ui.label("Then");
choose(
ui,
"rule_effect_kind",
&mut effect_kind,
&["Drive parameter".into(), "Drive suppression".into(), "Range".into(), "Allowed values".into()],
);
});
if prior != effect_kind {
let parameter = parameters.first().cloned().unwrap_or_default();
editor.rule.effect = match effect_kind.as_str() {
"Drive suppression" => RuleEffect::DriveSuppression { feature: suppression_features.first().map(|f| f.0).unwrap_or(0), value: true },
"Range" => RuleEffect::ParameterRange { parameter, minimum: "0 mm".into(), maximum: "100 mm".into() },
"Allowed values" => RuleEffect::AllowParameterValues { parameter, expressions: vec!["10 mm".into(), "20 mm".into()] },
_ => RuleEffect::DriveParameter { parameter, expression: "10 mm".into() },
};
}
match &mut editor.rule.effect {
RuleEffect::DriveParameter { parameter, expression: value } => {
ui.horizontal(|ui| {
choose(ui, "effect_parameter", parameter, &parameters);
expression(ui, "Value", value);
});
}
RuleEffect::DriveSuppression { feature, value } => {
ui.horizontal(|ui| {
feature_choice(ui, "effect_feature", feature, &suppression_features);
ui.checkbox(value, "Suppressed");
});
}
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
choose(ui, "range_parameter", parameter, &parameters);
ui.horizontal(|ui| {
expression(ui, "Minimum", minimum);
});
ui.horizontal(|ui| {
expression(ui, "Maximum", maximum);
});
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
choose(ui, "allowed_parameter", parameter, &parameters);
let mut text = expressions.join("; ");
ui.label("Allowed expressions, separated by semicolons");
if ui.add(egui::TextEdit::singleline(&mut text).desired_width(300.0)).changed() {
*expressions = text.split(';').map(|s| s.trim().to_string()).collect();
}
}
}
if ui.add_enabled(!app.session.doc.configs.rows.is_empty(), egui::Button::new("Save Rule")).clicked() {
let result = (|| -> Result<(), String> {
let mut rule = editor.rule.clone();
if rule.id.is_empty() {
rule.id = (1..).map(|n| format!("Rule{n}")).find(|id| !rules.iter().any(|r| r.id == *id)).unwrap_or_default();
}
app.run("config.rule", json!({"rule":rule.clone()}))?;
editor.rule = app.session.doc.configs.rules.iter().find(|existing| existing.id == rule.id).cloned().unwrap_or(rule);
Ok(())
})();
*error = result.err();
}
});
let rows: Vec<String> = app.session.doc.configs.rows.iter().map(|r| r.name.clone()).collect();
if !rows.contains(&editor.row) {
editor.row = app.session.doc.configs.active.clone().or_else(|| rows.first().cloned()).unwrap_or_default();
}
let previous_row = editor.row.clone();
ui.horizontal(|ui| {
choose(ui, "preview_configuration", &mut editor.row, &rows);
if ui.add_enabled(!editor.row.is_empty(), egui::Button::new("Preview Row")).clicked() {
match app.session.execute("config.preview", &json!({"row":editor.row})) {
Ok(result) => {
editor.preview = Some(result);
editor.preview_revision = app.session.revision;
*error = None;
}
Err(e) => {
editor.preview = None;
*error = Some(e.to_string());
}
}
}
});
if previous_row != editor.row {
editor.preview = None;
}
if let Some(preview) = &editor.preview {
ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| {
ui.label("Preview leaves the current design unchanged.");
if let Some(trace) = preview["trace"].as_array() {
for entry in trace {
ui.label(format!(
"{} — {}",
entry["name"].as_str().unwrap_or("rule"),
if entry["matched"].as_bool() == Some(true) { "matched" } else { "skipped" }
));
if entry["matched"].as_bool() == Some(true)
&& let Ok(effect) = serde_json::from_value::<RuleEffect>(entry["effect"].clone())
{
ui.label(match effect {
RuleEffect::DriveParameter { parameter, expression } => format!("Driven: {parameter} = {expression}"),
RuleEffect::DriveSuppression { feature, value } => format!(
"Driven: {} is {}",
features.iter().find(|f| f.0 == feature).map(|f| f.1.as_str()).unwrap_or("feature"),
if value { "suppressed" } else { "unsuppressed" }
),
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
format!("Restricted: {parameter}, {minimum} through {maximum}")
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
format!("Restricted: {parameter}, {}", expressions.join("; "))
}
});
}
}
}
if let Some(parameters) = preview["parameters"].as_array() {
for parameter in parameters {
if let Some(name) = parameter["name"].as_str()
&& all_parameters.iter().any(|p| p == name)
{
ui.label(format!("{name} = {}", parameter["expression"].as_str().unwrap_or("")));
}
}
}
});
}
}
fn feature_choice(ui: &mut Ui, id: &str, feature: &mut u64, features: &[(u64, String)]) {
egui::ComboBox::from_id_salt(id)
.selected_text(features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("Choose feature…"))
.show_ui(ui, |ui| {
for (id, name) in features {
ui.selectable_value(feature, *id, name);
}
});
}
fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[String], features: &[(u64, String)], depth: usize) {
let mut kind = match condition {
RuleCondition::Always => "Always",
RuleCondition::Parameter { .. } => "Parameter",
RuleCondition::Suppression { .. } => "Suppression",
RuleCondition::All { .. } => "All",
RuleCondition::Any { .. } => "Any",
RuleCondition::Not { .. } => "Not",
}
.to_string();
let previous = kind.clone();
let mut choices = vec!["Always".into(), "Parameter".into(), "Suppression".into()];
if depth < 8 {
choices.extend(["All".into(), "Any".into(), "Not".into()]);
}
ui.horizontal(|ui| {
ui.label("When");
choose(ui, "condition_kind", &mut kind, &choices);
});
if previous != kind {
*condition = match kind.as_str() {
"Parameter" => RuleCondition::Parameter {
parameter: parameters.first().cloned().unwrap_or_default(),
operator: RuleOperator::Ge,
expression: "10 mm".into(),
},
"Suppression" => RuleCondition::Suppression { feature: features.first().map(|f| f.0).unwrap_or(0), value: true },
"All" => RuleCondition::All { conditions: vec![RuleCondition::Always] },
"Any" => RuleCondition::Any { conditions: vec![RuleCondition::Always] },
"Not" => RuleCondition::Not { condition: Box::new(RuleCondition::Always) },
_ => RuleCondition::Always,
};
}
match condition {
RuleCondition::Parameter { parameter, operator, expression } => {
ui.horizontal(|ui| {
choose(ui, "condition_parameter", parameter, parameters);
egui::ComboBox::from_id_salt("condition_operator")
.width(45.0)
.selected_text(match operator {
RuleOperator::Eq => "=",
RuleOperator::Ne => "≠",
RuleOperator::Lt => "<",
RuleOperator::Le => "≤",
RuleOperator::Gt => ">",
RuleOperator::Ge => "≥",
})
.show_ui(ui, |ui| {
for (label, op) in [
("=", RuleOperator::Eq),
("≠", RuleOperator::Ne),
("<", RuleOperator::Lt),
("≤", RuleOperator::Le),
(">", RuleOperator::Gt),
("≥", RuleOperator::Ge),
] {
ui.selectable_value(operator, op, label);
}
});
ui.add(egui::TextEdit::singleline(expression).desired_width(110.0));
});
}
RuleCondition::Suppression { feature, value } => {
ui.horizontal(|ui| {
feature_choice(ui, "condition_feature", feature, features);
ui.checkbox(value, "Suppressed");
});
}
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
let mut remove = None;
for (index, child) in conditions.iter_mut().enumerate() {
ui.push_id(index, |ui| {
ui.indent("condition_group", |ui| {
condition_editor(ui, child, parameters, features, depth + 1);
if ui.small_button("Remove condition").clicked() {
remove = Some(index);
}
});
});
}
if let Some(index) = remove {
conditions.remove(index);
}
if ui.add_enabled(conditions.len() < 32, egui::Button::new("Add condition").small()).clicked() {
conditions.push(RuleCondition::Always);
}
}
RuleCondition::Not { condition } => {
ui.indent("negated_condition", |ui| {
condition_editor(ui, condition, parameters, features, depth + 1);
});
}
RuleCondition::Always => {}
}
}