Harden parametric sketch constraints

This commit is contained in:
Hakan Seven committed 2026-09-11 09:45:57 +03:00
1 parent 41c07e9858
commit 9b58ca8a93
101 files changed
+3128 -13767

No files matched your search

-2
View File
@@ -1057,7 +1057,6 @@ mod tests {
"CHANGELOG",
"CUI",
"ALIASEDIT",
"OPTIONS",
];
for cmd in standalone {
assert!(
@@ -1613,4 +1612,3 @@ mod tests {
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
}
}
+135 -156
View File
@@ -915,16 +915,8 @@ impl OpenCADStudio {
TableCellEditStart::Started
}
/// Design doc §6.2 (stage 8, Phase B live inference): after a `LINE`
/// command commits `new_handle`, auto-adds a Coincident constraint for
/// every endpoint that already coincides with an existing entity's
/// point in the current scope (`sketch_constraints::infer_coincident_refs`).
/// Suppressible by holding Shift while placing the point — reuses the
/// existing `shift_down` tracking rather than the design doc's proposed
/// new `CadCommand::set_modifier` mechanism, since this dispatch site
/// already reads `self.shift_down` directly (`apply_cmd_result_inner`'s
/// Ctrl/Shift push at :392-393) and needs no per-command plumbing.
/// No-op (and no undo entry) when nothing was inferred.
/// Adds inferred Coincident constraints after a line is committed.
/// Holding Shift suppresses inference. No match means no undo entry.
fn infer_and_add_coincident_constraints(&mut self, i: usize, new_handle: Handle, entity: &acadrust::EntityType) {
if self.shift_down {
return;
@@ -939,7 +931,12 @@ impl OpenCADStudio {
if pairs.is_empty() {
return;
}
let constraints_before = self.tabs[i].scene.sketch_constraint_set(scope).cloned();
let constraints_before = self
.tabs[i]
.scene
.sketch_constraint_set(scope)
.cloned()
.unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope));
let mut touched: Vec<Handle> = Vec::new();
for (new_ref, existing_ref) in &pairs {
touched.push(new_ref.entity);
@@ -948,13 +945,10 @@ impl OpenCADStudio {
touched.sort();
touched.dedup();
let label = "Coincident (inferred)";
// Mirrors `CmdResult::AddSketchConstraint`'s handler: brackets the
// add-and-solve in its own `begin_undo`/`commit_undo_delta` so any
// geometry the solve moves (the two points may be within epsilon but
// not bit-identical) is captured as an undoable `Delta`, then pushes
// the constraint-set edit itself as the separate, adjacent
// `SketchConstraints` entry stage 9 established.
let pending = self.begin_undo(i, label, touched.len(), true);
self.tabs[i]
.scene
.record_undo_sketch_constraints_before(scope, constraints_before);
for (new_ref, existing_ref) in pairs {
self.tabs[i].scene.sketch_constraint_set_mut(scope).add(
crate::scene::sketch_constraints::ConstraintKind::Coincident,
@@ -968,31 +962,9 @@ impl OpenCADStudio {
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.push_sketch_constraints_history(i, label, scope, constraints_before);
}
/// Design doc §6.4 (stage 11, Phase C): removes the first
/// redundant/conflicting constraint `SketchConstraintSet::conflicts`
/// (populated by `solve_scope`'s `ocs_gcs::diagnosis::classify_redundant`
/// call, stage 10) flags in the active tab's current sketch scope, and
/// re-solves.
///
/// **Scope-down from this doc**: a full `ConflictResolverPanel` — a
/// window listing every flagged constraint by name with a cyclable live
/// preview of each candidate fix before committing — is real, standalone
/// UI work (new window state, `Message` plumbing for open/close/select/
/// preview/commit) this pass didn't have live-UI-testing access to
/// verify (the desktop session's app focus was blocked by an unrelated
/// stuck System Settings window for the whole of this stage). What's
/// built instead is bounded but real: a status-bar pill, visible only
/// when the current scope has at least one flagged conflict, that
/// removes exactly one flagged constraint per click — "guided" in that
/// the flagged set already comes from `classify_redundant`, just without
/// per-candidate naming/preview/choice. A user with several conflicts
/// clicks it repeatedly, watching the DOF/conflict count drop, same
/// outcome as the panel's "remove and re-solve" action minus the
/// browsing UI. The full panel remains open follow-up work, not silently
/// dropped.
/// Removes and re-solves the first conflicting constraint in the active scope.
pub(super) fn resolve_one_sketch_conflict(&mut self) {
let i = self.active_tab;
let scope = self.tabs[i].current_sketch_scope();
@@ -1002,8 +974,11 @@ impl OpenCADStudio {
let touched: Vec<Handle> = constraint.refs.iter().map(|r| r.entity).collect();
let label = "Remove conflicting constraint";
let constraints_before = Some(set.clone());
let constraints_before = set.clone();
let pending = self.begin_undo(i, label, touched.len(), true);
self.tabs[i]
.scene
.record_undo_sketch_constraints_before(scope, constraints_before);
self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id);
let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
@@ -1011,28 +986,10 @@ impl OpenCADStudio {
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
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.
/// Rebuilds the active tab's `Scene::named_parameters` from the
/// editor's working buffer and re-solves dependent constraints.
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
@@ -1057,7 +1014,6 @@ impl OpenCADStudio {
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 —
@@ -1073,17 +1029,18 @@ impl OpenCADStudio {
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
self.tabs[i].dirty = true;
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 let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if failed == 0 {
self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref());
}
@@ -1111,13 +1068,9 @@ impl OpenCADStudio {
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) —
@@ -1150,24 +1103,30 @@ impl OpenCADStudio {
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())
match field {
ParamField::Formula if typed.is_empty() || typed == current.source => {
self.refresh_properties();
return Task::none();
}
ParamField::Name if typed.is_empty() || typed == current.name => {
self.refresh_properties();
return Task::none();
}
ParamField::Name 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();
}
_ => {}
}
let pending = self.begin_undo(i, "Edit named parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
let (result, resolve_name) = match field {
ParamField::Formula =>
(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() {
@@ -1178,17 +1137,49 @@ impl OpenCADStudio {
};
if let Err(e) = result {
self.tabs[i].scene.take_undo_recording();
self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref());
self.refresh_properties();
return Task::none();
}
if field == ParamField::Name {
let affected: Vec<usize> = self.tabs[i]
.scene
.sketch_constraints
.iter()
.enumerate()
.filter_map(|(index, set)| {
set.constraints
.iter()
.any(|constraint| {
matches!(&constraint.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(name)) if name == &current.name)
})
.then_some(index)
})
.collect();
for set_index in affected {
let scope = self.tabs[i].scene.sketch_constraints[set_index].scope;
let before = self.tabs[i].scene.sketch_constraints[set_index].clone();
self.tabs[i].scene.record_undo_sketch_constraints_before(scope, before);
for constraint in &mut self.tabs[i].scene.sketch_constraints[set_index].constraints {
if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = &mut constraint.driving_param {
if name == &current.name {
*name = typed.clone();
}
}
}
}
}
self.resolve_named_parameter_edit(i, &resolve_name);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
/// Removes Parameters-section row `index` immediately — no confirmation,
/// matching AutoCAD's own Parameters Manager delete button. Any
/// Removes Parameters-section row `index` immediately. 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
@@ -1198,8 +1189,13 @@ impl OpenCADStudio {
let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else {
return Task::none();
};
let pending = self.begin_undo(i, "Delete named parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters_mut().remove(&name);
self.resolve_named_parameter_edit(i, &name);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
@@ -1225,7 +1221,13 @@ impl OpenCADStudio {
}
n += 1;
};
let pending = self.begin_undo(i, "Add named parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1");
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
@@ -2540,14 +2542,29 @@ impl OpenCADStudio {
self.refresh_properties();
}
CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => {
if let Err(message) = self.tabs[i]
.scene
.validate_sketch_constraint(kind, &refs, driving_param.as_ref())
{
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.command_line.push_error(message);
return Task::none();
}
let scope = self.tabs[i].current_sketch_scope();
// Design doc §5.1/§9: captured before the add so the
// constraint-set edit itself (not just the geometry it
// triggers) is undoable — pushed as its own history entry
// below, adjacent to (not merged into) the entity delta.
let constraints_before = self.tabs[i].scene.sketch_constraint_set(scope).cloned();
let constraints_before = self
.tabs[i]
.scene
.sketch_constraint_set(scope)
.cloned()
.unwrap_or_else(|| {
crate::scene::sketch_constraints::SketchConstraintSet::new(scope)
});
let touched: Vec<Handle> = refs.iter().map(|r| r.entity).collect();
let pending = self.begin_undo(i, label, touched.len(), true);
self.tabs[i]
.scene
.record_undo_sketch_constraints_before(scope, constraints_before);
self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched
.into_iter()
@@ -2562,7 +2579,6 @@ impl OpenCADStudio {
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.push_sketch_constraints_history(i, label, scope, constraints_before);
}
CmdResult::AddCoincidentConstraint { point_a, point_b, label } => {
let scope = self.tabs[i].current_sketch_scope();
@@ -5466,8 +5482,7 @@ impl OpenCADStudio {
let groups = self.clipboard_deps.groups.clone();
self.tabs[i].scene.recreate_groups(groups, &handle_map);
}
// Design doc §12: a constraint entirely between pasted entities
// follows them, matching in-drawing COPY (`Scene::copy_entities`).
// Constraints wholly within the pasted selection follow it.
self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map);
// Incremental: `add_entity` already tessellated every pasted top-level
// solid, and existing document solids are still cached — so only newly
@@ -6432,18 +6447,9 @@ mod sketch_constraint_undo_tests {
}
}
/// Design doc §5.1/§9: adding a persistent constraint must itself be
/// undoable/redoable — not just the geometry it happens to move. This is
/// the scoped-down §5.1(a) alternative (not the §5.1(b) "live XRecord"
/// approach, which needs stage 4/save-load): the constraint-set edit is
/// pushed as its own `HistorySnapshot::SketchConstraints` entry, adjacent
/// to but not merged into the entity `Delta` the solve produced. So a
/// compound add-and-solve costs two undo/redo presses, not one — the
/// first press (topmost entry, pushed last) removes the constraint
/// record while leaving the solved geometry in place; the second
/// (the earlier `Delta` entry) reverts the geometry itself.
/// Constraint state and solved geometry share one undo/redo transaction.
#[test]
fn adding_a_constraint_is_undoable_and_redoable_in_two_steps() {
fn adding_a_constraint_is_undoable_and_redoable_atomically() {
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, 3.0);
@@ -6462,9 +6468,6 @@ mod sketch_constraint_undo_tests {
1
);
// First undo: pops the (later-pushed) SketchConstraints entry —
// removes the constraint record but leaves the already-solved
// geometry alone.
app.undo_steps(1);
assert_eq!(
app.tabs[app.active_tab]
@@ -6473,41 +6476,24 @@ mod sketch_constraint_undo_tests {
.map(|s| s.constraints.len())
.unwrap_or(0),
0,
"first undo should remove the constraint record"
"undo should remove the constraint record"
);
assert!(line_angle_deg(&app, line).abs() < 1e-6, "first undo should not yet touch the solved geometry");
// Second undo: pops the Delta entry — reverts the geometry.
app.undo_steps(1);
assert!(
(line_angle_deg(&app, line) - original_angle).abs() < 1e-3,
"second undo should restore the pre-constraint angle"
);
// Redo replays in the same two-step order, symmetric with undo.
app.redo_steps(1);
assert!(line_angle_deg(&app, line).abs() < 1e-6, "first redo should re-level the line");
assert_eq!(
app.tabs[app.active_tab]
.scene
.sketch_constraint_set(SketchScope::ModelSpace)
.map(|s| s.constraints.len())
.unwrap_or(0),
0,
"first redo should not yet restore the constraint record"
"undo should restore the pre-constraint angle"
);
app.redo_steps(1);
assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line");
assert_eq!(
app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(),
1,
"second redo should restore the constraint record"
"redo should restore the constraint record"
);
}
/// Design doc §6.2 (stage 8): drawing a `LINE` whose endpoint already
/// coincides with an existing entity's point should auto-add a
/// Coincident constraint — suppressible by holding Shift.
/// A line endpoint landing on an existing point infers Coincident unless
/// Shift is held.
#[test]
fn drawing_a_line_onto_an_existing_endpoint_infers_a_coincident_constraint() {
let mut app = OpenCADStudio::new_for_test();
@@ -6556,9 +6542,7 @@ mod sketch_constraint_undo_tests {
);
}
/// Design doc §6.4 (stage 11): the bounded v1 conflict resolver removes
/// one flagged constraint per call and re-solves, and the removal itself
/// is undoable/redoable via the same stage-9 mechanism.
/// Conflict resolution removes one flagged constraint and is undoable.
#[test]
fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() {
let mut app = OpenCADStudio::new_for_test();
@@ -6587,19 +6571,7 @@ mod sketch_constraint_undo_tests {
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap();
assert_eq!(set.constraints.len(), 1, "one of the two duplicates should have been removed");
// Undo restores it. The removal pushes at least its own
// `SketchConstraints` entry (stage 9's mechanism); it may or may not
// also push a geometry `Delta`, depending on whether the resolve's
// own re-solve actually moved anything (here it shouldn't — removing
// a duplicate Horizontal changes nothing already-horizontal) — so
// undo one step at a time until the constraint reappears, rather
// than asserting a fixed step count.
for _ in 0..5 {
if app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len() == 2 {
break;
}
app.undo_steps(1);
}
app.undo_steps(1);
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap();
assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint");
}
@@ -6645,6 +6617,17 @@ mod sketch_constraint_undo_tests {
};
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}");
app.undo_steps(1);
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0));
let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(line)) => line.length(),
other => panic!("expected a Line, got {other:?}"),
};
assert!((length_after_undo - 8.0).abs() < 1e-6);
app.redo_steps(1);
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(3.0));
}
/// End-to-end through the actual `Message` handlers a Properties-panel
@@ -6765,11 +6748,7 @@ mod sketch_constraint_undo_tests {
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`
/// → `nearest_sketch_point` → `AddSketchConstraint` path
/// `CoincidentConstraintCommand` drives via its two `on_point` calls.
/// Two endpoint picks resolve to a Coincident constraint.
#[test]
fn coincident_command_resolves_two_picked_points_into_a_constraint() {
let mut app = OpenCADStudio::new_for_test();
+1 -3
View File
@@ -963,9 +963,7 @@ impl OpenCADStudio {
}
}
// ── Constraints (persistent: added to the scope's SketchConstraintSet,
// solved via Scene::bump_entities, re-solved on every later edit —
// see docs/parametric_system_design.md §6.1) ──
// ── Persistent constraints ────────────────────────────────────
"HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
+4 -2
View File
@@ -298,10 +298,12 @@ pub fn start_allowed(cmd: &str) -> bool {
| "PERF"
| "CUI"
| "ALIASEDIT"
| "OPTIONS"
| "OP"
| "CUILOAD"
| "CUIIMPORT"
// The start page already offers Options as a button, so the
// command that opens the same dialog belongs here too.
| "OPTIONS"
| "OP"
)
}
+9 -16
View File
@@ -240,30 +240,23 @@ 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.
// ── CUI — keyboard shortcut / key-binding editor ───────────────
"CUI" => {
return Some(Task::done(Message::ShortcutsPanelOpen));
}
// ── OPTIONS / OP — the preferences dialog ──────────────────────
// Both names were registered for autocomplete but never dispatched,
// so typing either one answered "Unknown command" and the dialog
// could only be reached from the button on the start page.
"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));
}
// CUIEXPORT <path> — write the keyboard-shortcut customizations
// (the drawing-independent CUI data) to a plain "KEY COMMAND" file.
"CUIEXPORT" => {
+1 -16
View File
@@ -435,25 +435,10 @@ pub struct StartConfig {
pub section: super::StartSection,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RibbonConfig {
pub collapse: CollapseMode,
/// Font size (px) of the caption under a large ribbon button's icon.
pub label_font_size: i32,
/// Font size (px) of a panel's own title ("Draw", "Modify", "Layers", …)
/// shown beneath its tools.
pub group_title_font_size: i32,
}
impl Default for RibbonConfig {
fn default() -> Self {
Self {
collapse: CollapseMode::default(),
label_font_size: 10,
group_title_font_size: 9,
}
}
}
impl AppConfig {
+13 -43
View File
@@ -661,7 +661,6 @@ pub(super) enum HistorySnapshot {
// which one it actually holds.
Delta(Box<DeltaSnapshot>),
ObjectVisibility(ObjectVisibilitySnapshot),
SketchConstraints(SketchConstraintsSnapshot),
}
impl HistorySnapshot {
@@ -669,7 +668,6 @@ impl HistorySnapshot {
match self {
HistorySnapshot::Delta(d) => &d.label,
HistorySnapshot::ObjectVisibility(v) => &v.label,
HistorySnapshot::SketchConstraints(s) => &s.label,
}
}
@@ -699,7 +697,12 @@ impl HistorySnapshot {
.iter()
.map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len()))
.sum::<usize>()
.saturating_mul(96), // matches HistorySnapshot::SketchConstraints's own rough per-constraint estimate
.saturating_mul(96),
)
.saturating_add(
d.named_parameters
.as_ref()
.map_or(0, |(before, after)| before.len().saturating_add(after.len()).saturating_mul(128)),
)
.saturating_add(d.label.len()),
HistorySnapshot::ObjectVisibility(v) => v
@@ -723,40 +726,10 @@ impl HistorySnapshot {
.saturating_add(v.selected_after.len())
.saturating_mul(16)
.saturating_add(v.label.len()),
HistorySnapshot::SketchConstraints(s) => s
.before
.as_ref()
.map_or(0, |set| set.constraints.len())
.saturating_add(s.after.as_ref().map_or(0, |set| set.constraints.len()))
.saturating_mul(96) // rough per-constraint size (a handful of refs + an id/kind/f64)
.saturating_add(s.label.len()),
}
}
}
/// Undo/redo image for one persistent-constraint edit (design doc §5.1/§9):
/// a `SketchConstraintSet` add/remove, captured as a whole-set before/after
/// pair rather than a per-constraint diff — sketches are small, so cloning
/// the whole set is cheap, and it sidesteps needing a separate "undo one
/// constraint add" vs. "undo one constraint remove" representation. Kept as
/// its own `HistorySnapshot` variant (not folded into `DeltaSnapshot`) since
/// `SketchConstraintSet` lives on `Scene`, not in the entity/object store
/// `DeltaSnapshot` already covers — see the design doc's §5.1 discussion of
/// why this needs its own path. A geometry-changing constraint add (the
/// common case) pushes this *alongside* its own `DeltaSnapshot` entry rather
/// than merged into one atomic step — two adjacent undo presses instead of
/// one, a deliberate scope-down from the design doc's recommended "live
/// XRecord" approach (§5.1(b)), which needs stage 4 (save/load) wired up
/// first; this variant needs nothing beyond what already exists.
#[derive(Clone)]
pub(super) struct SketchConstraintsSnapshot {
pub(super) scope: crate::scene::sketch_constraints::SketchScope,
/// `None` when the scope had no constraint set at all yet.
pub(super) before: Option<crate::scene::sketch_constraints::SketchConstraintSet>,
pub(super) after: Option<crate::scene::sketch_constraints::SketchConstraintSet>,
pub(super) label: String,
}
/// Symmetric undo/redo image for session-only object visibility. It contains
/// no document entity data and therefore can never make isolation serializable.
#[derive(Clone)]
@@ -793,6 +766,11 @@ pub(super) struct DeltaSnapshot {
/// `document.objects`, so it needs its own channel here rather than
/// riding along with `entities`/`structure`. Almost always empty.
pub(super) sketch_constraints: Vec<SketchConstraintsEntryDelta>,
/// Drawing-wide parameter table changed by this transaction.
pub(super) named_parameters: Option<(
crate::scene::named_parameters::ParameterTable,
crate::scene::named_parameters::ParameterTable,
)>,
pub(super) label: String,
}
@@ -853,16 +831,8 @@ pub(super) struct ObjectEntryDelta {
pub(super) after: Option<acadrust::objects::ObjectType>,
}
/// One sketch-constraint scope's before/after image within an entity
/// `DeltaSnapshot` — e.g. ERASE removing an entity's constraints along with
/// it (`Scene::refresh_sketch_constraints`'s deletion policy). Distinct from
/// [`SketchConstraintsSnapshot`]: that type is its own separate
/// `HistorySnapshot` entry for a dedicated constraint add/remove command
/// (still a deliberate two-step undo, design doc's own scope-down); this one
/// rides inside the *same* delta as the entity change that caused it, so one
/// undo restores both together. `before`/`after` are always `Some` scope
/// state (never absent) — a scope's `Vec` entry, once created, is never
/// removed, so there is no "scope didn't exist" case to represent here.
/// One sketch-constraint scope's before/after image within an entity delta.
/// This keeps constraint cleanup atomic with edits such as entity erasure.
#[derive(Clone)]
pub(super) struct SketchConstraintsEntryDelta {
pub(super) scope: crate::scene::sketch_constraints::SketchScope,
+19 -52
View File
@@ -1,12 +1,11 @@
use super::{
document::{
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
PendingHistorySnapshot, SketchConstraintsEntryDelta, SketchConstraintsSnapshot,
StructureSnapshot, TableEntryDelta,
PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta,
},
OpenCADStudio,
};
use crate::scene::{ChangeKind, ObjectIsolationState};
use crate::scene::ObjectIsolationState;
use acadrust::{EntityType, Handle};
use rustc_hash::{FxHashMap, FxHashSet as HashSet};
use std::sync::Arc;
@@ -185,40 +184,6 @@ impl OpenCADStudio {
);
}
/// Design doc §5.1/§9: makes a persistent-constraint add/remove
/// undoable. `before` is the scope's constraint set captured *before*
/// the caller's edit (by the caller, since this fn only sees "now");
/// `after` is read fresh here, so call this once the edit is done.
pub(super) fn push_sketch_constraints_history(
&mut self,
i: usize,
label: impl Into<String>,
scope: crate::scene::sketch_constraints::SketchScope,
before: Option<crate::scene::sketch_constraints::SketchConstraintSet>,
) {
self.finish_pending_history(i);
let after = self.tabs[i].scene.sketch_constraint_set(scope).cloned();
self.push_undo_entry(
i,
HistorySnapshot::SketchConstraints(SketchConstraintsSnapshot { scope, before, after, label: label.into() }),
);
}
fn apply_sketch_constraints_state(&mut self, i: usize, snapshot: &SketchConstraintsSnapshot, undo: bool) {
let state = if undo { snapshot.before.clone() } else { snapshot.after.clone() };
let scene = &mut self.tabs[i].scene;
let set = state.unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(snapshot.scope));
// The restored set's geometry may be stale (e.g. redo re-adds a
// constraint whose entities moved via other edits since) — resolve
// every entity it references, same as a fresh add would.
let touched: Vec<Handle> = set.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.entity)).collect();
*scene.sketch_constraint_set_mut(snapshot.scope) = set;
if !touched.is_empty() {
let changes: Vec<(Handle, ChangeKind)> = touched.into_iter().map(|h| (h, ChangeKind::Modified)).collect();
scene.bump_entities(&changes);
}
}
pub(super) fn push_entity_group_history(
&mut self,
i: usize,
@@ -261,6 +226,7 @@ impl OpenCADStudio {
active_layer: None,
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
sketch_constraints: Vec::new(),
named_parameters: None,
label: label.into(),
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -371,6 +337,7 @@ impl OpenCADStudio {
.then_some((pending.active_layer, active_layer_after)),
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)),
sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -479,6 +446,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::Layers(entries)),
sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -535,6 +503,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::TextStyles(entries)),
sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -591,6 +560,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::DimStyles(entries)),
sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -655,6 +625,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::Objects(entries)),
sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -689,6 +660,7 @@ impl OpenCADStudio {
object_handles,
}),
sketch_constraints: Vec::new(),
named_parameters: None,
label: "STYLE".to_string(),
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -769,7 +741,8 @@ impl OpenCADStudio {
pending.label
);
}
let (entity_before, object_before, sketch_constraints_before) = rec.into_recorded_images();
let (entity_before, object_before, sketch_constraints_before, named_parameters_before) =
rec.into_recorded_images();
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = entity_before
.into_iter()
.map(|(h, before)| {
@@ -803,6 +776,10 @@ impl OpenCADStudio {
})
})
.collect();
let named_parameters = named_parameters_before.and_then(|before| {
let after = self.tabs[i].scene.named_parameters().clone();
(before != after).then_some((before, after))
});
let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let dirty_after = self.tabs[i].dirty;
let mut structure = pending.structure_before.map(StructureSnapshot::Full);
@@ -839,6 +816,7 @@ impl OpenCADStudio {
active_layer: None,
structure,
sketch_constraints,
named_parameters,
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -971,6 +949,9 @@ impl OpenCADStudio {
let value = if undo { &entry.before } else { &entry.after };
*scene.sketch_constraint_set_mut(entry.scope) = value.clone();
}
if let Some((before, after)) = &d.named_parameters {
scene.named_parameters = if undo { before } else { after }.clone();
}
let (sel, dirty) = if undo {
(&d.selected_before, d.dirty_before)
} else {
@@ -1168,13 +1149,6 @@ impl OpenCADStudio {
.redo_stack
.push(HistorySnapshot::ObjectVisibility(v));
}
HistorySnapshot::SketchConstraints(s) => {
self.apply_sketch_constraints_state(i, &s, true);
self.tabs[i]
.history
.redo_stack
.push(HistorySnapshot::SketchConstraints(s));
}
}
}
self.finish_history_apply(i, had_full, layer_panel_changed, &changes);
@@ -1230,13 +1204,6 @@ impl OpenCADStudio {
.undo_stack
.push(HistorySnapshot::ObjectVisibility(v));
}
HistorySnapshot::SketchConstraints(s) => {
self.apply_sketch_constraints_state(i, &s, false);
self.tabs[i]
.history
.undo_stack
.push(HistorySnapshot::SketchConstraints(s));
}
}
}
self.finish_history_apply(i, had_full, layer_panel_changed, &changes);
+11 -46
View File
@@ -524,12 +524,6 @@ pub(super) struct OpenCADStudio {
snap_angle_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,
@@ -553,25 +547,13 @@ pub(super) struct OpenCADStudio {
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
/// handler on each launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool,
/// When true, saving a drawing with sketch constraints also materializes
/// them as AutoCAD's own native `AssocNetwork`/`Assoc2dConstraintGroup`
/// object graph (`scene::dwg_native_constraints`), so AutoCAD/BricsCAD
/// recognize them as real constraints rather than an opaque OCS-only
/// XRecord. Off by default (#opt-in — adds objects/file weight every
/// user pays for save-after-save, whether or not they need AutoCAD
/// interop). A drawing that already carries this native graph (from a
/// prior save with this on, or from real AutoCAD) keeps it synced on
/// every save regardless of this setting — see
/// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment;
/// this only governs whether the graph is *created* in a scope that
/// doesn't have one yet.
/// When true, saving creates native constraint objects alongside the
/// application's own persistence record. Existing native objects remain
/// synchronized regardless of this setting.
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).
/// glyph plus a driven value or named-parameter name. When false, every
/// pill shows only the glyph.
pub show_constraint_values: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave.
@@ -1066,7 +1048,7 @@ 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) ────────
// ── Named Parameters ──────────────────────────────────────────────────
/// 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
@@ -1999,12 +1981,6 @@ 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.
RibbonGroupTitleFontSizeChanged(i32),
/// Edit the optional crosshair RGB value; blank restores automatic contrast.
CrosshairColorChanged(String),
/// Set the default type/version used when first saving a new drawing.
@@ -2113,8 +2089,7 @@ pub enum Message {
/// Register or unregister as the .dwg/.dxf handler, from Options. Same
/// setting the FILEASSOC command carries.
FileAssocChanged(bool),
/// Toggle writing AutoCAD-native constraint objects on save, from
/// Options. See `write_dwg_native_constraints`'s doc comment.
/// Toggle writing native constraint objects on save.
WriteDwgNativeConstraintsChanged(bool),
/// Toggle showing driven values/named-parameter names on constraint
/// pills, from Options. See `show_constraint_values`'s doc comment.
@@ -2542,12 +2517,8 @@ pub enum Message {
CloseLayoutList,
/// Cycle the coordinate readout mode ($COORDS): static → live → polar.
CycleCoordsMode,
/// Design doc §6.4, stage 11: removes one flagged redundant/conflicting
/// constraint from the current sketch scope (a bounded v1 of the
/// "SketchXpert-style" resolver — one guided removal per click rather
/// than a candidate-list panel with a cyclable preview; see the status
/// bar's `ResolveOneSketchConflict` pill and
/// `OpenCADStudio::resolve_one_sketch_conflict` for the full rationale).
/// Removes one flagged redundant or conflicting constraint from the
/// current sketch scope.
/// No-op if the scope currently has no flagged conflict.
ResolveOneSketchConflict,
/// Toggle the status-bar customization menu open/closed.
@@ -2863,9 +2834,7 @@ 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) ─────────────────────
// ── Parameters / Constraints sections embedded in Properties ──────────
/// Live text of one column of parameter row `index`, keyed by its
/// `ParameterTable::iter()` position — see `PropValue::ParamRow`.
PropParamInput {
@@ -2880,8 +2849,7 @@ pub enum Message {
index: usize,
field: crate::ui::window::named_parameters::ParamField,
},
/// Remove parameter row `index` immediately (no confirmation, matching
/// AutoCAD's own Parameters Manager delete button).
/// Remove parameter row `index` immediately.
PropParamDelete(usize),
/// Append a fresh, uniquely-named parameter to `Scene::named_parameters`.
PropParamAddNew,
@@ -3566,7 +3534,6 @@ impl OpenCADStudio {
isolines_awaiting_regen: false,
snap_angle_input: "0".to_string(),
lineweight_display_scale: 100,
ui_scale: 100,
isometric_drafting: false,
iso_plane: settings::IsoPlane::Left,
snap_angle_deg: 0.0,
@@ -4180,7 +4147,6 @@ 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) {
@@ -4225,7 +4191,6 @@ 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()
})
+1 -2
View File
@@ -406,8 +406,7 @@ impl OpenCADStudio {
read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()),
],
},
// Document-wide named-parameter table (AutoCAD
// Parameters Manager equivalent), embedded here
// Document-wide named-parameter table 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
+2 -16
View File
@@ -221,14 +221,6 @@ 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,
@@ -284,13 +276,8 @@ pub struct UserSettings {
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
/// handler on every launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool,
/// When true, saving a drawing also writes its sketch constraints as
/// AutoCAD's own native object graph (`scene::dwg_native_constraints`),
/// not just the app's own XRecord format. Off by default — opt-in, since
/// it adds file weight every save whether or not AutoCAD/BricsCAD
/// interop is needed. See `write_dwg_native_constraints` on the app
/// struct for the full contract (a drawing that already has this native
/// graph keeps it synced regardless of this setting).
/// When true, saving also writes sketch constraints as native drawing
/// objects alongside the application's own persistence record.
#[serde(default)]
pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its
@@ -402,7 +389,6 @@ 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,
+3 -14
View File
@@ -407,7 +407,6 @@ 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,
@@ -477,7 +476,6 @@ 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() {
@@ -836,8 +834,6 @@ impl OpenCADStudio {
annotation_auto_scale: self.annotation_auto_scale,
ribbon: crate::app::config::RibbonConfig {
collapse: self.ribbon.collapse_mode(),
label_font_size: self.ribbon.label_font_size(),
group_title_font_size: self.ribbon.group_title_font_size(),
},
plot: self.plot_dialog.clone(),
shortcuts: crate::app::config::ShortcutConfig {
@@ -895,9 +891,6 @@ impl OpenCADStudio {
self.dock = dock;
self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4);
self.ribbon.set_collapse_mode(cfg.ribbon.collapse);
self.ribbon.set_label_font_size(cfg.ribbon.label_font_size);
self.ribbon
.set_group_title_font_size(cfg.ribbon.group_title_font_size);
self.plot_dialog = cfg.plot;
self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect();
self.shortcut_bindings
@@ -1449,8 +1442,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].scene.material_base_dir =
path.parent().map(std::path::Path::to_path_buf);
self.tabs[i].scene.document = doc;
// Design doc §8 stage 4: read back any persisted sketch
// constraint sets right after the document is installed.
// Load persisted constraints after installing the document.
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.
@@ -1718,15 +1710,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64;
self.sync_solid_models_for_save(i);
// Design doc §8 stage 4: write any sketch constraint sets into the
// document right before it's serialized.
// Materialize constraints immediately before serialization.
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();
// docs/dwg_constraint_compatibility_design.md: additive DWG/DXF-
// native constraint graph, alongside (not instead of) the XRecord
// above — same save-time hook.
// Keep the optional native constraint graph synchronized on save.
self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints);
}
-18
View File
@@ -6040,24 +6040,6 @@ 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();
Task::none()
}
Message::RibbonGroupTitleFontSizeChanged(value) => {
self.ribbon.set_group_title_font_size(value);
self.save_config();
Task::none()
}
Message::CrosshairColorChanged(value) => {
self.crosshair_color_input = value.clone();
if value.trim().is_empty() {
+2 -10
View File
@@ -1706,16 +1706,8 @@ impl OpenCADStudio {
);
}
}
// Live sketch-constraint re-solve (design doc §7 open question
// 6): previously constrained neighbors only snapped into place
// on grip release — this runs the same rebuild-and-solve every
// frame so they track the drag live. First touch this gesture
// captures the neighbor's original state into the grip's own
// undo tracking (mirroring how `edited_handles` itself is
// captured above) and hides it from the resident tessellation;
// every frame folds it into this frame's `edited_handles` so the
// existing mesh/hatch/wire-preview refresh below already covers
// it for free.
// Re-solve constrained neighbors on each drag frame and include
// their original state in the gesture's undo record.
let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles);
for (handle, _) in &solved_by_constraints {
let handle = *handle;
+1 -3
View File
@@ -805,9 +805,7 @@ bg={bg_ms:.1}ms n={view_count}"
let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| {
!cmd.needs_entity_pick() && !cmd.is_selection_gathering()
});
// Persistent sketch-constraint glyphs (design doc §6.3/§7) — model
// space only, like the constraints themselves; paper space shows
// none (out of scope per the design doc §3.1).
// Constraint glyphs are displayed in model space only.
let constraint_glyphs: Vec<(iced::Point, String, bool)> = if is_paper {
Vec::new()
} else {
-3
View File
@@ -265,9 +265,6 @@ 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,
&self.model_bg_input,
&self.paper_bg_input,
+8 -20
View File
@@ -1408,8 +1408,8 @@ pub enum CmdResult {
ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>),
/// Replace several entities as one undo step while keeping the command active.
ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>),
/// Add a persistent parametric constraint (`docs/parametric_system_design.md`
/// §6.1) to the current scope's `SketchConstraintSet` and trigger its
/// Add a persistent parametric constraint to the current scope's
/// `SketchConstraintSet` and trigger its
/// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`,
/// nothing here is geometry to add or replace directly — the host adds
/// the constraint record, then re-solves through `Scene::bump_entities`'s
@@ -1419,8 +1419,7 @@ pub enum CmdResult {
kind: crate::scene::sketch_constraints::ConstraintKind,
refs: Vec<crate::scene::sketch_constraints::SketchRef>,
/// 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
/// a literal number, or a named-parameter reference 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
@@ -1429,14 +1428,9 @@ pub enum CmdResult {
/// Undo-history label, e.g. `"Horizontal constraint"`.
label: &'static str,
},
/// Add a persistent Coincident constraint between whatever sub-entity
/// point resolves nearest each picked point (design doc §6.1/§6.2's
/// manual Coincident UI — `CoincidentConstraintCommand`,
/// `src/modules/draw/constrain/coincident.rs`). Like
/// `ReassociateCenterMark`, a `CadCommand` has no document access to
/// resolve a marker itself, so it hands the two raw points back for the
/// host to resolve (`sketch_constraints::nearest_sketch_point`) and, if
/// both land near a real point, build an `AddSketchConstraint` from.
/// Adds a Coincident constraint between the sub-entity points nearest
/// the two picked positions. The host resolves the raw points because
/// `CadCommand` has no document access.
AddCoincidentConstraint {
point_a: DVec3,
point_b: DVec3,
@@ -2534,14 +2528,8 @@ pub fn all_registered_command_names() -> Vec<&'static str> {
mod constraint_registry_tests {
use super::*;
/// Regression guard: the whole `src/modules/draw/constrain/` family
/// (17 command ids, every one of the geometric/dimensional constraint
/// commands) predated `inventory::submit!` and was invisible to
/// command-line autocomplete and the MCP `commands` listing —
/// `docs/command_reference.md`'s own generator flagged this. Each
/// constraint module now registers itself; this pins that so the gap
/// can't silently reappear (e.g. a new constraint command added
/// without its own registration).
/// Ensures every constraint command remains discoverable by autocomplete
/// and command listings.
#[test]
fn every_constraint_command_is_in_the_autocomplete_registry() {
let names = all_registered_command_names();
+1 -11
View File
@@ -422,19 +422,9 @@ pub fn entity_with_lwpolyline_world_xy(entity: &EntityType) -> EntityType {
}
}
/// Fixed world-space tessellation angle used for on-screen wire points.
///
/// Finer than the kernel's own `DEFAULT_ANGLE` (7.5°, 48 segments per turn):
/// 3° gives 120 segments per full turn, so a typical circle at typical zoom
/// no longer shows its polygon joints. Still a fixed world-space angle rather
/// than one adaptive to view scale, so an aggressively zoomed-in view can
/// still reveal facets — it raises the zoom threshold rather than removing
/// the ceiling.
const DISPLAY_ANGLE: f64 = std::f64::consts::PI / 60.0;
/// World-space wire points sampled by the kernel's angular policy.
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> {
curve.tessellate_angle(DISPLAY_ANGLE)
curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
}
/// The snap candidates an entity's curve offers, in the two channels the
+12 -4
View File
@@ -1,4 +1,4 @@
//! CCONSTRAINT — the manual Coincident UI (design doc §6.1's open item):
//! CCONSTRAINT — manual Coincident constraint input.
//! unlike the rest of this module's plain select-and-click constraints,
//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc
//! center), not the whole entity, so it needs two point picks instead of a
@@ -23,7 +23,9 @@ pub mod coincident_tool {
ToolDef {
id: "CCONSTRAINT",
label: "Coincident",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/coincident.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/coincident.svg"
)),
event: ModuleEvent::Command("CCONSTRAINT".to_string()),
}
}
@@ -59,7 +61,11 @@ impl CadCommand for CoincidentConstraintCommand {
self.first = Some(pt);
CmdResult::NeedPoint
}
Some(first) => CmdResult::AddCoincidentConstraint { point_a: first, point_b: pt, label: "Coincident constraint" },
Some(first) => CmdResult::AddCoincidentConstraint {
point_a: first,
point_b: pt,
label: "Coincident constraint",
},
}
}
@@ -73,4 +79,6 @@ impl CadCommand for CoincidentConstraintCommand {
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["CCONSTRAINT"] });
inventory::submit!(crate::command::CommandRegistration {
names: &["CCONSTRAINT"]
});
+15 -7
View File
@@ -1,6 +1,5 @@
//! EDCONSTRAINT — AutoCAD's fourth `Equal` sub-kind
//! (`ACEQUALDISTANCECONSTRAINT`): the distance between one point pair
//! equals the distance between another, rather than two whole entities
//! EDCONSTRAINT makes the distance between one point pair equal the
//! distance between another pair, rather than comparing whole entities.
//! being equal length/radius. Needs four point picks (unlike the two
//! whole-entity selections `ECONSTRAINT` uses), so — like
//! `CoincidentConstraintCommand` — it accumulates raw points and hands them
@@ -17,7 +16,9 @@ pub mod equal_distance_tool {
ToolDef {
id: "EDCONSTRAINT",
label: "Equal Distance",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/equal_distance.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/equal_distance.svg"
)),
event: ModuleEvent::Command("EDCONSTRAINT".to_string()),
}
}
@@ -53,8 +54,13 @@ impl CadCommand for EqualDistanceConstraintCommand {
if self.points.len() < 4 {
return CmdResult::NeedPoint;
}
let [a, b, c, d] = self.points[..] else { unreachable!("just checked len == 4") };
CmdResult::AddEqualDistanceConstraint { points: [a, b, c, d], label: "Equal distance constraint" }
let [a, b, c, d] = self.points[..] else {
unreachable!("just checked len == 4")
};
CmdResult::AddEqualDistanceConstraint {
points: [a, b, c, d],
label: "Equal distance constraint",
}
}
fn on_enter(&mut self) -> CmdResult {
@@ -67,4 +73,6 @@ impl CadCommand for EqualDistanceConstraintCommand {
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["EDCONSTRAINT"] });
inventory::submit!(crate::command::CommandRegistration {
names: &["EDCONSTRAINT"]
});
+6 -4
View File
@@ -1,5 +1,4 @@
//! Ribbon tools and typed-value commands for the persistent "Constraints"
//! group (design doc `docs/parametric_system_design.md` §6.1).
//! Ribbon tools and typed-value commands for persistent constraints.
//!
//! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`]
//! to the current scope and lets `Scene::bump_entities` solve it, the same
@@ -29,8 +28,11 @@ mod tools;
mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand};
pub use point_on_entity::{center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand};
pub use point_on_entity::{
center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand,
};
pub use tools::{
concentric, equal, fixed, horizontal, colinear, normal, parallel, perpendicular, symmetric, tangent, vertical,
colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric,
tangent, vertical,
};
pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand};
+30 -7
View File
@@ -24,7 +24,9 @@ pub mod center_point_tool {
ToolDef {
id: "CPCONSTRAINT",
label: "Center Point",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/center_point.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/center_point.svg"
)),
event: ModuleEvent::Command("CPCONSTRAINT".to_string()),
}
}
@@ -36,7 +38,9 @@ pub mod midpoint_tool {
ToolDef {
id: "MPCONSTRAINT",
label: "Midpoint",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/midpoint.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/midpoint.svg"
)),
event: ModuleEvent::Command("MPCONSTRAINT".to_string()),
}
}
@@ -48,7 +52,9 @@ pub mod point_on_curve_tool {
ToolDef {
id: "OCCONSTRAINT",
label: "Point on Curve",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/point_on_curve.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/point_on_curve.svg"
)),
event: ModuleEvent::Command("OCCONSTRAINT".to_string()),
}
}
@@ -63,8 +69,18 @@ pub struct PointOnEntityConstraintCommand {
}
impl PointOnEntityConstraintCommand {
pub fn new(name: &'static str, kind: ConstraintKind, target: Handle, label: &'static str) -> Self {
Self { name, kind, target, label }
pub fn new(
name: &'static str,
kind: ConstraintKind,
target: Handle,
label: &'static str,
) -> Self {
Self {
name,
kind,
target,
label,
}
}
}
@@ -78,7 +94,12 @@ impl CadCommand for PointOnEntityConstraintCommand {
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
CmdResult::AddPointOnEntityConstraint { point: pt, target: self.target, kind: self.kind, label: self.label }
CmdResult::AddPointOnEntityConstraint {
point: pt,
target: self.target,
kind: self.kind,
label: self.label,
}
}
fn on_enter(&mut self) -> CmdResult {
@@ -91,4 +112,6 @@ impl CadCommand for PointOnEntityConstraintCommand {
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"] });
inventory::submit!(crate::command::CommandRegistration {
names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"]
});
+34 -13
View File
@@ -8,7 +8,9 @@ pub mod horizontal {
ToolDef {
id: "HCONSTRAINT",
label: "Horizontal",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/horizontal.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/horizontal.svg"
)),
event: ModuleEvent::Command("HCONSTRAINT".to_string()),
}
}
@@ -20,7 +22,9 @@ pub mod vertical {
ToolDef {
id: "VCONSTRAINT",
label: "Vertical",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/vertical.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/vertical.svg"
)),
event: ModuleEvent::Command("VCONSTRAINT".to_string()),
}
}
@@ -32,7 +36,9 @@ pub mod parallel {
ToolDef {
id: "PCONSTRAINT",
label: "Parallel",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/parallel.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/parallel.svg"
)),
event: ModuleEvent::Command("PCONSTRAINT".to_string()),
}
}
@@ -44,7 +50,9 @@ pub mod perpendicular {
ToolDef {
id: "QCONSTRAINT",
label: "Perpendicular",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/perpendicular.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/perpendicular.svg"
)),
event: ModuleEvent::Command("QCONSTRAINT".to_string()),
}
}
@@ -56,7 +64,9 @@ pub mod equal {
ToolDef {
id: "ECONSTRAINT",
label: "Equal",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/equal.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/equal.svg"
)),
event: ModuleEvent::Command("ECONSTRAINT".to_string()),
}
}
@@ -68,7 +78,9 @@ pub mod tangent {
ToolDef {
id: "TCONSTRAINT",
label: "Tangent",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/tangent.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/tangent.svg"
)),
event: ModuleEvent::Command("TCONSTRAINT".to_string()),
}
}
@@ -80,7 +92,9 @@ pub mod concentric {
ToolDef {
id: "NCONSTRAINT",
label: "Concentric",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/concentric.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/concentric.svg"
)),
event: ModuleEvent::Command("NCONSTRAINT".to_string()),
}
}
@@ -92,15 +106,16 @@ pub mod concentric {
/// always normal to its own tangent), so it's built from the same
/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line
/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only
/// covers line-to-line — AutoCAD's own `GeomConstraintType` enum lists
/// `kNormal` and `kPerpendicular` separately for exactly this reason.
/// covers line-to-line.
pub mod normal {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "NRCONSTRAINT",
label: "Normal",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/normal.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/normal.svg"
)),
event: ModuleEvent::Command("NRCONSTRAINT".to_string()),
}
}
@@ -112,7 +127,9 @@ pub mod colinear {
ToolDef {
id: "LCONSTRAINT",
label: "Colinear",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/colinear.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/colinear.svg"
)),
event: ModuleEvent::Command("LCONSTRAINT".to_string()),
}
}
@@ -124,7 +141,9 @@ pub mod fixed {
ToolDef {
id: "FXCONSTRAINT",
label: "Fixed",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/fixed.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/fixed.svg"
)),
event: ModuleEvent::Command("FXCONSTRAINT".to_string()),
}
}
@@ -136,7 +155,9 @@ pub mod symmetric {
ToolDef {
id: "SYCONSTRAINT",
label: "Symmetric",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/symmetric.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/symmetric.svg"
)),
event: ModuleEvent::Command("SYCONSTRAINT".to_string()),
}
}
+257 -80
View File
@@ -2,8 +2,7 @@
//! (a distance or an angle), unlike the plain select-and-click constraints
//! in `mod.rs`.
//!
//! Persistent (design doc `docs/parametric_system_design.md` §6.1): these
//! don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! gets `&mut self` — no document access — so it can't add a
//! `SketchConstraint` to the scene directly; instead these commands hand the
//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which
@@ -24,11 +23,7 @@ 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
/// reference to an existing named parameter. `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
@@ -57,7 +52,9 @@ pub mod distance_tool {
ToolDef {
id: "DCONSTRAINT",
label: "Distance",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/distance.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/distance.svg"
)),
event: ModuleEvent::Command("DCONSTRAINT".to_string()),
}
}
@@ -69,17 +66,15 @@ pub mod angle_tool {
ToolDef {
id: "ACONSTRAINT",
label: "Angle",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/angle.svg")),
icon: IconKind::Svg(include_bytes!(
"../../../../assets/icons/constrain/angle.svg"
)),
event: ModuleEvent::Command("ACONSTRAINT".to_string()),
}
}
}
/// Which flavor of dimensional constraint the typed value ultimately
/// drives — selectable via a prompt keyword before the value itself is
/// typed, mirroring how real AutoCAD's own `DCCONSTRAINT` offers
/// `[Xdistance/Ydistance]` or `[Diameter]` as options on the same command
/// rather than as separate tools.
/// Which flavor of dimensional constraint the typed value drives.
#[derive(Clone, Copy, PartialEq, Eq)]
enum DistanceMode {
/// Line length (two-point distance) or a circle/arc's radius —
@@ -107,6 +102,8 @@ pub struct DistanceConstraintCommand {
is_arc: bool,
mode: DistanceMode,
default_value: f64,
default_x: f64,
default_y: f64,
/// The entity's current arc length, for `ArcLength` mode's `<default>`
/// — meaningless (left at 0) unless `is_arc`.
default_arc_length: f64,
@@ -119,49 +116,89 @@ impl DistanceConstraintCommand {
/// `None` if `handle` isn't a Line, Circle, or Arc.
pub fn new(scene: &Scene, handle: Handle) -> Option<Self> {
let entity = scene.document.get_entity(handle)?;
let (is_circle, is_arc, default_value, default_arc_length) = match entity {
EntityType::Line(l) => {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
(false, false, (dx * dx + dy * dy).sqrt(), 0.0)
}
EntityType::Circle(c) => (true, false, c.radius, 0.0),
EntityType::Arc(a) => (true, true, a.radius, a.arc_length()),
_ => return None,
};
let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect();
Some(Self { handle, is_circle, is_arc, mode: DistanceMode::Auto, default_value, default_arc_length, known_param_names })
let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) =
match entity {
EntityType::Line(l) => {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
(false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0)
}
EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0),
EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()),
_ => return None,
};
let known_param_names = scene
.named_parameters()
.iter()
.map(|p| p.name.clone())
.collect();
Some(Self {
handle,
is_circle,
is_arc,
mode: DistanceMode::Auto,
default_value,
default_x,
default_y,
default_arc_length,
known_param_names,
})
}
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
if let DrivingValue::Literal(v) = target {
if v <= 0.0 {
if let DrivingValue::Literal(v) = &target {
let signed_component = matches!(self.mode, DistanceMode::X | DistanceMode::Y);
if !v.is_finite() || (!signed_component && *v <= 0.0) {
return None;
}
}
let (kind, refs, label) = match (self.is_circle, self.mode) {
(true, DistanceMode::Diameter) => (ConstraintKind::Diameter, vec![SketchRef::whole(self.handle)], "Diameter constraint"),
(true, DistanceMode::ArcLength) if self.is_arc => {
(ConstraintKind::ArcLength, vec![SketchRef::whole(self.handle)], "Arc length constraint")
}
(true, _) => (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint"),
(true, DistanceMode::Diameter) => (
ConstraintKind::Diameter,
vec![SketchRef::whole(self.handle)],
"Diameter constraint",
),
(true, DistanceMode::ArcLength) if self.is_arc => (
ConstraintKind::ArcLength,
vec![SketchRef::whole(self.handle)],
"Arc length constraint",
),
(true, _) => (
ConstraintKind::Radius,
vec![SketchRef::whole(self.handle)],
"Radius constraint",
),
(false, DistanceMode::X) => (
ConstraintKind::DistanceX,
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
vec![
SketchRef::point(self.handle, 0),
SketchRef::point(self.handle, 1),
],
"DistanceX constraint",
),
(false, DistanceMode::Y) => (
ConstraintKind::DistanceY,
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
vec![
SketchRef::point(self.handle, 0),
SketchRef::point(self.handle, 1),
],
"DistanceY constraint",
),
(false, _) => (
ConstraintKind::Distance,
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
vec![
SketchRef::point(self.handle, 0),
SketchRef::point(self.handle, 1),
],
"Distance constraint",
),
};
Some(CmdResult::AddSketchConstraint { kind, refs, driving_param: Some(target), label })
Some(CmdResult::AddSketchConstraint {
kind,
refs,
driving_param: Some(target),
label,
})
}
}
@@ -172,15 +209,30 @@ impl CadCommand for DistanceConstraintCommand {
fn prompt(&self) -> String {
match (self.is_circle, self.mode) {
(true, DistanceMode::Diameter) => format!("Specify diameter <{:.4}> or [Radius]: ", self.default_value * 2.0),
(true, DistanceMode::Diameter) => format!(
"Specify diameter <{:.4}> or [Radius]: ",
self.default_value * 2.0
),
(true, DistanceMode::ArcLength) if self.is_arc => {
format!("Specify arc length <{:.4}> or [Radius]: ", self.default_arc_length)
format!(
"Specify arc length <{:.4}> or [Radius]: ",
self.default_arc_length
)
}
(true, _) if self.is_arc => format!("Specify distance <{:.4}> or [Diameter/Arclength]: ", self.default_value),
(true, _) => format!("Specify distance <{:.4}> or [Diameter]: ", self.default_value),
(false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_value),
(false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_value),
(false, _) => format!("Specify distance <{:.4}> or [Xdistance/Ydistance]: ", self.default_value),
(true, _) if self.is_arc => format!(
"Specify distance <{:.4}> or [Diameter/Arclength]: ",
self.default_value
),
(true, _) => format!(
"Specify distance <{:.4}> or [Diameter]: ",
self.default_value
),
(false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_x),
(false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_y),
(false, _) => format!(
"Specify distance <{:.4}> or [Xdistance/Ydistance]: ",
self.default_value
),
}
}
@@ -196,9 +248,12 @@ impl CadCommand for DistanceConstraintCommand {
let default = match self.mode {
DistanceMode::Diameter => self.default_value * 2.0,
DistanceMode::ArcLength if self.is_arc => self.default_arc_length,
DistanceMode::X => self.default_x,
DistanceMode::Y => self.default_y,
_ => self.default_value,
};
self.build(DrivingValue::Literal(default)).unwrap_or(CmdResult::Cancel)
self.build(DrivingValue::Literal(default))
.unwrap_or(CmdResult::Cancel)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
@@ -262,14 +317,29 @@ 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();
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 })
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: DrivingValue) -> Option<CmdResult> {
if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) {
return None;
}
Some(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Angle,
refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)],
refs: vec![
SketchRef::whole(self.fixed_handle),
SketchRef::whole(self.moving_handle),
],
driving_param: Some(target),
label: "Angle constraint",
})
@@ -294,7 +364,8 @@ impl CadCommand for AngleConstraintCommand {
}
fn on_enter(&mut self) -> CmdResult {
self.build(DrivingValue::Literal(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> {
@@ -308,7 +379,9 @@ impl CadCommand for AngleConstraintCommand {
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DCONSTRAINT", "ACONSTRAINT"] });
inventory::submit!(crate::command::CommandRegistration {
names: &["DCONSTRAINT", "ACONSTRAINT"]
});
#[cfg(test)]
mod tests {
@@ -317,16 +390,25 @@ mod tests {
#[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)));
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)));
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())));
assert_eq!(
parse_driving_value("hole_dia", &known),
Some(DrivingValue::Named("hole_dia".to_string()))
);
}
#[test]
@@ -342,8 +424,14 @@ mod tests {
// here, so a lowercase-named parameter must still resolve — and
// resolve to its canonically-cased name, not the uppercased token.
let known = vec!["hole_dia".to_string()];
assert_eq!(parse_driving_value("HOLE_DIA", &known), Some(DrivingValue::Named("hole_dia".to_string())));
assert_eq!(parse_driving_value("Hole_Dia", &known), Some(DrivingValue::Named("hole_dia".to_string())));
assert_eq!(
parse_driving_value("HOLE_DIA", &known),
Some(DrivingValue::Named("hole_dia".to_string()))
);
assert_eq!(
parse_driving_value("Hole_Dia", &known),
Some(DrivingValue::Named("hole_dia".to_string()))
);
}
fn add_line(scene: &mut Scene) -> Handle {
@@ -354,34 +442,47 @@ mod tests {
}
fn add_circle(scene: &mut Scene) -> Handle {
scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
3.0,
)))
scene.add_entity(EntityType::Circle(
acadrust::entities::Circle::from_center_radius(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
3.0,
),
))
}
fn add_arc(scene: &mut Scene) -> Handle {
scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
3.0,
0.0,
std::f64::consts::FRAC_PI_2,
)))
scene.add_entity(EntityType::Arc(
acadrust::entities::Arc::from_center_radius_angles(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
3.0,
0.0,
std::f64::consts::FRAC_PI_2,
),
))
}
#[test]
fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() {
let mut scene = Scene::new();
let line = add_line(&mut scene);
let mut cmd = DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
let mut cmd =
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
// The keyword itself only switches mode and re-prompts — it must
// not build a constraint yet.
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
assert!(cmd.prompt().contains("X distance"), "prompt should reflect the new mode: {}", cmd.prompt());
assert!(
cmd.prompt().contains("X distance"),
"prompt should reflect the new mode: {}",
cmd.prompt()
);
match cmd.on_text_input("10") {
Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => {
Some(CmdResult::AddSketchConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
}) => {
assert_eq!(kind, ConstraintKind::DistanceX);
assert_eq!(v, 10.0);
}
@@ -389,17 +490,71 @@ mod tests {
}
}
#[test]
fn component_distances_accept_signed_values() {
let mut scene = Scene::new();
let line = add_line(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
assert!(matches!(
cmd.on_text_input("-3"),
Some(CmdResult::AddSketchConstraint {
kind: ConstraintKind::DistanceX,
driving_param: Some(DrivingValue::Literal(-3.0)),
..
})
));
}
#[test]
fn component_distance_defaults_use_the_selected_axis() {
let mut scene = Scene::new();
let line = add_line(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint)));
assert!(cmd.prompt().contains("<8.0000>"));
assert!(matches!(
cmd.on_enter(),
CmdResult::AddSketchConstraint {
kind: ConstraintKind::DistanceY,
driving_param: Some(DrivingValue::Literal(8.0)),
..
}
));
}
#[test]
fn ordinary_distances_reject_nonpositive_and_nonfinite_values() {
let mut scene = Scene::new();
let line = add_line(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
assert!(cmd.on_text_input("-3").is_none());
assert!(cmd.build(DrivingValue::Literal(f64::INFINITY)).is_none());
}
#[test]
fn d_keyword_switches_a_circles_command_to_diameter_mode() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint)));
assert!(cmd.prompt().contains("diameter"), "prompt should reflect Diameter mode: {}", cmd.prompt());
assert!(
cmd.prompt().contains("diameter"),
"prompt should reflect Diameter mode: {}",
cmd.prompt()
);
match cmd.on_text_input("16") {
Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => {
Some(CmdResult::AddSketchConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
}) => {
assert_eq!(kind, ConstraintKind::Diameter);
assert_eq!(v, 16.0);
}
@@ -411,10 +566,13 @@ mod tests {
fn default_mode_still_builds_a_plain_radius_constraint() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
match cmd.on_text_input("5") {
Some(CmdResult::AddSketchConstraint { kind, .. }) => assert_eq!(kind, ConstraintKind::Radius),
Some(CmdResult::AddSketchConstraint { kind, .. }) => {
assert_eq!(kind, ConstraintKind::Radius)
}
_ => panic!("expected an AddSketchConstraint{{Radius}}"),
}
}
@@ -423,13 +581,22 @@ mod tests {
fn a_keyword_switches_an_arcs_command_to_arc_length_mode() {
let mut scene = Scene::new();
let arc = add_arc(&mut scene);
let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
let mut cmd =
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint)));
assert!(cmd.prompt().contains("arc length"), "prompt should reflect ArcLength mode: {}", cmd.prompt());
assert!(
cmd.prompt().contains("arc length"),
"prompt should reflect ArcLength mode: {}",
cmd.prompt()
);
match cmd.on_text_input("8") {
Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => {
Some(CmdResult::AddSketchConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
}) => {
assert_eq!(kind, ConstraintKind::ArcLength);
assert_eq!(v, 8.0);
}
@@ -441,9 +608,14 @@ mod tests {
fn arc_length_keyword_is_not_offered_for_a_plain_circle() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
assert!(!cmd.prompt().contains("Arclength"), "a plain circle has no arc length option: {}", cmd.prompt());
assert!(
!cmd.prompt().contains("Arclength"),
"a plain circle has no arc length option: {}",
cmd.prompt()
);
// "A" isn't a recognized keyword here and doesn't parse as a
// number or a named parameter either, so it's rejected outright.
assert!(cmd.on_text_input("A").is_none());
@@ -453,10 +625,15 @@ mod tests {
fn arc_length_default_is_the_arcs_current_swept_length() {
let mut scene = Scene::new();
let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2
let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
let mut cmd =
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
cmd.on_text_input("A");
let expected = 3.0 * std::f64::consts::FRAC_PI_2;
assert!(cmd.prompt().contains(&format!("{expected:.4}")), "prompt should default to the arc's current length: {}", cmd.prompt());
assert!(
cmd.prompt().contains(&format!("{expected:.4}")),
"prompt should default to the arc's current length: {}",
cmd.prompt()
);
}
}
File diff suppressed because it is too large. Load diff
+24 -28
View File
@@ -342,6 +342,7 @@ pub struct UndoRecording {
/// scope's `Vec` entry, once created, is never removed.
sketch_constraints_before: HashMap<sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet>,
sketch_constraints_order: Vec<sketch_constraints::SketchScope>,
named_parameters_before: Option<named_parameters::ParameterTable>,
poisoned: bool,
}
@@ -351,12 +352,15 @@ impl UndoRecording {
self.poisoned
}
/// No entity, object, or sketch-constraint entry was recorded (nothing to undo).
/// No entity, object, constraint, or parameter state was recorded.
pub fn is_empty(&self) -> bool {
self.order.is_empty() && self.object_order.is_empty() && self.sketch_constraints_order.is_empty()
self.order.is_empty()
&& self.object_order.is_empty()
&& self.sketch_constraints_order.is_empty()
&& self.named_parameters_before.is_none()
}
/// Consume all three recording directories in deterministic first-touch
/// Consume the recording directories in deterministic first-touch
/// order. A `None` image means the entity/object was added by the
/// command; every sketch-constraint scope's before-image is a real
/// `SketchConstraintSet` (its `Vec` entry, once created, is never
@@ -367,6 +371,7 @@ impl UndoRecording {
Vec<(Handle, Option<Arc<EntityType>>)>,
Vec<(Handle, Option<ObjectType>)>,
Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>,
Option<named_parameters::ParameterTable>,
) {
let entities = self
.order
@@ -383,7 +388,7 @@ impl UndoRecording {
.drain(..)
.filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before)))
.collect();
(entities, objects, sketch_constraints)
(entities, objects, sketch_constraints, self.named_parameters_before)
}
/// Entity-only convenience used by the focused Scene delta tests.
@@ -1925,29 +1930,11 @@ pub struct Scene {
/// Conservative association hint: unknown until scanned, then updated from
/// changed entities. Retaining `true` after deletion only costs an extra scan.
has_associative_centers: std::cell::Cell<Option<bool>>,
/// Persistent parametric constraint sets, one per active
/// [`sketch_constraints::SketchScope`]. Lives on `Scene` (not
/// `DocumentTab`, despite `docs/parametric_system_design.md` §2.1's
/// original suggestion) because `bump_entities` — the hook
/// `refresh_sketch_constraints` rides, per that doc's §4.1 — is a
/// `Scene` method with no access to the owning `DocumentTab`; discovered
/// while implementing that stage, not anticipated in the design doc.
/// Synced with each scope's own `OCS_SKETCH_CONSTRAINTS` XRecord around
/// open/save (design doc §8 stage 4, `sketch_persist.rs`) — the "lazy"
/// model: this `Vec` is the live in-memory state every add/remove/solve
/// touches directly, materialized into `document.objects` only right
/// before a save and read back right after a load.
/// Persistent parametric constraint sets, one per sketch scope.
/// Materialized into scoped XRecords during save and restored on open.
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`).
/// Document-wide named-parameter and expression table.
/// Persisted through a single drawing XRecord.
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
@@ -2730,6 +2717,15 @@ impl Scene {
}
}
/// Records the drawing-wide parameter table before its first mutation in
/// the current undo transaction.
pub(crate) fn record_undo_named_parameters_before(&mut self) {
let before = self.named_parameters.clone();
if let Some(recording) = self.undo_recording.as_mut() {
recording.named_parameters_before.get_or_insert(before);
}
}
/// Flag the open recording as touching non-entity state (a new layer, a
/// group edit, a `*D` block record) that a pure-entity delta cannot
/// restore. The app's per-command predicate keeps this from firing.
@@ -12123,7 +12119,7 @@ mod delta_undo_tests {
let handle = scene.add_entity(EntityType::RasterImage(image));
let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned());
let (entities, objects, _sketch_constraints) = rec.into_recorded_images();
let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images();
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, handle);
assert_eq!(objects.len(), 1);
@@ -12147,7 +12143,7 @@ mod delta_undo_tests {
scene.erase_entities(&[h1]);
let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned());
let (entities, objects, _sketch_constraints) = rec.into_recorded_images();
let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images();
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, h1);
assert_eq!(objects.len(), 1);
+1 -4
View File
@@ -752,10 +752,7 @@ impl Scene {
.map(|&h| (h, ChangeKind::Added))
.collect();
self.bump_entities(&changes);
// Design doc §12: a constraint entirely between duplicated entities
// follows them (Coincident between two copied points, Parallel
// between two copied lines, ...); one straddling a duplicated and a
// non-duplicated entity can't sensibly follow, so is left alone.
// Constraints wholly within the duplicated selection follow it.
self.duplicate_sketch_constraints_for(&handle_map);
self.refresh_meshes_for_handles(&refresh_solid_handles);
new_handles
+155 -95
View File
@@ -1,32 +1,5 @@
//! 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.
//! Named, formula-capable parameters stored per drawing.
//! Formulas are parsed locally so dependency cycles can be rejected before evaluation.
use super::Scene;
use serde::{Deserialize, Serialize};
@@ -34,12 +7,8 @@ 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`).
/// another parameter by name) and [`Expr::Call`] (a function call). Only the
/// small builtin set in `call_builtin` is accepted.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Expr {
Number(f64),
@@ -66,7 +35,11 @@ impl Expr {
}
}
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) => {
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);
}
@@ -98,7 +71,11 @@ pub enum ParamError {
/// `b = a`.
Cycle(Vec<String>),
UnknownFunction(String),
WrongArgCount { function: String, expected: usize, found: usize },
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
@@ -114,8 +91,15 @@ impl fmt::Display for ParamError {
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::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"),
@@ -125,9 +109,8 @@ impl fmt::Display for ParamError {
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
/// The builtin function set. It also supplies the reserved-word list that
/// `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"];
@@ -137,7 +120,7 @@ pub fn is_reserved_name(name: &str) -> bool {
}
/// A valid parameter name: starts with a letter or underscore, followed by
/// letters/digits/underscores. (Design doc §5 open question 5.)
/// letters, digits, or underscores.
pub fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
@@ -147,12 +130,7 @@ pub fn is_valid_name(name: &str) -> bool {
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.
/// A dimensional constraint's literal or named driving value.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DrivingValue {
Literal(f64),
@@ -182,7 +160,11 @@ impl From<f64> for DrivingValue {
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() });
return Err(ParamError::WrongArgCount {
function: name.to_string(),
expected,
found: args.len(),
});
}
Ok(())
}
@@ -302,7 +284,9 @@ fn tokenize(src: &str) -> Result<Vec<Token>, ParamError> {
}
}
let text: String = chars[start..i].iter().collect();
let value: f64 = text.parse().map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?;
let value: f64 = text
.parse()
.map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?;
tokens.push(Token::Number(value));
}
_ if c.is_alphabetic() || c == '_' => {
@@ -340,8 +324,12 @@ impl<'a> Parser<'a> {
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"))),
Some(t) => Err(ParamError::Parse(format!(
"expected {expected:?}, found {t:?}"
))),
None => Err(ParamError::Parse(format!(
"expected {expected:?}, found end of formula"
))),
}
}
@@ -453,10 +441,16 @@ pub fn parse(source: &str) -> Result<Expr, ParamError> {
if tokens.is_empty() {
return Err(ParamError::Parse("empty formula".to_string()));
}
let mut parser = Parser { tokens: &tokens, pos: 0 };
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])));
return Err(ParamError::Parse(format!(
"unexpected trailing token {:?}",
tokens[parser.pos]
)));
}
Ok(expr)
}
@@ -471,17 +465,14 @@ pub struct Parameter {
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).
/// Every named parameter for one document.
///
/// `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)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ParameterTable {
parameters: Vec<Parameter>,
}
@@ -523,10 +514,8 @@ impl ParameterTable {
/// 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
/// Rejects an invalid or reserved identifier, malformed formula, or
/// circular reference. 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`]
@@ -542,7 +531,11 @@ impl ParameterTable {
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 }),
None => self.parameters.push(Parameter {
name: name.to_string(),
source: source.to_string(),
expr,
}),
}
Ok(())
}
@@ -589,7 +582,12 @@ impl ParameterTable {
/// 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>> {
fn find_path(
&self,
current: &str,
target: &str,
visiting: &mut Vec<String>,
) -> Option<Vec<String>> {
if current == target {
return Some(vec![current.to_string()]);
}
@@ -617,12 +615,8 @@ impl ParameterTable {
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.
/// Resolves one parameter's numeric value and dependency chain using a
/// fresh memo cache.
pub fn resolve(&self, name: &str) -> Result<f64, ParamError> {
let mut cache = HashMap::new();
let mut stack = Vec::new();
@@ -642,7 +636,12 @@ impl ParameterTable {
out
}
fn resolve_inner(&self, name: &str, cache: &mut HashMap<String, f64>, stack: &mut Vec<String>) -> Result<f64, ParamError> {
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);
}
@@ -654,7 +653,9 @@ impl ParameterTable {
cyc.push(name.to_string());
return Err(ParamError::Cycle(cyc));
}
let param = self.get(name).ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?;
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();
@@ -662,7 +663,12 @@ impl ParameterTable {
Ok(value)
}
fn eval(&self, expr: &Expr, cache: &mut HashMap<String, f64>, stack: &mut Vec<String>) -> Result<f64, ParamError> {
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)?,
@@ -677,7 +683,9 @@ impl ParameterTable {
}
self.eval(a, cache, stack)? / denom
}
Expr::Pow(a, b) => self.eval(a, cache, stack)?.powf(self.eval(b, cache, stack)?),
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 {
@@ -690,8 +698,8 @@ impl ParameterTable {
}
impl Scene {
/// The document's named-parameter table (design doc §2: one per
/// document). Read-only accessor; use [`Scene::named_parameters_mut`] to
/// The document's named-parameter table. Use
/// [`Scene::named_parameters_mut`] to
/// define/redefine/remove a parameter.
pub fn named_parameters(&self) -> &ParameterTable {
&self.named_parameters
@@ -726,7 +734,9 @@ mod tests {
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}"))
table
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
.unwrap_or_else(|e| panic!("eval '{src}': {e}"))
}
#[test]
@@ -754,11 +764,22 @@ mod tests {
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 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();
let err = table
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
.unwrap_err();
assert_eq!(err, ParamError::UnknownFunction("bogus".to_string()));
}
@@ -766,16 +787,31 @@ mod tests {
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));
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!(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());
}
@@ -800,7 +836,10 @@ mod tests {
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())));
assert_eq!(
table.resolve("a"),
Err(ParamError::UndefinedReference("b".to_string()))
);
}
#[test]
@@ -808,7 +847,10 @@ mod tests {
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");
assert!(
!table.contains("a"),
"a rejected formula must not be stored"
);
}
#[test]
@@ -816,7 +858,10 @@ mod tests {
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())));
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();
@@ -824,7 +869,10 @@ mod tests {
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())));
assert_eq!(
table.resolve("a"),
Err(ParamError::UndefinedReference("b".to_string()))
);
}
#[test]
@@ -849,8 +897,14 @@ mod tests {
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()))));
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]
@@ -859,8 +913,14 @@ mod tests {
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())));
assert!(
!table.remove("a"),
"removing an already-removed name reports false"
);
assert_eq!(
table.resolve("b"),
Err(ParamError::UndefinedReference("a".to_string()))
);
}
#[test]
+55 -59
View File
@@ -1,50 +1,8 @@
//! 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.
//! Persists the drawing-wide named-parameter table in a versioned XRecord.
use super::named_parameters::ParameterTable;
use super::Scene;
use bincode::Options;
const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS";
@@ -53,6 +11,7 @@ const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS";
/// bytes, so a table past a handful of parameters must be split across
/// multiple same-code `310` entries on write and concatenated back on read.
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024;
/// Prefixed onto the serialized blob so a future schema change can be
/// detected and gracefully skipped rather than silently misreading bytes —
@@ -62,7 +21,11 @@ const FORMAT_VERSION: u8 = 1;
fn encode(table: &ParameterTable) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
bytes.extend(bincode::serialize(table).ok()?);
let body = bincode::serialize(table).ok()?;
if body.len() >= MAX_RECORD_BYTES {
return None;
}
bytes.extend(body);
Some(bytes)
}
@@ -71,7 +34,12 @@ fn decode(bytes: &[u8]) -> Option<ParameterTable> {
if version != FORMAT_VERSION {
return None;
}
bincode::deserialize(body).ok()
bincode::DefaultOptions::new()
.with_fixint_encoding()
.allow_trailing_bytes()
.with_limit(MAX_RECORD_BYTES as u64)
.deserialize(body)
.ok()
}
impl Scene {
@@ -89,7 +57,9 @@ impl Scene {
if self.named_parameters.is_empty() {
return;
}
let Some(bytes) = encode(&self.named_parameters) else { return };
let Some(bytes) = encode(&self.named_parameters) else {
return;
};
let owner = self.document.header.model_space_block_handle;
if owner.is_null() {
return;
@@ -100,9 +70,10 @@ impl Scene {
// not accumulate more Chunk entries every time.
record.entries.clear();
for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
record
.entries
.push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(chunk.to_vec())));
record.entries.push(acadrust::objects::XRecordEntry::new(
310,
acadrust::objects::XRecordValue::Chunk(chunk.to_vec()),
));
}
}
}
@@ -121,12 +92,18 @@ impl Scene {
if owner.is_null() {
return;
}
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return };
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else {
return;
};
// Concatenate every Chunk entry in order, not just the first — see
// `MAX_CHUNK_BYTES`'s doc comment.
let mut bytes = Vec::new();
for entry in &record.entries {
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES {
bytes.clear();
break;
}
bytes.extend_from_slice(chunk);
}
}
@@ -147,7 +124,10 @@ mod tests {
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
.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
@@ -158,7 +138,10 @@ mod tests {
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");
assert_eq!(
scene.named_parameters().get("hole_dia").unwrap().source,
"5"
);
}
/// The above test only proves `materialize`/`load` agree with each
@@ -169,15 +152,22 @@ mod tests {
/// `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
.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.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
@@ -202,13 +192,19 @@ mod tests {
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");
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");
assert!(
decode(&bytes).is_none(),
"a future/unknown format version must be rejected, not misread"
);
}
}
+1 -1
View File
@@ -2492,7 +2492,6 @@ mod texture_limit_tests {
let rgba = vec![0u8; 10];
assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none());
}
#[test]
fn malformed_material_images_are_rejected_even_below_the_texture_limit() {
for (width, height, pixels) in [(0, 1, vec![]), (1, 0, vec![]), (1, 1, vec![0; 3]), (1, 1, vec![0; 5])] {
@@ -2501,4 +2500,5 @@ mod texture_limit_tests {
}
assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none());
}
}
+271 -165
View File
@@ -1,15 +1,4 @@
//! Persistent, parametric geometric constraints — data model.
//!
//! Design: `docs/parametric_system_design.md`. This is Stage 1 of that
//! doc's §8 staged plan: pure data types plus unit tests, no document/scene
//! integration yet (that's `refresh_sketch_constraints`, wired into
//! `Scene::bump_entities` in a later stage).
//!
//! Distinct from the one-shot "Constraints" ribbon commands
//! (`crate::modules::draw::constrain`), which solve once and forget. A
//! [`SketchConstraint`] is a persisted record: it stays attached to its
//! entities and is meant to be re-solved every time referenced geometry
//! changes, not applied once.
//! Persistent sketch-constraint data types and scope management.
use super::named_parameters::DrivingValue;
use acadrust::types::{Handle, Vector3};
@@ -40,23 +29,32 @@ pub struct SketchRef {
impl SketchRef {
pub fn whole(entity: Handle) -> Self {
Self { entity, marker: None }
Self {
entity,
marker: None,
}
}
pub fn point(entity: Handle, marker: i32) -> Self {
Self { entity, marker: Some(marker) }
Self {
entity,
marker: Some(marker),
}
}
/// The circle/arc-center special case (`marker == -3`), broken out as
/// its own constructor since `-3` alone reads as a magic number
/// everywhere it would otherwise appear.
pub fn center(entity: Handle) -> Self {
Self { entity, marker: Some(-3) }
Self {
entity,
marker: Some(-3),
}
}
}
/// The friendly, user-facing constraint types — the "what button did they
/// click" vocabulary, one layer above the `ocs_gcs` primitives each maps
/// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps
/// onto (that mapping is `constraint_map`, a later stage; see the design
/// doc §2). Named and grouped the same way the existing one-shot ribbon
/// tools are (`crate::modules::draw::constrain::tools`), plus the
@@ -102,11 +100,8 @@ pub enum ConstraintKind {
PointOnCurve,
/// The distance between one point pair equals the distance between
/// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`).
/// AutoCAD's fourth `Equal` sub-kind (`ACEQUALDISTANCECONSTRAINT`) —
/// see the design doc for why `EqualCurvature`, the other missing
/// sub-kind, isn't modeled: for the only entity types this system
/// solves (Line, Circle), it would be mathematically identical to this
/// `Equal`'s existing circle/circle (radius) branch.
/// For the supported entity types, equal curvature is already covered
/// by the circle/circle radius branch of `Equal`.
EqualDistance,
/// Two circles/arcs are mirror images of each other across a line —
/// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the
@@ -118,15 +113,12 @@ pub enum ConstraintKind {
/// `[whole(circle_or_arc)]`. Same DWG class as `Radius`
/// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the
/// `RadiusDiameterConstrType` mode byte
/// (`dwg_native_constraints.rs`), matching how real AutoCAD represents
/// it — not a distinct native object type.
/// (`dwg_native_constraints.rs`), rather than a distinct object type.
Diameter,
/// The X-only (resp. Y-only) component of the distance between two
/// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG
/// class as `Distance` (`ACDISTANCECONSTRAINT`) with its
/// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction, again
/// matching AutoCAD's own representation rather than inventing a new
/// class.
/// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction.
DistanceX,
DistanceY,
/// A line perpendicular to a circle/arc's tangent at their point of
@@ -135,9 +127,7 @@ pub enum ConstraintKind {
/// passes through the circle's center" (a circle's radius is always
/// normal to its own tangent), so it solves via the same `PointOnLine`
/// primitive `PointOnCurve` already uses. Distinct from
/// `Perpendicular` (line-to-line only) — AutoCAD's own
/// `GeomConstraintType` enum lists `kNormal` and `kPerpendicular`
/// separately for exactly this reason. Line-Line has no meaning here
/// `Perpendicular` (line-to-line only). Line-Line has no meaning here
/// (that's plain `Perpendicular`) and isn't buildable.
Normal,
/// An arc's arc length (`radius * sweep angle`) — `refs`:
@@ -164,8 +154,7 @@ pub struct SketchConstraint {
pub refs: Vec<SketchRef>,
/// 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
/// named-parameter reference 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).
@@ -175,10 +164,7 @@ pub struct SketchConstraint {
pub enabled: bool,
}
/// What "one sketch" scopes to, given the app has no dedicated Sketch
/// grouping (design doc §3.1): the only existing sub-document boundary is a
/// block record, so model space and each block definition get their own
/// independent constraint set.
/// A constraint scope: model space or one block definition.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SketchScope {
ModelSpace,
@@ -189,12 +175,7 @@ pub enum SketchScope {
}
impl SketchScope {
/// The handle a [`SketchConstraintSet`] for this scope is persisted
/// under — `CadDocument::ensure_xrecord`/`xrecord`/`xrecord_mut`'s
/// `owner` argument (design doc §1.4/§5.2). Model space resolves
/// through the document rather than being a fixed handle because a
/// document's model-space block record handle is assigned when the
/// document is built, not a constant.
/// The owner handle under which this scope is persisted.
pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle {
match self {
SketchScope::ModelSpace => document.header.model_space_block_handle,
@@ -203,9 +184,8 @@ impl SketchScope {
}
}
/// Every persisted constraint for one [`SketchScope`]. Rebuilt-and-solved
/// wholesale on every trigger (design doc §4.2) — this struct holds only
/// the constraint records themselves, no solver state.
/// Every persisted constraint for one [`SketchScope`]. Solver state is rebuilt
/// on demand and is not stored here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SketchConstraintSet {
pub scope: SketchScope,
@@ -215,30 +195,49 @@ pub struct SketchConstraintSet {
/// independent solve partition in this scope — updated by
/// `sketch_solve::solve_scope` each time this set is resolved. `None`
/// until the first resolve (e.g. right after loading from disk, before
/// any edit has touched this scope yet). Design doc §6.3's DOF badge
/// reads this rather than recomputing it every frame. Not persisted:
/// any edit has touched this scope yet). The status badge reads this
/// rather than recomputing it every frame. Not persisted:
/// it's a derived cache, not real constraint state.
#[serde(skip)]
pub dof: Option<usize>,
/// Cached redundant/conflicting constraints found by the last resolve
/// (design doc §6.4, stage 11) — empty whenever the scope isn't
/// over-constrained, which is the common case. Also a derived cache, not
/// Cached redundant/conflicting constraints found by the last resolve.
/// Also a derived cache, not
/// persisted; a `ConflictResolverPanel` reads this rather than calling
/// `ocs_gcs::diagnosis::classify_redundant` itself.
/// `cadkernel_constraints::diagnosis::classify_redundant` itself.
#[serde(skip)]
pub conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>,
pub conflicts: Vec<(
ConstraintId,
cadkernel_constraints::diagnosis::RedundancyKind,
)>,
}
impl SketchConstraintSet {
pub fn new(scope: SketchScope) -> Self {
Self { scope, constraints: Vec::new(), next_id: 0, dof: None, conflicts: Vec::new() }
Self {
scope,
constraints: Vec::new(),
next_id: 0,
dof: None,
conflicts: Vec::new(),
}
}
/// 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<DrivingValue>) -> 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 });
self.constraints.push(SketchConstraint {
id,
kind,
refs,
driving_param,
enabled: true,
});
id
}
@@ -253,28 +252,27 @@ impl SketchConstraintSet {
self.constraints.iter().find(|c| c.id == id)
}
/// Every enabled constraint referencing `entity`, regardless of which
/// of its sub-elements — the query `refresh_sketch_constraints`'
/// reverse index (design doc §4.1) is built from.
/// Every enabled constraint referencing `entity`.
pub fn constraints_touching(&self, entity: Handle) -> impl Iterator<Item = &SketchConstraint> {
self.constraints.iter().filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity))
self.constraints
.iter()
.filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity))
}
/// Drops every constraint that references `entity` at all — the
/// dangling-constraint-on-delete policy (design doc §5.3, open question
/// 4): entities silently take their constraints with them when erased,
/// rather than leaving a dangling reference around. Returns the
/// removed constraints' ids, so a caller can log/report what vanished.
/// Drops every constraint that references `entity` and returns the
/// removed constraint ids.
pub fn remove_all_touching(&mut self, entity: Handle) -> Vec<ConstraintId> {
let (removed, kept): (Vec<_>, Vec<_>) =
self.constraints.drain(..).partition(|c| c.refs.iter().any(|r| r.entity == entity));
let (removed, kept): (Vec<_>, Vec<_>) = self
.constraints
.drain(..)
.partition(|c| c.refs.iter().any(|r| r.entity == entity));
self.constraints = kept;
removed.into_iter().map(|c| c.id).collect()
}
}
/// Resolves a [`SketchRef`] to its current world-space point, for building
/// an `ocs_gcs` `ParamStore` from live document geometry — the constraint
/// an `cadkernel_constraints` `ParamStore` from live document geometry — the constraint
/// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted
/// to the marker conventions constraint endpoints actually use (whole-entity
/// `None`, an ordinary `source_points()` index, or the `-3` center case).
@@ -282,11 +280,8 @@ impl SketchConstraintSet {
/// marker this scheme doesn't (yet) support (e.g. `-2`, or an out-of-range
/// index).
///
/// `sketch_solve`'s solver-side entity registration reads entity fields
/// directly rather than through this (it needs raw, not-yet-WCS-resolved
/// coordinates to seed `ParamId`s) — this is for UI-side consumers instead:
/// currently `glyph_anchor` (below), eventually Phase B live inference
/// (design doc §6.2) querying "what point is near the cursor".
/// Solver-side registration reads raw entity fields directly. This helper is
/// for UI-side consumers that need the current world-space position.
pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option<Vector3> {
if marker == -3 {
return match entity {
@@ -298,7 +293,9 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio
if marker < 0 {
return None;
}
super::dimension_assoc::source_points(entity).get(marker as usize).copied()
super::dimension_assoc::source_points(entity)
.get(marker as usize)
.copied()
}
/// Below this squared distance (1e-6 world units), two points count as
@@ -306,16 +303,9 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio
/// purposes.
const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12;
/// Design doc §6.1's manual Coincident UI: given a world point (from a
/// `CadCommand::on_point` pick — trusted to already be OSNAP-snapped onto a
/// real feature, the same trust `infer_coincident_refs` places in a
/// just-drawn entity's own coordinates, for the same reason: no camera or
/// pixel radius is available once a point reaches this far from the click),
/// finds the closest addressable point (an ordinary `source_points()` index,
/// or a circle/arc's center) on any entity in `scope` other than `exclude`,
/// within [`COINCIDENT_EPSILON_SQ`]. `None` means the pick didn't land on a
/// real point — the caller should ask the user to enable an Endpoint/Center
/// object snap and try again, not silently constrain nothing.
/// Finds the addressable entity point nearest a snapped world position.
/// Returns `None` when no point in the scope is within the coincidence
/// tolerance.
pub(crate) fn nearest_sketch_point(
document: &acadrust::CadDocument,
scope: SketchScope,
@@ -338,7 +328,10 @@ pub(crate) fn nearest_sketch_point(
if common.owner_handle != owner || Some(common.handle) == exclude {
continue;
}
for (marker, point) in super::dimension_assoc::source_points(candidate).into_iter().enumerate() {
for (marker, point) in super::dimension_assoc::source_points(candidate)
.into_iter()
.enumerate()
{
consider(common.handle, marker as i32, point);
}
match candidate {
@@ -350,23 +343,13 @@ pub(crate) fn nearest_sketch_point(
best.map(|(_, r)| r)
}
/// Design doc §6.2 (stage 8, Phase B live inference): for each of
/// `new_entity`'s own points, finds an existing entity in `scope` (other
/// For each point on `new_entity`, finds an existing entity in `scope` (other
/// than `new_handle` itself) whose corresponding point already coincides,
/// and returns a `(new, existing)` `SketchRef` pair per match — the caller
/// adds each as a `Coincident` constraint.
///
/// **Deviation from this doc**: the doc's own §6.2 suggests reusing OSNAP's
/// screen-space tolerance/query; this instead checks for near-exact
/// world-space coincidence. The natural hook point is where a committed
/// entity becomes a document write (`CmdResult::CommitEntity`'s dispatch in
/// `command_driver.rs`) — well after the cursor's pixel position and camera
/// are available, with only the entity's final world coordinates left. If
/// OSNAP's endpoint mode grabbed the point while the user was drawing, the
/// new entity's coordinate already matches the existing one to floating-point
/// precision, so a tight world-space epsilon reproduces the same result
/// without re-deriving OSNAP's screen-space math or threading camera state
/// into this dispatch site.
/// Matching uses a tight world-space tolerance because this runs after the
/// snapped entity has been committed.
pub(crate) fn infer_coincident_refs(
document: &acadrust::CadDocument,
scope: SketchScope,
@@ -385,12 +368,18 @@ pub(crate) fn infer_coincident_refs(
if common.handle == new_handle || common.owner_handle != owner {
continue;
}
for (marker, point) in super::dimension_assoc::source_points(candidate).iter().enumerate() {
for (marker, point) in super::dimension_assoc::source_points(candidate)
.iter()
.enumerate()
{
let dx = point.x - new_point.x;
let dy = point.y - new_point.y;
let dz = point.z - new_point.z;
if dx * dx + dy * dy + dz * dz <= COINCIDENT_EPSILON_SQ {
pairs.push((SketchRef::point(new_handle, new_marker as i32), SketchRef::point(common.handle, marker as i32)));
pairs.push((
SketchRef::point(new_handle, new_marker as i32),
SketchRef::point(common.handle, marker as i32),
));
break;
}
}
@@ -437,8 +426,12 @@ impl ConstraintKind {
/// 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(DrivingValue::Literal(value))) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()),
(_, Some(DrivingValue::Literal(value))) => format!("{} {value:.2}", constraint.kind.glyph_symbol()),
(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;
@@ -446,7 +439,9 @@ pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String {
// 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()),
(_, Some(DrivingValue::Named(name))) => {
format!("{} {name}", constraint.kind.glyph_symbol())
}
(_, None) => constraint.kind.glyph_symbol().to_string(),
}
}
@@ -457,16 +452,21 @@ pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String {
/// back to a representative point on the entity (a line's midpoint, a
/// circle's rightmost point) since there's no single named point to anchor
/// on otherwise.
pub(crate) fn glyph_anchor(document: &acadrust::CadDocument, constraint: &SketchConstraint) -> Option<Vector3> {
pub(crate) fn glyph_anchor(
document: &acadrust::CadDocument,
constraint: &SketchConstraint,
) -> Option<Vector3> {
let r = constraint.refs.first()?;
let entity = document.get_entity(r.entity)?;
if let Some(marker) = r.marker {
return resolve_point(entity, marker);
}
match entity {
acadrust::EntityType::Line(l) => {
Some(Vector3::new((l.start.x + l.end.x) * 0.5, (l.start.y + l.end.y) * 0.5, (l.start.z + l.end.z) * 0.5))
}
acadrust::EntityType::Line(l) => Some(Vector3::new(
(l.start.x + l.end.x) * 0.5,
(l.start.y + l.end.y) * 0.5,
(l.start.z + l.end.z) * 0.5,
)),
acadrust::EntityType::Circle(c) => Some(c.point_at_angle_wcs(0.0)),
_ => None,
}
@@ -484,16 +484,20 @@ impl super::Scene {
/// scope — this is the one way to reach a scope's set for both reading
/// and mutating.
pub fn sketch_constraint_set_mut(&mut self, scope: SketchScope) -> &mut SketchConstraintSet {
if let Some(index) = self.sketch_constraints.iter().position(|s| s.scope == scope) {
if let Some(index) = self
.sketch_constraints
.iter()
.position(|s| s.scope == scope)
{
&mut self.sketch_constraints[index]
} else {
self.sketch_constraints.push(SketchConstraintSet::new(scope));
self.sketch_constraints
.push(SketchConstraintSet::new(scope));
self.sketch_constraints.last_mut().expect("just pushed")
}
}
/// Design doc §5.3/§12 (open question 3, now resolved): copy/paste's
/// handle remapping lives locally in each command that duplicates
/// Handle remapping lives in each command that duplicates
/// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR,
/// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own
/// (clipboard paste, `src/app/command_driver.rs`) — rather than in one
@@ -507,18 +511,28 @@ impl super::Scene {
/// scope, then triggers a solve for the newly duplicated geometry the
/// same way any other edit would. A no-op when `handle_map` is empty or
/// nothing constrained was duplicated.
pub fn duplicate_sketch_constraints_for(&mut self, handle_map: &rustc_hash::FxHashMap<Handle, Handle>) {
pub fn duplicate_sketch_constraints_for(
&mut self,
handle_map: &rustc_hash::FxHashMap<Handle, Handle>,
) {
if handle_map.is_empty() {
return;
}
let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<DrivingValue>)> = 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)) {
continue;
}
let new_refs: Vec<SketchRef> =
c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect();
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.clone()));
}
}
@@ -532,7 +546,10 @@ impl super::Scene {
}
touched.sort();
touched.dedup();
let changes: Vec<(Handle, super::ChangeKind)> = touched.into_iter().map(|h| (h, super::ChangeKind::Modified)).collect();
let changes: Vec<(Handle, super::ChangeKind)> = touched
.into_iter()
.map(|h| (h, super::ChangeKind::Modified))
.collect();
self.bump_entities(&changes);
}
@@ -547,14 +564,21 @@ impl super::Scene {
let mut out = Vec::new();
for set in &self.sketch_constraints {
for c in &set.constraints {
let Some(DrivingValue::Named(n)) = &c.driving_param else { continue };
let Some(DrivingValue::Named(n)) = &c.driving_param else {
continue;
};
if n != name {
continue;
}
let mut entities: Vec<Handle> = c.refs.iter().map(|r| r.entity).collect();
entities.sort();
entities.dedup();
out.push(ParameterUsage { scope: set.scope, constraint_id: c.id, kind: c.kind, entities });
out.push(ParameterUsage {
scope: set.scope,
constraint_id: c.id,
kind: c.kind,
entities,
});
}
}
out
@@ -582,20 +606,38 @@ mod tests {
#[test]
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(DrivingValue::Literal(25.0)));
let a = set.add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(h(1))],
None,
);
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(DrivingValue::Literal(25.0)));
assert_eq!(
set.get(b).unwrap().driving_param,
Some(DrivingValue::Literal(25.0))
);
}
#[test]
fn remove_drops_only_the_matching_id() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None);
let a = set.add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(h(1))],
None,
);
let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None);
assert!(set.remove(a));
assert!(!set.remove(a), "removing twice should report nothing removed the second time");
assert!(
!set.remove(a),
"removing twice should report nothing removed the second time"
);
assert!(set.get(a).is_none());
assert!(set.get(b).is_some());
}
@@ -603,8 +645,16 @@ mod tests {
#[test]
fn constraints_touching_finds_entity_regardless_of_marker() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None);
set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None);
set.add(
ConstraintKind::Coincident,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)],
None,
);
set.add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(h(3))],
None,
);
let touching_1: Vec<_> = set.constraints_touching(h(1)).collect();
assert_eq!(touching_1.len(), 1);
@@ -616,21 +666,40 @@ mod tests {
#[test]
fn constraints_touching_skips_disabled() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let id = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None);
set.constraints.iter_mut().find(|c| c.id == id).unwrap().enabled = false;
let id = set.add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(h(1))],
None,
);
set.constraints
.iter_mut()
.find(|c| c.id == id)
.unwrap()
.enabled = false;
assert_eq!(set.constraints_touching(h(1)).count(), 0);
}
#[test]
fn remove_all_touching_drops_every_constraint_referencing_the_entity() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let coincident = set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None);
let horizontal_other = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None);
let coincident = set.add(
ConstraintKind::Coincident,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)],
None,
);
let horizontal_other = set.add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(h(3))],
None,
);
let removed = set.remove_all_touching(h(1));
assert_eq!(removed, vec![coincident]);
assert!(set.get(coincident).is_none());
assert!(set.get(horizontal_other).is_some(), "unrelated entity's constraint must survive");
assert!(
set.get(horizontal_other).is_some(),
"unrelated entity's constraint must survive"
);
}
#[test]
@@ -644,54 +713,87 @@ mod tests {
#[test]
fn ref_center_constructor_matches_the_dash_three_convention() {
let r = SketchRef::center(h(7));
assert_eq!(r, SketchRef { entity: h(7), marker: Some(-3) });
assert_eq!(
r,
SketchRef {
entity: h(7),
marker: Some(-3)
}
);
}
#[test]
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(DrivingValue::Literal(12.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(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(DrivingValue::Literal(12.5)));
assert_eq!(
restored.constraints[1].driving_param,
Some(DrivingValue::Literal(12.5))
);
assert_eq!(restored.constraints[0].refs, set.constraints[0].refs);
}
#[test]
fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() {
let mut scene = super::super::Scene::new();
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Distance,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
Some(DrivingValue::Named("gap".to_string())),
);
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(2))],
Some(DrivingValue::Named("gap".to_string())),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
Some(DrivingValue::Named("gap".to_string())),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(2))],
Some(DrivingValue::Named("gap".to_string())),
);
// Unrelated: a literal-driven constraint and one driven by a
// different name must not show up.
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(3))],
Some(DrivingValue::Literal(5.0)),
);
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Distance,
vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)],
Some(DrivingValue::Named("other".to_string())),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(3))],
Some(DrivingValue::Literal(5.0)),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)],
Some(DrivingValue::Named("other".to_string())),
);
let usage = scene.parameter_usage("gap");
assert_eq!(usage.len(), 2, "exactly the two constraints driven by 'gap', got {usage:?}");
assert!(usage.iter().any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)]));
assert!(usage.iter().any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)]));
assert_eq!(
usage.len(),
2,
"exactly the two constraints driven by 'gap', got {usage:?}"
);
assert!(usage
.iter()
.any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)]));
assert!(usage
.iter()
.any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)]));
assert_eq!(scene.parameter_usage("nonexistent").len(), 0);
}
@@ -700,11 +802,13 @@ mod tests {
fn parameter_usage_searches_every_scope_not_just_model_space() {
let mut scene = super::super::Scene::new();
let block = h(99);
scene.sketch_constraint_set_mut(SketchScope::Block(block)).add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(1))],
Some(DrivingValue::Named("r".to_string())),
);
scene
.sketch_constraint_set_mut(SketchScope::Block(block))
.add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(1))],
Some(DrivingValue::Named("r".to_string())),
);
let usage = scene.parameter_usage("r");
assert_eq!(usage.len(), 1);
assert_eq!(usage[0].scope, SketchScope::Block(block));
@@ -716,11 +820,13 @@ mod tests {
// A Distance constraint whose two points are both on the same
// entity (e.g. a line's own start and end) must list that entity
// once, not twice.
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Distance,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
Some(DrivingValue::Named("len".to_string())),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
Some(DrivingValue::Named("len".to_string())),
);
let usage = scene.parameter_usage("len");
assert_eq!(usage.len(), 1);
assert_eq!(usage[0].entities, vec![h(1)]);
+115 -68
View File
@@ -1,23 +1,9 @@
//! Save/load integration for [`super::sketch_constraints::SketchConstraintSet`]
//! — design doc §5.2/§8 stage 4, the "lazy" persistence model that section
//! recommends: constraint sets live only in `Scene::sketch_constraints`
//! in-memory (§8 stage 3) and are materialized into `document.objects` as
//! XRecords only around a save, and read back out right after a load. The
//! XRecord round-trip mechanism itself (`CadDocument::ensure_xrecord`/
//! `xrecord`/`xrecord_mut`) was proven in stage 2
//! (`tests/sketch_constraints_xrecord_roundtrip.rs`) — this module is just
//! the app-level wiring: which owner handles to check, and when.
//!
//! One named XRecord (keyed [`XRECORD_KEY`]) hangs off each scope's own
//! owner handle's extension dictionary — model space's `BlockRecord` handle,
//! or a `SketchScope::Block`'s handle — rather than a single shared
//! dictionary, since [`super::sketch_constraints::SketchScope::owner_handle`]
//! already gives that handle directly and `ensure_xrecord`/`xrecord` are
//! themselves per-owner.
//! Persists each sketch constraint set in a versioned, chunked XRecord.
use super::sketch_constraints::SketchConstraintSet;
use super::Scene;
use acadrust::{CadDocument, Handle};
use bincode::Options;
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
@@ -35,6 +21,7 @@ const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
/// around it without touching the vendored crate — `CadDocument::xrecord`'s
/// `entries: Vec<XRecordEntry>` already supports repeating a code.
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024;
/// Removes a named XRecord from `owner`'s extension dictionary, if present.
/// The dictionary itself is left in place even if now empty — other code may
@@ -50,32 +37,28 @@ fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) {
else {
return;
};
let Some(index) = dictionary.entries.iter().position(|(name, _)| name.eq_ignore_ascii_case(key)) else {
let Some(index) = dictionary
.entries
.iter()
.position(|(name, _)| name.eq_ignore_ascii_case(key))
else {
return;
};
let (_, record_handle) = dictionary.entries.remove(index);
document.objects.remove(&record_handle);
}
/// Prefixed onto every serialized blob so a future schema change can be
/// detected and gracefully skipped (dropping just that one scope's
/// constraints, not the whole load) rather than silently misreading bytes
/// 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.
///
/// 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).
/// Serialized schema version. Unknown versions are skipped without affecting
/// the rest of the drawing.
const FORMAT_VERSION: u8 = 2;
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
bytes.extend(bincode::serialize(set).ok()?);
let body = bincode::serialize(set).ok()?;
if body.len() >= MAX_RECORD_BYTES {
return None;
}
bytes.extend(body);
Some(bytes)
}
@@ -84,7 +67,12 @@ fn decode(bytes: &[u8]) -> Option<SketchConstraintSet> {
if version != FORMAT_VERSION {
return None;
}
bincode::deserialize(body).ok()
bincode::DefaultOptions::new()
.with_fixint_encoding()
.allow_trailing_bytes()
.with_limit(MAX_RECORD_BYTES as u64)
.deserialize(body)
.ok()
}
impl Scene {
@@ -145,16 +133,26 @@ impl Scene {
/// blank document has nothing to find anyway).
pub(crate) fn load_sketch_constraints_from_document(&mut self) {
self.sketch_constraints.clear();
let owners: Vec<acadrust::Handle> =
self.document.block_records.iter().map(|record| record.handle).collect();
let owners: Vec<acadrust::Handle> = self
.document
.block_records
.iter()
.map(|record| record.handle)
.collect();
for owner in owners {
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { continue };
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else {
continue;
};
// Concatenate every Chunk entry in order, not just the first —
// a set materialized past `MAX_CHUNK_BYTES` spans more than one
// same-code `310` entry (see that constant's doc comment).
let mut bytes = Vec::new();
for entry in &record.entries {
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES {
bytes.clear();
break;
}
bytes.extend_from_slice(chunk);
}
}
@@ -182,11 +180,9 @@ mod tests {
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(a)],
None,
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
// Simulate the load-time side of a save/reload round trip: drop the
@@ -195,7 +191,9 @@ mod tests {
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
let set = scene.sketch_constraint_set(SketchScope::ModelSpace).expect("constraint set should have been reloaded");
let set = scene
.sketch_constraint_set(SketchScope::ModelSpace)
.expect("constraint set should have been reloaded");
assert_eq!(set.constraints.len(), 1);
assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal);
assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]);
@@ -218,16 +216,20 @@ mod tests {
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(20.0, 5.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Coincident,
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(crate::scene::named_parameters::DrivingValue::Literal(12.5)),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Coincident,
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(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)
@@ -237,9 +239,12 @@ mod tests {
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
reloaded_scene.load_sketch_constraints_from_document();
reloaded_scene.sketch_constraint_set(SketchScope::ModelSpace).cloned().unwrap_or_else(|| {
panic!("no ModelSpace constraint set survived the {ext} save/load round trip")
})
reloaded_scene
.sketch_constraint_set(SketchScope::ModelSpace)
.cloned()
.unwrap_or_else(|| {
panic!("no ModelSpace constraint set survived the {ext} save/load round trip")
})
}
#[test]
@@ -248,7 +253,10 @@ 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(crate::scene::named_parameters::DrivingValue::Literal(12.5)));
assert_eq!(
restored.constraints[1].driving_param,
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))
);
}
#[test]
@@ -257,7 +265,10 @@ 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(crate::scene::named_parameters::DrivingValue::Literal(12.5)));
assert_eq!(
restored.constraints[1].driving_param,
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))
);
}
#[test]
@@ -274,10 +285,22 @@ mod tests {
// handle"), so look it up from the table by name instead of using
// the return value directly.
let _insert_handle = scene
.create_block_from_entities(&[a], "OCS_STAGE4_TEST_BLOCK", &acadrust::types::Transform::identity(), &acadrust::types::Transform::identity())
.create_block_from_entities(
&[a],
"OCS_STAGE4_TEST_BLOCK",
&acadrust::types::Transform::identity(),
&acadrust::types::Transform::identity(),
)
.expect("create a block definition to own the constraint set");
let br_handle = scene.document.block_records.get("OCS_STAGE4_TEST_BLOCK").expect("block record should exist").handle;
scene.sketch_constraint_set_mut(SketchScope::Block(br_handle)).add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None);
let br_handle = scene
.document
.block_records
.get("OCS_STAGE4_TEST_BLOCK")
.expect("block record should exist")
.handle;
scene
.sketch_constraint_set_mut(SketchScope::Block(br_handle))
.add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
scene.sketch_constraints.clear();
@@ -300,7 +323,10 @@ mod tests {
scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty constraint set should not create an XRecord");
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
"an empty constraint set should not create an XRecord"
);
}
#[test]
@@ -316,29 +342,50 @@ mod tests {
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_some(), "sanity: the first save should have materialized an XRecord");
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_some(),
"sanity: the first save should have materialized an XRecord"
);
// Clear every constraint (mirrors deleting the constrained entity, or
// removing the last constraint by hand) and save again.
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).constraints.clear();
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.constraints
.clear();
scene.materialize_sketch_constraints_for_save();
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "the stale XRecord from the earlier non-empty save must be removed, not left behind");
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
"the stale XRecord from the earlier non-empty save must be removed, not left behind"
);
// And the load path must agree: nothing resurrects on reload.
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
assert!(scene.sketch_constraint_set(SketchScope::ModelSpace).is_none(), "no constraints should come back after the set was cleared and re-saved");
assert!(
scene
.sketch_constraint_set(SketchScope::ModelSpace)
.is_none(),
"no constraints should come back after the set was cleared and re-saved"
);
}
#[test]
fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap());
assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread");
bytes.extend(
bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap(),
);
assert!(
decode(&bytes).is_none(),
"a future/unknown format version must be rejected, not misread"
);
}
}
+714 -317
View File
File diff suppressed because it is too large. Load diff
+17 -89
View File
@@ -153,9 +153,8 @@ pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
}
let built = sysfont::with_face_data(family, |data, index| {
let mut face = ttf_parser::Face::parse(data, index).ok()?;
let face = ttf_parser::Face::parse(data, index).ok()?;
let gid = face.glyph_index(ch)?;
pin_default_variations(&mut face);
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
@@ -339,12 +338,11 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let script = crate::scene::text::web_font::script_of(ch)?;
let bytes = crate::scene::text::web_font::request(script)?;
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
let (mut face, gid) = (0..face_count).find_map(|face_index| {
let (face, gid) = (0..face_count).find_map(|face_index| {
let face = ttf_parser::Face::parse(&bytes, face_index).ok()?;
let gid = face.glyph_index(ch)?;
Some((face, gid))
})?;
pin_default_variations(&mut face);
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
@@ -361,46 +359,6 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
}))
}
/// A variable font (e.g. macOS's system font, San Francisco — cosmic-text's
/// own fallback search happily picks it for a codepoint no static font
/// nearby covers) needs every variation axis pinned before its outline is
/// readable: `ttf-parser` resolves a variable glyph's contours through its
/// `gvar` deltas relative to whatever coordinates are currently set on the
/// `Face`, and an unset `Face` has none — `outline_glyph` then silently
/// returns `None` (not an error) for such a glyph, which upstream code was
/// treating as "this glyph has no outline" (e.g. a space) rather than "this
/// extraction failed", producing a `Some(Glyph { strokes: vec![], .. })`
/// that renders nothing without ever reporting a problem. Setting each axis
/// to its own default value (`def_value`) reproduces the font's un-varied,
/// default instance — the same shape a non-variable static font would give.
fn pin_default_variations(face: &mut ttf_parser::Face) {
if !face.is_variable() {
return;
}
for axis in face.variation_axes() {
let _ = face.set_variation(axis.tag, axis.def_value);
}
}
/// Outlines `gid` from an already-parsed, variation-pinned `face` — the
/// shared last step both [`build_fallback`] candidates below use. `None`
/// (not just an empty glyph) whenever `outline_glyph` itself reports no
/// contours, so a caller can tell "this font can't actually render this
/// glyph" apart from "this glyph is legitimately blank" and keep looking —
/// see [`pin_default_variations`]'s doc comment for why a resolved glyph ID
/// doesn't guarantee an outline comes back.
fn outline_from_face(face: &ttf_parser::Face, gid: ttf_parser::GlyphId, k: f32) -> Option<Arc<Glyph>> {
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
face.outline_glyph(gid, &mut fl);
fl.flush();
if fl.contours.is_empty() {
return None;
}
let fill_tris = triangulate_contours(&fl.contours);
Some(Arc::new(Glyph { strokes: fl.contours, advance, fill_tris }))
}
#[cfg(not(target_arch = "wasm32"))]
fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
use cosmic_text::{Attrs, Buffer, Metrics, Shaping};
@@ -413,54 +371,26 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
let mut tried: Vec<fontdb::ID> = Vec::new();
for run in buf.layout_runs() {
for g in run.glyphs.iter() {
if g.glyph_id == 0 {
continue; // .notdef — this font doesn't really cover it
}
tried.push(g.font_id);
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue };
let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { continue };
pin_default_variations(&mut face);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let k = cap_scale(&face);
if let Some(glyph) = outline_from_face(&face, ttf_parser::GlyphId(g.glyph_id), k) {
return Some(glyph);
}
// cosmic-text resolved a real glyph here, but this font's outline
// couldn't actually be extracted — confirmed (design doc-worthy,
// not a guess) on this dev machine for macOS's system font
// (San Francisco): it's a variable TrueType font whose `gvar`
// outline path needs base `glyf` glyph data `ttf-parser` can't
// retrieve for at least some of its accented Latin glyphs, even
// after every variation axis is pinned to its default. Rather
// than silently rendering nothing, fall through to a direct
// database scan below for any other installed font that also
// covers `ch` and can actually be outlined.
}
}
// Second-chance search: cosmic-text's own fallback chose one font (and
// it failed to outline above); look for a different installed font that
// also covers `ch`, skipping whatever was already tried. Bounded by the
// database size, not attempted per keystroke — `fallback_glyph`'s own
// cache means this only runs once per distinct character ever asked
// for.
let candidates: Vec<(fontdb::ID, u32)> = fs
.db()
.faces()
.filter(|f| !tried.contains(&f.id))
.map(|f| (f.id, f.index))
.collect();
for (id, index) in candidates {
let Some(font) = fs.get_font(id, cosmic_text::fontdb::Weight::NORMAL) else { continue };
let Ok(mut face) = ttf_parser::Face::parse(font.data(), index) else { continue };
let Some(gid) = face.glyph_index(ch) else { continue };
pin_default_variations(&mut face);
let k = cap_scale(&face);
if let Some(glyph) = outline_from_face(&face, gid, k) {
return Some(glyph);
let gid = ttf_parser::GlyphId(g.glyph_id);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
face.outline_glyph(gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
return Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
fill_tris,
}));
}
}
None
@@ -526,10 +456,9 @@ fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else {
continue;
};
let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else {
let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else {
continue;
};
pin_default_variations(&mut face);
let scale = (SHAPE_FS / face.units_per_em() as f32) * px_to_9;
let pen_x = (glyph.x + SHAPE_FS * glyph.x_offset) * px_to_9;
let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9;
@@ -596,10 +525,9 @@ fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
let Some(font) = fs.get_font(g.font_id, g.font_weight) else {
continue;
};
let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else {
let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else {
continue;
};
pin_default_variations(&mut face);
let upem_g = face.units_per_em() as f32;
// Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units.
let scale_g = (SHAPE_FS / upem_g) * px_to_9;
+4 -12
View File
@@ -728,12 +728,7 @@ struct SelectionCanvas {
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions,
/// One glyph per persistent sketch constraint in the current scope
/// (design doc §6.3/§7): screen anchor + label text (its symbol, plus a
/// dimensional kind's driving value — `sketch_constraints::glyph_label`)
/// + whether `solve_scope`'s `classify_redundant` call (stage 10/11)
/// flagged this constraint as redundant/conflicting — the design doc
/// §6.3's recommended "glyph color, not wire recoloring" state cue.
/// Constraint glyph anchor, label, and conflict state for the current scope.
constraint_glyphs: Vec<(Point, String, bool)>,
}
@@ -1630,18 +1625,15 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke);
}
// Persistent sketch-constraint glyphs (design doc §6.3/§7): a small
// pill at each constraint's anchor point, its symbol (plus value for
// a dimensional kind) as text — visual-only for now, no hit-testing.
// Constraint glyphs are visual-only and have no hit testing.
if !self.constraint_glyphs.is_empty() {
const GLYPH_SIZE: f32 = 11.0;
const GLYPH_PAD_X: f32 = 5.0;
const GLYPH_PAD_Y: f32 = 2.0;
let normal_bg = theme.palette().primary.base.color;
let normal_fg = theme.palette().primary.base.text;
// Design doc §6.3's recommended color cue: a constraint
// `solve_scope` flagged as redundant/conflicting (stage 10/11's
// `classify_redundant`) gets the danger palette instead of the
// A redundant or conflicting constraint gets the danger palette
// instead of the
// ordinary primary one — same information a resolver panel
// would show, surfaced right on the geometry.
let conflict_bg = theme.palette().danger.base.color;
+1 -2
View File
@@ -1397,8 +1397,7 @@ impl PropertiesPanel {
}
/// One named-parameter row: editable name + formula, with the live-
/// resolved value (or error) shown alongside — the "AutoCAD style" the
/// Parameters section is modeled on, embedded in the panel instead of
/// resolved value (or error) shown alongside, embedded in the panel instead of
/// the old separate modal. Each field commits on submit (Enter / losing
/// focus), not per keystroke — `ParameterTable::set` validates
/// immediately and would otherwise reject a formula mid-type (the same
+3 -3
View File
@@ -133,9 +133,9 @@ pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> El
}
}
pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>, font_size: f32) -> Element<'a, Message> {
pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Element<'a, Message> {
if title != "Draw" || TOOLS.is_empty() {
return container(text(t!(title)).size(font_size).style(muted_text_style))
return container(text(t!(title)).size(9).style(muted_text_style))
.padding([1, 4])
.into();
}
@@ -148,7 +148,7 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>, font_s
PosReport::new(
PANEL_ID,
button(
row![text(t!(title)).size(font_size), arrow]
row![text(t!(title)).size(9), arrow]
.spacing(4)
.align_y(iced::Center),
)
+6 -51
View File
@@ -96,12 +96,6 @@ pub struct Ribbon {
tool_bar_h: Arc<AtomicU32>,
/// User-chosen panel density (persisted). `Auto` sizes by window width.
collapse_mode: CollapseMode,
/// Font size (px) of the caption under a large button's icon (persisted;
/// Options → Display → "Ribbon label size").
label_font_size: i32,
/// Font size (px) of a panel's own title, shown beneath its tools
/// (persisted; Options → Display → "Ribbon section title size").
group_title_font_size: i32,
/// Set by `CollapsePanels` when the tool row is in its tight state; the mode
/// selector hides itself then to give the cramped tab row its space back.
collapse_tight: Arc<AtomicBool>,
@@ -198,8 +192,6 @@ impl Ribbon {
tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())),
collapse_mode: CollapseMode::default(),
collapse_tight: Arc::new(AtomicBool::new(false)),
label_font_size: 10,
group_title_font_size: 9,
}
}
@@ -214,28 +206,6 @@ impl Ribbon {
self.collapse_mode
}
/// Change the icon-label font size. Persistence is handled by the caller
/// via the consolidated app config (`save_config`).
pub fn set_label_font_size(&mut self, size: i32) {
self.label_font_size = size.clamp(8, 16);
}
/// The current icon-label font size (for saving into the app config).
pub fn label_font_size(&self) -> i32 {
self.label_font_size
}
/// Change the panel-title font size. Persistence is handled by the
/// caller via the consolidated app config (`save_config`).
pub fn set_group_title_font_size(&mut self, size: i32) {
self.group_title_font_size = size.clamp(7, 14);
}
/// The current panel-title font size (for saving into the app config).
pub fn group_title_font_size(&self) -> i32 {
self.group_title_font_size
}
/// Current tab-bar height as last measured by the `WrapBar` widget.
fn tab_bar_height(&self) -> f32 {
f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed))
@@ -633,8 +603,6 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
self.group_title_font_size,
),
render_group(
true,
@@ -649,8 +617,6 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
self.group_title_font_size,
),
collapse_button(
g,
@@ -665,7 +631,6 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
false,
),
collapse_button(
@@ -681,14 +646,13 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
true,
),
],
}
})
.collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), widgets::tool_bar_h(self.group_title_font_size as f32))
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.report_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone())
.mode(self.collapse_mode)
@@ -1339,7 +1303,7 @@ impl Ribbon {
}
}
/// Render a single ribbon panel (tools + group label), `tool_bar_h(group_title_font_size)` tall.
/// Render a single ribbon panel (tools + group label), fixed `TOOL_BAR_H` tall.
/// When `compact`, large tools/dropdowns are drawn as small icon columns.
#[allow(clippy::too_many_arguments)]
fn render_group<'a>(
@@ -1355,8 +1319,6 @@ fn render_group<'a>(
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
label_font_size: i32,
group_title_font_size: i32,
) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = Vec::new();
let mut small_buf: Vec<Element<Message>> = Vec::new();
@@ -1373,7 +1335,6 @@ fn render_group<'a>(
active_lineweight,
style_ctx,
compact,
label_font_size: label_font_size as f32,
};
for item in &group.tools {
@@ -1411,12 +1372,12 @@ fn render_group<'a>(
draw_panel::group_anchor(group.title, column![
tools_el,
draw_panel::group_title(group.title, open_dd, group_title_font_size as f32),
draw_panel::group_title(group.title, open_dd),
]
.align_x(iced::Center)
.spacing(0)
.padding([3u16, 4])
.height(Length::Fixed(widgets::tool_bar_h(group_title_font_size as f32)))
.height(Length::Fixed(TOOL_BAR_H))
.into())
}
@@ -1479,7 +1440,6 @@ fn collapse_button<'a>(
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
label_font_size: i32,
// When set, this is the TIGHT step (reached once even the all-collapsed row
// overflows): the whole panel becomes a single non-running button — the first
// tool's icon + a ▾ that opens the tools flyout. Nothing runs directly here.
@@ -1520,9 +1480,7 @@ fn collapse_button<'a>(
.style(button::subtle)
.width(Fill)
.padding([3, 5]);
// This tight-mode panel *title* (not an icon caption) keeps its own
// fixed size regardless of the icon-label font-size setting.
return automatic_large_button(localized_title, content.into(), 9.0);
return automatic_large_button(localized_title, content.into());
}
// Collapsed (not yet tight): a large representative-tool face — a live button
@@ -1545,7 +1503,6 @@ fn collapse_button<'a>(
active_lineweight,
style_ctx,
compact: false,
label_font_size: label_font_size as f32,
},
)
}
@@ -1564,7 +1521,6 @@ fn collapse_button<'a>(
active_lineweight,
style_ctx,
compact: false,
label_font_size: label_font_size as f32,
},
)
}
@@ -1589,8 +1545,7 @@ fn collapse_button<'a>(
.style(button::subtle)
.width(Fill)
.padding([1, 4]);
// Same fixed-size opener title as the tight-mode branch above.
let opener = automatic_large_button(localized_title, opener.into(), 9.0);
let opener = automatic_large_button(localized_title, opener.into());
// The large face fills a fixed slot so a collapsed panel is shorter than a full
// 3-row panel, letting `CollapsePanels` shrink the ribbon row.
+28 -92
View File
@@ -56,40 +56,15 @@ pub(super) const LARGE_W: f32 = ROW_H * 2.2;
const LARGE_LABEL_HPAD: f32 = 8.0;
/// Large ribbon labels use at most two lines before their button grows.
const LARGE_LABEL_LINES: f32 = 2.0;
const LARGE_LABEL_SIZE: f32 = 10.0;
/// Width of a 1-row (small) button.
pub(super) const SMALL_W: f32 = ROW_H;
/// Width of the ▾ strip on a small dropdown.
pub(super) const ARROW_W: f32 = ROW_H * 0.4;
/// Height of the ▾ strip at the bottom of a large dropdown.
pub(super) const LARGE_ARR: f32 = ROW_H * 0.55;
/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label,
/// at the default 9px group-title font size. Use [`tool_bar_h`] once the live
/// `group_title_font_size` setting is in scope — this constant is only the
/// floor `tool_bar_h` never drops below, kept as its own name for the few
/// call sites that predate the size becoming user-configurable.
/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label.
pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0;
/// The group-title label's rendered height (`group_title_font_size == 9`)
/// baked into [`TOOL_BAR_H`]'s fixed 18px allowance (6px padding + this).
const GROUP_TITLE_LABEL_H_AT_DEFAULT: f32 = 12.0;
const GROUP_TITLE_FONT_SIZE_DEFAULT: f32 = 9.0;
/// Ribbon tool-area height for a given group-title font size.
///
/// The panel column that holds a group's icons above its title label is a
/// fixed height (`TOOL_BAR_H`, sized for the *default* title font). Raising
/// the "Section title size" option (`RibbonConfig::group_title_font_size`,
/// `src/app/config.rs`) grows the title text without growing that fixed
/// column, so the icon row above it — which fills whatever space is left —
/// gets squeezed and its icons visibly shrink. Scaling the label's share of
/// the height with the font size (proportionally to the default 12px
/// allowance at the default 9px size) keeps the icon row's share constant
/// instead. Clamped to never go *below* `TOOL_BAR_H`, so the default and
/// smaller settings render pixel-identically to before this existed.
pub(super) fn tool_bar_h(group_title_font_size: f32) -> f32 {
let label_h = GROUP_TITLE_LABEL_H_AT_DEFAULT * (group_title_font_size / GROUP_TITLE_FONT_SIZE_DEFAULT);
(3.0 * ROW_H + 6.0 + label_h).max(TOOL_BAR_H)
}
/// Height of a collapsed panel button's large representative face (big icon +
/// its label). A collapsed button is this face plus the title opener, so it is
/// shorter than a full 3-row panel — the ribbon height follows it down.
@@ -111,11 +86,8 @@ const COMBO_PANEL_PAD: Padding = Padding {
thread_local! {
/// Ribbon layout runs on every pointer-driven view update. Cache the font-
/// measured width per (translated label, font size) so the automatic
/// sizing stays cheap — keyed on both since the same label text is
/// measured at more than one size (a full-density button's own caption
/// vs. a collapsed panel's smaller opener title).
static LARGE_WIDTH_CACHE: RefCell<HashMap<(String, u32), f32>> =
/// measured width per translated label so the automatic sizing stays cheap.
static LARGE_WIDTH_CACHE: RefCell<HashMap<String, f32>> =
RefCell::new(HashMap::default());
}
@@ -124,7 +96,6 @@ fn ribbon_label_bounds(
label: &str,
width: f32,
wrapping: advanced_text::Wrapping,
label_size: f32,
) -> Size {
use advanced_text::{Paragraph as _, Renderer as _};
@@ -132,7 +103,7 @@ fn ribbon_label_bounds(
advanced_text::Text {
content: label,
bounds: Size::new(width, f32::INFINITY),
size: Pixels(label_size),
size: Pixels(LARGE_LABEL_SIZE),
line_height: advanced_text::LineHeight::default(),
font: renderer.default_font(),
align_x: advanced_text::Alignment::Center,
@@ -146,41 +117,27 @@ fn ribbon_label_bounds(
paragraph.min_bounds()
}
/// Widest a large button will grow to keep its label on one line before
/// falling back to two-line wrapping instead. A label pre-broken with an
/// explicit `\n` (long labels get hand-wrapped at the source, e.g. "Viewport
/// Configuration") measures its own natural per-line width regardless, so
/// this only ever engages for a plain label with no manual break.
const LARGE_LABEL_MAX_SINGLE_LINE_W: f32 = LARGE_W * 2.0;
/// Measure the translated label at the normal button width. Prefers widening
/// the button to keep the label on one line — most labels are short enough
/// for this to be a small, one-time bump — falling back to two-line wrapping
/// only once even a single line would need excessive width. The binary
/// search uses the renderer's real font metrics, so locale and UI scale
/// Measure the translated label at the normal button width. It wraps first;
/// only labels that would need more than two lines widen their button. The
/// binary search uses the renderer's real font metrics, so locale and UI scale
/// changes do not rely on character-count estimates.
fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 {
fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
let base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0);
let line_height = advanced_text::LineHeight::default()
.to_absolute(Pixels(label_size))
.to_absolute(Pixels(LARGE_LABEL_SIZE))
.0;
// `Wrapping::Word` (not `WordOrGlyph`): a label that doesn't fit at this
// width wraps at the next word boundary instead of splitting mid-word.
// The width check catches the rare case Word-wrapping can't help — a
// single unbreakable "word" wider than the box — so that case correctly
// falls through to the wider stage below instead of reporting a false fit.
let fits_one_line = |width: f32| {
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
let fits = |width: f32| {
let bounds = ribbon_label_bounds(
renderer,
label,
width,
advanced_text::Wrapping::Word,
label_size,
);
bounds.width <= width + 0.5 && bounds.height <= line_height + 0.5
bounds.width <= width + 0.5 && bounds.height <= max_label_height
};
if fits_one_line(base_inner) {
if fits(base_inner) {
return LARGE_W;
}
@@ -189,33 +146,18 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32)
label,
f32::INFINITY,
advanced_text::Wrapping::None,
label_size,
)
.width
.max(base_inner);
if natural <= LARGE_LABEL_MAX_SINGLE_LINE_W {
if !fits(natural) {
return (natural + LARGE_LABEL_HPAD).ceil();
}
// Long enough that a single line would make the button unreasonably
// wide — wrap to two lines instead, at the narrowest width (up to the
// same cap) that fits.
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
let fits_two_lines = |width: f32| {
let bounds = ribbon_label_bounds(
renderer,
label,
width,
advanced_text::Wrapping::Word,
label_size,
);
bounds.width <= width + 0.5 && bounds.height <= max_label_height
};
let mut low = base_inner;
let mut high = LARGE_LABEL_MAX_SINGLE_LINE_W;
let mut high = natural;
for _ in 0..10 {
let mid = (low + high) * 0.5;
if fits_two_lines(mid) {
if fits(mid) {
high = mid;
} else {
low = mid;
@@ -224,15 +166,14 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32)
(high + LARGE_LABEL_HPAD).ceil().max(LARGE_W)
}
fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 {
let key = (label.to_string(), label_size.to_bits());
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(&key).copied()) {
fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) {
return width;
}
let width = measure_large_width(renderer, label, label_size);
let width = measure_large_width(renderer, label);
LARGE_WIDTH_CACHE.with(|cache| {
cache.borrow_mut().insert(key, width);
cache.borrow_mut().insert(label.to_string(), width);
});
width
}
@@ -240,7 +181,6 @@ fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32
struct AutomaticLargeWidth<'a> {
label: String,
content: Element<'a, Message>,
label_size: f32,
}
impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
@@ -266,7 +206,7 @@ impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
renderer: &iced::Renderer,
limits: &layout::Limits,
) -> layout::Node {
let width = automatic_large_width(renderer, &self.label, self.label_size);
let width = automatic_large_width(renderer, &self.label);
self.content.as_widget_mut().layout(
tree,
renderer,
@@ -346,9 +286,8 @@ impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
pub(super) fn automatic_large_button<'a>(
label: String,
content: Element<'a, Message>,
label_size: f32,
) -> Element<'a, Message> {
Element::new(AutomaticLargeWidth { label, content, label_size })
Element::new(AutomaticLargeWidth { label, content })
}
// ── Tab-bar constants ──────────────────────────────────────────────────────
@@ -693,9 +632,6 @@ pub(super) struct RenderCtx<'a> {
pub style_ctx: &'a StyleContext<'a>,
/// When compact, the Properties panel's Match button shrinks to a small icon.
pub compact: bool,
/// Font size (px) of the caption under a large button's icon (Options →
/// Display → "Ribbon label size").
pub label_font_size: f32,
}
/// A large dropdown button: the current icon on top, its ▾ directly beneath the
@@ -745,7 +681,7 @@ pub(super) fn render_large_dropdown<'a>(
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(ctx.label_font_size)
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
@@ -798,7 +734,7 @@ pub(super) fn render_large_dropdown<'a>(
.width(Fill)
.height(Fill);
PosReport::new(id, automatic_large_button(label, content.into(), ctx.label_font_size))
PosReport::new(id, automatic_large_button(label, content.into()))
.into()
}
@@ -878,7 +814,7 @@ pub(super) fn render_large<'a>(
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(ctx.label_font_size)
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
@@ -899,7 +835,7 @@ pub(super) fn render_large<'a>(
left: 4.0,
});
tooltip(
automatic_large_button(label, btn.into(), ctx.label_font_size),
automatic_large_button(label, btn.into()),
make_tip(tip_text),
TipPos::Right,
)
+6 -13
View File
@@ -125,13 +125,13 @@ impl StatusBar {
// Which pills the user has chosen to show on the bar.
config: &'a StatusBarConfig,
menu_data: StatusMenuData<'a>,
// Remaining degrees of freedom for the current sketch scope's
// persistent constraints (design doc §6.3) — `None` when the scope
// Remaining degrees of freedom for the current sketch scope.
// `None` when the scope
// has no `SketchConstraintSet` yet (nothing constrained), so the
// badge stays invisible until it's actually relevant.
sketch_dof: Option<usize>,
// Number of redundant/conflicting constraints `solve_scope` flagged
// in the current sketch scope (design doc §6.4, stage 11) — 0 hides
// Number of redundant/conflicting constraints in the current scope.
// Zero hides
// the pill entirely, so an ordinarily/fully-constrained drawing sees
// no new clutter.
sketch_conflicts: usize,
@@ -274,11 +274,7 @@ impl StatusBar {
.into(),
);
}
// Design doc §6.4 (stage 11): a bounded v1 of the guided conflict
// resolver — click removes one flagged constraint at a time. See
// `OpenCADStudio::resolve_one_sketch_conflict`'s doc comment for the
// full scope-down rationale versus the design doc's named-candidate,
// cyclable-preview panel.
// Each click removes one flagged constraint.
if sketch_conflicts > 0 {
pills.push(
tip(
@@ -1082,10 +1078,7 @@ fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
.into()
}
/// Like [`status_pill`], but with the theme's `success` accent — design doc
/// §6.3's "simple color-coded constraint state" cue: used for the DOF badge
/// when a sketch scope has reached zero remaining degrees of freedom, so
/// "fully constrained" is visible at a glance without opening anything.
/// A success-colored status pill used when no degrees of freedom remain.
fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
container(text(label.into()).size(12))
.style(|theme: &Theme| {
+104 -39
View File
@@ -1,6 +1,4 @@
//! 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)`,
//! Named-parameter table editor using buffered `(name, formula)` rows.
//! 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
@@ -25,9 +23,7 @@ use iced::widget::tooltip::Position as TipPos;
use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space};
use iced::{Background, Element, Length, Theme};
/// Short "Kind handle" label for one referenced entity — the same handle
/// display (`0x…`) AutoCAD/this app's own conventions use, since entities
/// have no user-facing name to show instead.
/// Short "Kind handle" label for an entity without a user-facing name.
fn entity_label(scene: &Scene, handle: Handle) -> String {
match scene.document.get_entity(handle) {
Some(EntityType::Line(_)) => format!("Line {handle}"),
@@ -46,7 +42,12 @@ fn usage_lines(scene: &Scene, name: &str) -> Vec<String> {
.parameter_usage(name)
.iter()
.map(|u| {
let entities = u.entities.iter().map(|h| entity_label(scene, *h)).collect::<Vec<_>>().join(", ");
let entities = u
.entities
.iter()
.map(|h| entity_label(scene, *h))
.collect::<Vec<_>>()
.join(", ");
format!("{:?}: {entities}", u.kind)
})
.collect()
@@ -74,14 +75,20 @@ fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> {
None => counts.push((kind, 1)),
}
}
let summary = counts.iter().map(|(k, n)| format!("{k} ×{n}")).collect::<Vec<_>>().join(", ");
let summary = counts
.iter()
.map(|(k, n)| format!("{k} ×{n}"))
.collect::<Vec<_>>()
.join(", ");
let mut body = column![].spacing(2);
for line in &lines {
body = body.push(text(line.clone()).size(11));
}
tooltip(
text(summary).size(11).style(muted_style),
container(body).style(container::bordered_box).padding([4, 8]),
container(body)
.style(container::bordered_box)
.padding([4, 8]),
TipPos::Top,
)
.into()
@@ -107,11 +114,15 @@ pub struct ParamEditorRow {
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)) }
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) }
iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
}
}
/// Every buffer index sharing a (trimmed) name with at least one other
@@ -130,7 +141,11 @@ pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::
by_name.entry(name).or_default().push(idx);
}
}
by_name.into_values().filter(|idxs| idxs.len() > 1).flatten().collect()
by_name
.into_values()
.filter(|idxs| idxs.len() > 1)
.flatten()
.collect()
}
/// Builds a scratch table from every row's *current* text and resolves each
@@ -184,7 +199,11 @@ fn preview(rows: &[ParamEditorRow]) -> Vec<Option<Result<f64, String>>> {
/// unapplied edits to a row's name in the buffer don't retroactively
/// relabel what's shown, same as the rest of this editor only takes effect
/// on Apply.
pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> {
pub fn view_window<'a>(
rows: &'a [ParamEditorRow],
scene: &'a Scene,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, 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."
@@ -192,17 +211,25 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
.size(11)
.style(muted_style);
let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 };
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 = 100.0;
const USED_BY_WIDTH: f32 = 160.0;
let head = container(
row![
container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)),
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)),
container(text(t!("Used by")).size(11).style(muted_style)).width(Length::Fixed(USED_BY_WIDTH)),
container(text(t!("Value")).size(11).style(muted_style))
.width(Length::Fixed(VALUE_WIDTH)),
container(text(t!("Used by")).size(11).style(muted_style))
.width(Length::Fixed(USED_BY_WIDTH)),
Space::new().width(Length::Fixed(30.0)),
]
.spacing(8),
@@ -213,25 +240,38 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
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 })
.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 })
.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(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 used_by_cell = used_by_cell(scene, row.name.trim());
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);
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,
@@ -272,7 +312,10 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
.padding(12)
.width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() })
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
..Default::default()
})
.into()
}
@@ -281,12 +324,18 @@ mod tests {
use super::*;
fn row(name: &str, formula: &str) -> ParamEditorRow {
ParamEditorRow { name: name.to_string(), formula: formula.to_string() }
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 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)));
@@ -309,7 +358,10 @@ mod tests {
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[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")));
}
@@ -332,27 +384,40 @@ mod tests {
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");
assert_eq!(
results[2],
Some(Ok(3.0)),
"an unrelated row's name must not be affected"
);
}
#[test]
fn usage_lines_names_the_constraint_kind_and_its_entities() {
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
let mut scene = Scene::new();
let line = scene.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
Some(crate::scene::named_parameters::DrivingValue::Named("gap".to_string())),
);
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
),
));
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
Some(crate::scene::named_parameters::DrivingValue::Named(
"gap".to_string(),
)),
);
let lines = usage_lines(&scene, "gap");
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}");
assert!(lines[0].contains(&format!("{line}")), "should name the actual entity handle, got {lines:?}");
assert!(
lines[0].contains(&format!("{line}")),
"should name the actual entity handle, got {lines:?}"
);
}
#[test]
+3 -76
View File
@@ -163,9 +163,6 @@ 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,
model_bg_input: &'a str,
paper_bg_input: &'a str,
@@ -359,16 +356,14 @@ pub fn view_window<'a>(
iced::widget::checkbox(write_dwg_native_constraints)
.on_toggle(Message::WriteDwgNativeConstraintsChanged)
.size(15),
text(crate::t!("Write AutoCAD-compatible constraint objects on save")).size(12),
text(crate::t!("Write native constraint objects on save")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Saves sketch constraints as AutoCAD/BricsCAD's own native objects too, alongside \
this app's own format, so other CAD software recognizes them. Off by default — \
adds some file size on every save. A drawing that already has these objects keeps \
them up to date regardless of this setting."
"Saves sketch constraints as native drawing objects alongside this app's own format. \
Off by default because it adds file size. Existing native objects stay synchronized."
))
.size(11)
.width(sizing.width),
@@ -596,24 +591,6 @@ 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![
@@ -871,56 +848,6 @@ pub fn view_window<'a>(
))
.size(11)
.width(sizing.width),
)
.push(Space::new().height(24))
.push(text(crate::t!("Ribbon")).size(15))
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Icon label size")).size(12).width(140),
slider(
8..=16,
ribbon_label_font_size.clamp(8, 16),
Message::RibbonLabelFontSizeChanged,
)
.step(1)
.width(Fill),
text(format!("{}px", ribbon_label_font_size.clamp(8, 16)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Font size of the caption under each ribbon button's icon."))
.size(11)
.width(sizing.width),
)
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Section title size")).size(12).width(140),
slider(
7..=14,
ribbon_group_title_font_size.clamp(7, 14),
Message::RibbonGroupTitleFontSizeChanged,
)
.step(1)
.width(Fill),
text(format!("{}px", ribbon_group_title_font_size.clamp(7, 14)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Font size of each panel's own title (Draw, Modify, Layers, …)."))
.size(11)
.width(sizing.width),
);
let display_element = display.spacing(0).width(sizing.width);