Add named parameters, UI scale setting, OPTIONS/OP dispatch, and help manual

Named parameters (docs/named_parameters_design.md, stages 3-4):
- ParameterTable (src/scene/named_parameters.rs): a document-wide table of
  named formulas, each resolved through a shared expression evaluator with
  cross-parameter references, cycle detection, and division-by-zero/parse
  error reporting. SketchConstraint::driving_param becomes DrivingValue
  (Literal | Named) instead of a bare f64, resolved at solve time by
  sketch_solve::build_constraint via Scene::named_parameters.
- Persistence (src/scene/named_parameters_persist.rs): same lazy XRecord
  model sketch_persist.rs already established, synced around open/save.
  Bumps the sketch-constraint XRecord's own format version (1 -> 2) since
  DrivingValue's shape doesn't line up with a version-1 blob's bytes.
- PARAMETERS command and its editor (src/ui/window/named_parameters.rs):
  add/remove/edit rows, live validation, duplicate-name detection: Apply
  defines the table and re-solves every constraint scope with a
  named-parameter-driven constraint (command_driver.rs). Distance/Angle
  constraint prompts (modules/draw/constrain/value.rs) now accept either a
  literal or an existing parameter's name, mirroring how the constraint's
  own value prompt already worked.
- Undo scope-down carried over from this project's own established pattern
  (constraint-system stage 9): the geometry an Apply moves is one undo
  step, but the parameter table edit itself has no undo entry yet — a
  documented follow-up, not a silent gap.

UI scale setting (src/app/settings.rs, app/mod.rs, ui/window/options.rs):
  a whole-interface scale percentage (50-200%), wired directly into iced's
  own per-window scale_factor hook rather than per-panel font-size
  plumbing, so it applies immediately to every panel, dialog, and the
  ribbon at once. Persisted alongside the existing lineweight-display-scale
  setting.

OPTIONS/OP command dispatch (app/commands/view.rs, app/commands/mod.rs):
  OPTIONS was registered for autocomplete but had no dispatch arm — the
  Start-tab welcome screen's own button was the only way to reach it.
  Found live-testing the UI-scale setting, unrelated to that feature
  itself. "OP" is AutoCAD's own short alias.

help/: a new self-contained static user-help manual (manual.md as the
authored source, tools/build_help.py splits it into the committed static
HTML pages plus search index) — not yet wired into the application.

64 new/updated tests across the named-parameters and constraint-prompt
changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 13:50:08 +02:00
1 parent e2d39b049d
commit 7cddf31bcb
47 files changed
+4136 -52

No files matched your search

