forked from 1b-agent/opencadstudio-upstream
Harden parametric sketch constraints
This commit is contained in:
1 parent
41c07e9858
commit
9b58ca8a93
101 files changed
+3128
-13767
No files matched your search
@@ -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
@@ -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(¤t.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(¤t.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(¤t.name);
|
||||
let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.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 == ¤t.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 == ¤t.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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
});
|
||||
@@ -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"]
|
||||
});
|
||||
@@ -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};
|
||||
@@ -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"]
|
||||
});
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+471
-263
File diff suppressed because it is too large.
Load diff
+24
-28
@@ -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
@@ -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
@@ -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(¶m.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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+17
-89
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user