Add 8 geometric constraint kinds and an opt-in DWG-native persistence toggle

New ConstraintKind variants — Concentric, CenterPoint, Colinear, Midpoint,
Fixed, PointOnCurve, EqualDistance, Symmetric — each built entirely from
existing ocs_gcs (FreeCAD planegcs) primitives, no new solver math needed.
build_constraint now returns Vec<Rc<dyn Constraint>> instead of
Option<Rc<...>>, generalizing the old Coincident-only "needs a second
system-level constraint" special case to every kind that needs one.
Wired into the ribbon (7 new buttons), a new point+pre-selected-entity
command for CenterPoint/Midpoint/PointOnCurve, a new 4-point command for
EqualDistance, and the existing DWG-native class-name mapping (all 8 reuse
the same generic "plain Geometrical node" path already built for
Coincident/Horizontal/etc.). Deliberately not modeled: Normal and
EqualCurvature, which for this app's Line/Circle-only entity model would
just be Perpendicular and Equal wearing different DWG class names.

Also makes DWG-native constraint persistence opt-in (Options > General,
off by default) rather than unconditional: a scope that doesn't yet have
the native graph is only given one when the setting is on, but a scope
that already has one — from an earlier save, or a file that came from
real AutoCAD — keeps getting it resynced regardless of the setting, so
toggling it off never freezes a stale graph in place.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 19:38:31 +02:00
1 parent cf33a1d019
commit 31b5ef4ec3
18 files changed
+1076 -106

No files matched your search

