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:
Felix RiestraandClaude Sonnet 5 committed 2026-09-08 22:19:23 +02:00
1 parent a39c7b8f71
commit 63a74b19a9
63 files changed
+12193 -49

No files matched your search

+2
View File
@@ -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,
+414
View File
@@ -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"
);
}
}
+111
View File
@@ -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
View File
@@ -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 {
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+12
View File
@@ -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,
+16
View File
@@ -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() {
+32 -1
View File
@@ -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()
+47
View File
@@ -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)
+2
View File
@@ -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,
+29
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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
}
}
+21
View File
@@ -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};
+75
View File
@@ -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()),
}
}
}
+184
View File
@@ -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
}
}
+16
View File
@@ -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![
+7 -1
View File
@@ -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()],
+24
View File
@@ -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);
}
+5
View File
@@ -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
}
+4 -2
View File
@@ -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 {
+534
View File
@@ -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);
}
}
+248
View File
@@ -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");
}
}
+562
View File
@@ -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:?}");
}
}
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+67
View File
@@ -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, …).
+52
View File
@@ -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);