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 <noreply@anthropic.com>
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,
|
||||
|
||||
Reference in new issue
Block a user