+56
View File
@@ -2375,6 +2375,62 @@ impl OpenCADStudio {
}
}
}
CmdResult::AddPointOnEntityConstraint { point, target, kind, 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 = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target));
match resolved {
Some(point_ref) => {
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
// `CenterPoint` addresses its whole-entity side via
// the center marker (same solve as Coincident /
// Concentric — `ConstraintKind::CenterPoint`'s own
// doc comment); `Midpoint`/`PointOnCurve` address it
// as a whole entity.
let target_ref =
if kind == ConstraintKind::CenterPoint { SketchRef::center(target) } else { SketchRef::whole(target) };
return self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind,
refs: vec![point_ref, target_ref],
driving_param: None,
label,
});
}
None => {
self.command_line.push_error(
"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::AddEqualDistanceConstraint { points, 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 mut refs = Vec::with_capacity(4);
for p in points {
let Some(r) = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(p), None)
else {
refs.clear();
break;
};
refs.push(r);
}
if refs.len() == 4 {
return self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: crate::scene::sketch_constraints::ConstraintKind::EqualDistance,
refs,
driving_param: None,
label,
});
}
self.command_line.push_error(
"Equal Distance: a 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();
+84 -7
View File
@@ -959,7 +959,7 @@ impl OpenCADStudio {
// ── Constraints (persistent: added to the scope's SketchConstraintSet,
// solved via Scene::bump_entities, re-solved on every later edit —
// see docs/parametric_system_design.md §6.1) ──
"HCONSTRAINT" | "VCONSTRAINT" => {
"HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -968,14 +968,14 @@ impl OpenCADStudio {
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.");
.push_output("Select exactly one entity, 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")
let (kind, label) = match cmd {
"HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"),
"VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"),
_ => (ConstraintKind::Fixed, "Fixed constraint"),
};
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind,
@@ -993,7 +993,14 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" => {
"EDCONSTRAINT" => {
use crate::modules::draw::constrain::EqualDistanceConstraintCommand;
let new_cmd = EqualDistanceConstraintCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -1011,6 +1018,7 @@ impl OpenCADStudio {
"PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"),
"QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"),
"TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"),
"LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"),
_ => (ConstraintKind::Equal, "Equal constraint"),
};
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
@@ -1022,6 +1030,75 @@ impl OpenCADStudio {
}
}
"NCONSTRAINT" => {
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 circles/arcs, then run this constraint again.");
} else {
use crate::command::CmdResult;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Concentric,
refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])],
driving_param: None,
label: "Concentric constraint",
}));
}
}
"SYCONSTRAINT" => {
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() != 3 {
self.command_line.push_output(
"Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.",
);
} else {
use crate::command::CmdResult;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Symmetric,
refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])],
driving_param: None,
label: "Symmetric constraint",
}));
}
}
"CPCONSTRAINT" | "MPCONSTRAINT" | "OCCONSTRAINT" => {
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 entity (the circle or line the point should attach to), then run this constraint again.",
);
} else {
use crate::modules::draw::constrain::PointOnEntityConstraintCommand;
use crate::scene::sketch_constraints::ConstraintKind;
let (name, kind, label) = match cmd {
"CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"),
"MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"),
_ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"),
};
let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
}
"DCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
+17
View File
@@ -533,6 +533,19 @@ pub(super) struct OpenCADStudio {
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
/// handler on each launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool,
/// When true, saving a drawing with sketch constraints also materializes
/// them as AutoCAD's own native `AssocNetwork`/`Assoc2dConstraintGroup`
/// object graph (`scene::dwg_native_constraints`), so AutoCAD/BricsCAD
/// recognize them as real constraints rather than an opaque OCS-only
/// XRecord. Off by default (#opt-in — adds objects/file weight every
/// user pays for save-after-save, whether or not they need AutoCAD
/// interop). A drawing that already carries this native graph (from a
/// prior save with this on, or from real AutoCAD) keeps it synced on
/// every save regardless of this setting — see
/// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment;
/// this only governs whether the graph is *created* in a scope that
/// doesn't have one yet.
pub write_dwg_native_constraints: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave.
pub savetime_min: i32,
@@ -2002,6 +2015,9 @@ pub enum Message {
/// Register or unregister as the .dwg/.dxf handler, from Options. Same
/// setting the FILEASSOC command carries.
FileAssocChanged(bool),
/// Toggle writing AutoCAD-native constraint objects on save, from
/// Options. See `write_dwg_native_constraints`'s doc comment.
WriteDwgNativeConstraintsChanged(bool),
/// Switch the interface language and redraw localized views.
LanguageChanged(crate::i18n::Language),
/// Drop every entity from the active drawing.
@@ -3426,6 +3442,7 @@ impl OpenCADStudio {
dimension_continue_mode: 1,
backup_on_save: true,
file_assoc_enabled: true,
write_dwg_native_constraints: false,
savetime_min: 10,
default_bg_color: None,
default_paper_bg_color: None,
+10
View File
@@ -225,6 +225,15 @@ pub struct UserSettings {
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
/// handler on every launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool,
/// When true, saving a drawing also writes its sketch constraints as
/// AutoCAD's own native object graph (`scene::dwg_native_constraints`),
/// not just the app's own XRecord format. Off by default — opt-in, since
/// it adds file weight every save whether or not AutoCAD/BricsCAD
/// interop is needed. See `write_dwg_native_constraints` on the app
/// struct for the full contract (a drawing that already has this native
/// graph keeps it synced regardless of this setting).
#[serde(default)]
pub write_dwg_native_constraints: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
@@ -338,6 +347,7 @@ impl Default for UserSettings {
textfill: true,
backup_on_save: true,
file_assoc_enabled: true,
write_dwg_native_constraints: false,
savetime_min: 10,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
pick_add: true,
+4 -2
View File
@@ -393,6 +393,7 @@ impl OpenCADStudio {
textfill: crate::scene::text::sdf_atlas::textfill(),
backup_on_save: self.backup_on_save,
file_assoc_enabled: self.file_assoc_enabled,
write_dwg_native_constraints: self.write_dwg_native_constraints,
savetime_min: self.savetime_min,
default_save_format: self.default_save_format.clone(),
pick_add: self.pick_add,
@@ -459,6 +460,7 @@ impl OpenCADStudio {
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
self.backup_on_save = s.backup_on_save;
self.file_assoc_enabled = s.file_assoc_enabled;
self.write_dwg_native_constraints = s.write_dwg_native_constraints;
self.savetime_min = s.savetime_min;
self.default_save_format =
crate::io::canonical_save_format(&s.default_save_format).to_string();
@@ -1671,7 +1673,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// docs/dwg_constraint_compatibility_design.md: additive DWG/DXF-
// native constraint graph, alongside (not instead of) the XRecord
// above — same save-time hook.
self.tabs[i].scene.materialize_dwg_native_constraints_for_save();
self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints);
}
#[cfg(not(target_arch = "wasm32"))]
@@ -2624,7 +2626,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.sync_solid_models_for_save(i);
self.tabs[i].scene.materialize_sketch_constraints_for_save();
self.tabs[i].scene.materialize_named_parameters_for_save();
self.tabs[i].scene.materialize_dwg_native_constraints_for_save();
self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints);
let tab_id = self.tabs[i].id;
let bounds = crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
+5
View File
@@ -6198,6 +6198,11 @@ impl OpenCADStudio {
}
Task::none()
}
Message::WriteDwgNativeConstraintsChanged(enabled) => {
self.write_dwg_native_constraints = enabled;
self.persist_settings_if_changed();
Task::none()
}
Message::LanguageChanged(language) => {
if self.language == language {
return Task::none();
+1
View File
@@ -195,6 +195,7 @@ impl OpenCADStudio {
crate::ui::window::options::view_window(
&self.default_save_format,
self.file_assoc_enabled,
self.write_dwg_native_constraints,
&self.ui_theme,
&self.theme_color_inputs,
self.language,