forked from 1b-agent/opencadstudio-upstream
Add persistent sketch-constraint system (ocs_gcs port) and named-parameters handoff docs
Ports FreeCAD's planegcs 2D geometric constraint solver to Rust as a new crates/ocs_gcs workspace member, and wires it into the document/scene model as a persistent SketchConstraintSet: Coincident, Horizontal/Vertical, Parallel/Perpendicular, Equal, Tangent, Distance, Angle and Radius constraints that re-solve live (including mid-drag, not just on release), render glyphs and a DOF badge, flag conflicts with a one-click resolver pill, and round-trip through save/load via an XRecord blob. Also includes docs/command_reference.md (a statically generated reference of every registered command and its prompt sequence) and docs/named_parameters_design.md + docs/backlog.md, which hand off the next queued feature (a named-parameter/expression table) and consolidate every deliberate scope-down and open gap found along the way, so a fresh session has a complete starting point. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
a39c7b8f71
commit
63a74b19a9
63 files changed
+12193
-49
No files matched your search
@@ -268,6 +268,7 @@ impl OpenCADStudio {
|
||||
"new" => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.document = acadrust::CadDocument::new();
|
||||
self.tabs[i].scene.sketch_constraints.clear();
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.tabs[i].current_path = None;
|
||||
// The headless session starts on the welcome (Start) tab, which
|
||||
@@ -293,6 +294,7 @@ impl OpenCADStudio {
|
||||
Ok(doc) => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.document = doc;
|
||||
self.tabs[i].scene.load_sketch_constraints_from_document();
|
||||
self.tabs[i].scene.deselect_all();
|
||||
crate::app::style_ops::ensure_standard_styles(
|
||||
&mut self.tabs[i].scene.document,
|
||||
|
||||
@@ -849,6 +849,105 @@ 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.
|
||||
fn infer_and_add_coincident_constraints(&mut self, i: usize, new_handle: Handle, entity: &acadrust::EntityType) {
|
||||
if self.shift_down {
|
||||
return;
|
||||
}
|
||||
let scope = self.tabs[i].current_sketch_scope();
|
||||
let pairs = crate::scene::sketch_constraints::infer_coincident_refs(
|
||||
&self.tabs[i].scene.document,
|
||||
scope,
|
||||
new_handle,
|
||||
entity,
|
||||
);
|
||||
if pairs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let constraints_before = self.tabs[i].scene.sketch_constraint_set(scope).cloned();
|
||||
let mut touched: Vec<Handle> = Vec::new();
|
||||
for (new_ref, existing_ref) in &pairs {
|
||||
touched.push(new_ref.entity);
|
||||
touched.push(existing_ref.entity);
|
||||
}
|
||||
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);
|
||||
for (new_ref, existing_ref) in pairs {
|
||||
self.tabs[i].scene.sketch_constraint_set_mut(scope).add(
|
||||
crate::scene::sketch_constraints::ConstraintKind::Coincident,
|
||||
vec![new_ref, existing_ref],
|
||||
None,
|
||||
);
|
||||
}
|
||||
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.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.
|
||||
pub(super) fn resolve_one_sketch_conflict(&mut self) {
|
||||
let i = self.active_tab;
|
||||
let scope = self.tabs[i].current_sketch_scope();
|
||||
let Some(set) = self.tabs[i].scene.sketch_constraint_set(scope) else { return };
|
||||
let Some(&(id, _kind)) = set.conflicts.first() else { return };
|
||||
let Some(constraint) = set.get(id) else { return };
|
||||
let touched: Vec<Handle> = constraint.refs.iter().map(|r| r.entity).collect();
|
||||
let label = "Remove conflicting constraint";
|
||||
|
||||
let constraints_before = Some(set.clone());
|
||||
let pending = self.begin_undo(i, label, touched.len(), true);
|
||||
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();
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
if let Some(pd) = pending {
|
||||
self.commit_undo_delta(i, pd);
|
||||
}
|
||||
self.push_sketch_constraints_history(i, label, scope, constraints_before);
|
||||
}
|
||||
|
||||
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let preserve_commit_layer = self.tabs[i]
|
||||
@@ -973,6 +1072,20 @@ impl OpenCADStudio {
|
||||
if let Some(pd) = pending {
|
||||
self.commit_undo_delta(i, pd);
|
||||
}
|
||||
// Stage 8/§6.2: live inference, scoped to `LINE` (the design
|
||||
// doc's own example) rather than every `CommitEntity` source —
|
||||
// FILLET/OFFSET/MIRROR/etc. also route Lines through this same
|
||||
// dispatch and shouldn't silently acquire constraints a user
|
||||
// never asked for.
|
||||
if let Some(handle) = committed {
|
||||
let is_line_command =
|
||||
self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.name() == "LINE");
|
||||
if is_line_command {
|
||||
if let Some(new_entity) = self.tabs[i].scene.document.get_entity(handle).cloned() {
|
||||
self.infer_and_add_coincident_constraints(i, handle, &new_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CmdResult::CommitEntities(entities) => {
|
||||
let locked_source = entities.iter().find_map(|entity| {
|
||||
@@ -2132,6 +2245,58 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.refresh_properties();
|
||||
}
|
||||
CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => {
|
||||
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 touched: Vec<Handle> = refs.iter().map(|r| r.entity).collect();
|
||||
let pending = self.begin_undo(i, label, touched.len(), true);
|
||||
self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param);
|
||||
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);
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.command_line.push_output("Constraint applied.");
|
||||
self.refresh_properties();
|
||||
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();
|
||||
let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z);
|
||||
let resolved_a =
|
||||
crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_a), None);
|
||||
let resolved_b = resolved_a.and_then(|ref_a| {
|
||||
crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_b), None)
|
||||
.filter(|ref_b| *ref_b != ref_a)
|
||||
});
|
||||
match (resolved_a, resolved_b) {
|
||||
(Some(ref_a), Some(ref_b)) => {
|
||||
return self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||
kind: crate::scene::sketch_constraints::ConstraintKind::Coincident,
|
||||
refs: vec![ref_a, ref_b],
|
||||
driving_param: None,
|
||||
label,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_error(
|
||||
"Coincident: pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.",
|
||||
);
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
CmdResult::ReassociateCenterMark { target, source, point } => {
|
||||
if self.reject_locked_edit(i, target) {
|
||||
return Task::none();
|
||||
@@ -4709,6 +4874,9 @@ 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`).
|
||||
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
|
||||
// introduced block-definition solids need building. The full rebuild
|
||||
@@ -5650,3 +5818,249 @@ fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sketch_constraint_undo_tests {
|
||||
use super::*;
|
||||
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
|
||||
|
||||
fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle {
|
||||
app.tabs[app.active_tab]
|
||||
.scene
|
||||
.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points(
|
||||
acadrust::types::Vector3::new(x1, y1, 0.0),
|
||||
acadrust::types::Vector3::new(x2, y2, 0.0),
|
||||
)))
|
||||
}
|
||||
|
||||
fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 {
|
||||
match app.tabs[app.active_tab].scene.document.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y).atan2(l.end.x - l.start.x).to_degrees(),
|
||||
other => panic!("expected a Line, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn adding_a_constraint_is_undoable_and_redoable_in_two_steps() {
|
||||
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);
|
||||
let original_angle = line_angle_deg(&app, line);
|
||||
assert!((original_angle - 16.699244).abs() < 1e-3);
|
||||
|
||||
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::Horizontal,
|
||||
refs: vec![SketchRef::whole(line)],
|
||||
driving_param: None,
|
||||
label: "Horizontal constraint",
|
||||
});
|
||||
assert!(line_angle_deg(&app, line).abs() < 1e-6, "constraint should have leveled the line");
|
||||
assert_eq!(
|
||||
app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(),
|
||||
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]
|
||||
.scene
|
||||
.sketch_constraint_set(SketchScope::ModelSpace)
|
||||
.map(|s| s.constraints.len())
|
||||
.unwrap_or(0),
|
||||
0,
|
||||
"first 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"
|
||||
);
|
||||
|
||||
app.redo_steps(1);
|
||||
assert_eq!(
|
||||
app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(),
|
||||
1,
|
||||
"second 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.
|
||||
#[test]
|
||||
fn drawing_a_line_onto_an_existing_endpoint_infers_a_coincident_constraint() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
|
||||
|
||||
let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points(
|
||||
acadrust::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point
|
||||
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
|
||||
));
|
||||
app.tabs[app.active_tab].active_cmd = Some(Box::new(crate::modules::draw::draw::line::LineCommand::new()));
|
||||
let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line));
|
||||
|
||||
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).expect("a constraint set should exist");
|
||||
assert_eq!(set.constraints.len(), 1, "an inferred Coincident constraint should have been added");
|
||||
let c = &set.constraints[0];
|
||||
assert_eq!(c.kind, ConstraintKind::Coincident);
|
||||
let touches_existing = c.refs.iter().any(|r| r.entity == existing);
|
||||
assert!(touches_existing, "the inferred constraint should reference the pre-existing line");
|
||||
}
|
||||
|
||||
/// Same setup as above, but with Shift held: inference must be
|
||||
/// suppressed entirely.
|
||||
#[test]
|
||||
fn shift_suppresses_the_coincident_inference() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let _existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
|
||||
|
||||
let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points(
|
||||
acadrust::types::Vector3::new(5.0, 0.0, 0.0),
|
||||
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
|
||||
));
|
||||
app.tabs[app.active_tab].active_cmd = Some(Box::new(crate::modules::draw::draw::line::LineCommand::new()));
|
||||
app.shift_down = true;
|
||||
let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line));
|
||||
|
||||
assert_eq!(
|
||||
app.tabs[app.active_tab]
|
||||
.scene
|
||||
.sketch_constraint_set(SketchScope::ModelSpace)
|
||||
.map(|s| s.constraints.len())
|
||||
.unwrap_or(0),
|
||||
0,
|
||||
"Shift should have suppressed the inference"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() {
|
||||
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);
|
||||
|
||||
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::Horizontal,
|
||||
refs: vec![SketchRef::whole(line)],
|
||||
driving_param: None,
|
||||
label: "Horizontal constraint",
|
||||
});
|
||||
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::Horizontal,
|
||||
refs: vec![SketchRef::whole(line)],
|
||||
driving_param: None,
|
||||
label: "Horizontal constraint",
|
||||
}); // exact duplicate — flagged redundant once bumped
|
||||
app.tabs[app.active_tab].scene.bump_entities(&[(line, crate::scene::ChangeKind::Modified)]);
|
||||
|
||||
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap();
|
||||
assert_eq!(set.constraints.len(), 2);
|
||||
assert_eq!(set.conflicts.len(), 1, "the duplicate should have been flagged");
|
||||
|
||||
app.resolve_one_sketch_conflict();
|
||||
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);
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn coincident_command_resolves_two_picked_points_into_a_constraint() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
|
||||
let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous
|
||||
|
||||
let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint {
|
||||
point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end
|
||||
point_b: glam::DVec3::new(20.0, 0.0, 0.0), // b's start
|
||||
label: "Coincident constraint",
|
||||
});
|
||||
|
||||
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).expect("a constraint set should exist");
|
||||
assert_eq!(set.constraints.len(), 1);
|
||||
let c = &set.constraints[0];
|
||||
assert_eq!(c.kind, ConstraintKind::Coincident);
|
||||
let entities: Vec<Handle> = c.refs.iter().map(|r| r.entity).collect();
|
||||
assert!(entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines");
|
||||
}
|
||||
|
||||
/// A pick that doesn't land near any real point (no object snap match)
|
||||
/// must not add a constraint — it should report an error and leave the
|
||||
/// scope untouched rather than silently constraining the wrong thing.
|
||||
#[test]
|
||||
fn coincident_command_reports_an_error_when_a_pick_misses() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
|
||||
|
||||
let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint {
|
||||
point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end — a real point
|
||||
point_b: glam::DVec3::new(500.0, 500.0, 0.0), // nowhere near anything
|
||||
label: "Coincident constraint",
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
app.tabs[app.active_tab]
|
||||
.scene
|
||||
.sketch_constraint_set(SketchScope::ModelSpace)
|
||||
.map(|s| s.constraints.len())
|
||||
.unwrap_or(0),
|
||||
0,
|
||||
"a missed pick must not add a constraint"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -956,6 +956,117 @@ 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) ──
|
||||
"HCONSTRAINT" | "VCONSTRAINT" => {
|
||||
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let sel = SelectObjectsCommand::new(cmd);
|
||||
self.command_line.push_info(&sel.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||
} else if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_output("Select exactly one line, then run this constraint again.");
|
||||
} else {
|
||||
use crate::command::CmdResult;
|
||||
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||
let (kind, label) = if cmd == "HCONSTRAINT" {
|
||||
(ConstraintKind::Horizontal, "Horizontal constraint")
|
||||
} else {
|
||||
(ConstraintKind::Vertical, "Vertical constraint")
|
||||
};
|
||||
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||
kind,
|
||||
refs: vec![SketchRef::whole(handles[0])],
|
||||
driving_param: None,
|
||||
label,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
"CCONSTRAINT" => {
|
||||
use crate::modules::draw::constrain::CoincidentConstraintCommand;
|
||||
let new_cmd = CoincidentConstraintCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" => {
|
||||
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let sel = SelectObjectsCommand::new(cmd);
|
||||
self.command_line.push_info(&sel.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||
} else if handles.len() != 2 {
|
||||
self.command_line.push_output(
|
||||
"Select exactly two entities (first = reference, second = the one that moves), then run this constraint again.",
|
||||
);
|
||||
} else {
|
||||
use crate::command::CmdResult;
|
||||
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||
let (kind, label) = match cmd {
|
||||
"PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"),
|
||||
"QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"),
|
||||
"TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"),
|
||||
_ => (ConstraintKind::Equal, "Equal constraint"),
|
||||
};
|
||||
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||
kind,
|
||||
refs: vec![SketchRef::whole(handles[0]), SketchRef::whole(handles[1])],
|
||||
driving_param: None,
|
||||
label,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
"DCONSTRAINT" => {
|
||||
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let sel = SelectObjectsCommand::new(cmd);
|
||||
self.command_line.push_info(&sel.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||
} else if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_output("Select exactly one line or circle, then run this constraint again.");
|
||||
} else {
|
||||
use crate::modules::draw::constrain::DistanceConstraintCommand;
|
||||
match DistanceConstraintCommand::new(&self.tabs[i].scene, handles[0]) {
|
||||
Some(new_cmd) => {
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
None => self.command_line.push_output("Select a line or a circle for a distance constraint."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"ACONSTRAINT" => {
|
||||
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let sel = SelectObjectsCommand::new(cmd);
|
||||
self.command_line.push_info(&sel.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||
} else if handles.len() != 2 {
|
||||
self.command_line.push_output(
|
||||
"Select exactly two lines (first = reference, second = the one that rotates), then run this constraint again.",
|
||||
);
|
||||
} else {
|
||||
use crate::modules::draw::constrain::AngleConstraintCommand;
|
||||
match AngleConstraintCommand::new(&self.tabs[i].scene, handles[0], handles[1]) {
|
||||
Some(new_cmd) => {
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
None => self.command_line.push_output("Select two lines for an angle constraint."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model commands (3D primitives) ─────────────────────────────
|
||||
"CYLINDER" => {
|
||||
use crate::modules::model::cylinder_cmd::CylinderCommand;
|
||||
|
||||
+16
-1
@@ -435,10 +435,25 @@ pub struct StartConfig {
|
||||
pub section: super::StartSection,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[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 {
|
||||
|
||||
@@ -240,6 +240,17 @@ impl DocumentTab {
|
||||
.and_then(|index| self.block_edits.get_mut(index))
|
||||
}
|
||||
|
||||
/// The [`crate::scene::sketch_constraints::SketchScope`] a new persistent
|
||||
/// constraint should attach to right now — whatever's actually being
|
||||
/// edited: the open block definition if a BEDIT session is active, model
|
||||
/// space otherwise.
|
||||
pub(super) fn current_sketch_scope(&self) -> crate::scene::sketch_constraints::SketchScope {
|
||||
match self.active_block_edit_session() {
|
||||
Some(session) => crate::scene::sketch_constraints::SketchScope::Block(session.br_handle),
|
||||
None => crate::scene::sketch_constraints::SketchScope::ModelSpace,
|
||||
}
|
||||
}
|
||||
|
||||
/// The active WCS↔UCS converter for this tab — identity when no UCS is set.
|
||||
/// Every consumer that needs UCS-relative coordinates goes through this.
|
||||
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
|
||||
@@ -646,6 +657,7 @@ impl DocumentTab {
|
||||
pub(super) enum HistorySnapshot {
|
||||
Delta(DeltaSnapshot),
|
||||
ObjectVisibility(ObjectVisibilitySnapshot),
|
||||
SketchConstraints(SketchConstraintsSnapshot),
|
||||
}
|
||||
|
||||
impl HistorySnapshot {
|
||||
@@ -653,6 +665,7 @@ impl HistorySnapshot {
|
||||
match self {
|
||||
HistorySnapshot::Delta(d) => &d.label,
|
||||
HistorySnapshot::ObjectVisibility(v) => &v.label,
|
||||
HistorySnapshot::SketchConstraints(s) => &s.label,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,10 +712,40 @@ 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)]
|
||||
|
||||
+50
-2
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
document::{
|
||||
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
|
||||
PendingHistorySnapshot, StructureSnapshot, TableEntryDelta,
|
||||
PendingHistorySnapshot, SketchConstraintsSnapshot, StructureSnapshot, TableEntryDelta,
|
||||
},
|
||||
OpenCADStudio,
|
||||
};
|
||||
use crate::scene::ObjectIsolationState;
|
||||
use crate::scene::{ChangeKind, ObjectIsolationState};
|
||||
use acadrust::{EntityType, Handle};
|
||||
use rustc_hash::{FxHashMap, FxHashSet as HashSet};
|
||||
use std::sync::Arc;
|
||||
@@ -184,6 +184,40 @@ 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,
|
||||
@@ -1102,6 +1136,13 @@ 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);
|
||||
@@ -1157,6 +1198,13 @@ 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);
|
||||
|
||||
+15
-3
@@ -1939,6 +1939,10 @@ pub enum Message {
|
||||
CursorTypeChanged(settings::CursorType),
|
||||
/// Set the model-space lineweight preview scale from Options.
|
||||
LineweightDisplayScaleChanged(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.
|
||||
@@ -2394,6 +2398,14 @@ 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).
|
||||
/// No-op if the scope currently has no flagged conflict.
|
||||
ResolveOneSketchConflict,
|
||||
/// Toggle the status-bar customization menu open/closed.
|
||||
ToggleStatusBarMenu,
|
||||
/// Close the status-bar customization menu.
|
||||
@@ -3965,9 +3977,9 @@ pub fn run() -> iced::Result {
|
||||
if let Some(tab) = state.tabs.get(state.active_tab) {
|
||||
let dot = if tab.dirty { "● " } else { "" };
|
||||
let name = tab.tab_display_name();
|
||||
format!("{}Open CAD Studio {} - {}", dot, env!("OCS_APP_VERSION"), name)
|
||||
format!("{}OCS-{} - {}", dot, env!("OCS_BUILD_STAMP"), name)
|
||||
} else {
|
||||
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
|
||||
concat!("OCS-", env!("OCS_BUILD_STAMP")).to_string()
|
||||
}
|
||||
})
|
||||
.theme(|state: &OpenCADStudio, _| state.active_theme.clone())
|
||||
@@ -3999,7 +4011,7 @@ pub fn run_web() -> iced::Result {
|
||||
OpenCADStudio::view_main,
|
||||
)
|
||||
.subscription(OpenCADStudio::subscription)
|
||||
.title(|_state: &OpenCADStudio| concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string())
|
||||
.title(|_state: &OpenCADStudio| concat!("OCS-", env!("OCS_BUILD_STAMP")).to_string())
|
||||
.theme(|state: &OpenCADStudio| state.active_theme.clone())
|
||||
.backend(iced::Backend::Hardware(iced::backend::Api::OpenGL))
|
||||
.font(iced_aw::ICED_AW_FONT_BYTES)
|
||||
|
||||
@@ -775,6 +775,8 @@ 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 {
|
||||
@@ -832,6 +834,9 @@ 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
|
||||
@@ -1383,6 +1388,9 @@ 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.
|
||||
self.tabs[i].scene.load_sketch_constraints_from_document();
|
||||
self.tabs[i].active_layer = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
@@ -1646,6 +1654,9 @@ 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.
|
||||
self.tabs[i].scene.materialize_sketch_constraints_for_save();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -2596,6 +2607,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||
self.stamp_header_sysvars(i);
|
||||
self.sync_solid_models_for_save(i);
|
||||
self.tabs[i].scene.materialize_sketch_constraints_for_save();
|
||||
let tab_id = self.tabs[i].id;
|
||||
let bounds = crate::ui::wrap_bar::dropdown_bounds(
|
||||
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
||||
|
||||
@@ -3292,6 +3292,10 @@ impl OpenCADStudio {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ResolveOneSketchConflict => {
|
||||
self.resolve_one_sketch_conflict();
|
||||
Task::none()
|
||||
}
|
||||
Message::TogglePolar => {
|
||||
self.polar_mode ^= true;
|
||||
if self.polar_mode {
|
||||
@@ -5872,6 +5876,18 @@ impl OpenCADStudio {
|
||||
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() {
|
||||
|
||||
@@ -1246,7 +1246,7 @@ impl OpenCADStudio {
|
||||
};
|
||||
|
||||
let mut seen_handles = rustc_hash::FxHashSet::default();
|
||||
let edited_handles: Vec<_> = grip
|
||||
let mut edited_handles: Vec<_> = grip
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.handle)
|
||||
@@ -1681,6 +1681,37 @@ 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.
|
||||
let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles);
|
||||
for (handle, _) in &solved_by_constraints {
|
||||
let handle = *handle;
|
||||
if !self.grip_preview_handles.contains(&handle) {
|
||||
if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() {
|
||||
self.grip_originals.push((handle, original));
|
||||
}
|
||||
self.grip_preview_handles.push(handle);
|
||||
if !self.tabs[i].scene.meshes.contains_key(&handle) {
|
||||
self.tabs[i].scene.preview_hidden.insert(handle);
|
||||
}
|
||||
}
|
||||
if !edited_handles.contains(&handle) {
|
||||
edited_handles.push(handle);
|
||||
}
|
||||
}
|
||||
for (handle, new_entity) in solved_by_constraints {
|
||||
if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
*slot = new_entity;
|
||||
}
|
||||
}
|
||||
let mesh_changes: Vec<_> = edited_handles
|
||||
.iter()
|
||||
.copied()
|
||||
|
||||
@@ -805,6 +805,50 @@ 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).
|
||||
let constraint_glyphs: Vec<(iced::Point, String, bool)> = if is_paper {
|
||||
Vec::new()
|
||||
} else {
|
||||
let (vw, vh) = sel_ref.vp_size;
|
||||
let scope = tab.current_sketch_scope();
|
||||
match tab.scene.sketch_constraint_set(scope) {
|
||||
Some(set) if !set.constraints.is_empty() => {
|
||||
let edit_frame = tab.scene.viewport_edit_frame((vw, vh));
|
||||
let bounds = match &edit_frame {
|
||||
Some((_, full)) => *full,
|
||||
None => tab.scene.active_model_tile_bounds(vw, vh),
|
||||
};
|
||||
let (view_rot, eye) = if let Some((cam, _)) = &edit_frame {
|
||||
(cam.view_proj_rte(bounds), cam.eye())
|
||||
} else {
|
||||
let cam = tab.scene.camera.borrow();
|
||||
(cam.view_proj_rte(bounds), cam.eye())
|
||||
};
|
||||
set.constraints
|
||||
.iter()
|
||||
.filter(|c| c.enabled)
|
||||
.filter_map(|c| {
|
||||
let anchor = crate::scene::sketch_constraints::glyph_anchor(&tab.scene.document, c)?;
|
||||
let screen = crate::scene::pick::grip::project_rte(
|
||||
glam::DVec3::new(anchor.x, anchor.y, anchor.z),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
)?;
|
||||
let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
|
||||
let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
|
||||
point
|
||||
.x
|
||||
.is_finite()
|
||||
.then(|| (point, crate::scene::sketch_constraints::glyph_label(c), is_conflicting))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
crate::ui::overlay::selection_overlay(
|
||||
std::sync::Arc::clone(&tab.scene.selection),
|
||||
snap_info,
|
||||
@@ -847,6 +891,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
grip_hot: self.model_space.grip_hot,
|
||||
grip_hover: self.model_space.grip_hover,
|
||||
},
|
||||
constraint_glyphs,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -2055,6 +2100,8 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
self.selection_cycling,
|
||||
&self.statusbar_config,
|
||||
status_menu_data,
|
||||
tab.scene.sketch_constraint_set(tab.current_sketch_scope()).and_then(|s| s.dof),
|
||||
tab.scene.sketch_constraint_set(tab.current_sketch_scope()).map(|s| s.conflicts.len()).unwrap_or(0),
|
||||
)
|
||||
})
|
||||
.width(Fill)
|
||||
|
||||
@@ -191,6 +191,8 @@ impl OpenCADStudio {
|
||||
self.crosshair_color,
|
||||
&self.crosshair_color_input,
|
||||
self.lineweight_display_scale,
|
||||
self.ribbon.label_font_size(),
|
||||
self.ribbon.group_title_font_size(),
|
||||
&self.model_space,
|
||||
&self.model_bg_input,
|
||||
&self.paper_bg_input,
|
||||
|
||||
@@ -1317,6 +1317,35 @@ 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
|
||||
/// 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
|
||||
/// existing chain (`refresh_sketch_constraints`) the same way any later
|
||||
/// edit to these entities will.
|
||||
AddSketchConstraint {
|
||||
kind: crate::scene::sketch_constraints::ConstraintKind,
|
||||
refs: Vec<crate::scene::sketch_constraints::SketchRef>,
|
||||
/// The typed target for a dimensional kind (Distance/Angle/Radius);
|
||||
/// `None` for a purely geometric one.
|
||||
driving_param: Option<f64>,
|
||||
/// 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.
|
||||
AddCoincidentConstraint {
|
||||
point_a: DVec3,
|
||||
point_b: DVec3,
|
||||
label: &'static str,
|
||||
},
|
||||
/// Attach one smart centre mark to a newly selected circular source.
|
||||
ReassociateCenterMark {
|
||||
target: Handle,
|
||||
|
||||
+11
-1
@@ -422,9 +422,19 @@ 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(cadkernel::tessellation::DEFAULT_ANGLE)
|
||||
curve.tessellate_angle(DISPLAY_ANGLE)
|
||||
}
|
||||
|
||||
/// The snap candidates an entity's curve offers, in the two channels the
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! CCONSTRAINT — the manual Coincident UI (design doc §6.1's open item):
|
||||
//! 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
|
||||
//! selection.
|
||||
//!
|
||||
//! A `CadCommand` has no document access (see `value.rs`'s module doc
|
||||
//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this
|
||||
//! command only accumulates the two raw points `on_point` receives and hands
|
||||
//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves
|
||||
//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`)
|
||||
//! and adds the constraint, the same split `ReassociateCenterMark` already
|
||||
//! uses for a similar reason.
|
||||
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub mod coincident_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "CCONSTRAINT",
|
||||
label: "Coincident",
|
||||
icon: IconKind::Glyph("≡"),
|
||||
event: ModuleEvent::Command("CCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CoincidentConstraintCommand {
|
||||
first: Option<DVec3>,
|
||||
}
|
||||
|
||||
impl CoincidentConstraintCommand {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CoincidentConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CCONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.first.is_none() {
|
||||
"COINCIDENT Specify first point:".to_string()
|
||||
} else {
|
||||
"COINCIDENT Specify second point:".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match self.first {
|
||||
None => {
|
||||
self.first = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(first) => CmdResult::AddCoincidentConstraint { point_a: first, point_b: pt, label: "Coincident constraint" },
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Ribbon tools and typed-value commands for the persistent "Constraints"
|
||||
//! group (design doc `docs/parametric_system_design.md` §6.1).
|
||||
//!
|
||||
//! All eight — Horizontal, Vertical, Parallel, Perpendicular, Equal,
|
||||
//! Tangent, Distance, Angle — add a [`crate::scene::sketch_constraints::SketchConstraint`]
|
||||
//! to the current scope and let `Scene::bump_entities` solve it, the same
|
||||
//! path any later edit to the constrained entities re-solves through
|
||||
//! (`src/scene/sketch_solve.rs`). Horizontal/Vertical/Parallel/Perpendicular/
|
||||
//! Equal/Tangent dispatch straight to `CmdResult::AddSketchConstraint` from
|
||||
//! `src/app/commands/draw.rs` (no typed value needed, so no `CadCommand` of
|
||||
//! their own); Distance and Angle need one (`value.rs`), since the target
|
||||
//! value comes from the command line after the tool is clicked; Coincident
|
||||
//! needs one too (`coincident.rs`), since it addresses a *point* on an
|
||||
//! entity rather than the whole thing, so it can't reuse plain selection.
|
||||
|
||||
mod coincident;
|
||||
mod tools;
|
||||
mod value;
|
||||
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
|
||||
pub use tools::{equal, horizontal, parallel, perpendicular, tangent, vertical};
|
||||
pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand};
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Ribbon button definitions for the Constraints group.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub mod horizontal {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "HCONSTRAINT",
|
||||
label: "Horizontal",
|
||||
icon: IconKind::Glyph("—"),
|
||||
event: ModuleEvent::Command("HCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod vertical {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "VCONSTRAINT",
|
||||
label: "Vertical",
|
||||
icon: IconKind::Glyph("│"),
|
||||
event: ModuleEvent::Command("VCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod parallel {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "PCONSTRAINT",
|
||||
label: "Parallel",
|
||||
icon: IconKind::Glyph("∥"),
|
||||
event: ModuleEvent::Command("PCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod perpendicular {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "QCONSTRAINT",
|
||||
label: "Perpendicular",
|
||||
icon: IconKind::Glyph("⊥"),
|
||||
event: ModuleEvent::Command("QCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod equal {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "ECONSTRAINT",
|
||||
label: "Equal",
|
||||
icon: IconKind::Glyph("="),
|
||||
event: ModuleEvent::Command("ECONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod tangent {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "TCONSTRAINT",
|
||||
label: "Tangent",
|
||||
icon: IconKind::Glyph("⌒"),
|
||||
event: ModuleEvent::Command("TCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! DCONSTRAINT / ACONSTRAINT — constraints that need a typed target value
|
||||
//! (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
|
||||
//! 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
|
||||
//! does have `&mut Scene`) adds the record and solves it via the same
|
||||
//! `Scene::bump_entities` path any later edit to these entities will use too.
|
||||
//! `default_value` (what the prompt shows in `<...>`, and what a bare Enter
|
||||
//! submits) is still computed from a one-time read of current geometry —
|
||||
//! that part doesn't change.
|
||||
|
||||
use acadrust::entities::EntityType;
|
||||
use acadrust::types::Handle;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, InputKind};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||
use crate::scene::Scene;
|
||||
|
||||
pub mod distance_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "DCONSTRAINT",
|
||||
label: "Distance",
|
||||
icon: IconKind::Glyph("↔"),
|
||||
event: ModuleEvent::Command("DCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod angle_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "ACONSTRAINT",
|
||||
label: "Angle",
|
||||
icon: IconKind::Glyph("∠"),
|
||||
event: ModuleEvent::Command("ACONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrains a single line's length (between its two endpoints) or a
|
||||
/// circle's radius, to a typed target value.
|
||||
pub struct DistanceConstraintCommand {
|
||||
handle: Handle,
|
||||
/// Which kind this becomes once a value comes in — decided once at
|
||||
/// construction from the entity's type, matching `ConstraintKind`'s own
|
||||
/// split between line-length (`Distance`) and circle-radius (`Radius`).
|
||||
is_circle: bool,
|
||||
default_value: f64,
|
||||
}
|
||||
|
||||
impl DistanceConstraintCommand {
|
||||
/// `None` if `handle` isn't a Line or Circle.
|
||||
pub fn new(scene: &Scene, handle: Handle) -> Option<Self> {
|
||||
let entity = scene.document.get_entity(handle)?;
|
||||
let (is_circle, default_value) = match entity {
|
||||
EntityType::Line(l) => {
|
||||
let dx = l.end.x - l.start.x;
|
||||
let dy = l.end.y - l.start.y;
|
||||
(false, (dx * dx + dy * dy).sqrt())
|
||||
}
|
||||
EntityType::Circle(c) => (true, c.radius),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self { handle, is_circle, default_value })
|
||||
}
|
||||
|
||||
fn build(&self, target: f64) -> Option<CmdResult> {
|
||||
if target <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (kind, refs, label) = if self.is_circle {
|
||||
(ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint")
|
||||
} else {
|
||||
(
|
||||
ConstraintKind::Distance,
|
||||
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
|
||||
"Distance constraint",
|
||||
)
|
||||
};
|
||||
Some(CmdResult::AddSketchConstraint { kind, refs, driving_param: Some(target), label })
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for DistanceConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"DCONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
format!("Specify distance <{:.4}>: ", self.default_value)
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
self.build(self.default_value).unwrap_or(CmdResult::Cancel)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let value: f64 = text.trim().parse().ok()?;
|
||||
self.build(value)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrains the angle (in degrees) from `fixed`'s direction to `moving`'s.
|
||||
pub struct AngleConstraintCommand {
|
||||
fixed_handle: Handle,
|
||||
moving_handle: Handle,
|
||||
default_value: f64,
|
||||
}
|
||||
|
||||
impl AngleConstraintCommand {
|
||||
/// `None` unless both `fixed` and `moving` are lines.
|
||||
pub fn new(scene: &Scene, fixed: Handle, moving: Handle) -> Option<Self> {
|
||||
let fixed_entity = scene.document.get_entity(fixed)?;
|
||||
let moving_entity = scene.document.get_entity(moving)?;
|
||||
let (EntityType::Line(f), EntityType::Line(m)) = (fixed_entity, moving_entity) else {
|
||||
return None;
|
||||
};
|
||||
let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x);
|
||||
let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x);
|
||||
let default_value = (a2 - a1).to_degrees();
|
||||
Some(Self { fixed_handle: fixed, moving_handle: moving, default_value })
|
||||
}
|
||||
|
||||
fn build(&self, target_degrees: f64) -> Option<CmdResult> {
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::Angle,
|
||||
refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)],
|
||||
driving_param: Some(target_degrees),
|
||||
label: "Angle constraint",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for AngleConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ACONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
format!("Specify angle in degrees <{:.4}>: ", self.default_value)
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
self.build(self.default_value).unwrap_or(CmdResult::Cancel)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let value: f64 = text.trim().parse().ok()?;
|
||||
self.build(value)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod changelog;
|
||||
pub mod clipboard;
|
||||
pub mod constrain;
|
||||
pub mod defaults;
|
||||
mod donate;
|
||||
pub mod draw;
|
||||
@@ -33,6 +34,7 @@ impl CadModule for DrawModule {
|
||||
};
|
||||
use crate::modules::insert::{create_block, insert_block};
|
||||
use clipboard::{copy_clip, cut, paste};
|
||||
use constrain::{angle_tool, coincident_tool, distance_tool, equal, horizontal, parallel, perpendicular, tangent, vertical};
|
||||
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
|
||||
use groups::{group, ungroup};
|
||||
use layers::{
|
||||
@@ -120,6 +122,20 @@ impl CadModule for DrawModule {
|
||||
offset::tool().into(),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Constraints",
|
||||
tools: vec![
|
||||
horizontal::tool().into(),
|
||||
vertical::tool().into(),
|
||||
parallel::tool().into(),
|
||||
perpendicular::tool().into(),
|
||||
equal::tool().into(),
|
||||
tangent::tool().into(),
|
||||
coincident_tool::tool().into(),
|
||||
distance_tool::tool().into(),
|
||||
angle_tool::tool().into(),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Annotation",
|
||||
tools: vec![
|
||||
|
||||
@@ -264,7 +264,13 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option<Vector3> {
|
||||
}
|
||||
}
|
||||
|
||||
fn source_points(entity: &EntityType) -> Vec<Vector3> {
|
||||
/// Ordered, named points for an entity (line start/end, polyline vertices,
|
||||
/// ...), indexed by the same non-negative GsMarker convention
|
||||
/// `AssocDimensionReference::main_gs_marker` uses. Promoted to
|
||||
/// `pub(crate)` so `sketch::constraint_set` can address constraint
|
||||
/// endpoints the same way associative dimensions already address theirs,
|
||||
/// rather than inventing a second sub-element scheme.
|
||||
pub(crate) fn source_points(entity: &EntityType) -> Vec<Vector3> {
|
||||
match entity {
|
||||
EntityType::Line(line) => vec![line.start, line.end],
|
||||
EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()],
|
||||
|
||||
@@ -43,6 +43,9 @@ mod preview;
|
||||
mod project;
|
||||
mod scene_markers;
|
||||
mod selection;
|
||||
pub mod sketch_constraints;
|
||||
mod sketch_persist;
|
||||
mod sketch_solve;
|
||||
|
||||
pub(crate) use boundary::{
|
||||
boundary_entities, boundary_entities_from_sources, boundary_faces,
|
||||
@@ -1863,6 +1866,19 @@ 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.
|
||||
pub(crate) sketch_constraints: Vec<sketch_constraints::SketchConstraintSet>,
|
||||
/// Tessellated block definitions in block-local coords, keyed by render
|
||||
/// background and block epoch. Model and Paper adapt black/white colours
|
||||
/// differently; retaining both variants prevents a full block rebuild on
|
||||
@@ -2094,6 +2110,7 @@ impl Scene {
|
||||
layout_type_names_cache: RefCell::new(None),
|
||||
dependency_index_cache: RefCell::new(None),
|
||||
associative_hatch_source_cache: RefCell::new(None),
|
||||
sketch_constraints: Vec::new(),
|
||||
has_associative_centers: std::cell::Cell::new(None),
|
||||
block_defn_cache: RefCell::new(HashMap::default()),
|
||||
entity_index_cache: RefCell::new(None),
|
||||
@@ -2642,6 +2659,13 @@ impl Scene {
|
||||
changes.push(change);
|
||||
}
|
||||
}
|
||||
if !self.sketch_constraints.is_empty() {
|
||||
for change in self.refresh_sketch_constraints(&changes) {
|
||||
if !changes.iter().any(|(handle, _)| *handle == change.0) {
|
||||
changes.push(change);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changes.is_empty() {
|
||||
self.refresh_dependency_index_for_changes(&changes);
|
||||
}
|
||||
|
||||
@@ -752,6 +752,11 @@ 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.
|
||||
self.duplicate_sketch_constraints_for(&handle_map);
|
||||
self.refresh_meshes_for_handles(&refresh_solid_handles);
|
||||
new_handles
|
||||
}
|
||||
|
||||
@@ -217,8 +217,10 @@ pub fn find_hit_grip(
|
||||
/// Project an f64 world point with an explicit relative-to-eye `(view_rot,
|
||||
/// eye)` pair — the camera-less form of `Camera::project`. Used by the
|
||||
/// in-viewport editing path, which supplies a *composed* model→screen view
|
||||
/// (see `Scene::composed_viewport_view`) instead of a real camera.
|
||||
fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option<Vec2> {
|
||||
/// (see `Scene::composed_viewport_view`) instead of a real camera. `pub(crate)`
|
||||
/// so any other single-point world→screen projection (e.g. constraint-glyph
|
||||
/// anchors) can reuse it instead of re-deriving the same NDC math.
|
||||
pub(crate) fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option<Vec2> {
|
||||
let rel = (world - eye).as_vec3();
|
||||
let clip = view_rot * rel.extend(1.0);
|
||||
if clip.w.abs() < 1e-9 {
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
//! 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.
|
||||
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One endpoint a constraint attaches to: an entity plus which sub-element
|
||||
/// of it.
|
||||
///
|
||||
/// Reuses the GsMarker convention `AssocDimensionReference::main_gs_marker`
|
||||
/// already carries for associative-dimension endpoints
|
||||
/// (`src/scene/dimension_assoc.rs`), rather than inventing a second
|
||||
/// sub-element addressing scheme: `marker` indexes into
|
||||
/// [`dimension_assoc::source_points`](super::dimension_assoc::source_points)'s
|
||||
/// ordered per-entity-type point list when non-negative (0/1 = a line's
|
||||
/// start/end, ...), or names a special case when negative (-3 = a
|
||||
/// circle/arc's center; -2 = a point at a stored parameter, not currently
|
||||
/// used by constraint endpoints but reserved for consistency with the
|
||||
/// dimension scheme).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SketchRef {
|
||||
pub entity: Handle,
|
||||
/// `None` addresses the entity as a whole — what a Radius, Length, or
|
||||
/// whole-curve constraint (Parallel, Perpendicular, Equal, Horizontal,
|
||||
/// Vertical) needs; a point-level constraint (Coincident, Distance
|
||||
/// between two points, Angle at a shared vertex) sets `Some(marker)`.
|
||||
pub marker: Option<i32>,
|
||||
}
|
||||
|
||||
impl SketchRef {
|
||||
pub fn whole(entity: Handle) -> Self {
|
||||
Self { entity, marker: None }
|
||||
}
|
||||
|
||||
pub fn point(entity: Handle, marker: i32) -> Self {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The friendly, user-facing constraint types — the "what button did they
|
||||
/// click" vocabulary, one layer above the `ocs_gcs` 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
|
||||
/// endpoint-picking kinds (`Coincident`, `Radius`, `Tangent`) that one-shot
|
||||
/// group never needed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ConstraintKind {
|
||||
Coincident,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Parallel,
|
||||
Perpendicular,
|
||||
Equal,
|
||||
/// Distance between two points, or a single line's length, or a
|
||||
/// circle/arc's diameter — which reading applies depends on `refs`'
|
||||
/// shape, mirroring how `DistanceConstraintCommand`
|
||||
/// (`src/modules/draw/constrain/value.rs`) already infers it from the
|
||||
/// selected entity.
|
||||
Distance,
|
||||
Angle,
|
||||
Radius,
|
||||
Tangent,
|
||||
}
|
||||
|
||||
pub type ConstraintId = u32;
|
||||
|
||||
/// One persisted constraint record: a friendly [`ConstraintKind`], the
|
||||
/// entities/points it relates, and — for a dimensional kind — the value
|
||||
/// driving it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SketchConstraint {
|
||||
pub id: ConstraintId,
|
||||
pub kind: ConstraintKind,
|
||||
pub refs: Vec<SketchRef>,
|
||||
/// The typed target for a dimensional constraint (a `Distance`'s
|
||||
/// length, an `Angle`'s degrees, a `Radius`'s radius). `None` for every
|
||||
/// purely-geometric kind (Coincident, Horizontal, Vertical, Parallel,
|
||||
/// Perpendicular, Equal, Tangent).
|
||||
pub driving_param: Option<f64>,
|
||||
/// Lets a user suppress a constraint without losing it — a re-solve
|
||||
/// skips a disabled constraint entirely.
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SketchScope {
|
||||
ModelSpace,
|
||||
/// A block definition's `BlockRecord` handle — matches
|
||||
/// `BlockEditSession::br_handle`
|
||||
/// (`src/modules/draw/modify/block_edit.rs`).
|
||||
Block(Handle),
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle {
|
||||
match self {
|
||||
SketchScope::ModelSpace => document.header.model_space_block_handle,
|
||||
SketchScope::Block(handle) => *handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SketchConstraintSet {
|
||||
pub scope: SketchScope,
|
||||
pub constraints: Vec<SketchConstraint>,
|
||||
next_id: ConstraintId,
|
||||
/// Cached total remaining degrees of freedom, summed across every
|
||||
/// 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:
|
||||
/// 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
|
||||
/// persisted; a `ConflictResolverPanel` reads this rather than calling
|
||||
/// `ocs_gcs::diagnosis::classify_redundant` itself.
|
||||
#[serde(skip)]
|
||||
pub conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>,
|
||||
}
|
||||
|
||||
impl SketchConstraintSet {
|
||||
pub fn new(scope: SketchScope) -> Self {
|
||||
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<f64>) -> ConstraintId {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.constraints.push(SketchConstraint { id, kind, refs, driving_param, enabled: true });
|
||||
id
|
||||
}
|
||||
|
||||
/// Removes a constraint by id. Returns whether one was actually removed.
|
||||
pub fn remove(&mut self, id: ConstraintId) -> bool {
|
||||
let before = self.constraints.len();
|
||||
self.constraints.retain(|c| c.id != id);
|
||||
self.constraints.len() != before
|
||||
}
|
||||
|
||||
pub fn get(&self, id: ConstraintId) -> Option<&SketchConstraint> {
|
||||
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.
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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));
|
||||
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
|
||||
/// 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).
|
||||
/// Returns `None` for a dangling reference (handle doesn't resolve) or a
|
||||
/// 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".
|
||||
pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option<Vector3> {
|
||||
if marker == -3 {
|
||||
return match entity {
|
||||
acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()),
|
||||
acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if marker < 0 {
|
||||
return None;
|
||||
}
|
||||
super::dimension_assoc::source_points(entity).get(marker as usize).copied()
|
||||
}
|
||||
|
||||
/// Below this squared distance (1e-6 world units), two points count as
|
||||
/// already coincident for [`infer_coincident_refs`]/[`nearest_sketch_point`]'s
|
||||
/// 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.
|
||||
pub(crate) fn nearest_sketch_point(
|
||||
document: &acadrust::CadDocument,
|
||||
scope: SketchScope,
|
||||
world_point: Vector3,
|
||||
exclude: Option<Handle>,
|
||||
) -> Option<SketchRef> {
|
||||
let owner = scope.owner_handle(document);
|
||||
let mut best: Option<(f64, SketchRef)> = None;
|
||||
let mut consider = |handle: Handle, marker: i32, point: Vector3| {
|
||||
let dx = point.x - world_point.x;
|
||||
let dy = point.y - world_point.y;
|
||||
let dz = point.z - world_point.z;
|
||||
let dist_sq = dx * dx + dy * dy + dz * dz;
|
||||
if dist_sq <= COINCIDENT_EPSILON_SQ && best.as_ref().is_none_or(|(d, _)| dist_sq < *d) {
|
||||
best = Some((dist_sq, SketchRef::point(handle, marker)));
|
||||
}
|
||||
};
|
||||
for candidate in document.entities() {
|
||||
let common = candidate.common();
|
||||
if common.owner_handle != owner || Some(common.handle) == exclude {
|
||||
continue;
|
||||
}
|
||||
for (marker, point) in super::dimension_assoc::source_points(candidate).into_iter().enumerate() {
|
||||
consider(common.handle, marker as i32, point);
|
||||
}
|
||||
match candidate {
|
||||
acadrust::EntityType::Circle(c) => consider(common.handle, -3, c.center_wcs()),
|
||||
acadrust::EntityType::Arc(a) => consider(common.handle, -3, a.center_wcs()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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
|
||||
/// 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.
|
||||
pub(crate) fn infer_coincident_refs(
|
||||
document: &acadrust::CadDocument,
|
||||
scope: SketchScope,
|
||||
new_handle: Handle,
|
||||
new_entity: &acadrust::EntityType,
|
||||
) -> Vec<(SketchRef, SketchRef)> {
|
||||
let owner = scope.owner_handle(document);
|
||||
let new_points = super::dimension_assoc::source_points(new_entity);
|
||||
if new_points.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
for (new_marker, new_point) in new_points.iter().enumerate() {
|
||||
for candidate in document.entities() {
|
||||
let common = candidate.common();
|
||||
if common.handle == new_handle || common.owner_handle != owner {
|
||||
continue;
|
||||
}
|
||||
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)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
impl ConstraintKind {
|
||||
/// The short symbol a constraint glyph shows — matches the existing
|
||||
/// ribbon icons (`crate::modules::draw::constrain::{tools,value}`) for
|
||||
/// the kinds that have a one-click button, so the same glyph means the
|
||||
/// same thing in both places.
|
||||
pub fn glyph_symbol(&self) -> &'static str {
|
||||
match self {
|
||||
ConstraintKind::Coincident => "≡",
|
||||
ConstraintKind::Horizontal => "—",
|
||||
ConstraintKind::Vertical => "│",
|
||||
ConstraintKind::Parallel => "∥",
|
||||
ConstraintKind::Perpendicular => "⊥",
|
||||
ConstraintKind::Equal => "=",
|
||||
ConstraintKind::Distance => "↔",
|
||||
ConstraintKind::Angle => "∠",
|
||||
ConstraintKind::Radius => "R",
|
||||
ConstraintKind::Tangent => "T",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full glyph text for one constraint: its symbol, plus the driving
|
||||
/// value for a dimensional kind (Distance/Angle/Radius).
|
||||
pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String {
|
||||
match (constraint.kind, constraint.driving_param) {
|
||||
(ConstraintKind::Angle, Some(value)) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()),
|
||||
(_, Some(value)) => format!("{} {value:.2}", constraint.kind.glyph_symbol()),
|
||||
(_, None) => constraint.kind.glyph_symbol().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// World-space point to anchor `constraint`'s glyph at, or `None` if it
|
||||
/// can't be resolved (dangling ref, unsupported entity/marker). A point ref
|
||||
/// resolves straight through [`resolve_point`]; a whole-entity ref falls
|
||||
/// 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> {
|
||||
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::Circle(c) => Some(c.point_at_angle_wcs(0.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Scene {
|
||||
/// The constraint set for `scope`, if one has been created.
|
||||
pub fn sketch_constraint_set(&self, scope: SketchScope) -> Option<&SketchConstraintSet> {
|
||||
self.sketch_constraints.iter().find(|s| s.scope == scope)
|
||||
}
|
||||
|
||||
/// The constraint set for `scope`, creating an empty one on first use.
|
||||
/// The `pub(crate)` `sketch_constraints` field itself stays private so
|
||||
/// nothing outside this module can end up with two sets for the same
|
||||
/// 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) {
|
||||
&mut self.sketch_constraints[index]
|
||||
} else {
|
||||
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
|
||||
/// 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
|
||||
/// shared table, so each call site passes its own `handle_map` here
|
||||
/// after adding the duplicated entities.
|
||||
///
|
||||
/// For every enabled constraint whose *every* referenced entity was
|
||||
/// duplicated (a constraint straddling a duplicated and a
|
||||
/// non-duplicated entity can't sensibly follow — only one side moved),
|
||||
/// adds an equivalent constraint over the new handles to the same
|
||||
/// 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>) {
|
||||
if handle_map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<f64>)> = 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();
|
||||
to_add.push((scope_index, c.kind, new_refs, c.driving_param));
|
||||
}
|
||||
}
|
||||
if to_add.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut touched: Vec<Handle> = Vec::new();
|
||||
for (scope_index, kind, refs, driving_param) in to_add {
|
||||
touched.extend(refs.iter().map(|r| r.entity));
|
||||
self.sketch_constraints[scope_index].add(kind, refs, driving_param);
|
||||
}
|
||||
touched.sort();
|
||||
touched.dedup();
|
||||
let changes: Vec<(Handle, super::ChangeKind)> = touched.into_iter().map(|h| (h, super::ChangeKind::Modified)).collect();
|
||||
self.bump_entities(&changes);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn h(v: u64) -> Handle {
|
||||
Handle::new(v)
|
||||
}
|
||||
|
||||
#[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(25.0));
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal);
|
||||
assert_eq!(set.get(b).unwrap().driving_param, Some(25.0));
|
||||
}
|
||||
|
||||
#[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 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.get(a).is_none());
|
||||
assert!(set.get(b).is_some());
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
let touching_1: Vec<_> = set.constraints_touching(h(1)).collect();
|
||||
assert_eq!(touching_1.len(), 1);
|
||||
let touching_3: Vec<_> = set.constraints_touching(h(3)).collect();
|
||||
assert_eq!(touching_3.len(), 1);
|
||||
assert_eq!(set.constraints_touching(h(99)).count(), 0);
|
||||
}
|
||||
|
||||
#[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;
|
||||
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 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_owner_handle_resolves_block_directly() {
|
||||
let block_handle = h(42);
|
||||
let scope = SketchScope::Block(block_handle);
|
||||
let doc = acadrust::CadDocument::new();
|
||||
assert_eq!(scope.owner_handle(&doc), block_handle);
|
||||
}
|
||||
|
||||
#[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) });
|
||||
}
|
||||
|
||||
#[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(12.5));
|
||||
|
||||
let bytes = bincode::serialize(&set).expect("serialize");
|
||||
let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(restored.scope, set.scope);
|
||||
assert_eq!(restored.constraints.len(), set.constraints.len());
|
||||
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
|
||||
assert_eq!(restored.constraints[0].refs, set.constraints[0].refs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! 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.
|
||||
|
||||
use super::sketch_constraints::SketchConstraintSet;
|
||||
use super::Scene;
|
||||
|
||||
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
|
||||
|
||||
/// 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.
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
|
||||
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
|
||||
let mut bytes = vec![FORMAT_VERSION];
|
||||
bytes.extend(bincode::serialize(set).ok()?);
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<SketchConstraintSet> {
|
||||
let (&version, body) = bytes.split_first()?;
|
||||
if version != FORMAT_VERSION {
|
||||
return None;
|
||||
}
|
||||
bincode::deserialize(body).ok()
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Writes every current `sketch_constraints` entry into its scope's
|
||||
/// owner-handle XRecord, ready for whatever save call happens next to
|
||||
/// serialize `self.document` as-is. Called right before a native/web
|
||||
/// save (`OpenCADStudio::prepare_native_save` and the wasm save path)
|
||||
/// — see those call sites' doc comments for why "right before save"
|
||||
/// rather than keeping the XRecord resident and live-updated (design
|
||||
/// doc §5.2's "live" model, deferred: it would also let constraint-set
|
||||
/// edits ride the ordinary object-delta undo path per §5.1(b), but this
|
||||
/// lazy model was the doc's own recommended first step).
|
||||
///
|
||||
/// A scope with an empty constraint set is skipped rather than writing
|
||||
/// an empty XRecord — an unconstrained drawing (the common case) should
|
||||
/// not gain persisted-but-empty bookkeeping on every save.
|
||||
pub(crate) fn materialize_sketch_constraints_for_save(&mut self) {
|
||||
for index in 0..self.sketch_constraints.len() {
|
||||
let set = &self.sketch_constraints[index];
|
||||
if set.constraints.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let owner = set.scope.owner_handle(&self.document);
|
||||
if owner.is_null() {
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = encode(set) else { continue };
|
||||
self.document.ensure_xrecord(owner, XRECORD_KEY);
|
||||
if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) {
|
||||
// Overwrite, not append: a resave must replace the prior
|
||||
// blob, not accumulate one more Chunk entry every time.
|
||||
record.entries.clear();
|
||||
record.entries.push(acadrust::objects::XRecordEntry::new(
|
||||
310,
|
||||
acadrust::objects::XRecordValue::Chunk(bytes),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates `sketch_constraints` from every `OCS_SKETCH_CONSTRAINTS`
|
||||
/// XRecord found in the just-loaded `self.document` — called right
|
||||
/// after a document open installs its `CadDocument` into this `Scene`
|
||||
/// (`OpenCADStudio::on_file_opened` and the automation `"open"`/`"new"`
|
||||
/// ops). Every `BlockRecord` (model space, paper space, and named
|
||||
/// block definitions) is a candidate owner; most will have no such
|
||||
/// XRecord and are skipped cheaply. Replaces whatever was already in
|
||||
/// `sketch_constraints` (a fresh/empty `Vec` for a real open; `"new"`'s
|
||||
/// 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();
|
||||
for owner in owners {
|
||||
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { continue };
|
||||
let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value {
|
||||
acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(set) = decode(&bytes) {
|
||||
self.sketch_constraints.push(set);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
|
||||
use super::*;
|
||||
use acadrust::entities::EntityType;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
#[test]
|
||||
fn a_model_space_constraint_set_survives_materialize_and_reload() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
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.materialize_sketch_constraints_for_save();
|
||||
// Simulate the load-time side of a save/reload round trip: drop the
|
||||
// in-memory sets (a fresh open starts with none) and repopulate
|
||||
// purely from what materialize just wrote into `document.objects`.
|
||||
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");
|
||||
assert_eq!(set.constraints.len(), 1);
|
||||
assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal);
|
||||
assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]);
|
||||
}
|
||||
|
||||
/// The above test only proves `materialize`/`load` agree with each
|
||||
/// other on the same in-memory `CadDocument` — it never actually
|
||||
/// serializes anything. This drives a real `save_to_bytes`/`load_bytes`
|
||||
/// round trip (the same primitives `OpenCADStudio::on_file_opened`'s
|
||||
/// native save/open path uses), through both supported formats, closing
|
||||
/// the gap stage 2's spike proved for the raw XRecord mechanism up to
|
||||
/// this stage's actual app-level wiring.
|
||||
fn full_bytes_roundtrip(ext: &str) -> SketchConstraintSet {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(10.0, 5.0, 0.0),
|
||||
)));
|
||||
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
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(12.5));
|
||||
|
||||
scene.materialize_sketch_constraints_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!("stage4_roundtrip.{ext}"), bytes)
|
||||
.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")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_constraint_set_survives_a_real_dxf_save_and_load_round_trip() {
|
||||
let restored = full_bytes_roundtrip("dxf");
|
||||
assert_eq!(restored.constraints.len(), 2);
|
||||
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
|
||||
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
|
||||
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_constraint_set_survives_a_real_dwg_save_and_load_round_trip() {
|
||||
let restored = full_bytes_roundtrip("dwg");
|
||||
assert_eq!(restored.constraints.len(), 2);
|
||||
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
|
||||
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
|
||||
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_scoped_constraint_set_survives_materialize_and_reload() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
)));
|
||||
// `create_block_from_entities` returns the placed INSERT's handle,
|
||||
// not the BlockRecord's own handle — `SketchScope::Block` needs the
|
||||
// latter (it's what `BlockEditSession::br_handle` and
|
||||
// `SketchScope::owner_handle` both mean by "a block definition's
|
||||
// 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())
|
||||
.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);
|
||||
|
||||
scene.materialize_sketch_constraints_for_save();
|
||||
scene.sketch_constraints.clear();
|
||||
scene.load_sketch_constraints_from_document();
|
||||
|
||||
let set = scene
|
||||
.sketch_constraint_set(SketchScope::Block(br_handle))
|
||||
.expect("block-scoped constraint set should have been reloaded");
|
||||
assert_eq!(set.constraints.len(), 1);
|
||||
assert_eq!(set.constraints[0].kind, ConstraintKind::Vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_constraint_set_is_not_materialized() {
|
||||
// A scope touched (e.g. `sketch_constraint_set_mut` called once,
|
||||
// then every constraint removed) but left with zero constraints
|
||||
// should not persist an empty XRecord.
|
||||
let mut scene = Scene::new();
|
||||
let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
||||
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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! Wires [`sketch_constraints::SketchConstraintSet`] into `Scene::bump_entities`
|
||||
//! — design doc §4.1/§4.2, stage 3 of its §8 staged plan.
|
||||
//!
|
||||
//! `refresh_sketch_constraints` is one more link in `bump_entities`'s
|
||||
//! existing in-line derived-geometry chain, alongside
|
||||
//! `refresh_associative_dimensions`/`_hatches`/`_centerlines`: given the
|
||||
//! handles that just changed, find which constraint scopes are affected,
|
||||
//! rebuild each affected scope's `ocs_gcs::System` from scratch (full
|
||||
//! rebuild-and-solve, no incremental state — see the design doc §4.2 for
|
||||
//! why), solve, and write any moved geometry back through the same
|
||||
//! undo-recording path those other passes use.
|
||||
//!
|
||||
//! Maps every [`ConstraintKind`]. `Tangent` covers Line-Circle
|
||||
//! (`C2LDistance` with a driven zero-distance target aliased to the
|
||||
//! circle's own radius via `internal: false`) and Circle-Circle
|
||||
//! (`TangentCircumf`) — Line-Line has no meaning and Arc isn't a supported
|
||||
//! [`EntityGeom`] yet (matches that type's own documented scope), so those
|
||||
//! ref shapes build nothing, same "skip, don't panic" contract every other
|
||||
//! unbuildable constraint already gets. `ccw`/`internal` are picked from
|
||||
//! the pair's *current* geometry (which side of the line the circle already
|
||||
//! sits on; whether the circles are already nested) so the very first solve
|
||||
//! doesn't have to cross a sign-flip singularity to reach the nearest valid
|
||||
//! tangent configuration.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use acadrust::entities::EntityType;
|
||||
use acadrust::types::Handle;
|
||||
|
||||
use ocs_gcs::constraints::angle_distance::L2LAngle;
|
||||
use ocs_gcs::constraints::circle_arc::{C2LDistance, TangentCircumf};
|
||||
use ocs_gcs::constraints::point_line::{
|
||||
Equal, EqualLineLength, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, P2PDistance,
|
||||
};
|
||||
use ocs_gcs::constraints::Constraint;
|
||||
use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint};
|
||||
use ocs_gcs::solvers::dogleg::solve_dl;
|
||||
use ocs_gcs::system::System;
|
||||
|
||||
use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef};
|
||||
use super::{ChangeKind, Scene};
|
||||
|
||||
/// One referenced entity's geometry, registered into an `ocs_gcs::System`'s
|
||||
/// parameter store. Only the two entity types the existing one-shot
|
||||
/// constraint commands already support (`crate::modules::draw::constrain`)
|
||||
/// — extending this to arcs/ellipses is future work, not a gap introduced
|
||||
/// here.
|
||||
#[derive(Clone, Copy)]
|
||||
enum EntityGeom {
|
||||
Line(GLine),
|
||||
Circle(GCircle),
|
||||
}
|
||||
|
||||
impl EntityGeom {
|
||||
/// The `ocs_gcs::geo::Point` for a marker on this entity — `None` for
|
||||
/// a marker this entity type/value doesn't support (see
|
||||
/// `sketch_constraints::resolve_point` for the same convention on the
|
||||
/// read side).
|
||||
fn point_for_marker(&self, marker: i32) -> Option<GPoint> {
|
||||
match (self, marker) {
|
||||
(EntityGeom::Line(l), 0) => Some(l.p1),
|
||||
(EntityGeom::Line(l), 1) => Some(l.p2),
|
||||
(EntityGeom::Circle(c), -3) => Some(c.center),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `handle`'s live geometry and registers it as fresh, free (never
|
||||
/// `driven`) parameters in `sys` — every referenced entity is solved for,
|
||||
/// nothing is a priori fixed; per planegcs-style solvers, an under-
|
||||
/// constrained scope simply converges to the nearest configuration to its
|
||||
/// current one, which is the correct behavior for a persistent system where
|
||||
/// "what stays put" is a property of how many constraints exist, not a
|
||||
/// convention about which entity was picked first (contrast the one-shot
|
||||
/// `constrain::apply_*` commands, which do fix the first-picked entity —
|
||||
/// they solve exactly one constraint in isolation, so they need that
|
||||
/// convention; a whole scope's constraint graph does not).
|
||||
fn register_entity(document: &acadrust::CadDocument, sys: &mut System, handle: Handle) -> Option<EntityGeom> {
|
||||
match document.get_entity(handle)? {
|
||||
EntityType::Line(l) => {
|
||||
let p1 = GPoint::new(sys.add_param(l.start.x, false), sys.add_param(l.start.y, false));
|
||||
let p2 = GPoint::new(sys.add_param(l.end.x, false), sys.add_param(l.end.y, false));
|
||||
Some(EntityGeom::Line(GLine { p1, p2 }))
|
||||
}
|
||||
EntityType::Circle(c) => {
|
||||
let center = GPoint::new(sys.add_param(c.center.x, false), sys.add_param(c.center.y, false));
|
||||
let rad = sys.add_param(c.radius, false);
|
||||
Some(EntityGeom::Circle(GCircle { center, rad }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves one [`SketchRef`] against the entity-geometry cache, registering
|
||||
/// the entity on first use. `None` if the handle is dangling, isn't a
|
||||
/// supported entity type, or (for a point ref) uses a marker that entity
|
||||
/// type doesn't support.
|
||||
fn resolve_ref(
|
||||
document: &acadrust::CadDocument,
|
||||
sys: &mut System,
|
||||
cache: &mut HashMap<Handle, EntityGeom>,
|
||||
r: SketchRef,
|
||||
) -> Option<EntityGeom> {
|
||||
if !cache.contains_key(&r.entity) {
|
||||
let geom = register_entity(document, sys, r.entity)?;
|
||||
cache.insert(r.entity, geom);
|
||||
}
|
||||
Some(*cache.get(&r.entity)?)
|
||||
}
|
||||
|
||||
/// One [`SketchConstraint`]'s `ocs_gcs` construction, or `None` if it can't
|
||||
/// be built (an unsupported/not-yet-mapped kind, a dangling ref, a ref
|
||||
/// shape the kind doesn't expect — e.g. `Parallel` needs two whole-line
|
||||
/// refs). A constraint that can't be built is simply skipped for this
|
||||
/// solve, not an error: geometry it would have constrained is left alone,
|
||||
/// matching how a dangling associative-dimension reference degrades today
|
||||
/// rather than aborting the whole recompute.
|
||||
fn build_constraint(
|
||||
document: &acadrust::CadDocument,
|
||||
sys: &mut System,
|
||||
cache: &mut HashMap<Handle, EntityGeom>,
|
||||
c: &SketchConstraint,
|
||||
) -> Option<Rc<dyn Constraint>> {
|
||||
if !c.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? {
|
||||
EntityGeom::Line(l) => Some(l),
|
||||
EntityGeom::Circle(_) => None,
|
||||
};
|
||||
let whole_circle = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? {
|
||||
EntityGeom::Circle(circ) => Some(circ),
|
||||
EntityGeom::Line(_) => None,
|
||||
};
|
||||
let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| {
|
||||
let marker = r.marker?;
|
||||
resolve_ref(document, sys, cache, r)?.point_for_marker(marker)
|
||||
};
|
||||
|
||||
match c.kind {
|
||||
ConstraintKind::Coincident => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?);
|
||||
// Two coordinate-equal constraints packaged as one via a small
|
||||
// local combinator isn't worth it for a single call site — the
|
||||
// caller (`solve_scope`) already `extend`s a `Vec` per
|
||||
// constraint, so this returns just the X constraint and Y is
|
||||
// added as a second, independent `SketchConstraint`-less
|
||||
// push — see `solve_scope`'s handling of this arm specifically.
|
||||
Some(Rc::new(Equal::new(pa.x, pb.x, 1.0)))
|
||||
}
|
||||
ConstraintKind::Horizontal => {
|
||||
let l = whole_line(sys, cache, *c.refs.first()?)?;
|
||||
Some(Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0)))
|
||||
}
|
||||
ConstraintKind::Vertical => {
|
||||
let l = whole_line(sys, cache, *c.refs.first()?)?;
|
||||
Some(Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0)))
|
||||
}
|
||||
ConstraintKind::Parallel => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
|
||||
Some(Rc::new(ParallelConstraint::new(sys.store(), moving, fixed)))
|
||||
}
|
||||
ConstraintKind::Perpendicular => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
|
||||
Some(Rc::new(PerpendicularConstraint::new(sys.store(), moving, fixed)))
|
||||
}
|
||||
ConstraintKind::Equal => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) {
|
||||
return Some(Rc::new(EqualLineLength::new(lb, la)));
|
||||
}
|
||||
let (ca, cb) = (whole_circle(sys, cache, *a)?, whole_circle(sys, cache, *b)?);
|
||||
Some(Rc::new(Equal::new(cb.rad, ca.rad, 1.0)))
|
||||
}
|
||||
ConstraintKind::Distance => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?);
|
||||
let target = sys.add_param(c.driving_param?, true);
|
||||
Some(Rc::new(P2PDistance::new(pa, pb, target)))
|
||||
}
|
||||
ConstraintKind::Angle => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
|
||||
let angle = sys.add_param(c.driving_param?.to_radians(), true);
|
||||
Some(Rc::new(L2LAngle::new(fixed, moving, angle)))
|
||||
}
|
||||
ConstraintKind::Radius => {
|
||||
let circle = whole_circle(sys, cache, *c.refs.first()?)?;
|
||||
let target = sys.add_param(c.driving_param?, true);
|
||||
Some(Rc::new(Equal::new(circle.rad, target, 1.0)))
|
||||
}
|
||||
ConstraintKind::Tangent => {
|
||||
let [a, b] = c.refs.as_slice() else { return None };
|
||||
let (ga, gb) = (resolve_ref(document, sys, cache, *a)?, resolve_ref(document, sys, cache, *b)?);
|
||||
match (ga, gb) {
|
||||
(EntityGeom::Circle(c1), EntityGeom::Circle(c2)) => {
|
||||
let store = sys.store();
|
||||
let (x1, y1, r1) = (store.get(c1.center.x), store.get(c1.center.y), store.get(c1.rad));
|
||||
let (x2, y2, r2) = (store.get(c2.center.x), store.get(c2.center.y), store.get(c2.rad));
|
||||
let center_dist = ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt();
|
||||
// One circle already sits inside the other's span, rather
|
||||
// than the two side by side — pick internal tangency so
|
||||
// the first solve doesn't have to cross the singularity
|
||||
// between the two tangency configurations.
|
||||
let internal = center_dist < (r1 - r2).abs();
|
||||
Some(Rc::new(TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, internal)))
|
||||
}
|
||||
(EntityGeom::Circle(circ), EntityGeom::Line(line)) | (EntityGeom::Line(line), EntityGeom::Circle(circ)) => {
|
||||
let store = sys.store();
|
||||
let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y));
|
||||
let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y));
|
||||
let (x2, y2) = (store.get(line.p2.x), store.get(line.p2.y));
|
||||
// Signed area of (p2-p1) × (center-p1) — same formula
|
||||
// `C2LDistance::signed_value` itself uses — so `ccw`'s
|
||||
// sign matches whichever side the circle already sits on.
|
||||
let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1);
|
||||
let ccw = area >= 0.0;
|
||||
// A driven, fixed zero: with `internal: false` this makes
|
||||
// `C2LDistance`'s target exactly the circle's own radius
|
||||
// (see its `error_grad`), i.e. plain tangency rather than
|
||||
// an offset distance.
|
||||
let zero = sys.add_param(0.0, true);
|
||||
Some(Rc::new(C2LDistance::new(circ, line, zero, ccw, false)))
|
||||
}
|
||||
// Line-Line tangency has no meaning; Arc isn't a supported
|
||||
// `EntityGeom` yet (see that type's own doc comment).
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MOVE_EPS: f64 = 1e-9;
|
||||
|
||||
/// Rebuilds `set`'s entire `ocs_gcs::System` from current document
|
||||
/// geometry, solves it, and returns the resulting entity states for every
|
||||
/// handle whose registered coordinates actually moved beyond floating-point
|
||||
/// noise, plus the scope's total remaining degrees of freedom (summed across
|
||||
/// every independent `SubSystem` — design doc §6.3's DOF badge), plus (design
|
||||
/// doc §6.4, stage 11) any redundant/conflicting constraints found, resolved
|
||||
/// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer
|
||||
/// to remove. `None` if nothing in the scope could be built (no constraint
|
||||
/// resolved to anything). DOF/conflicts are still returned even when every
|
||||
/// subsystem fails to solve (rank is a structural property of the Jacobian,
|
||||
/// not of whether Dogleg converged) — only the geometry write-back is gated
|
||||
/// on a successful solve.
|
||||
fn solve_scope(
|
||||
document: &acadrust::CadDocument,
|
||||
set: &SketchConstraintSet,
|
||||
) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> {
|
||||
let mut sys = System::new();
|
||||
let mut cache: HashMap<Handle, EntityGeom> = HashMap::new();
|
||||
// Tracks which system-level `ocs_gcs` constraint came from which
|
||||
// `SketchConstraint` — a `SubSystem`'s redundant-row indices (from
|
||||
// `ocs_gcs::diagnosis`) are local to that partition's own constraint
|
||||
// list, not `set.constraints`' indices, and Coincident contributes two
|
||||
// system-level constraints (X and Y halves) for one `SketchConstraint`.
|
||||
// `Rc::ptr_eq` against this after partitioning resolves a row back to
|
||||
// the `ConstraintId` the UI actually names.
|
||||
let mut owner: Vec<(Rc<dyn Constraint>, ConstraintId)> = Vec::new();
|
||||
|
||||
for c in &set.constraints {
|
||||
let Some(constraint) = build_constraint(document, &mut sys, &mut cache, c) else { continue };
|
||||
owner.push((constraint.clone(), c.id));
|
||||
sys.add_constraint(constraint);
|
||||
// Coincident needs both X and Y equal; `build_constraint` returns
|
||||
// only the X half (see its doc comment there) since one
|
||||
// `SketchConstraint` maps to one `Rc<dyn Constraint>` everywhere
|
||||
// else — add the Y half here instead of complicating that
|
||||
// one-constraint-per-kind contract for a single kind.
|
||||
if c.kind == ConstraintKind::Coincident {
|
||||
if let [a, b] = c.refs.as_slice() {
|
||||
if let (Some(pa), Some(pb)) = (
|
||||
a.marker.and_then(|m| cache.get(&a.entity).and_then(|g| g.point_for_marker(m))),
|
||||
b.marker.and_then(|m| cache.get(&b.entity).and_then(|g| g.point_for_marker(m))),
|
||||
) {
|
||||
let y_half: Rc<dyn Constraint> = Rc::new(Equal::new(pa.y, pb.y, 1.0));
|
||||
owner.push((y_half.clone(), c.id));
|
||||
sys.add_constraint(y_half);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cache.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let partitions = sys.partition();
|
||||
for sub in &partitions {
|
||||
solve_dl(sub, sys.store_mut());
|
||||
}
|
||||
// `System::partition`'s subsystems only include params actually
|
||||
// referenced by some constraint (design doc's own note on
|
||||
// `System::partition` — the driven-param fix found while building stage
|
||||
// 3) — so a registered entity coordinate nothing constrains yet (e.g. a
|
||||
// line's X after only a Horizontal constraint pins its Ys) is invisible
|
||||
// to `diagnose` entirely, undercounting DOF. Every such untouched free
|
||||
// param is unconstrained on its own, i.e. exactly 1 DOF each — added
|
||||
// back here rather than fixed in `ocs_gcs::diagnosis`, which correctly
|
||||
// has no opinion on params outside the `SubSystem` it was handed.
|
||||
let total_free: usize = cache
|
||||
.values()
|
||||
.map(|g| match g {
|
||||
EntityGeom::Line(_) => 4,
|
||||
EntityGeom::Circle(_) => 3,
|
||||
})
|
||||
.sum();
|
||||
let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum();
|
||||
let mut dof = total_free.saturating_sub(touched_free);
|
||||
let mut conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)> = Vec::new();
|
||||
for sub in &partitions {
|
||||
let diag = ocs_gcs::diagnosis::diagnose(sub, sys.store());
|
||||
dof += diag.dof;
|
||||
if diag.redundant.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Opt-in per `classify_redundant`'s own doc comment (one extra solve
|
||||
// per redundant row) — only reached when a partition is actually
|
||||
// over-constrained, which is rare, so this never costs anything on
|
||||
// the common "no redundancy" path.
|
||||
for (row, kind) in ocs_gcs::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) {
|
||||
let row_constraint = &sub.constraints()[row];
|
||||
if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) {
|
||||
conflicts.push((id, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let store = sys.store();
|
||||
let mut results = Vec::new();
|
||||
for (&handle, geom) in &cache {
|
||||
let Some(entity) = document.get_entity(handle) else { continue };
|
||||
match (entity, geom) {
|
||||
(EntityType::Line(l), EntityGeom::Line(g)) => {
|
||||
let (x1, y1, x2, y2) = (store.get(g.p1.x), store.get(g.p1.y), store.get(g.p2.x), store.get(g.p2.y));
|
||||
if (x1 - l.start.x).abs() > MOVE_EPS
|
||||
|| (y1 - l.start.y).abs() > MOVE_EPS
|
||||
|| (x2 - l.end.x).abs() > MOVE_EPS
|
||||
|| (y2 - l.end.y).abs() > MOVE_EPS
|
||||
{
|
||||
let mut updated = l.clone();
|
||||
updated.start.x = x1;
|
||||
updated.start.y = y1;
|
||||
updated.end.x = x2;
|
||||
updated.end.y = y2;
|
||||
results.push((handle, EntityType::Line(updated)));
|
||||
}
|
||||
}
|
||||
(EntityType::Circle(c), EntityGeom::Circle(g)) => {
|
||||
let (cx, cy, r) = (store.get(g.center.x), store.get(g.center.y), store.get(g.rad));
|
||||
if (cx - c.center.x).abs() > MOVE_EPS || (cy - c.center.y).abs() > MOVE_EPS || (r - c.radius).abs() > MOVE_EPS
|
||||
{
|
||||
let mut updated = c.clone();
|
||||
updated.center.x = cx;
|
||||
updated.center.y = cy;
|
||||
updated.radius = r;
|
||||
results.push((handle, EntityType::Circle(updated)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((results, dof, conflicts))
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Design doc §4.1's `bump_entities` hook: for every constraint scope
|
||||
/// touched by `changes`, re-solve it and write back what moved. Returns
|
||||
/// the resulting `(Handle, ChangeKind::Modified)` entries the same way
|
||||
/// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities`
|
||||
/// to fold into its own `changes` vec.
|
||||
pub(crate) fn refresh_sketch_constraints(&mut self, changes: &[(Handle, ChangeKind)]) -> Vec<(Handle, ChangeKind)> {
|
||||
// Deletion policy (design doc §5.3/§12, open question 4): an erased
|
||||
// entity silently takes its constraints with it — matching FreeCAD
|
||||
// — rather than leaving a dangling `SketchRef` around. Done first,
|
||||
// and unconditionally over every scope (not just ones a `touched`
|
||||
// check would catch), so a scope left with zero constraints after
|
||||
// this doesn't attempt a pointless resolve below.
|
||||
for (handle, kind) in changes {
|
||||
if *kind != ChangeKind::Removed {
|
||||
continue;
|
||||
}
|
||||
for set in &mut self.sketch_constraints {
|
||||
set.remove_all_touching(*handle);
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
for i in 0..self.sketch_constraints.len() {
|
||||
let touched = changes
|
||||
.iter()
|
||||
.any(|(handle, _)| self.sketch_constraints[i].constraints_touching(*handle).next().is_some());
|
||||
if !touched {
|
||||
continue;
|
||||
}
|
||||
let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.sketch_constraints[i]) else { continue };
|
||||
self.sketch_constraints[i].dof = Some(dof);
|
||||
self.sketch_constraints[i].conflicts = conflicts;
|
||||
for (handle, new_entity) in solved {
|
||||
if let Some(before) = self.document.get_entity_arc(handle) {
|
||||
self.record_undo_before(handle, Some(before));
|
||||
}
|
||||
if let Some(slot) = self.document.get_entity_mut(handle) {
|
||||
*slot = new_entity;
|
||||
}
|
||||
result.push((handle, ChangeKind::Modified));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Design doc §7 open question 6, live-drag re-solve: a lighter sibling
|
||||
/// of `refresh_sketch_constraints` for a grip drag's per-mouse-move
|
||||
/// update — same "find touched scopes, rebuild the `ocs_gcs::System`
|
||||
/// from scratch, solve" core, but:
|
||||
/// - only reports what moved (`(Handle, EntityType)`, the actual new
|
||||
/// state) rather than writing it into `self.document` itself. A live
|
||||
/// grip drag already writes the directly-dragged handle's geometry
|
||||
/// straight into the document each frame (`Scene::apply_grip`), so
|
||||
/// this can read that live state via `solve_scope`'s normal document
|
||||
/// read — but the caller (the drag's per-frame update in
|
||||
/// `viewport.rs`) owns deciding how/when to write the *solved*
|
||||
/// result back, since it also has to fold the touched handles into
|
||||
/// this frame's preview/mesh/hatch refresh and the eventual
|
||||
/// grip-release undo group.
|
||||
/// - skips `record_undo_before` entirely: a grip drag tracks its
|
||||
/// before/after through its own `grip_originals`/`grip_preview_handles`
|
||||
/// arrays, committed as one `push_entity_group_history` group on
|
||||
/// release, not through the recording session
|
||||
/// `refresh_sketch_constraints` otherwise participates in — calling
|
||||
/// `record_undo_before` here with no such session active for a grip
|
||||
/// drag would either no-op uselessly or, worse, assume a recording
|
||||
/// context that doesn't exist for this call path.
|
||||
/// - skips the deletion-policy pass: nothing is erased mid-drag.
|
||||
///
|
||||
/// Cheap no-op when there are no sketch constraints at all (the common
|
||||
/// case), and per the design doc's own §4.2 architecture this still
|
||||
/// does a full rebuild-and-solve per touched scope on every call — i.e.
|
||||
/// every mouse-move frame during a drag that touches constrained
|
||||
/// geometry. Acceptable for the sketch scales this system has been
|
||||
/// exercised at so far; §7 open question 9 ("performance at scale
|
||||
/// untested") already flags this as an accepted, unprofiled risk this
|
||||
/// doesn't newly introduce.
|
||||
pub(crate) fn solve_sketch_constraints_preview(&self, touched: &[Handle]) -> Vec<(Handle, EntityType)> {
|
||||
if self.sketch_constraints.is_empty() || touched.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
for set in &self.sketch_constraints {
|
||||
let is_touched = touched.iter().any(|handle| set.constraints_touching(*handle).next().is_some());
|
||||
if !is_touched {
|
||||
continue;
|
||||
}
|
||||
let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, set) else { continue };
|
||||
result.extend(solved);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::sketch_constraints::{ConstraintKind, SketchConstraintSet, SketchRef, SketchScope};
|
||||
use super::Scene;
|
||||
use acadrust::entities::EntityType;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
// The bulk of this stage's coverage (Horizontal/Parallel/Distance/
|
||||
// Coincident solving, unrelated edits not triggering a resolve) lives
|
||||
// in `tests/sketch_constraints_solve.rs`, which only needs `pub` API.
|
||||
// This one test needs `record_undo_before`/`take_undo_recording`
|
||||
// (`pub(crate)`), so it stays internal.
|
||||
#[test]
|
||||
fn one_edit_that_ripples_through_a_constraint_still_records_as_one_undo_step() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(10.0, 0.0, 0.0),
|
||||
)));
|
||||
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 5.0, 0.0),
|
||||
Vector3::new(10.0, 5.0, 0.0),
|
||||
)));
|
||||
|
||||
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
|
||||
set.add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None);
|
||||
scene.sketch_constraints.push(set);
|
||||
|
||||
scene.begin_undo_recording();
|
||||
// A real command (Move, grip-drag commit, ...) records its own
|
||||
// before-image before mutating.
|
||||
let before_a = scene.document.get_entity_arc(a);
|
||||
scene.record_undo_before(a, before_a);
|
||||
if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) {
|
||||
l.end = Vector3::new(10.0, 6.0, 0.0);
|
||||
}
|
||||
scene.bump_entities(&[(a, super::ChangeKind::Modified)]);
|
||||
let recording = scene.take_undo_recording().expect("an undo recording should still be open");
|
||||
|
||||
let (entities, _objects) = recording.into_recorded_images();
|
||||
let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect();
|
||||
assert!(touched.contains(&a), "the directly-edited line must be in the undo delta");
|
||||
assert!(touched.contains(&b), "the constraint-solved neighbor must ride the same undo delta");
|
||||
}
|
||||
|
||||
/// Design doc §7 open question 6 (live-drag re-solve):
|
||||
/// `solve_sketch_constraints_preview` must report the constrained
|
||||
/// neighbor's solved position without writing anything into the
|
||||
/// document itself — the grip-drag caller owns applying it.
|
||||
#[test]
|
||||
fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(10.0, 0.0, 0.0),
|
||||
)));
|
||||
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 5.0, 0.0),
|
||||
Vector3::new(10.0, 5.0, 0.0),
|
||||
)));
|
||||
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
|
||||
set.add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None);
|
||||
scene.sketch_constraints.push(set);
|
||||
|
||||
// Simulate a live grip drag: mutate `a` directly (as `apply_grip`
|
||||
// would each frame), without going through `bump_entities` at all.
|
||||
if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) {
|
||||
l.end = Vector3::new(10.0, 6.0, 0.0);
|
||||
}
|
||||
let b_before = scene.document.get_entity(b).cloned();
|
||||
|
||||
let solved = scene.solve_sketch_constraints_preview(&[a]);
|
||||
|
||||
assert_eq!(scene.document.get_entity(b), b_before.as_ref(), "preview must not mutate the document");
|
||||
// The whole scope solves together (every registered entity's params
|
||||
// are free, not just `b`'s — see `register_entity`'s doc comment),
|
||||
// so `a` itself may also have shifted slightly to reach the nearest
|
||||
// mutually-parallel configuration; read whichever position `a` ends
|
||||
// up at from `solved` too, rather than assuming it stayed exactly
|
||||
// where the simulated drag put it.
|
||||
let line_dir = |entity: &EntityType| {
|
||||
let EntityType::Line(l) = entity else { panic!("expected a Line") };
|
||||
(l.end.x - l.start.x, l.end.y - l.start.y)
|
||||
};
|
||||
let dir_a = solved
|
||||
.iter()
|
||||
.find(|(h, _)| *h == a)
|
||||
.map(|(_, e)| line_dir(e))
|
||||
.unwrap_or((10.0, 6.0));
|
||||
let (_, moved_entity) = solved.iter().find(|(h, _)| *h == b).expect("b should be reported as moved");
|
||||
let dir_b = line_dir(moved_entity);
|
||||
let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0;
|
||||
assert!(cross.abs() < 1e-6, "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}");
|
||||
}
|
||||
}
|
||||
@@ -645,6 +645,7 @@ pub fn selection_overlay<'a>(
|
||||
crosshair_bg: [f32; 4],
|
||||
crosshair: CrosshairOptions,
|
||||
selection_visual: SelectionVisualOptions,
|
||||
constraint_glyphs: Vec<(Point, String, bool)>,
|
||||
) -> Element<'a, Message> {
|
||||
canvas(SelectionCanvas {
|
||||
selection,
|
||||
@@ -668,6 +669,7 @@ pub fn selection_overlay<'a>(
|
||||
crosshair_bg,
|
||||
crosshair,
|
||||
selection_visual,
|
||||
constraint_glyphs,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -726,6 +728,13 @@ 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_glyphs: Vec<(Point, String, bool)>,
|
||||
}
|
||||
|
||||
fn draw_grip_marker(
|
||||
@@ -1618,6 +1627,44 @@ 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.
|
||||
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
|
||||
// ordinary primary one — same information a resolver panel
|
||||
// would show, surfaced right on the geometry.
|
||||
let conflict_bg = theme.palette().danger.base.color;
|
||||
let conflict_fg = theme.palette().danger.base.text;
|
||||
for (anchor, label, is_conflicting) in &self.constraint_glyphs {
|
||||
if !anchor.x.is_finite() || !anchor.y.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let (bg, fg) = if *is_conflicting { (conflict_bg, conflict_fg) } else { (normal_bg, normal_fg) };
|
||||
// Rough width estimate (monospace-ish glyph set, short labels)
|
||||
// avoids a text-measurement pass just to size the background pill.
|
||||
let w = label.chars().count() as f32 * GLYPH_SIZE * 0.62 + GLYPH_PAD_X * 2.0;
|
||||
let h = GLYPH_SIZE + GLYPH_PAD_Y * 2.0;
|
||||
let top_left = Point::new(anchor.x - w * 0.5, anchor.y - h * 0.5);
|
||||
let pill = canvas::Path::rounded_rectangle(top_left, iced::Size::new(w, h), (h * 0.5).into());
|
||||
frame.fill(&pill, bg);
|
||||
frame.fill_text(canvas::Text {
|
||||
content: label.clone(),
|
||||
position: Point::new(top_left.x + GLYPH_PAD_X, top_left.y + GLYPH_PAD_Y),
|
||||
color: fg,
|
||||
size: iced::Pixels(GLYPH_SIZE),
|
||||
shaping: iced::advanced::text::Shaping::Advanced,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
// Small cross at each acquired tracking point.
|
||||
for ost in &self.ost_points {
|
||||
let tp = ost.screen;
|
||||
|
||||
+56
-6
@@ -95,6 +95,12 @@ 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>,
|
||||
@@ -191,6 +197,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +213,28 @@ 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))
|
||||
@@ -599,6 +629,8 @@ impl Ribbon {
|
||||
&self.active_linetype,
|
||||
self.active_lineweight,
|
||||
&style_ctx,
|
||||
self.label_font_size,
|
||||
self.group_title_font_size,
|
||||
),
|
||||
render_group(
|
||||
true,
|
||||
@@ -613,6 +645,8 @@ impl Ribbon {
|
||||
&self.active_linetype,
|
||||
self.active_lineweight,
|
||||
&style_ctx,
|
||||
self.label_font_size,
|
||||
self.group_title_font_size,
|
||||
),
|
||||
collapse_button(
|
||||
g,
|
||||
@@ -627,6 +661,7 @@ impl Ribbon {
|
||||
&self.active_linetype,
|
||||
self.active_lineweight,
|
||||
&style_ctx,
|
||||
self.label_font_size,
|
||||
false,
|
||||
),
|
||||
collapse_button(
|
||||
@@ -642,13 +677,14 @@ impl Ribbon {
|
||||
&self.active_linetype,
|
||||
self.active_lineweight,
|
||||
&style_ctx,
|
||||
self.label_font_size,
|
||||
true,
|
||||
),
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
|
||||
CollapsePanels::new(panels, self.collapsed_open.clone(), widgets::tool_bar_h(self.group_title_font_size as f32))
|
||||
.report_height(self.tool_bar_h.clone())
|
||||
.report_tight(self.collapse_tight.clone())
|
||||
.mode(self.collapse_mode)
|
||||
@@ -1295,7 +1331,7 @@ impl Ribbon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single ribbon panel (tools + group label), fixed `TOOL_BAR_H` tall.
|
||||
/// Render a single ribbon panel (tools + group label), `tool_bar_h(group_title_font_size)` tall.
|
||||
/// When `compact`, large tools/dropdowns are drawn as small icon columns.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_group<'a>(
|
||||
@@ -1311,6 +1347,8 @@ 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();
|
||||
@@ -1327,6 +1365,7 @@ fn render_group<'a>(
|
||||
active_lineweight,
|
||||
style_ctx,
|
||||
compact,
|
||||
label_font_size: label_font_size as f32,
|
||||
};
|
||||
|
||||
for item in &group.tools {
|
||||
@@ -1364,12 +1403,17 @@ fn render_group<'a>(
|
||||
|
||||
column![
|
||||
tools_el,
|
||||
container(text(t!(group.title)).size(9).style(muted_text_style)).padding([1, 4]),
|
||||
container(
|
||||
text(t!(group.title))
|
||||
.size(group_title_font_size as f32)
|
||||
.style(muted_text_style),
|
||||
)
|
||||
.padding([1, 4]),
|
||||
]
|
||||
.align_x(iced::Center)
|
||||
.spacing(0)
|
||||
.padding([3u16, 4])
|
||||
.height(Length::Fixed(TOOL_BAR_H))
|
||||
.height(Length::Fixed(widgets::tool_bar_h(group_title_font_size as f32)))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1432,6 +1476,7 @@ 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.
|
||||
@@ -1472,7 +1517,9 @@ fn collapse_button<'a>(
|
||||
.style(button::subtle)
|
||||
.width(Fill)
|
||||
.padding([3, 5]);
|
||||
return automatic_large_button(localized_title, content.into());
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Collapsed (not yet tight): a large representative-tool face — a live button
|
||||
@@ -1495,6 +1542,7 @@ fn collapse_button<'a>(
|
||||
active_lineweight,
|
||||
style_ctx,
|
||||
compact: false,
|
||||
label_font_size: label_font_size as f32,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1513,6 +1561,7 @@ fn collapse_button<'a>(
|
||||
active_lineweight,
|
||||
style_ctx,
|
||||
compact: false,
|
||||
label_font_size: label_font_size as f32,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1537,7 +1586,8 @@ fn collapse_button<'a>(
|
||||
.style(button::subtle)
|
||||
.width(Fill)
|
||||
.padding([1, 4]);
|
||||
let opener = automatic_large_button(localized_title, opener.into());
|
||||
// Same fixed-size opener title as the tight-mode branch above.
|
||||
let opener = automatic_large_button(localized_title, opener.into(), 9.0);
|
||||
|
||||
// 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.
|
||||
|
||||
+88
-28
@@ -56,15 +56,40 @@ 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.
|
||||
/// 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.
|
||||
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.
|
||||
@@ -86,8 +111,11 @@ 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 so the automatic sizing stays cheap.
|
||||
static LARGE_WIDTH_CACHE: RefCell<HashMap<String, f32>> =
|
||||
/// 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>> =
|
||||
RefCell::new(HashMap::default());
|
||||
}
|
||||
|
||||
@@ -96,6 +124,7 @@ fn ribbon_label_bounds(
|
||||
label: &str,
|
||||
width: f32,
|
||||
wrapping: advanced_text::Wrapping,
|
||||
label_size: f32,
|
||||
) -> Size {
|
||||
use advanced_text::{Paragraph as _, Renderer as _};
|
||||
|
||||
@@ -103,7 +132,7 @@ fn ribbon_label_bounds(
|
||||
advanced_text::Text {
|
||||
content: label,
|
||||
bounds: Size::new(width, f32::INFINITY),
|
||||
size: Pixels(LARGE_LABEL_SIZE),
|
||||
size: Pixels(label_size),
|
||||
line_height: advanced_text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: advanced_text::Alignment::Center,
|
||||
@@ -117,28 +146,37 @@ fn ribbon_label_bounds(
|
||||
paragraph.min_bounds()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// changes do not rely on character-count estimates.
|
||||
fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
|
||||
fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 {
|
||||
let base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0);
|
||||
let line_height = advanced_text::LineHeight::default()
|
||||
.to_absolute(Pixels(LARGE_LABEL_SIZE))
|
||||
.to_absolute(Pixels(label_size))
|
||||
.0;
|
||||
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
|
||||
let fits = |width: f32| {
|
||||
|
||||
let fits_one_line = |width: f32| {
|
||||
ribbon_label_bounds(
|
||||
renderer,
|
||||
label,
|
||||
width,
|
||||
advanced_text::Wrapping::WordOrGlyph,
|
||||
label_size,
|
||||
)
|
||||
.height
|
||||
<= max_label_height
|
||||
<= line_height + 0.5
|
||||
};
|
||||
|
||||
if fits(base_inner) {
|
||||
if fits_one_line(base_inner) {
|
||||
return LARGE_W;
|
||||
}
|
||||
|
||||
@@ -147,18 +185,34 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
|
||||
label,
|
||||
f32::INFINITY,
|
||||
advanced_text::Wrapping::None,
|
||||
label_size,
|
||||
)
|
||||
.width
|
||||
.max(base_inner);
|
||||
if !fits(natural) {
|
||||
if natural <= LARGE_LABEL_MAX_SINGLE_LINE_W {
|
||||
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| {
|
||||
ribbon_label_bounds(
|
||||
renderer,
|
||||
label,
|
||||
width,
|
||||
advanced_text::Wrapping::WordOrGlyph,
|
||||
label_size,
|
||||
)
|
||||
.height
|
||||
<= max_label_height
|
||||
};
|
||||
let mut low = base_inner;
|
||||
let mut high = natural;
|
||||
let mut high = LARGE_LABEL_MAX_SINGLE_LINE_W;
|
||||
for _ in 0..10 {
|
||||
let mid = (low + high) * 0.5;
|
||||
if fits(mid) {
|
||||
if fits_two_lines(mid) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = mid;
|
||||
@@ -167,14 +221,15 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
|
||||
(high + LARGE_LABEL_HPAD).ceil().max(LARGE_W)
|
||||
}
|
||||
|
||||
fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
|
||||
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) {
|
||||
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()) {
|
||||
return width;
|
||||
}
|
||||
|
||||
let width = measure_large_width(renderer, label);
|
||||
let width = measure_large_width(renderer, label, label_size);
|
||||
LARGE_WIDTH_CACHE.with(|cache| {
|
||||
cache.borrow_mut().insert(label.to_string(), width);
|
||||
cache.borrow_mut().insert(key, width);
|
||||
});
|
||||
width
|
||||
}
|
||||
@@ -182,6 +237,7 @@ fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
|
||||
struct AutomaticLargeWidth<'a> {
|
||||
label: String,
|
||||
content: Element<'a, Message>,
|
||||
label_size: f32,
|
||||
}
|
||||
|
||||
impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
|
||||
@@ -207,7 +263,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);
|
||||
let width = automatic_large_width(renderer, &self.label, self.label_size);
|
||||
self.content.as_widget_mut().layout(
|
||||
tree,
|
||||
renderer,
|
||||
@@ -287,8 +343,9 @@ 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 })
|
||||
Element::new(AutomaticLargeWidth { label, content, label_size })
|
||||
}
|
||||
|
||||
// ── Tab-bar constants ──────────────────────────────────────────────────────
|
||||
@@ -633,6 +690,9 @@ 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
|
||||
@@ -682,7 +742,7 @@ pub(super) fn render_large_dropdown<'a>(
|
||||
.align_x(iced::Center)
|
||||
.align_y(iced::Center),
|
||||
text(label.clone())
|
||||
.size(10)
|
||||
.size(ctx.label_font_size)
|
||||
.width(Fill)
|
||||
.align_x(iced::Center)
|
||||
.wrapping(advanced_text::Wrapping::WordOrGlyph),
|
||||
@@ -735,7 +795,7 @@ pub(super) fn render_large_dropdown<'a>(
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
|
||||
PosReport::new(id, automatic_large_button(label, content.into()))
|
||||
PosReport::new(id, automatic_large_button(label, content.into(), ctx.label_font_size))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -815,7 +875,7 @@ pub(super) fn render_large<'a>(
|
||||
.align_x(iced::Center)
|
||||
.align_y(iced::Center),
|
||||
text(label.clone())
|
||||
.size(10)
|
||||
.size(ctx.label_font_size)
|
||||
.width(Fill)
|
||||
.align_x(iced::Center)
|
||||
.wrapping(advanced_text::Wrapping::WordOrGlyph),
|
||||
@@ -836,7 +896,7 @@ pub(super) fn render_large<'a>(
|
||||
left: 4.0,
|
||||
});
|
||||
tooltip(
|
||||
automatic_large_button(label, btn.into()),
|
||||
automatic_large_button(label, btn.into(), ctx.label_font_size),
|
||||
make_tip(tip_text),
|
||||
TipPos::Right,
|
||||
)
|
||||
|
||||
@@ -125,6 +125,16 @@ 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
|
||||
// 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
|
||||
// the pill entirely, so an ordinarily/fully-constrained drawing sees
|
||||
// no new clutter.
|
||||
sketch_conflicts: usize,
|
||||
) -> Element<'a, Message> {
|
||||
let StatusMenuData {
|
||||
layout_names,
|
||||
@@ -243,6 +253,44 @@ impl StatusBar {
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Not a `StatusPill` (so not user-hideable yet): invisible until the
|
||||
// current scope actually has a SketchConstraintSet, so a drawing
|
||||
// that never uses parametric constraints sees no new clutter.
|
||||
if let Some(dof) = sketch_dof {
|
||||
let pill = if dof == 0 {
|
||||
success_pill(crate::tf!("DOF: {dof}").into_owned())
|
||||
} else {
|
||||
status_pill(crate::tf!("DOF: {dof}").into_owned())
|
||||
};
|
||||
pills.push(
|
||||
tip(
|
||||
pill,
|
||||
if dof == 0 {
|
||||
t!("Fully constrained — no remaining degrees of freedom")
|
||||
} else {
|
||||
t!("Remaining degrees of freedom in the current sketch's persistent constraints")
|
||||
},
|
||||
)
|
||||
.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.
|
||||
if sketch_conflicts > 0 {
|
||||
pills.push(
|
||||
tip(
|
||||
action_pill(
|
||||
crate::tf!("⚠ {sketch_conflicts} conflicting").into_owned(),
|
||||
Message::ResolveOneSketchConflict,
|
||||
),
|
||||
t!("One or more constraints in this sketch conflict or are redundant\nClick to remove one and re-solve"),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if vis(StatusPill::Ortho) {
|
||||
pills.push(
|
||||
tip(
|
||||
@@ -1034,6 +1082,25 @@ 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.
|
||||
fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
|
||||
container(text(label.into()).size(12))
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.success.weak.color)),
|
||||
text_color: Some(palette.success.weak.text),
|
||||
border: Border { color: palette.success.base.color, width: 1.0, radius: 4.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.padding([4, 8])
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Scale popup button ────────────────────────────────────────────────────
|
||||
|
||||
/// Shared visual root for labelled status-bar menus (units, scale, …).
|
||||
|
||||
@@ -41,6 +41,8 @@ pub fn view_window<'a>(
|
||||
crosshair_color: Option<[u8; 3]>,
|
||||
crosshair_color_input: &'a str,
|
||||
lineweight_display_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,
|
||||
@@ -535,6 +537,56 @@ pub fn view_window<'a>(
|
||||
text(crate::t!("Changes the on-screen width in Model without affecting plotted output."))
|
||||
.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