Add Properties-panel Constraints/Parameters sections, a constraint-pill

value toggle, and redesigned constraint-ribbon icons

Properties panel:
- New "Parameters" section (shown with no selection) listing every named
  parameter with inline name/formula editing, add, and delete, backed by
  new PropValue::ParamRow/ParamAddRow and command_driver handlers
  (resolve_named_parameter_edit, on_prop_param_commit/_delete/_add_new).
  A fresh parameter's formula now defaults to "1", not "0" — a 0-length
  seed value is a genuine solver singularity for a Distance constraint
  driven by that parameter, with no defined direction to grow back out of.
- New "Constraints" section (shown for a single selected entity) listing
  every constraint touching it as a clickable PropValue::EntityLink that
  selects the linked entities in the viewport, danger-tinted when the
  constraint conflicts.

Constraint pills:
- New Options > General toggle, "Show values and parameter names on
  constraint markers" (on by default, persisted). Off, every pill shows
  just its bare glyph instead of glyph+driven-value/parameter-name — the
  glyph alone is enough to see a constraint is present, without the value
  text covering nearby geometry on a dense sketch.

Constraint-ribbon icons:
- Replaced all 18 unicode/letter glyph icons (including the plain "F",
  "L", "M", "S" letters) with proper SVGs under assets/icons/constrain/,
  matching the Modify section's two-color style (#B4B6B9 white / #6DB7ED
  blue, consistent stroke width). Tangent reuses the Draw section's
  white-arc-plus-blue-dots motif.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-10 23:16:11 +02:00
1 parent faf3473b2b
commit bd9a2d08f7
36 files changed
+707 -26

No files matched your search