+3
View File
@@ -302,6 +302,7 @@ impl OpenCADStudio {
let i = self.active_tab;
self.tabs[i].scene.document = acadrust::CadDocument::new();
self.tabs[i].scene.sketch_constraints.clear();
self.tabs[i].scene.named_parameters = crate::scene::named_parameters::ParameterTable::new();
self.tabs[i].scene.deselect_all();
self.tabs[i].current_path = None;
// The headless session starts on the welcome (Start) tab, which
@@ -331,6 +332,7 @@ impl OpenCADStudio {
let i = self.active_tab;
self.tabs[i].scene.document = doc;
self.tabs[i].scene.load_sketch_constraints_from_document();
self.tabs[i].scene.load_named_parameters_from_document();
self.tabs[i].scene.deselect_all();
crate::app::style_ops::ensure_standard_styles(
&mut self.tabs[i].scene.document,
@@ -1063,6 +1065,7 @@ mod tests {
"CHANGELOG",
"CUI",
"ALIASEDIT",
"OPTIONS",
];
for cmd in standalone {
assert!(
+162
View File
@@ -951,6 +951,81 @@ impl OpenCADStudio {
self.push_sketch_constraints_history(i, label, scope, constraints_before);
}
/// PARAMETERS' Apply button (`docs/named_parameters_design.md` stage 4):
/// rebuilds the active tab's `Scene::named_parameters` from the
/// editor's working buffer, reports any row that failed to validate via
/// the command line, and re-solves every scope with a constraint driven
/// by a named parameter — the entire point of a *named* parameter is
/// that editing it ripples to whatever references it, the same way
/// editing a constraint's literal value already does.
///
/// **Deliberate scope-down, matching this project's established
/// treatment of undo-granularity questions** (design doc §5 open
/// question 3, and the sibling constraint-system project's stage 9): the
/// geometry this ripple moves is bracketed into one undo step below
/// (same `begin_undo`/`bump_entities`/`commit_undo_delta` pattern
/// `resolve_one_sketch_conflict` above uses), but the parameter *table*
/// edit itself has no undo entry of its own yet — that would need a new
/// `HistorySnapshot` variant mirroring `SketchConstraints`
/// (`push_sketch_constraints_history`). Left for a follow-up, not
/// silently dropped: undoing after an Apply undoes the geometry it
/// moved, not the parameter values themselves.
pub(super) fn apply_named_parameter_editor_rows(&mut self) {
let i = self.active_tab;
// A name shared by more than one row can't be resolved by picking
// one arbitrarily (`ParameterTable::set` would just let the last
// one silently win) -- refuse every row sharing that name up front,
// same as `named_parameters::preview`'s live check.
let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows);
let mut table = crate::scene::named_parameters::ParameterTable::new();
let mut failed = 0usize;
for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() {
let name = row.name.trim();
if name.is_empty() || duplicates.contains(&idx) {
continue;
}
if let Err(e) = table.set(name, row.formula.trim()) {
self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref());
failed += 1;
}
}
if !duplicates.is_empty() {
self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref());
failed += duplicates.len();
}
let param_count = table.len();
self.tabs[i].scene.named_parameters = table;
// The whole table just changed, not one isolated value, so there is
// no cheaper "which handles actually moved" test worth doing here —
// re-solve every scope with a constraint driven by *any* named
// parameter (mirrors `solve_scope`'s own "full rebuild, not
// incremental" philosophy).
let touched: Vec<Handle> = self.tabs[i]
.scene
.sketch_constraints
.iter()
.flat_map(|set| set.constraints.iter())
.filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_))))
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
if !touched.is_empty() {
let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true);
let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
self.tabs[i].dirty = true;
if failed == 0 {
self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref());
}
}
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
let i = self.active_tab;
let preserve_commit_layer = self.tabs[i]
@@ -6075,6 +6150,93 @@ mod sketch_constraint_undo_tests {
assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint");
}
/// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's
/// working buffer both defines the parameter and re-solves whatever
/// constraint already references it by name — the entire point of a
/// *named* parameter, not just data plumbing.
#[test]
fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0);
// The constraint is added *before* the parameter exists -- allowed,
// same as `ParameterTable::set`'s own forward-reference tolerance;
// it just doesn't resolve to anything until the parameter is defined.
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Distance,
refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())),
label: "Distance constraint",
});
app.named_parameter_editor_rows =
vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }];
app.apply_named_parameter_editor_rows();
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0));
let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(l)) => (l.start, l.end),
other => panic!("expected a Line, got {other:?}"),
};
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}");
// Redefining the parameter and re-applying should ripple again.
app.named_parameter_editor_rows[0].formula = "3".to_string();
app.apply_named_parameter_editor_rows();
let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(l)) => (l.start, l.end),
other => panic!("expected a Line, got {other:?}"),
};
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}");
}
/// A row that fails to validate (here, a formula that doesn't parse)
/// must not silently vanish or corrupt the table: it's reported as an
/// error and simply isn't added, while a valid row alongside it still
/// commits — the same isolate-the-failure treatment
/// `ParameterTable::resolve_all` already gives a bad reference.
#[test]
fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
app.named_parameter_editor_rows = vec![
crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() },
crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() },
];
app.apply_named_parameter_editor_rows();
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0));
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added");
assert!(
app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error),
"a malformed row must be reported as an error, not silently dropped"
);
}
/// Two rows typed with the same name must not silently collapse into
/// "whichever `set` call happens last wins" — both are refused, and an
/// unrelated row still commits normally.
#[test]
fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
app.named_parameter_editor_rows = vec![
crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() },
crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() },
crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() },
];
app.apply_named_parameter_editor_rows();
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all");
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0));
assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error));
}
/// Design doc §6.1's manual Coincident UI: two picks landing on two
/// different lines' endpoints should resolve to a real `SketchRef` pair
/// and add a Coincident constraint — the same `AddCoincidentConstraint`
+3
View File
@@ -292,6 +292,8 @@ pub fn start_allowed(cmd: &str) -> bool {
| "PERF"
| "CUI"
| "ALIASEDIT"
| "OPTIONS"
| "OP"
| "CUILOAD"
| "CUIIMPORT"
)
@@ -312,6 +314,7 @@ inventory::submit!(crate::command::CommandRegistration {
"CLEANSCREEN",
"CUI",
"DSETTINGS",
"PARAMETERS",
"GRID",
"ISODRAFT",
"ISOPLANE",
+19
View File
@@ -240,6 +240,25 @@ impl OpenCADStudio {
return Some(Task::done(Message::AliasEditorOpen));
}
// ── OPTIONS — app preferences dialog ────────────────────────────
// Registered for autocomplete (`commands/mod.rs`'s "one-shot
// commands" list) but had no actual dispatch arm anywhere — the
// Start-tab welcome screen's "Options" button emits
// `Message::OptionsOpen` directly and was the only way to reach
// it. Found while live-testing the new UI-scale setting (nothing
// to do with that feature itself, just discovered along the
// way). "OP" is AutoCAD's own short alias for this command.
"OPTIONS" | "OP" => {
return Some(Task::done(Message::OptionsOpen));
}
// ── PARAMETERS — named-parameter/expression table editor ───────
// `docs/named_parameters_design.md` stage 4. AutoCAD's own
// command name for the equivalent feature (Parameters Manager).
"PARAMETERS" => {
return Some(Task::done(Message::NamedParametersOpen));
}
// ── CUI — keyboard shortcut / key-binding editor ───────────────
"CUI" => {
return Some(Task::done(Message::ShortcutsPanelOpen));
+40
View File
@@ -504,6 +504,12 @@ pub(super) struct OpenCADStudio {
crosshair_color_input: String,
/// Model-space lineweight preview scale, in percent (25..=200).
lineweight_display_scale: i32,
/// Whole-interface scale, in percent (50..=200) — drives iced's own
/// per-window `scale_factor` (`run`'s `.scale_factor(...)` hook), so it
/// resizes every panel, dialog, the ribbon, and their text uniformly
/// rather than needing a separate font-size knob threaded through each
/// panel's own view code.
ui_scale: i32,
/// Isometric drafting state and active axis pair.
isometric_drafting: bool,
iso_plane: settings::IsoPlane,
@@ -1018,6 +1024,15 @@ pub(super) struct OpenCADStudio {
/// edited. Seeded from `command_aliases` on open, committed back on close.
alias_editor_rows: Vec<(String, String)>,
// ── Named Parameters (docs/named_parameters_design.md, stage 4) ────────
/// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`,
/// this isn't a copy of a separate app-level store — the real state
/// lives per-document at `Scene::named_parameters`; this buffer is
/// seeded from the active tab's table on open and only written back to
/// it on Apply (`apply_named_parameter_editor_rows`,
/// `src/app/named_parameters.rs`).
named_parameter_editor_rows: Vec<crate::ui::window::named_parameters::ParamEditorRow>,
// ── Layout Manager Panel ──────────────────────────────────────────────
layout_manager_selected: String,
layout_manager_rename_buf: String,
@@ -1695,6 +1710,7 @@ pub enum ModalKind {
AttributeEditor,
LayerDeleteWarning,
Aliases,
NamedParameters,
ScaleManager,
/// Add / remove the annotation scales a single selected object has a
/// per-object representation for.
@@ -1937,6 +1953,8 @@ pub enum Message {
CursorTypeChanged(settings::CursorType),
/// Set the model-space lineweight preview scale from Options.
LineweightDisplayScaleChanged(i32),
/// Set the whole-interface scale (`ui_scale`) from Options.
UiScaleChanged(i32),
/// Set the ribbon's large-button icon-label font size from Options.
RibbonLabelFontSizeChanged(i32),
/// Set the ribbon's panel-title ("Draw", "Modify", …) font size from Options.
@@ -2706,6 +2724,24 @@ pub enum Message {
AliasEditorRemove(usize),
/// Commit the edited rows to the alias table (Apply button); stays open.
AliasEditorApply,
// ── Named Parameters (PARAMETERS) ───────────────────────────────────
/// Open the named-parameter editor, seeding rows from the active tab's
/// `Scene::named_parameters`.
NamedParametersOpen,
/// Live edit of the name or formula in row `idx`.
NamedParametersInput {
idx: usize,
field: crate::ui::window::named_parameters::ParamField,
value: String,
},
/// Append a blank parameter row.
NamedParametersAdd,
/// Remove parameter row `idx`.
NamedParametersRemove(usize),
/// Commit the edited rows to `Scene::named_parameters` (Apply button)
/// and re-solve every constraint that reads a named parameter; stays
/// open.
NamedParametersApply,
// ── About window ────────────────────────────────────────────────────
AboutOpen,
/// Close whatever in-canvas modal dialog is open (Plan B).
@@ -3378,6 +3414,7 @@ impl OpenCADStudio {
crosshair_color: None,
crosshair_color_input: String::new(),
lineweight_display_scale: 100,
ui_scale: 100,
isometric_drafting: false,
iso_plane: settings::IsoPlane::Left,
snap_angle_deg: 0.0,
@@ -3579,6 +3616,7 @@ impl OpenCADStudio {
// Command aliases (populated from ocad.pgp just after construction)
command_aliases: rustc_hash::FxHashMap::default(),
alias_editor_rows: Vec::new(),
named_parameter_editor_rows: Vec::new(),
// Layout Manager
layout_manager_selected: "Model".to_string(),
layer_state_selected: None,
@@ -3978,6 +4016,7 @@ pub fn run() -> iced::Result {
..iced::Settings::default()
})
.subscription(OpenCADStudio::subscription)
.scale_factor(|state: &OpenCADStudio, _window: window::Id| state.ui_scale as f32 / 100.0)
.title(|state: &OpenCADStudio, window_id: window::Id| {
let _ = window_id; // all dialogs are in-canvas modals now
if let Some(tab) = state.tabs.get(state.active_tab) {
@@ -4022,6 +4061,7 @@ pub fn run_web() -> iced::Result {
OpenCADStudio::view_main,
)
.subscription(OpenCADStudio::subscription)
.scale_factor(|state: &OpenCADStudio| state.ui_scale as f32 / 100.0)
.title(|_state: &OpenCADStudio| {
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
})
+9
View File
@@ -166,6 +166,14 @@ pub struct UserSettings {
pub crosshair_color: Option<[u8; 3]>,
/// Model-space lineweight preview scale as a percentage.
pub lineweight_display_scale: i32,
/// Whole-interface scale, as a percentage — every panel, dialog, the
/// ribbon, and their text, not just one widget's font size. Wired
/// straight into iced's own per-window `scale_factor` hook
/// (`OpenCADStudio::run`), so a change takes effect immediately with no
/// per-panel plumbing needed. Relies on the struct-level `#[serde(default)]`
/// (via `impl Default` below) to backfill this field for a config file
/// saved before this setting existed, same as every other field here.
pub ui_scale: i32,
/// Isometric drafting changes the grid and crosshair to the active axis pair.
pub isometric_drafting: bool,
pub iso_plane: IsoPlane,
@@ -310,6 +318,7 @@ impl Default for UserSettings {
cursor_type: CursorType::Crosshair,
crosshair_color: None,
lineweight_display_scale: 100,
ui_scale: 100,
isometric_drafting: false,
iso_plane: IsoPlane::Left,
snap_angle_deg: 0.0,
+9
View File
@@ -368,6 +368,7 @@ impl OpenCADStudio {
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
lineweight_display_scale: self.lineweight_display_scale,
ui_scale: self.ui_scale,
isometric_drafting: self.isometric_drafting,
iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg,
@@ -425,6 +426,7 @@ impl OpenCADStudio {
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200);
self.ui_scale = s.ui_scale.clamp(50, 200);
self.isometric_drafting = s.isometric_drafting;
self.iso_plane = s.iso_plane;
self.snap_angle_deg = if s.snap_angle_deg.is_finite() {
@@ -1394,6 +1396,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// Design doc §8 stage 4: read back any persisted sketch
// constraint sets right after the document is installed.
self.tabs[i].scene.load_sketch_constraints_from_document();
// named_parameters_design.md stage 2: same load-time hook,
// for the document-wide parameter table.
self.tabs[i].scene.load_named_parameters_from_document();
self.tabs[i].active_layer = self.tabs[i]
.scene
.document
@@ -1660,6 +1665,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// Design doc §8 stage 4: write any sketch constraint sets into the
// document right before it's serialized.
self.tabs[i].scene.materialize_sketch_constraints_for_save();
// named_parameters_design.md stage 2: same save-time hook, for the
// document-wide parameter table.
self.tabs[i].scene.materialize_named_parameters_for_save();
}
#[cfg(not(target_arch = "wasm32"))]
@@ -2611,6 +2619,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.stamp_header_sysvars(i);
self.sync_solid_models_for_save(i);
self.tabs[i].scene.materialize_sketch_constraints_for_save();
self.tabs[i].scene.materialize_named_parameters_for_save();
let tab_id = self.tabs[i].id;
let bounds = crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
+43
View File
@@ -5861,6 +5861,43 @@ impl OpenCADStudio {
Task::none()
}
// ── Named Parameters (PARAMETERS) ─────────────────────────────────
Message::NamedParametersOpen => {
let i = self.active_tab;
self.named_parameter_editor_rows = self.tabs[i]
.scene
.named_parameters()
.iter()
.map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() })
.collect();
self.active_modal = Some(super::ModalKind::NamedParameters);
Task::none()
}
Message::NamedParametersInput { idx, field, value } => {
use crate::ui::window::named_parameters::ParamField;
if let Some(row) = self.named_parameter_editor_rows.get_mut(idx) {
match field {
ParamField::Name => row.name = value,
ParamField::Formula => row.formula = value,
}
}
Task::none()
}
Message::NamedParametersAdd => {
self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default());
Task::none()
}
Message::NamedParametersRemove(idx) => {
if idx < self.named_parameter_editor_rows.len() {
self.named_parameter_editor_rows.remove(idx);
}
Task::none()
}
Message::NamedParametersApply => {
self.apply_named_parameter_editor_rows();
Task::none()
}
// ── Options / About windows ───────────────────────────────────
Message::OptionsOpen => {
self.active_modal = Some(super::ModalKind::Options);
@@ -5900,6 +5937,12 @@ impl OpenCADStudio {
Task::none()
}
Message::UiScaleChanged(value) => {
self.ui_scale = value.clamp(50, 200);
self.persist_settings_if_changed();
Task::none()
}
Message::RibbonLabelFontSizeChanged(value) => {
self.ribbon.set_label_font_size(value);
self.save_config();
+15
View File
@@ -13,6 +13,7 @@ impl OpenCADStudio {
Some(K::About) => crate::tr!("modal", "about"),
Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"),
Some(K::Aliases) => crate::tr!("modal", "command-aliases"),
Some(K::NamedParameters) => crate::t!("Named Parameters").into_owned(),
Some(K::Options) => crate::tr!("action", "options"),
Some(K::FindReplace) => crate::tr!("modal", "find-replace"),
Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"),
@@ -173,6 +174,19 @@ impl OpenCADStudio {
},
)
}
super::super::ModalKind::NamedParameters => {
sized_flow(
ex,
620,
520,
|flow| {
crate::ui::window::named_parameters::view_window(
&self.named_parameter_editor_rows,
flow,
)
},
)
}
super::super::ModalKind::Options => sized_flow(
ex,
540,
@@ -191,6 +205,7 @@ impl OpenCADStudio {
self.crosshair_color,
&self.crosshair_color_input,
self.lineweight_display_scale,
self.ui_scale,
self.ribbon.label_font_size(),
self.ribbon.group_title_font_size(),
&self.model_space,
+8 -3
View File
@@ -1336,9 +1336,14 @@ pub enum CmdResult {
AddSketchConstraint {
kind: crate::scene::sketch_constraints::ConstraintKind,
refs: Vec<crate::scene::sketch_constraints::SketchRef>,
/// The typed target for a dimensional kind (Distance/Angle/Radius);
/// `None` for a purely geometric one.
driving_param: Option<f64>,
/// The typed target for a dimensional kind (Distance/Angle/Radius) —
/// a literal number, or a named-parameter reference
/// (`docs/named_parameters_design.md` stage 4, recognized by
/// `DistanceConstraintCommand`/`AngleConstraintCommand::
/// on_text_input` when the typed token matches a known parameter
/// name instead of parsing as a number); `None` for a purely
/// geometric kind.
driving_param: Option<crate::scene::named_parameters::DrivingValue>,
/// Undo-history label, e.g. `"Horizontal constraint"`.
label: &'static str,
},
+69 -11
View File
@@ -19,9 +19,30 @@ use glam::DVec3;
use crate::command::{CadCommand, CmdResult, InputKind};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::named_parameters::DrivingValue;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
use crate::scene::Scene;
/// Recognizes a typed prompt token as either a numeric literal or a
/// reference to an existing named parameter (`docs/
/// named_parameters_design.md` stage 4's "a way to pick a named parameter
/// instead of typing a literal" — the same prompt takes either, mirroring
/// how AutoCAD's own dimensional-constraint prompts accept an expression in
/// place of a bare value). `known_names` is a one-time snapshot taken at
/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand
/// ::new`, which already has `&Scene`) since `on_text_input` itself has no
/// document access — the same constraint `default_value` already works
/// around. Only *existence* is checked here; the actual value is resolved
/// fresh at solve time (`sketch_solve::build_constraint`), consistent with
/// this project's "no incremental/cached resolution" approach throughout.
fn parse_driving_value(text: &str, known_names: &[String]) -> Option<DrivingValue> {
let text = text.trim();
if let Ok(value) = text.parse::<f64>() {
return Some(DrivingValue::Literal(value));
}
known_names.iter().find(|n| n.as_str() == text).map(|n| DrivingValue::Named(n.clone()))
}
pub mod distance_tool {
use super::*;
pub fn tool() -> ToolDef {
@@ -55,6 +76,9 @@ pub struct DistanceConstraintCommand {
/// split between line-length (`Distance`) and circle-radius (`Radius`).
is_circle: bool,
default_value: f64,
/// Snapshot of `Scene::named_parameters`' names at construction time —
/// see `parse_driving_value`'s doc comment for why a snapshot.
known_param_names: Vec<String>,
}
impl DistanceConstraintCommand {
@@ -70,12 +94,15 @@ impl DistanceConstraintCommand {
EntityType::Circle(c) => (true, c.radius),
_ => return None,
};
Some(Self { handle, is_circle, default_value })
let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect();
Some(Self { handle, is_circle, default_value, known_param_names })
}
fn build(&self, target: f64) -> Option<CmdResult> {
if target <= 0.0 {
return None;
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
if let DrivingValue::Literal(v) = target {
if v <= 0.0 {
return None;
}
}
let (kind, refs, label) = if self.is_circle {
(ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint")
@@ -108,11 +135,11 @@ impl CadCommand for DistanceConstraintCommand {
}
fn on_enter(&mut self) -> CmdResult {
self.build(self.default_value).unwrap_or(CmdResult::Cancel)
self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value: f64 = text.trim().parse().ok()?;
let value = parse_driving_value(text, &self.known_param_names)?;
self.build(value)
}
@@ -126,6 +153,9 @@ pub struct AngleConstraintCommand {
fixed_handle: Handle,
moving_handle: Handle,
default_value: f64,
/// Snapshot of `Scene::named_parameters`' names at construction time —
/// see `parse_driving_value`'s doc comment for why a snapshot.
known_param_names: Vec<String>,
}
impl AngleConstraintCommand {
@@ -139,14 +169,15 @@ impl AngleConstraintCommand {
let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x);
let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x);
let default_value = (a2 - a1).to_degrees();
Some(Self { fixed_handle: fixed, moving_handle: moving, default_value })
let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect();
Some(Self { fixed_handle: fixed, moving_handle: moving, default_value, known_param_names })
}
fn build(&self, target_degrees: f64) -> Option<CmdResult> {
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
Some(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Angle,
refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)],
driving_param: Some(target_degrees),
driving_param: Some(target),
label: "Angle constraint",
})
}
@@ -170,11 +201,11 @@ impl CadCommand for AngleConstraintCommand {
}
fn on_enter(&mut self) -> CmdResult {
self.build(self.default_value).unwrap_or(CmdResult::Cancel)
self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value: f64 = text.trim().parse().ok()?;
let value = parse_driving_value(text, &self.known_param_names)?;
self.build(value)
}
@@ -182,3 +213,30 @@ impl CadCommand for AngleConstraintCommand {
CmdResult::Cancel
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_driving_value_prefers_a_numeric_literal() {
let known = vec!["hole_dia".to_string()];
assert_eq!(parse_driving_value("12.5", &known), Some(DrivingValue::Literal(12.5)));
// Distance's own `build` rejects a non-positive target later; the
// parse step itself accepts any number, negative included.
assert_eq!(parse_driving_value("-3", &known), Some(DrivingValue::Literal(-3.0)));
}
#[test]
fn parse_driving_value_recognizes_a_known_parameter_name() {
let known = vec!["hole_dia".to_string(), "plate_len".to_string()];
assert_eq!(parse_driving_value("hole_dia", &known), Some(DrivingValue::Named("hole_dia".to_string())));
}
#[test]
fn parse_driving_value_rejects_an_unknown_token() {
let known = vec!["hole_dia".to_string()];
assert_eq!(parse_driving_value("bogus", &known), None);
assert_eq!(parse_driving_value("", &known), None);
}
}
+14
View File
@@ -36,6 +36,8 @@ mod group_layer;
mod layout;
mod limits;
mod modify;
pub mod named_parameters;
mod named_parameters_persist;
mod mspace;
mod page_setup;
mod paper;
@@ -1934,6 +1936,17 @@ pub struct Scene {
/// touches directly, materialized into `document.objects` only right
/// before a save and read back right after a load.
pub(crate) sketch_constraints: Vec<sketch_constraints::SketchConstraintSet>,
/// Document-wide named-parameter/expression table (`docs/
/// named_parameters_design.md`). One table per document (not per-scope,
/// unlike `sketch_constraints` — a parameter is meant to be shared
/// across the whole drawing), lives on `Scene` for the same reason
/// `sketch_constraints` does: co-located with the `document: CadDocument`
/// it's synced against. Synced with a single document-level
/// `OCS_NAMED_PARAMETERS` XRecord (hung off `document.header.
/// named_objects_dict_handle`, not a per-`BlockRecord` one) around
/// open/save (`named_parameters_persist.rs`, same "lazy" model as
/// `sketch_persist.rs`).
pub(crate) named_parameters: named_parameters::ParameterTable,
/// Tessellated block definitions in block-local coords, keyed by render
/// background and block epoch. Model and Paper adapt black/white colours
/// differently; retaining both variants prevents a full block rebuild on
@@ -2167,6 +2180,7 @@ impl Scene {
dependency_index_cache: RefCell::new(None),
associative_hatch_source_cache: RefCell::new(None),
sketch_constraints: Vec::new(),
named_parameters: named_parameters::ParameterTable::new(),
has_associative_centers: std::cell::Cell::new(None),
block_defn_cache: RefCell::new(HashMap::default()),
entity_index_cache: RefCell::new(None),
+898
View File
@@ -0,0 +1,898 @@
//! Named, formula-capable document parameters — data model.
//!
//! Design: `docs/named_parameters_design.md`. This is stage 1 of that doc's
//! §4 staged plan: the parameter table type plus a small expression
//! parser/evaluator, unit-tested in isolation — no document/UI integration
//! yet (that's stage 2's XRecord persistence, mirroring
//! [`super::sketch_persist`], and stage 3's
//! `SketchConstraint::driving_param` wiring).
//!
//! A [`Parameter`] is a name plus a formula: either a bare literal (`12.5`)
//! or an expression referencing other parameters by name (`2 * hole_dia +
//! 1.5`). A [`ParameterTable`] holds every parameter for one document (per
//! the design doc's §2: one table per document, not per-[`super::
//! sketch_constraints::SketchScope`]) and resolves each one's numeric value,
//! rejecting a circular reference at the moment it would be introduced
//! rather than at evaluation time — the design doc's explicit requirement
//! ("A referencing B referencing A must be rejected, not silently
//! infinite-loop or panic"), and the same real-time-rejection behavior a
//! spreadsheet gives for a circular formula.
//!
//! No existing workspace dependency parses expressions (the design doc's §2
//! flags checking `evalexpr`/`meval` before hand-rolling one). Hand-rolled
//! here instead: cycle detection needs the set of names an expression
//! references *before* it's evaluated, which means owning the parse tree —
//! treating evaluation as a black box, the way a generic expression-eval
//! crate is normally used, would just mean re-deriving that same dependency
//! information by other means. [`Expr::refs`] *is* that dependency
//! information, not extra work spent duplicating what such a crate computes
//! internally anyway.
use super::Scene;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to
/// another parameter by name — what makes this a *named*-parameter system
/// rather than a plain calculator) and [`Expr::Call`] (a function call).
/// `Call` is in the grammar now so it doesn't need a rewrite if/when more
/// functions are wanted later (design doc §5 open question 2 leaves the
/// exact v1 function set undecided); only a small, conservative builtin set
/// is actually wired up for now (see `call_builtin`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Expr {
Number(f64),
Ref(String),
Neg(Box<Expr>),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
Pow(Box<Expr>, Box<Expr>),
Call(String, Vec<Expr>),
}
impl Expr {
/// Every parameter name this expression reads, deduplicated in
/// first-seen order. This is the entire basis for cycle detection
/// (`ParameterTable::set`): one dependency edge per name returned here.
fn refs(&self, out: &mut Vec<String>) {
match self {
Expr::Number(_) => {}
Expr::Ref(name) => {
if !out.contains(name) {
out.push(name.clone());
}
}
Expr::Neg(a) => a.refs(out),
Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) | Expr::Div(a, b) | Expr::Pow(a, b) => {
a.refs(out);
b.refs(out);
}
Expr::Call(_, args) => {
for arg in args {
arg.refs(out);
}
}
}
}
}
/// Everything that can go wrong parsing, defining, or resolving a
/// parameter. Every case is reported back to the caller (and, in later
/// stages, the UI) rather than panicking — matching this codebase's
/// established pattern of isolating a bad derived value instead of letting
/// it take down an unrelated computation (e.g. dangling-constraint handling
/// in `sketch_solve.rs`).
#[derive(Debug, Clone, PartialEq)]
pub enum ParamError {
/// A formula's text doesn't parse. The `String` is a short, human-
/// readable reason, not a machine-parseable code.
Parse(String),
/// Not a valid identifier, or collides with a builtin function name
/// (`is_reserved_name`).
InvalidName(String),
/// Accepting this formula would close a circular reference right now.
/// The path reads start-to-finish, e.g. `["a", "b", "a"]` for `a = b`,
/// `b = a`.
Cycle(Vec<String>),
UnknownFunction(String),
WrongArgCount { function: String, expected: usize, found: usize },
/// Raised by `resolve`/`resolve_all`, not `set` — a formula may
/// legitimately reference a parameter that doesn't exist *yet* (order of
/// definition shouldn't matter, mirroring a spreadsheet); this only
/// surfaces once something actually tries to read the missing value.
UndefinedReference(String),
DivisionByZero,
}
impl fmt::Display for ParamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParamError::Parse(msg) => write!(f, "formula error: {msg}"),
ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"),
ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")),
ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"),
ParamError::WrongArgCount { function, expected, found } => {
write!(f, "{function}() expects {expected} argument(s), found {found}")
}
ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"),
ParamError::DivisionByZero => write!(f, "division by zero"),
}
}
}
impl std::error::Error for ParamError {}
/// The v1 builtin function set — deliberately small (design doc §5 open
/// question 2 defers the full surface). Also doubles as the reserved-word
/// list `is_reserved_name` checks a parameter name against, so a formula
/// like `sqrt(x)` is never ambiguous between "call the builtin" and
/// "multiply by a parameter named sqrt".
const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"];
pub fn is_reserved_name(name: &str) -> bool {
BUILTIN_FUNCTIONS.contains(&name)
}
/// A valid parameter name: starts with a letter or underscore, followed by
/// letters/digits/underscores. (Design doc §5 open question 5.)
pub fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
/// A dimensional constraint's driving value (design doc §2's own suggested
/// shape for `SketchConstraint::driving_param`, stage 3 of the named-
/// parameters staged plan): either a literal number typed directly, or a
/// reference to a named parameter resolved through a [`ParameterTable`] at
/// solve time. Lives here rather than in `sketch_constraints.rs` since
/// resolving `Named` needs `ParameterTable`, which this module owns.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DrivingValue {
Literal(f64),
Named(String),
}
impl DrivingValue {
/// Resolves to a number: a literal resolves to itself (never fails); a
/// named reference resolves through `table`, which can fail the same
/// ways any [`ParameterTable::resolve`] call can (undefined reference,
/// division by zero — a cycle should be unreachable here since `set`
/// already refuses to create one).
pub fn resolve(&self, table: &ParameterTable) -> Result<f64, ParamError> {
match self {
DrivingValue::Literal(value) => Ok(*value),
DrivingValue::Named(name) => table.resolve(name),
}
}
}
impl From<f64> for DrivingValue {
fn from(value: f64) -> Self {
DrivingValue::Literal(value)
}
}
fn call_builtin(name: &str, args: &[f64]) -> Result<f64, ParamError> {
fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> {
if args.len() != expected {
return Err(ParamError::WrongArgCount { function: name.to_string(), expected, found: args.len() });
}
Ok(())
}
match name {
"sqrt" => {
check_arity(name, args, 1)?;
Ok(args[0].sqrt())
}
"abs" => {
check_arity(name, args, 1)?;
Ok(args[0].abs())
}
"pow" => {
check_arity(name, args, 2)?;
Ok(args[0].powf(args[1]))
}
"min" => {
check_arity(name, args, 2)?;
Ok(args[0].min(args[1]))
}
"max" => {
check_arity(name, args, 2)?;
Ok(args[0].max(args[1]))
}
_ => Err(ParamError::UnknownFunction(name.to_string())),
}
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(f64),
Ident(String),
Plus,
Minus,
Star,
Slash,
Caret,
LParen,
RParen,
Comma,
}
fn tokenize(src: &str) -> Result<Vec<Token>, ParamError> {
let chars: Vec<char> = src.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
i += 1;
continue;
}
match c {
'+' => {
tokens.push(Token::Plus);
i += 1;
}
'-' => {
tokens.push(Token::Minus);
i += 1;
}
'*' => {
tokens.push(Token::Star);
i += 1;
}
'/' => {
tokens.push(Token::Slash);
i += 1;
}
'^' => {
tokens.push(Token::Caret);
i += 1;
}
'(' => {
tokens.push(Token::LParen);
i += 1;
}
')' => {
tokens.push(Token::RParen);
i += 1;
}
',' => {
tokens.push(Token::Comma);
i += 1;
}
_ if c.is_ascii_digit() || c == '.' => {
let start = i;
let mut seen_dot = c == '.';
i += 1;
while i < chars.len() {
let d = chars[i];
if d.is_ascii_digit() {
i += 1;
} else if d == '.' && !seen_dot {
seen_dot = true;
i += 1;
} else {
break;
}
}
// Scientific notation: 1e-3, 2.5E10. Only consumed when a
// digit (optionally signed) actually follows the e/E, so a
// bare trailing "e" is left for the identifier lexer instead
// (not reachable today since no builtin/constant is named
// "e", but keeps this branch from eating an identifier that
// happens to start with it, e.g. a formula like `2 * ecc`).
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
let mut j = i + 1;
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
j += 1;
}
if j < chars.len() && chars[j].is_ascii_digit() {
while j < chars.len() && chars[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
let text: String = chars[start..i].iter().collect();
let value: f64 = text.parse().map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?;
tokens.push(Token::Number(value));
}
_ if c.is_alphabetic() || c == '_' => {
let start = i;
i += 1;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
tokens.push(Token::Ident(chars[start..i].iter().collect()));
}
other => return Err(ParamError::Parse(format!("unexpected character '{other}'"))),
}
}
Ok(tokens)
}
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn bump(&mut self) -> Option<Token> {
let t = self.tokens.get(self.pos).cloned();
if t.is_some() {
self.pos += 1;
}
t
}
fn expect(&mut self, expected: Token) -> Result<(), ParamError> {
match self.bump() {
Some(t) if t == expected => Ok(()),
Some(t) => Err(ParamError::Parse(format!("expected {expected:?}, found {t:?}"))),
None => Err(ParamError::Parse(format!("expected {expected:?}, found end of formula"))),
}
}
// Precedence, low to high: expr (+ -) > term (* /) > unary (-) > power (^) > primary.
// `^` binds tighter than unary minus on its left operand but is
// right-associative on its own exponent (`parse_unary` recursing back
// into `parse_power` covers `2^-2` == `2^(-2)`, and `2^3^2` ==
// `2^(3^2)`).
fn parse_expr(&mut self) -> Result<Expr, ParamError> {
let mut lhs = self.parse_term()?;
loop {
match self.peek() {
Some(Token::Plus) => {
self.pos += 1;
lhs = Expr::Add(Box::new(lhs), Box::new(self.parse_term()?));
}
Some(Token::Minus) => {
self.pos += 1;
lhs = Expr::Sub(Box::new(lhs), Box::new(self.parse_term()?));
}
_ => break,
}
}
Ok(lhs)
}
fn parse_term(&mut self) -> Result<Expr, ParamError> {
let mut lhs = self.parse_unary()?;
loop {
match self.peek() {
Some(Token::Star) => {
self.pos += 1;
lhs = Expr::Mul(Box::new(lhs), Box::new(self.parse_unary()?));
}
Some(Token::Slash) => {
self.pos += 1;
lhs = Expr::Div(Box::new(lhs), Box::new(self.parse_unary()?));
}
_ => break,
}
}
Ok(lhs)
}
fn parse_unary(&mut self) -> Result<Expr, ParamError> {
match self.peek() {
Some(Token::Minus) => {
self.pos += 1;
Ok(Expr::Neg(Box::new(self.parse_unary()?)))
}
Some(Token::Plus) => {
self.pos += 1;
self.parse_unary()
}
_ => self.parse_power(),
}
}
fn parse_power(&mut self) -> Result<Expr, ParamError> {
let base = self.parse_primary()?;
if let Some(Token::Caret) = self.peek() {
self.pos += 1;
let exponent = self.parse_unary()?;
return Ok(Expr::Pow(Box::new(base), Box::new(exponent)));
}
Ok(base)
}
fn parse_primary(&mut self) -> Result<Expr, ParamError> {
match self.bump() {
Some(Token::Number(n)) => Ok(Expr::Number(n)),
Some(Token::Ident(name)) => {
if matches!(self.peek(), Some(Token::LParen)) {
self.pos += 1;
let mut args = Vec::new();
if !matches!(self.peek(), Some(Token::RParen)) {
loop {
args.push(self.parse_expr()?);
if matches!(self.peek(), Some(Token::Comma)) {
self.pos += 1;
} else {
break;
}
}
}
self.expect(Token::RParen)?;
Ok(Expr::Call(name, args))
} else {
Ok(Expr::Ref(name))
}
}
Some(Token::LParen) => {
let inner = self.parse_expr()?;
self.expect(Token::RParen)?;
Ok(inner)
}
Some(other) => Err(ParamError::Parse(format!("unexpected token {other:?}"))),
None => Err(ParamError::Parse("unexpected end of formula".to_string())),
}
}
}
/// Parses a formula's text into an [`Expr`], without evaluating or
/// resolving any reference. `ParameterTable::set` is the usual entry point;
/// exposed standalone so a UI stage can validate-as-you-type without
/// touching the table.
pub fn parse(source: &str) -> Result<Expr, ParamError> {
let tokens = tokenize(source)?;
if tokens.is_empty() {
return Err(ParamError::Parse("empty formula".to_string()));
}
let mut parser = Parser { tokens: &tokens, pos: 0 };
let expr = parser.parse_expr()?;
if parser.pos != tokens.len() {
return Err(ParamError::Parse(format!("unexpected trailing token {:?}", tokens[parser.pos])));
}
Ok(expr)
}
/// One named parameter: a name plus its formula, both the original typed
/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`]
/// (`expr`, for evaluation).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
pub source: String,
pub expr: Expr,
}
/// Every named parameter for one document (design doc §2: per-document, not
/// per-[`super::sketch_constraints::SketchScope`] — a block/xref does not
/// get its own independent table; open question 4 leaves whether that's the
/// final answer, but it's this stage's working assumption).
///
/// `parameters` is intentionally not `pub` (unlike the sibling
/// `SketchConstraintSet::constraints`): this table's entire value is that
/// every stored formula is already known to be parse-valid and cycle-free,
/// an invariant only `set`/`remove` maintain. A direct external push could
/// silently violate it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ParameterTable {
parameters: Vec<Parameter>,
}
impl ParameterTable {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.parameters.is_empty()
}
pub fn len(&self) -> usize {
self.parameters.len()
}
/// Insertion order — the order parameters were first defined in, stable
/// across edits to existing ones. The natural listing order for a
/// future parameters panel.
pub fn iter(&self) -> impl Iterator<Item = &Parameter> {
self.parameters.iter()
}
pub fn get(&self, name: &str) -> Option<&Parameter> {
self.parameters.iter().find(|p| p.name == name)
}
pub fn contains(&self, name: &str) -> bool {
self.get(name).is_some()
}
fn index_of(&self, name: &str) -> Option<usize> {
self.parameters.iter().position(|p| p.name == name)
}
/// Defines a new parameter or redefines an existing one's formula (one
/// upsert operation — there is no separate "must not already exist"
/// entry point; a future UI stage that wants add-vs-rename semantics can
/// check `contains` itself first).
///
/// Rejects: an invalid identifier or one colliding with a builtin
/// function name (`is_valid_name`/`is_reserved_name`), a formula that
/// fails to parse, and — the design doc's explicit requirement — a
/// formula that would close a circular reference right now. A reference
/// to a parameter that doesn't exist *yet* is not rejected (order of
/// definition shouldn't matter, mirroring a spreadsheet); it only
/// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`]
/// actually needs that value.
pub fn set(&mut self, name: &str, source: &str) -> Result<(), ParamError> {
if !is_valid_name(name) || is_reserved_name(name) {
return Err(ParamError::InvalidName(name.to_string()));
}
let expr = parse(source)?;
self.check_no_cycle(name, &expr)?;
match self.index_of(name) {
Some(i) => {
self.parameters[i].source = source.to_string();
self.parameters[i].expr = expr;
}
None => self.parameters.push(Parameter { name: name.to_string(), source: source.to_string(), expr }),
}
Ok(())
}
/// Removes a parameter by name. Returns whether one was actually
/// removed. Deliberately does not check whether another parameter's
/// formula still references `name` — that becomes an
/// [`ParamError::UndefinedReference`] for the referencing parameter the
/// next time it's resolved, the same graceful-degradation treatment an
/// unresolvable reference gets everywhere else in this module, rather
/// than a special "can't delete, still in use" rule this stage doesn't
/// need to invent (a future UI stage may still want to warn before
/// deleting — that's a UI-layer decision, not a data-layer one).
pub fn remove(&mut self, name: &str) -> bool {
match self.index_of(name) {
Some(i) => {
self.parameters.remove(i);
true
}
None => false,
}
}
/// Would accepting `new_expr` as `name`'s formula close a circular
/// reference right now? Walks the dependency graph from each of
/// `new_expr`'s direct references, following each visited parameter's
/// *existing* stored formula, looking for a path back to `name`. A
/// reference to a name not currently in the table just terminates that
/// branch — no cycle through something that doesn't exist yet.
fn check_no_cycle(&self, name: &str, new_expr: &Expr) -> Result<(), ParamError> {
let mut direct_refs = Vec::new();
new_expr.refs(&mut direct_refs);
for start in &direct_refs {
let mut visiting = Vec::new();
if let Some(mut path) = self.find_path(start, name, &mut visiting) {
let mut full = vec![name.to_string()];
full.append(&mut path);
return Err(ParamError::Cycle(full));
}
}
Ok(())
}
/// Depth-first search from `current` to `target`, following each
/// visited parameter's existing formula's references. Returns the path
/// (starting with `current`) if `target` is reachable.
fn find_path(&self, current: &str, target: &str, visiting: &mut Vec<String>) -> Option<Vec<String>> {
if current == target {
return Some(vec![current.to_string()]);
}
if visiting.iter().any(|v| v == current) {
// A cycle that doesn't involve `target` shouldn't be reachable
// (`set` refuses to create one), but bail out rather than loop
// forever if that invariant is ever violated some other way
// (e.g. a foreign/hand-edited file deserialized straight into a
// table without going through `set`).
return None;
}
let param = self.get(current)?;
visiting.push(current.to_string());
let mut refs = Vec::new();
param.expr.refs(&mut refs);
for r in &refs {
if let Some(mut rest) = self.find_path(r, target, visiting) {
let mut path = vec![current.to_string()];
path.append(&mut rest);
visiting.pop();
return Some(path);
}
}
visiting.pop();
None
}
/// Resolves one parameter's numeric value, evaluating its dependency
/// chain as needed. No state is kept between calls (a fresh memo cache
/// every call) — mirrors `refresh_sketch_constraints`'s
/// rebuild-from-scratch-per-trigger model (design doc §2): this stage
/// has no document/UI integration yet to decide when a cached value
/// would go stale, so it simply never caches across calls.
pub fn resolve(&self, name: &str) -> Result<f64, ParamError> {
let mut cache = HashMap::new();
let mut stack = Vec::new();
self.resolve_inner(name, &mut cache, &mut stack)
}
/// Resolves every parameter, isolating a failure to just the parameters
/// it actually affects rather than aborting the whole table — the same
/// don't-let-one-bad-item-take-down-everything-else treatment this
/// codebase already gives dangling constraints and similar derived-data
/// failures elsewhere.
pub fn resolve_all(&self) -> HashMap<String, Result<f64, ParamError>> {
let mut out = HashMap::with_capacity(self.parameters.len());
for param in &self.parameters {
out.insert(param.name.clone(), self.resolve(&param.name));
}
out
}
fn resolve_inner(&self, name: &str, cache: &mut HashMap<String, f64>, stack: &mut Vec<String>) -> Result<f64, ParamError> {
if let Some(v) = cache.get(name) {
return Ok(*v);
}
if stack.iter().any(|s| s == name) {
// Defense in depth only — `set` already refuses to create a
// cycle; see `find_path`'s doc comment for how one could still
// end up here.
let mut cyc = stack.clone();
cyc.push(name.to_string());
return Err(ParamError::Cycle(cyc));
}
let param = self.get(name).ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?;
stack.push(name.to_string());
let value = self.eval(&param.expr, cache, stack)?;
stack.pop();
cache.insert(name.to_string(), value);
Ok(value)
}
fn eval(&self, expr: &Expr, cache: &mut HashMap<String, f64>, stack: &mut Vec<String>) -> Result<f64, ParamError> {
Ok(match expr {
Expr::Number(n) => *n,
Expr::Ref(name) => self.resolve_inner(name, cache, stack)?,
Expr::Neg(a) => -self.eval(a, cache, stack)?,
Expr::Add(a, b) => self.eval(a, cache, stack)? + self.eval(b, cache, stack)?,
Expr::Sub(a, b) => self.eval(a, cache, stack)? - self.eval(b, cache, stack)?,
Expr::Mul(a, b) => self.eval(a, cache, stack)? * self.eval(b, cache, stack)?,
Expr::Div(a, b) => {
let denom = self.eval(b, cache, stack)?;
if denom == 0.0 {
return Err(ParamError::DivisionByZero);
}
self.eval(a, cache, stack)? / denom
}
Expr::Pow(a, b) => self.eval(a, cache, stack)?.powf(self.eval(b, cache, stack)?),
Expr::Call(fname, args) => {
let mut values = Vec::with_capacity(args.len());
for arg in args {
values.push(self.eval(arg, cache, stack)?);
}
call_builtin(fname, &values)?
}
})
}
}
impl Scene {
/// The document's named-parameter table (design doc §2: one per
/// document). Read-only accessor; use [`Scene::named_parameters_mut`] to
/// define/redefine/remove a parameter.
pub fn named_parameters(&self) -> &ParameterTable {
&self.named_parameters
}
pub fn named_parameters_mut(&mut self) -> &mut ParameterTable {
&mut self.named_parameters
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_arithmetic_with_ordinary_precedence() {
// 2 + 3 * 4 == 14, not 20 -- proves * binds tighter than +.
assert_eq!(eval_literal("2 + 3 * 4"), 14.0);
// (2 + 3) * 4 == 20 -- parens override precedence.
assert_eq!(eval_literal("(2 + 3) * 4"), 20.0);
// 2 ^ 3 ^ 2 == 2 ^ (3 ^ 2) == 512 -- right-associative power.
assert_eq!(eval_literal("2 ^ 3 ^ 2"), 512.0);
// Unary minus binds tighter than power's left operand is *read*,
// but -2^2 must still equal -(2^2) = -4 under this grammar (unary
// parses before power), matching common calculator convention.
assert_eq!(eval_literal("-2^2"), -4.0);
assert_eq!(eval_literal("2^-2"), 0.25);
assert_eq!(eval_literal("10 / 4"), 2.5);
assert_eq!(eval_literal("-(3 + 4)"), -7.0);
}
fn eval_literal(src: &str) -> f64 {
let table = ParameterTable::new();
let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}"));
table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_or_else(|e| panic!("eval '{src}': {e}"))
}
#[test]
fn parses_scientific_notation() {
assert_eq!(eval_literal("1e3"), 1000.0);
assert_eq!(eval_literal("1.5e-2"), 0.015);
}
#[test]
fn rejects_malformed_formulas() {
assert!(matches!(parse(""), Err(ParamError::Parse(_))));
assert!(matches!(parse("1 +"), Err(ParamError::Parse(_))));
assert!(matches!(parse("(1 + 2"), Err(ParamError::Parse(_))));
assert!(matches!(parse("1 2"), Err(ParamError::Parse(_))));
assert!(matches!(parse("1 @ 2"), Err(ParamError::Parse(_))));
}
#[test]
fn builtin_functions_evaluate_and_check_arity() {
assert_eq!(eval_literal("sqrt(16)"), 4.0);
assert_eq!(eval_literal("abs(-5)"), 5.0);
assert_eq!(eval_literal("min(3, 7)"), 3.0);
assert_eq!(eval_literal("max(3, 7)"), 7.0);
assert_eq!(eval_literal("pow(2, 10)"), 1024.0);
let table = ParameterTable::new();
let expr = parse("sqrt(1, 2)").unwrap();
let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err();
assert_eq!(err, ParamError::WrongArgCount { function: "sqrt".to_string(), expected: 1, found: 2 });
let expr = parse("bogus(1)").unwrap();
let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err();
assert_eq!(err, ParamError::UnknownFunction("bogus".to_string()));
}
#[test]
fn division_by_zero_is_reported_not_panicked() {
let table = ParameterTable::new();
let expr = parse("1 / 0").unwrap();
assert_eq!(table.eval(&expr, &mut HashMap::new(), &mut Vec::new()), Err(ParamError::DivisionByZero));
}
#[test]
fn set_rejects_invalid_and_reserved_names() {
let mut table = ParameterTable::new();
assert!(matches!(table.set("2bad", "1"), Err(ParamError::InvalidName(_))));
assert!(matches!(table.set("has space", "1"), Err(ParamError::InvalidName(_))));
assert!(matches!(table.set("", "1"), Err(ParamError::InvalidName(_))));
assert!(matches!(table.set("sqrt", "1"), Err(ParamError::InvalidName(_))));
assert!(table.set("_valid1", "1").is_ok());
}
#[test]
fn a_literal_parameter_resolves_to_its_number() {
let mut table = ParameterTable::new();
table.set("plate_len", "42.5").unwrap();
assert_eq!(table.resolve("plate_len"), Ok(42.5));
}
#[test]
fn a_formula_resolves_through_a_reference_chain() {
let mut table = ParameterTable::new();
table.set("hole_dia", "5").unwrap();
table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
table.set("row_span", "3 * hole_spacing").unwrap();
assert_eq!(table.resolve("hole_spacing"), Ok(11.5));
assert_eq!(table.resolve("row_span"), Ok(34.5));
}
#[test]
fn resolving_an_undefined_reference_reports_which_name_is_missing() {
let mut table = ParameterTable::new();
table.set("a", "b + 1").unwrap();
assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string())));
}
#[test]
fn set_rejects_a_direct_self_reference() {
let mut table = ParameterTable::new();
let err = table.set("a", "a + 1").unwrap_err();
assert!(matches!(err, ParamError::Cycle(_)));
assert!(!table.contains("a"), "a rejected formula must not be stored");
}
#[test]
fn set_rejects_an_indirect_cycle_at_the_definition_that_closes_it() {
let mut table = ParameterTable::new();
table.set("a", "b").unwrap();
// b doesn't exist yet -- not a cycle, a is just unresolved for now.
assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string())));
// Defining b = a closes the loop right here and must be rejected,
// leaving b undefined rather than silently accepted.
let err = table.set("b", "a").unwrap_err();
assert!(matches!(err, ParamError::Cycle(_)));
assert!(!table.contains("b"));
// a's formula (still "b") is untouched -- it stays unresolved, not
// corrupted by the rejected attempt.
assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string())));
}
#[test]
fn set_allows_redefining_an_existing_parameter_to_break_a_would_be_cycle() {
let mut table = ParameterTable::new();
table.set("a", "1").unwrap();
table.set("b", "a + 1").unwrap();
// Redefining a in terms of b must be rejected (a <- b <- a)...
assert!(table.set("a", "b + 1").is_err());
// ...but redefining a to something unrelated is always fine.
table.set("a", "5").unwrap();
assert_eq!(table.resolve("a"), Ok(5.0));
assert_eq!(table.resolve("b"), Ok(6.0));
}
#[test]
fn resolve_all_isolates_a_failure_to_the_affected_parameters_only() {
let mut table = ParameterTable::new();
table.set("good", "10").unwrap();
table.set("bad", "missing + 1").unwrap();
table.set("depends_on_bad", "bad * 2").unwrap();
let results = table.resolve_all();
assert_eq!(results.get("good"), Some(&Ok(10.0)));
assert_eq!(results.get("bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string()))));
assert_eq!(results.get("depends_on_bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string()))));
}
#[test]
fn remove_drops_a_parameter_and_leaves_dependents_undefined_not_panicking() {
let mut table = ParameterTable::new();
table.set("a", "1").unwrap();
table.set("b", "a + 1").unwrap();
assert!(table.remove("a"));
assert!(!table.remove("a"), "removing an already-removed name reports false");
assert_eq!(table.resolve("b"), Err(ParamError::UndefinedReference("a".to_string())));
}
#[test]
fn set_upserts_an_existing_parameters_formula() {
let mut table = ParameterTable::new();
table.set("a", "1").unwrap();
table.set("a", "2").unwrap();
assert_eq!(table.len(), 1, "redefining must not create a second entry");
assert_eq!(table.resolve("a"), Ok(2.0));
}
#[test]
fn table_round_trips_through_bincode() {
let mut table = ParameterTable::new();
table.set("hole_dia", "5").unwrap();
table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
let bytes = bincode::serialize(&table).expect("serialize");
let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(restored.len(), 2);
assert_eq!(restored.resolve("hole_spacing"), Ok(11.5));
assert_eq!(restored.get("hole_dia").unwrap().source, "5");
}
#[test]
fn iter_preserves_insertion_order_across_redefinitions() {
let mut table = ParameterTable::new();
table.set("z", "1").unwrap();
table.set("a", "2").unwrap();
table.set("z", "3").unwrap(); // redefine, should not move to the end
let names: Vec<&str> = table.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["z", "a"]);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Save/load integration for [`super::named_parameters::ParameterTable`] —
//! design doc §4 stage 2 (the XRecord persistence spike) plus enough of
//! stage 2's "wire it into open/save" to actually be useful, following
//! [`super::sketch_persist`]'s exact precedent (itself the sketch-constraint
//! project's stage 4/§5.2): the "lazy" model, where the table lives only in
//! `Scene::named_parameters` in-memory during editing and is materialized
//! into `document.objects` as one XRecord right before a save, then read
//! back right after a load.
//!
//! The one structural difference from `sketch_persist`: a
//! [`super::named_parameters::ParameterTable`] is document-wide, not
//! per-[`super::sketch_constraints::SketchScope`], so there is exactly one
//! XRecord for the whole document rather than one per scope owner.
//!
//! **Owner handle, and a real round-trip gap found while building this**:
//! the obvious choice was `document.header.named_objects_dict_handle` (the
//! document's own root Named Objects Dictionary) — `extension_dictionary_
//! handle` in the vendored `cadcodec` source explicitly resolves an
//! extension dictionary for a `Dictionary`-type owner (`ObjectType::
//! Dictionary(value) => value.xdictionary_handle`), and that crate's own
//! tests (e.g. `tests/issue51.rs`) use `named_objects_dict_handle` as an
//! XRecord owner directly. In practice this round-tripped through DWG but
//! **silently lost the XRecord through DXF** (caught by this module's own
//! save/load tests, not assumed): `CadDocument::ensure_xrecord` only writes
//! the owner's *own* `xdictionary_handle` field when `get_entity_mut(owner)`
//! succeeds (a real "entity"); a `Dictionary` owner like the root NOD isn't
//! one, so `ensure_xrecord` falls back to a runtime-only side map
//! (`xdic_by_handle`) instead. The DWG writer consults that side map
//! (`io/dwg/dwg_document_builder.rs` et al.); the DXF writer never does (no
//! `xdic_by_handle` reference anywhere under `io/dxf/writer/`) — so a
//! Dictionary-owned extension dictionary is invisible to a DXF save. This
//! is a gap in the vendored crate for this specific owner-type combination,
//! not something introduced here; per this project's own stated preference
//! to avoid needing changes to `acadrust`/`cadkernel`, the fix is to pick a
//! different, already-proven owner rather than patch it.
//!
//! **Owner used instead**: `document.header.model_space_block_handle` — a
//! real `BlockRecord` entity, the exact anchor `sketch_persist`'s own
//! `SketchScope::ModelSpace` case already proved round-trips cleanly
//! through both DWG and DXF. Model space is not a semantically perfect fit
//! for "the whole document" (a parameter table has nothing to do with model
//! space specifically), but it is guaranteed to exist in every
//! `CadDocument` and is a real entity, which is the property that actually
//! matters here.
use super::named_parameters::ParameterTable;
use super::Scene;
const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS";
/// Prefixed onto the serialized blob so a future schema change can be
/// detected and gracefully skipped rather than silently misreading bytes —
/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the
/// migration path itself is still undesigned.
const FORMAT_VERSION: u8 = 1;
fn encode(table: &ParameterTable) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
bytes.extend(bincode::serialize(table).ok()?);
Some(bytes)
}
fn decode(bytes: &[u8]) -> Option<ParameterTable> {
let (&version, body) = bytes.split_first()?;
if version != FORMAT_VERSION {
return None;
}
bincode::deserialize(body).ok()
}
impl Scene {
/// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS`
/// XRecord, ready for whatever save call happens next to serialize
/// `self.document` as-is. Called from the same save entry points as
/// `materialize_sketch_constraints_for_save`
/// (`OpenCADStudio::prepare_native_save`, the wasm save path).
///
/// An empty table is skipped rather than writing an empty XRecord — an
/// unconstrained-by-parameters drawing (the common case, at least until
/// this feature is used) should not gain persisted-but-empty
/// bookkeeping on every save.
pub(crate) fn materialize_named_parameters_for_save(&mut self) {
if self.named_parameters.is_empty() {
return;
}
let Some(bytes) = encode(&self.named_parameters) else { return };
let owner = self.document.header.model_space_block_handle;
if owner.is_null() {
return;
}
self.document.ensure_xrecord(owner, XRECORD_KEY);
if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) {
// Overwrite, not append: a resave must replace the prior blob,
// not accumulate one more Chunk entry every time.
record.entries.clear();
record
.entries
.push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(bytes)));
}
}
/// Populates `named_parameters` from the just-loaded `self.document`'s
/// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a
/// document open installs its `CadDocument` into this `Scene`, the same
/// call sites `load_sketch_constraints_from_document` already has
/// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"`
/// ops). Replaces whatever was already in `named_parameters` (a fresh
/// table for a real open; `"new"`'s blank document has nothing to find
/// anyway).
pub(crate) fn load_named_parameters_from_document(&mut self) {
self.named_parameters = ParameterTable::new();
let owner = self.document.header.model_space_block_handle;
if owner.is_null() {
return;
}
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return };
let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value {
acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()),
_ => None,
}) else {
return;
};
if let Some(table) = decode(&bytes) {
self.named_parameters = table;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_parameter_table_survives_materialize_and_reload() {
let mut scene = Scene::new();
scene.named_parameters_mut().set("hole_dia", "5").unwrap();
scene.named_parameters_mut().set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
scene.materialize_named_parameters_for_save();
// Simulate the load-time side of a save/reload round trip: drop the
// in-memory table (a fresh open starts empty) and repopulate purely
// from what materialize just wrote into `document.objects`.
scene.named_parameters = ParameterTable::new();
scene.load_named_parameters_from_document();
assert_eq!(scene.named_parameters().len(), 2);
assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5));
assert_eq!(scene.named_parameters().get("hole_dia").unwrap().source, "5");
}
/// The above test only proves `materialize`/`load` agree with each
/// other on the same in-memory `CadDocument` — this drives a real
/// `save_to_bytes`/`load_bytes` round trip (the same primitives
/// `OpenCADStudio::on_file_opened`'s native save/open path uses),
/// through both supported formats, mirroring
/// `sketch_persist`'s own real-bytes tests.
fn full_bytes_roundtrip(ext: &str) -> ParameterTable {
let mut scene = Scene::new();
scene.named_parameters_mut().set("plate_len", "42.5").unwrap();
scene.named_parameters_mut().set("plate_width", "plate_len / 2").unwrap();
scene.materialize_named_parameters_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
let mut reloaded_scene = Scene::new();
reloaded_scene.document = crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes)
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
reloaded_scene.load_named_parameters_from_document();
reloaded_scene.named_parameters
}
#[test]
fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dxf");
assert_eq!(restored.len(), 2);
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
}
#[test]
fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dwg");
assert_eq!(restored.len(), 2);
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
}
#[test]
fn an_empty_table_is_not_materialized() {
let mut scene = Scene::new();
scene.materialize_named_parameters_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty table should not create an XRecord");
}
#[test]
fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap());
assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread");
}
}
+27 -15
View File
@@ -11,6 +11,7 @@
//! entities and is meant to be re-solved every time referenced geometry
//! changes, not applied once.
use super::named_parameters::DrivingValue;
use acadrust::types::{Handle, Vector3};
use serde::{Deserialize, Serialize};
@@ -90,11 +91,14 @@ pub struct SketchConstraint {
pub id: ConstraintId,
pub kind: ConstraintKind,
pub refs: Vec<SketchRef>,
/// The typed target for a dimensional constraint (a `Distance`'s
/// length, an `Angle`'s degrees, a `Radius`'s radius). `None` for every
/// purely-geometric kind (Coincident, Horizontal, Vertical, Parallel,
/// Perpendicular, Equal, Tangent).
pub driving_param: Option<f64>,
/// The target for a dimensional constraint (a `Distance`'s length, an
/// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a
/// named-parameter reference (`docs/named_parameters_design.md`,
/// resolved through `Scene::named_parameters` at solve time by
/// `sketch_solve::build_constraint`). `None` for every purely-geometric
/// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular,
/// Equal, Tangent).
pub driving_param: Option<DrivingValue>,
/// Lets a user suppress a constraint without losing it — a re-solve
/// skips a disabled constraint entirely.
pub enabled: bool,
@@ -160,7 +164,7 @@ impl SketchConstraintSet {
}
/// Appends a constraint, assigning it a fresh id unique within this set.
pub fn add(&mut self, kind: ConstraintKind, refs: Vec<SketchRef>, driving_param: Option<f64>) -> ConstraintId {
pub fn add(&mut self, kind: ConstraintKind, refs: Vec<SketchRef>, driving_param: Option<DrivingValue>) -> ConstraintId {
let id = self.next_id;
self.next_id += 1;
self.constraints.push(SketchConstraint { id, kind, refs, driving_param, enabled: true });
@@ -348,9 +352,17 @@ impl ConstraintKind {
/// The full glyph text for one constraint: its symbol, plus the driving
/// value for a dimensional kind (Distance/Angle/Radius).
pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String {
match (constraint.kind, constraint.driving_param) {
(ConstraintKind::Angle, Some(value)) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()),
(_, Some(value)) => format!("{} {value:.2}", constraint.kind.glyph_symbol()),
match (constraint.kind, &constraint.driving_param) {
(ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()),
(_, Some(DrivingValue::Literal(value))) => format!("{} {value:.2}", constraint.kind.glyph_symbol()),
// A named reference has no single resolved number to show without
// threading `ParameterTable` into every glyph-render call site
// (`src/ui/overlay.rs`) — showing the name itself is enough for now;
// stage 4's parameters panel is the natural place to reconsider this
// once a named `driving_param` can actually be authored through the
// UI (nothing can yet — this arm exists so the match is exhaustive
// and correct ahead of that UI, not because it's reachable today).
(_, Some(DrivingValue::Named(name))) => format!("{} {name}", constraint.kind.glyph_symbol()),
(_, None) => constraint.kind.glyph_symbol().to_string(),
}
}
@@ -415,7 +427,7 @@ impl super::Scene {
if handle_map.is_empty() {
return;
}
let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<f64>)> = Vec::new();
let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<DrivingValue>)> = Vec::new();
for (scope_index, set) in self.sketch_constraints.iter().enumerate() {
for c in &set.constraints {
if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) {
@@ -423,7 +435,7 @@ impl super::Scene {
}
let new_refs: Vec<SketchRef> =
c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect();
to_add.push((scope_index, c.kind, new_refs, c.driving_param));
to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone()));
}
}
if to_add.is_empty() {
@@ -453,10 +465,10 @@ mod tests {
fn add_assigns_increasing_ids_and_get_finds_them() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None);
let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(25.0));
let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(DrivingValue::Literal(25.0)));
assert_ne!(a, b);
assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal);
assert_eq!(set.get(b).unwrap().driving_param, Some(25.0));
assert_eq!(set.get(b).unwrap().driving_param, Some(DrivingValue::Literal(25.0)));
}
#[test]
@@ -521,14 +533,14 @@ mod tests {
fn sketch_constraint_set_round_trips_through_bincode() {
let mut set = SketchConstraintSet::new(SketchScope::Block(h(5)));
set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None);
set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(12.5));
set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(DrivingValue::Literal(12.5)));
let bytes = bincode::serialize(&set).expect("serialize");
let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(restored.scope, set.scope);
assert_eq!(restored.constraints.len(), set.constraints.len());
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
assert_eq!(restored.constraints[1].driving_param, Some(DrivingValue::Literal(12.5)));
assert_eq!(restored.constraints[0].refs, set.constraints[0].refs);
}
}
+16 -4
View File
@@ -48,7 +48,15 @@ fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) {
/// or panicking — design doc §5.2's explicit call-out, and §7's flagged
/// "no migration path designed yet": bumping this is the signal a real
/// migration is needed, not a substitute for one.
const FORMAT_VERSION: u8 = 1;
///
/// Bumped 1 -> 2 by `named_parameters_design.md` stage 3:
/// `SketchConstraint::driving_param` changed from a bare `Option<f64>` to
/// `Option<named_parameters::DrivingValue>` (an enum, with its own
/// discriminant), so a version-1 blob's bytes no longer line up with the
/// current struct shape — exactly the "detected and gracefully skipped"
/// case this version byte exists for, not something to migrate (no
/// migration path exists yet, per the call-out above).
const FORMAT_VERSION: u8 = 2;
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
@@ -192,7 +200,11 @@ mod tests {
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)],
None,
);
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Distance, vec![SketchRef::whole(b)], Some(12.5));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Distance,
vec![SketchRef::whole(b)],
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)),
);
scene.materialize_sketch_constraints_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
@@ -213,7 +225,7 @@ mod tests {
assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)));
}
#[test]
@@ -222,7 +234,7 @@ mod tests {
assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)));
}
#[test]
+21 -10
View File
@@ -38,6 +38,7 @@ use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint};
use ocs_gcs::solvers::dogleg::solve_dl;
use ocs_gcs::system::System;
use super::named_parameters::ParameterTable;
use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef};
use super::{ChangeKind, Scene};
@@ -113,14 +114,21 @@ fn resolve_ref(
/// One [`SketchConstraint`]'s `ocs_gcs` construction, or `None` if it can't
/// be built (an unsupported/not-yet-mapped kind, a dangling ref, a ref
/// shape the kind doesn't expect — e.g. `Parallel` needs two whole-line
/// refs). A constraint that can't be built is simply skipped for this
/// solve, not an error: geometry it would have constrained is left alone,
/// matching how a dangling associative-dimension reference degrades today
/// rather than aborting the whole recompute.
/// refs — or, for a dimensional kind, a `driving_param` that fails to
/// resolve: an undefined named-parameter reference or a division by zero in
/// its formula, design doc `named_parameters_design.md` stage 3). A
/// constraint that can't be built is simply skipped for this solve, not an
/// error: geometry it would have constrained is left alone, matching how a
/// dangling associative-dimension reference degrades today rather than
/// aborting the whole recompute. (A named-parameter *cycle* should be
/// unreachable here — `ParameterTable::set` already refuses to create one —
/// so it gets the same treatment as any other resolve failure rather than a
/// special case.)
fn build_constraint(
document: &acadrust::CadDocument,
sys: &mut System,
cache: &mut HashMap<Handle, EntityGeom>,
params: &ParameterTable,
c: &SketchConstraint,
) -> Option<Rc<dyn Constraint>> {
if !c.enabled {
@@ -181,18 +189,18 @@ fn build_constraint(
ConstraintKind::Distance => {
let [a, b] = c.refs.as_slice() else { return None };
let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?);
let target = sys.add_param(c.driving_param?, true);
let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true);
Some(Rc::new(P2PDistance::new(pa, pb, target)))
}
ConstraintKind::Angle => {
let [a, b] = c.refs.as_slice() else { return None };
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
let angle = sys.add_param(c.driving_param?.to_radians(), true);
let angle = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?.to_radians(), true);
Some(Rc::new(L2LAngle::new(fixed, moving, angle)))
}
ConstraintKind::Radius => {
let circle = whole_circle(sys, cache, *c.refs.first()?)?;
let target = sys.add_param(c.driving_param?, true);
let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true);
Some(Rc::new(Equal::new(circle.rad, target, 1.0)))
}
ConstraintKind::Tangent => {
@@ -252,6 +260,7 @@ const MOVE_EPS: f64 = 1e-9;
/// on a successful solve.
fn solve_scope(
document: &acadrust::CadDocument,
params: &ParameterTable,
set: &SketchConstraintSet,
) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> {
let mut sys = System::new();
@@ -266,7 +275,7 @@ fn solve_scope(
let mut owner: Vec<(Rc<dyn Constraint>, ConstraintId)> = Vec::new();
for c in &set.constraints {
let Some(constraint) = build_constraint(document, &mut sys, &mut cache, c) else { continue };
let Some(constraint) = build_constraint(document, &mut sys, &mut cache, params, c) else { continue };
owner.push((constraint.clone(), c.id));
sys.add_constraint(constraint);
// Coincident needs both X and Y equal; `build_constraint` returns
@@ -412,7 +421,9 @@ impl Scene {
if !touched {
continue;
}
let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.sketch_constraints[i]) else { continue };
let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.named_parameters, &self.sketch_constraints[i]) else {
continue;
};
self.sketch_constraints[i].dof = Some(dof);
self.sketch_constraints[i].conflicts = conflicts;
for (handle, new_entity) in solved {
@@ -470,7 +481,7 @@ impl Scene {
if !is_touched {
continue;
}
let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, set) else { continue };
let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, &self.named_parameters, set) else { continue };
result.extend(solved);
}
result
+1
View File
@@ -18,3 +18,4 @@ pub mod options;
pub mod attribute_editor;
pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
+258
View File
@@ -0,0 +1,258 @@
//! Named-parameter table editor — an in-canvas modal (Plan B), mirroring
//! `alias_editor.rs`'s buffered-rows pattern exactly. Opened by PARAMETERS
//! (`docs/named_parameters_design.md` stage 4). Rows are `(name, formula)`,
//! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`)
//! and committed to `Scene::named_parameters` only on Apply — matching the
//! alias editor's "closing discards unapplied edits" convention, which
//! suits a formula table even better: `ParameterTable::set` validates (and
//! can reject) a formula immediately, so typing a not-yet-finished name or
//! formula must not touch the live table on every keystroke.
//!
//! Unlike the alias editor, each row also shows a live-computed resolved
//! value or error (`preview`, below) — built fresh from the whole buffer on
//! every render, not cached: the same "cheap enough to recompute from
//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use
//! throughout this project, and it means the preview reflects circular/
//! undefined-reference problems across rows immediately, before Apply.
use crate::app::Message;
use crate::scene::named_parameters::ParameterTable;
use crate::t;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Element, Length, Theme};
/// Which column of a parameter row a text edit targets.
#[derive(Clone, Copy, Debug)]
pub enum ParamField {
Name,
Formula,
}
/// One working-buffer row. Free text until Apply — `ParameterTable::set`
/// does the real validation then, not on every keystroke.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParamEditorRow {
pub name: String,
pub formula: String,
}
/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕
/// column — same convention and value as `alias_editor::GUTTER`.
const GUTTER: f32 = 16.0;
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.68)) }
}
fn danger_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style { color: Some(theme.palette().danger.base.color) }
}
/// Every buffer index sharing a (trimmed) name with at least one other
/// non-blank row — `ParameterTable::set` upserts by name, so two rows typed
/// with the same name would otherwise silently collapse into "whichever one
/// happens to `set` last wins," discarding the other's formula with no
/// indication anything was lost. Both `preview` and the real Apply
/// (`OpenCADStudio::apply_named_parameter_editor_rows`) check this first and
/// refuse every row in a name collision rather than guessing which one the
/// user meant.
pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::HashSet<usize> {
let mut by_name: std::collections::HashMap<&str, Vec<usize>> = std::collections::HashMap::new();
for (idx, row) in rows.iter().enumerate() {
let name = row.name.trim();
if !name.is_empty() {
by_name.entry(name).or_default().push(idx);
}
}
by_name.into_values().filter(|idxs| idxs.len() > 1).flatten().collect()
}
/// Builds a scratch table from every row's *current* text and resolves each
/// row's own value against it, so the preview reflects cross-row references
/// (including a row referencing one defined further down — order in the
/// buffer doesn't matter, same as it doesn't for the real table). Returns
/// one `Result` per row, in row order:
/// - `Err` from a blank name is suppressed (`Ok` with no text shown instead,
/// handled by the caller) — a fresh row from "+ Add parameter" before the
/// user has typed a name yet isn't an error to flag.
/// - A name shared with another row is its own error (`duplicate_name_rows`)
/// before anything is even attempted.
/// - `Err` from `ParameterTable::set` itself (bad name, parse error, a
/// formula that would close a cycle right now) is that row's own problem.
/// - `Err(UndefinedReference)`/other resolve failure means the row's own
/// formula is fine but something *it* depends on isn't.
fn preview(rows: &[ParamEditorRow]) -> Vec<Option<Result<f64, String>>> {
let duplicates = duplicate_name_rows(rows);
let mut table = ParameterTable::new();
let mut set_errors: Vec<Option<String>> = vec![None; rows.len()];
for (idx, row) in rows.iter().enumerate() {
let name = row.name.trim();
if name.is_empty() || duplicates.contains(&idx) {
continue;
}
if let Err(e) = table.set(name, row.formula.trim()) {
set_errors[idx] = Some(e.to_string());
}
}
rows.iter()
.enumerate()
.map(|(idx, row)| {
let name = row.name.trim();
if name.is_empty() {
return None;
}
if duplicates.contains(&idx) {
return Some(Err(format!("duplicate name '{name}'")));
}
if let Some(err) = &set_errors[idx] {
return Some(Err(err.clone()));
}
Some(table.resolve(name).map_err(|e| e.to_string()))
})
.collect()
}
/// Build the named-parameter editor content. `rows` is the live working
/// buffer.
pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
let title = text(t!("Named Parameters")).size(15);
let hint = text(t!(
"Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits."
))
.size(11)
.style(muted_style);
let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 };
const NAME_WIDTH: f32 = 120.0;
const VALUE_WIDTH: f32 = 140.0;
let head = container(
row![
container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)),
container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width),
container(text(t!("Value")).size(11).style(muted_style)).width(Length::Fixed(VALUE_WIDTH)),
Space::new().width(Length::Fixed(30.0)),
]
.spacing(8),
)
.padding(gutter);
let results = preview(rows);
let mut list = column![].spacing(3);
for (idx, row) in rows.iter().enumerate() {
let name_box = text_input(t!("name").as_ref(), &row.name)
.on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Name, value: v })
.size(13)
.padding([3, 6])
.width(Length::Fixed(NAME_WIDTH));
let formula_box = text_input(t!("formula").as_ref(), &row.formula)
.on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Formula, value: v })
.size(13)
.padding([3, 6])
.width(sizing.width);
let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() {
Some(Ok(value)) => container(text(format!("= {value:.4}")).size(12).style(muted_style)).into(),
Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(),
None => Space::new().into(),
};
let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0))
.on_press(Message::NamedParametersRemove(idx))
.padding([2, 6])
.style(button::danger);
list = list.push(
row![name_box, formula_box, container(value_cell).width(Length::Fixed(VALUE_WIDTH)), del]
.spacing(8)
.align_y(iced::Center),
);
}
let add = button(text(t!("+ Add parameter")).size(12))
.on_press(Message::NamedParametersAdd)
.padding([4, 10])
.style(button::secondary);
let apply = button(text(t!("Apply")).size(12))
.on_press(Message::NamedParametersApply)
.padding([4, 16])
.style(button::primary);
container(
column![
title,
hint,
Space::new().height(6),
head,
scrollable(container(list).padding(gutter)).height(sizing.height),
Space::new().height(6),
row![add, Space::new().width(sizing.width), apply].align_y(iced::Center),
]
.spacing(6)
.width(sizing.width)
.height(sizing.height),
)
.padding(12)
.width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() })
.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str, formula: &str) -> ParamEditorRow {
ParamEditorRow { name: name.to_string(), formula: formula.to_string() }
}
#[test]
fn preview_resolves_a_literal_and_a_cross_row_reference() {
let rows = vec![row("hole_dia", "5"), row("hole_spacing", "2 * hole_dia + 1.5")];
let results = preview(&rows);
assert_eq!(results[0], Some(Ok(5.0)));
assert_eq!(results[1], Some(Ok(11.5)));
}
#[test]
fn preview_resolves_a_forward_reference_regardless_of_row_order() {
// `plate_width` (row 0) references `plate_len` (row 1) -- defined
// *after* it in the buffer. Order in the editor must not matter, same
// as it doesn't for the real `ParameterTable`.
let rows = vec![row("plate_width", "plate_len / 2"), row("plate_len", "20")];
let results = preview(&rows);
assert_eq!(results[0], Some(Ok(10.0)));
assert_eq!(results[1], Some(Ok(20.0)));
}
#[test]
fn preview_reports_a_cycle_on_the_row_that_closes_it() {
let rows = vec![row("a", "b"), row("b", "a")];
let results = preview(&rows);
// Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle);
// row 1 (b = a) is the one that would close the loop and is rejected.
assert!(matches!(results[0], Some(Ok(_))) || matches!(&results[0], Some(Err(e)) if e.contains("not defined")));
assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular")));
}
#[test]
fn preview_leaves_a_blank_row_without_a_name_unflagged() {
let rows = vec![row("", "")];
assert_eq!(preview(&rows), vec![None]);
}
#[test]
fn preview_reports_a_malformed_formula_on_its_own_row() {
let rows = vec![row("bad", "1 +")];
let results = preview(&rows);
assert!(matches!(&results[0], Some(Err(e)) if e.contains("formula error")));
}
#[test]
fn preview_flags_every_row_sharing_a_duplicate_name_instead_of_picking_one() {
let rows = vec![row("x", "1"), row("x", "2"), row("y", "3")];
let results = preview(&rows);
assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate")));
assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate")));
assert_eq!(results[2], Some(Ok(3.0)), "an unrelated row's name must not be affected");
}
}
+19
View File
@@ -41,6 +41,7 @@ pub fn view_window<'a>(
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
lineweight_display_scale: i32,
ui_scale: i32,
ribbon_label_font_size: i32,
ribbon_group_title_font_size: i32,
model_space: &'a crate::app::config::ModelSpaceThemeConfig,
@@ -349,6 +350,24 @@ pub fn view_window<'a>(
});
let mut display = column![
text(crate::t!("UI Scale")).size(15),
Space::new().height(10),
row![
text(crate::t!("Overall size")).size(12).width(150),
slider(50..=200, ui_scale.clamp(50, 200), Message::UiScaleChanged)
.step(5)
.width(Fill),
text(format!("{}%", ui_scale.clamp(50, 200))).size(11).width(44),
]
.spacing(12)
.align_y(iced::Center),
Space::new().height(8),
text(crate::t!(
"Scales the whole interface at once — every panel, dialog, the ribbon, and their text — rather than one widget's font size. Takes effect immediately."
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::tr!("options", "theme-section")).size(15),
Space::new().height(10),
row![