+215
View File
@@ -1051,6 +1051,147 @@ impl OpenCADStudio {
}
}
/// Re-solves every entity touched by an enabled constraint driven by
/// `name` — the targeted, single-row counterpart to
/// `apply_named_parameter_editor_rows`'s "re-solve what it affects": an
/// inline Properties-panel edit changes one parameter, not the whole
/// table, so unlike that whole-table Apply this can cheaply scope the
/// re-solve to just the entities that actually reference it.
fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) {
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(n)) if n == name)
})
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
self.tabs[i].dirty = true;
if touched.is_empty() {
return;
}
let pending = self.begin_undo(i, "Edit named parameter", 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);
}
}
/// Commits one field of one Parameters-section row (Properties panel) —
/// the per-row, commit-on-submit counterpart to
/// `apply_named_parameter_editor_rows`'s whole-table Apply. A Formula
/// edit is a plain redefine (`ParameterTable::set` on the existing
/// name); a Name edit is a remove-then-set (there's no rename primitive
/// — `ParameterTable` upserts by name), restoring the old name if the
/// new one fails to validate so nothing is silently lost. Either way
/// this only re-solves what actually depends on this one parameter —
/// no "duplicate name" scan across the whole table is needed the way
/// the buffered modal Apply needs one, since every row here is always
/// already a real, distinct table entry.
pub(super) fn on_prop_param_commit(
&mut self,
index: usize,
field: crate::ui::window::named_parameters::ParamField,
) -> Task<Message> {
use crate::ui::properties::FieldKey;
use crate::ui::window::named_parameters::ParamField;
let i = self.active_tab;
let key = FieldKey::Param(index, field);
self.tabs[i].properties.active_field = None;
let Some(typed) = self.tabs[i].properties.edit_buf.remove(&key) else {
return Task::none();
};
let typed = typed.trim().to_string();
let Some(current) = self.tabs[i].scene.named_parameters().iter().nth(index).cloned() else {
self.refresh_properties();
return Task::none();
};
let (result, resolve_name) = match field {
ParamField::Formula => {
if typed.is_empty() || typed == current.source {
self.refresh_properties();
return Task::none();
}
(self.tabs[i].scene.named_parameters_mut().set(&current.name, &typed), current.name.clone())
}
ParamField::Name => {
if typed.is_empty() || typed == current.name {
self.refresh_properties();
return Task::none();
}
if self.tabs[i].scene.named_parameters().contains(&typed) {
self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref());
self.refresh_properties();
return Task::none();
}
self.tabs[i].scene.named_parameters_mut().remove(&current.name);
let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, &current.source);
if outcome.is_err() {
let _ = self.tabs[i].scene.named_parameters_mut().set(&current.name, &current.source);
}
(outcome, typed.clone())
}
};
if let Err(e) = result {
self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref());
self.refresh_properties();
return Task::none();
}
self.resolve_named_parameter_edit(i, &resolve_name);
self.refresh_properties();
Task::none()
}
/// Removes Parameters-section row `index` immediately — no confirmation,
/// matching AutoCAD's own Parameters Manager delete button. Any
/// constraint that referenced it gets the same "undefined reference"
/// resolve-failure treatment a formula's own bad reference already gets
/// (`build_constraint`) — re-solving surfaces that rather than needing
/// special-case handling here.
pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task<Message> {
let i = self.active_tab;
let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else {
return Task::none();
};
self.tabs[i].scene.named_parameters_mut().remove(&name);
self.resolve_named_parameter_edit(i, &name);
self.refresh_properties();
Task::none()
}
/// The Parameters section's "+ Add parameter" row: appends a fresh,
/// uniquely-named parameter (`param1`, `param2`, …) the user then
/// renames/redefines inline via `on_prop_param_commit`. Defaults to `1`,
/// not `0`: renaming this onto a name an existing Distance/Radius
/// constraint already references (unusual, but reachable — define
/// first, wire up the constraint's reference later) would otherwise
/// briefly collapse that geometry to a zero-length/zero-radius
/// degenerate state, which is a genuine numerical singularity for a
/// point-to-point distance constraint to grow back out of (no defined
/// direction to separate two exactly-coincident points from). `1` never
/// hits that.
pub(super) fn on_prop_param_add_new(&mut self) -> Task<Message> {
let i = self.active_tab;
let mut n = 1usize;
let name = loop {
let candidate = format!("param{n}");
if !self.tabs[i].scene.named_parameters().contains(&candidate) {
break candidate;
}
n += 1;
};
let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1");
self.refresh_properties();
Task::none()
}
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
let i = self.active_tab;
let preserve_commit_layer = self.tabs[i]
@@ -6370,6 +6511,80 @@ mod sketch_constraint_undo_tests {
assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}");
}
/// End-to-end through the actual `Message` handlers a Properties-panel
/// click/keystroke would fire — not `apply_named_parameter_editor_rows`
/// directly — covering the whole Parameters-section lifecycle: add,
/// rename, redefine, and confirm a referencing constraint re-solves
/// after each commit (not just after a whole-table Apply, since this
/// path commits per field).
#[test]
fn properties_panel_parameter_row_add_rename_redefine_and_delete() {
use crate::ui::window::named_parameters::ParamField;
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);
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("len".to_string())),
label: "Distance constraint",
});
// Add: the first row is auto-named "param1".
let _ = app.update(Message::PropParamAddNew);
assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"));
let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap();
// Rename param1 -> len (commit-on-submit, not per keystroke: input
// alone must not touch the table yet).
let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string() });
assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"), "typing alone must not commit");
let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name });
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("param1"));
assert!(app.tabs[app.active_tab].scene.named_parameters().contains("len"));
// Redefine its formula to 8 and confirm the Distance constraint
// (already referencing "len" by name, defined before this) re-solves.
let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() });
let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula });
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("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 redefined parameter, got {len}");
// Delete: the row is gone from the table.
let _ = app.update(Message::PropParamDelete(index));
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("len"));
}
/// A Constraints-section row click selects every entity the constraint
/// references, replacing whatever was selected before.
#[test]
fn properties_panel_constraint_link_click_selects_its_entities() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0);
let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0);
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Parallel,
refs: vec![SketchRef::whole(a), SketchRef::whole(b)],
driving_param: None,
label: "Parallel constraint",
});
app.tabs[app.active_tab].scene.select_entity(unrelated, true);
let _ = app.update(Message::PropConstraintLinkClick(vec![a, b]));
let selected: std::collections::HashSet<Handle> =
app.tabs[app.active_tab].scene.selected_entities().into_iter().map(|(h, _)| h).collect();
assert_eq!(selected, std::collections::HashSet::from([a, b]));
}
/// 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
+36
View File
@@ -557,6 +557,13 @@ pub(super) struct OpenCADStudio {
/// this only governs whether the graph is *created* in a scope that
/// doesn't have one yet.
pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its
/// glyph plus a driven value or named-parameter name (e.g. "⌀ 5.000" or
/// "⌀ hole_dia"). When false, every pill shows just the bare glyph — the
/// value/name text can cover important canvas detail on a dense sketch,
/// and the glyph alone is already enough to see *that* a constraint is
/// present (`docs/parametric_system_design.md` §6.3/§7).
pub show_constraint_values: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave.
pub savetime_min: i32,
@@ -2046,6 +2053,9 @@ pub enum Message {
/// Toggle writing AutoCAD-native constraint objects on save, from
/// Options. See `write_dwg_native_constraints`'s doc comment.
WriteDwgNativeConstraintsChanged(bool),
/// Toggle showing driven values/named-parameter names on constraint
/// pills, from Options. See `show_constraint_values`'s doc comment.
ShowConstraintValuesChanged(bool),
/// Switch the interface language and redraw localized views.
LanguageChanged(crate::i18n::Language),
/// Drop every entity from the active drawing.
@@ -2790,6 +2800,31 @@ pub enum Message {
/// and re-solve every constraint that reads a named parameter; stays
/// open.
NamedParametersApply,
// ── Parameters / Constraints sections embedded in the Properties panel
// (AutoCAD-style: a live section instead of the modal above, per-row
// commit-on-submit instead of a whole-table Apply) ─────────────────────
/// Live text of one column of parameter row `index`, keyed by its
/// `ParameterTable::iter()` position — see `PropValue::ParamRow`.
PropParamInput {
index: usize,
field: crate::ui::window::named_parameters::ParamField,
value: String,
},
/// Commit row `index`'s buffered edit for `field` to `Scene::
/// named_parameters` (Enter / losing focus) and re-solve whatever it
/// drives.
PropParamCommit {
index: usize,
field: crate::ui::window::named_parameters::ParamField,
},
/// Remove parameter row `index` immediately (no confirmation, matching
/// AutoCAD's own Parameters Manager delete button).
PropParamDelete(usize),
/// Append a fresh, uniquely-named parameter to `Scene::named_parameters`.
PropParamAddNew,
/// A Constraints-section row was clicked: select every entity in the
/// list (replacing the current selection) in the viewport.
PropConstraintLinkClick(Vec<acadrust::Handle>),
// ── About window ────────────────────────────────────────────────────
AboutOpen,
/// Close whatever in-canvas modal dialog is open (Plan B).
@@ -3479,6 +3514,7 @@ impl OpenCADStudio {
backup_on_save: true,
file_assoc_enabled: true,
write_dwg_native_constraints: false,
show_constraint_values: true,
savetime_min: 10,
default_bg_color: None,
default_paper_bg_color: None,
+88
View File
@@ -406,6 +406,38 @@ impl OpenCADStudio {
read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()),
],
},
// Document-wide named-parameter table (AutoCAD
// Parameters Manager equivalent), embedded here
// instead of a separate modal — see `PropValue::
// ParamRow`'s doc comment. Belongs on the no-
// selection (drawing-level) page, not a per-entity
// one: a parameter isn't owned by any one entity.
PropSection {
title: t!("Parameters").into_owned(),
props: {
let mut props: Vec<Property> = scene
.named_parameters()
.iter()
.enumerate()
.map(|(index, p)| Property {
label: String::new(),
field: "named_parameter",
value: PropValue::ParamRow {
index,
name: p.name.clone(),
formula: p.source.clone(),
resolved: scene.named_parameters().resolve(&p.name).map_err(|e| e.to_string()),
},
})
.collect();
props.push(Property {
label: String::new(),
field: "named_parameter_add",
value: PropValue::ParamAddRow,
});
props
},
},
];
ui::PropertiesPanel {
title: t!("No selection").into_owned(),
@@ -2126,6 +2158,53 @@ impl OpenCADStudio {
if compact_solid {
retain_compact_solid_sections(&mut sections);
}
// Persistent sketch-constraint list for this entity —
// the Properties-panel analogue of the viewport's
// constraint glyph pills: one row per constraint
// touching `handle`, clickable to select every entity
// it relates (PropConstraintLinkClick). Omitted
// entirely when the entity has none — an unconstrained
// entity (the common case) shouldn't gain empty
// bookkeeping.
{
let scope = self.tabs[i].current_sketch_scope();
let scene = &self.tabs[i].scene;
if let Some(set) = scene.sketch_constraint_set(scope) {
let props: Vec<crate::scene::model::object::Property> = set
.constraints_touching(handle)
.map(|c| {
let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
let value_suffix = match &c.driving_param {
Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => {
format!(" = {v:.2}")
}
Some(crate::scene::named_parameters::DrivingValue::Named(name)) => {
match scene.named_parameters().resolve(name) {
Ok(v) => format!(" = {name} ({v:.2})"),
Err(_) => format!(" = {name} (?)"),
}
}
None => String::new(),
};
let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix);
let mut handles: Vec<acadrust::Handle> = c.refs.iter().map(|r| r.entity).collect();
handles.sort_by_key(|h| h.value());
handles.dedup();
crate::scene::model::object::Property {
label,
field: "sketch_constraint",
value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting },
}
})
.collect();
if !props.is_empty() {
sections.push(crate::scene::model::object::PropSection {
title: t!("Constraints").into_owned(),
props,
});
}
}
}
let title = match entity {
acadrust::EntityType::Insert(ins) => {
let is_xref = self.tabs[i]
@@ -3102,6 +3181,15 @@ fn make_sections_read_only(
}
PropValue::Stepper { display, .. } => display.clone(),
PropValue::AttrText { value, .. } => value.clone(),
// The Constraints/Parameters sections aren't per-entity editable
// fields to begin with (a constraint link is a navigation
// action, not a value; a parameter belongs to the whole
// document, not the locked entity) — locking the entity doesn't
// change either, so just carry through readable text instead of
// collapsing them to a value that was never editable anyway.
PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()),
PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"),
PropValue::ParamAddRow => String::new(),
};
property.field = "locked_read_only";
property.value = PropValue::ReadOnly(text);
+11
View File
@@ -244,6 +244,12 @@ pub struct UserSettings {
/// graph keeps it synced regardless of this setting).
#[serde(default)]
pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its
/// glyph plus a driven value or named-parameter name. When false, every
/// pill shows just the bare glyph, so the value/name text doesn't cover
/// canvas detail on a dense sketch.
#[serde(default = "default_show_constraint_values")]
pub show_constraint_values: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
@@ -312,6 +318,10 @@ fn default_dimension_continue_mode() -> i16 {
1
}
fn default_show_constraint_values() -> bool {
true
}
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
@@ -361,6 +371,7 @@ impl Default for UserSettings {
backup_on_save: true,
file_assoc_enabled: true,
write_dwg_native_constraints: false,
show_constraint_values: true,
savetime_min: 10,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
pick_add: true,
+2
View File
@@ -397,6 +397,7 @@ impl OpenCADStudio {
backup_on_save: self.backup_on_save,
file_assoc_enabled: self.file_assoc_enabled,
write_dwg_native_constraints: self.write_dwg_native_constraints,
show_constraint_values: self.show_constraint_values,
savetime_min: self.savetime_min,
default_save_format: self.default_save_format.clone(),
pick_add: self.pick_add,
@@ -467,6 +468,7 @@ impl OpenCADStudio {
self.backup_on_save = s.backup_on_save;
self.file_assoc_enabled = s.file_assoc_enabled;
self.write_dwg_native_constraints = s.write_dwg_native_constraints;
self.show_constraint_values = s.show_constraint_values;
self.savetime_min = s.savetime_min;
self.default_save_format =
crate::io::canonical_save_format(&s.default_save_format).to_string();
+27
View File
@@ -5949,6 +5949,28 @@ impl OpenCADStudio {
Task::none()
}
// ── Parameters / Constraints sections embedded in the
// Properties panel ──────────────────────────────────────────────
Message::PropParamInput { index, field, value } => {
self.tabs[self.active_tab]
.properties
.edit_buf
.insert(crate::ui::properties::FieldKey::Param(index, field), value);
Task::none()
}
Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field),
Message::PropParamDelete(index) => self.on_prop_param_delete(index),
Message::PropParamAddNew => self.on_prop_param_add_new(),
Message::PropConstraintLinkClick(handles) => {
let i = self.active_tab;
self.tabs[i].scene.deselect_all();
for h in handles {
self.tabs[i].scene.select_entity(h, false);
}
self.refresh_properties();
Task::none()
}
// ── Options / About windows ───────────────────────────────────
Message::OptionsOpen => {
self.active_modal = Some(super::ModalKind::Options);
@@ -6312,6 +6334,11 @@ impl OpenCADStudio {
self.persist_settings_if_changed();
Task::none()
}
Message::ShowConstraintValuesChanged(enabled) => {
self.show_constraint_values = enabled;
self.persist_settings_if_changed();
Task::none()
}
Message::LanguageChanged(language) => {
if self.language == language {
return Task::none();
+6 -4
View File
@@ -839,10 +839,12 @@ bg={bg_ms:.1}ms n={view_count}"
)?;
let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
point
.x
.is_finite()
.then(|| (point, crate::scene::sketch_constraints::glyph_label(c), is_conflicting))
let label = if self.show_constraint_values {
crate::scene::sketch_constraints::glyph_label(c)
} else {
c.kind.glyph_symbol().to_string()
};
point.x.is_finite().then(|| (point, label, is_conflicting))
})
.collect()
}
+1
View File
@@ -198,6 +198,7 @@ impl OpenCADStudio {
&self.default_save_format,
self.file_assoc_enabled,
self.write_dwg_native_constraints,
self.show_constraint_values,
&self.ui_theme,
&self.theme_color_inputs,
self.language,