From 31b5ef4ec3072e9e08c4a9de529461918c76d5fb Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Wed, 9 Sep 2026 19:38:31 +0200 Subject: [PATCH] Add 8 geometric constraint kinds and an opt-in DWG-native persistence toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> instead of Option>, 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 --- src/app/command_driver.rs | 56 +++++ src/app/commands/draw.rs | 91 ++++++- src/app/mod.rs | 17 ++ src/app/settings.rs | 10 + src/app/update/file.rs | 6 +- src/app/update/mod.rs | 5 + src/app/view/modal.rs | 1 + src/command.rs | 26 ++ src/modules/draw/constrain/equal_distance.rs | 67 +++++ src/modules/draw/constrain/mod.rs | 37 ++- src/modules/draw/constrain/point_on_entity.rs | 91 +++++++ src/modules/draw/constrain/tools.rs | 48 ++++ src/modules/draw/mod.rs | 14 +- src/scene/dwg_native_constraints.rs | 205 ++++++++++++++- src/scene/sketch_constraints.rs | 43 ++++ src/scene/sketch_solve.rs | 237 ++++++++++++------ src/ui/window/options.rs | 19 ++ tests/sketch_constraints_solve.rs | 209 +++++++++++++++ 18 files changed, 1076 insertions(+), 106 deletions(-) create mode 100644 src/modules/draw/constrain/equal_distance.rs create mode 100644 src/modules/draw/constrain/point_on_entity.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a455b7a0f..28280035d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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(); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 42afe995a..9e86e2434 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -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() { diff --git a/src/app/mod.rs b/src/app/mod.rs index b7723545f..90dd635d5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, diff --git a/src/app/settings.rs b/src/app/settings.rs index 589555127..b342766f9 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -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, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1b2cc5063..a4f61f3d2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 { // 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 { 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, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 76cca25dc..5ea85a579 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0d921a8c7..42f399036 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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, diff --git a/src/command.rs b/src/command.rs index 13a109e59..b40bbcc68 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1360,6 +1360,32 @@ pub enum CmdResult { point_b: DVec3, label: &'static str, }, + /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint + /// (`crate::modules::draw::constrain::point_on_entity`) between one + /// picked point and `target`, a whole entity selected before the tool + /// ran. Like `AddCoincidentConstraint`, the host resolves `point` via + /// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no + /// document access) and, for `CenterPoint` specifically, addresses + /// `target` via its center marker rather than as a whole entity — see + /// `ConstraintKind::CenterPoint`'s own doc comment for why it's the + /// same solve as `Coincident`/`Concentric` under a different DWG-native + /// class name. + AddPointOnEntityConstraint { + point: DVec3, + target: Handle, + kind: crate::scene::sketch_constraints::ConstraintKind, + label: &'static str, + }, + /// Add a persistent `EqualDistance` constraint + /// (`crate::modules::draw::constrain::equal_distance`): the distance + /// between `points[0]`/`points[1]` equals the distance between + /// `points[2]`/`points[3]`. The host resolves each point via + /// `sketch_constraints::nearest_sketch_point`, same reasoning as + /// `AddCoincidentConstraint`. + AddEqualDistanceConstraint { + points: [DVec3; 4], + label: &'static str, + }, /// Attach one smart centre mark to a newly selected circular source. ReassociateCenterMark { target: Handle, diff --git a/src/modules/draw/constrain/equal_distance.rs b/src/modules/draw/constrain/equal_distance.rs new file mode 100644 index 000000000..ed648a458 --- /dev/null +++ b/src/modules/draw/constrain/equal_distance.rs @@ -0,0 +1,67 @@ +//! EDCONSTRAINT — AutoCAD's fourth `Equal` sub-kind +//! (`ACEQUALDISTANCECONSTRAINT`): the distance between one point pair +//! equals the distance between another, rather than two whole entities +//! being equal length/radius. Needs four point picks (unlike the two +//! whole-entity selections `ECONSTRAINT` uses), so — like +//! `CoincidentConstraintCommand` — it accumulates raw points and hands them +//! back for the host to resolve (`CadCommand` has no document access). + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub mod equal_distance_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "EDCONSTRAINT", + label: "Equal Distance", + icon: IconKind::Glyph("≐"), + event: ModuleEvent::Command("EDCONSTRAINT".to_string()), + } + } +} + +#[derive(Default)] +pub struct EqualDistanceConstraintCommand { + points: Vec, +} + +impl EqualDistanceConstraintCommand { + pub fn new() -> Self { + Self::default() + } +} + +impl CadCommand for EqualDistanceConstraintCommand { + fn name(&self) -> &'static str { + "EDCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => "EQUAL DISTANCE Specify first point of first pair:".to_string(), + 1 => "EQUAL DISTANCE Specify second point of first pair:".to_string(), + 2 => "EQUAL DISTANCE Specify first point of second pair:".to_string(), + _ => "EQUAL DISTANCE Specify second point of second pair:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.points.push(pt); + if self.points.len() < 4 { + return CmdResult::NeedPoint; + } + let [a, b, c, d] = self.points[..] else { unreachable!("just checked len == 4") }; + CmdResult::AddEqualDistanceConstraint { points: [a, b, c, d], label: "Equal distance constraint" } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/draw/constrain/mod.rs b/src/modules/draw/constrain/mod.rs index 8cbb9ebc9..652166409 100644 --- a/src/modules/draw/constrain/mod.rs +++ b/src/modules/draw/constrain/mod.rs @@ -1,21 +1,36 @@ //! 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 +//! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`] +//! to the current scope and lets `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. +//! (`src/scene/sketch_solve.rs`). By how they gather input: +//! - **Plain selection, no picking** — Horizontal, Vertical, Fixed (select +//! one entity); Parallel, Perpendicular, Equal, Tangent, Colinear, +//! Concentric (select two); Symmetric (select three: the mirrored pair, +//! then the mirror line) — dispatch straight to +//! `CmdResult::AddSketchConstraint` from `src/app/commands/draw.rs`. +//! - **A typed value** — Distance, Angle (`value.rs`): the target value +//! comes from the command line after the tool is clicked. +//! - **A picked point, entity pre-selected** — CenterPoint, Midpoint, +//! PointOnCurve (`point_on_entity.rs`): the entity side has no ambiguity +//! (select it first, like Distance/Angle's target), but the point side +//! does (which of a line's two endpoints?), so it's picked interactively +//! and resolved by the host. +//! - **Picked points only, nothing pre-selected** — Coincident +//! (`coincident.rs`, two points), EqualDistance (`equal_distance.rs`, +//! four points): every side is a point, so plain selection can't express +//! it at all. mod coincident; +mod equal_distance; +mod point_on_entity; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; -pub use tools::{equal, horizontal, parallel, perpendicular, tangent, vertical}; +pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use point_on_entity::{center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand}; +pub use tools::{ + concentric, equal, fixed, horizontal, colinear, parallel, perpendicular, symmetric, tangent, vertical, +}; pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand}; diff --git a/src/modules/draw/constrain/point_on_entity.rs b/src/modules/draw/constrain/point_on_entity.rs new file mode 100644 index 000000000..88b5e141a --- /dev/null +++ b/src/modules/draw/constrain/point_on_entity.rs @@ -0,0 +1,91 @@ +//! CENTERPOINT / MIDPOINT / ONCURVE — constraints between one picked point +//! and a whole entity selected *before* the tool runs (same "select first, +//! then invoke" convention `DistanceConstraintCommand` +//! (`src/modules/draw/constrain/value.rs`) already uses for its own target +//! entity). The entity side has no ambiguity (a whole circle, or a whole +//! line whose midpoint/curve is being addressed, not one specific +//! endpoint) so it doesn't need interactive picking; the point side does +//! (which of a line's two endpoints, or some other addressable point), so +//! this command accumulates just that one point pick and hands it back for +//! the host to resolve via `nearest_sketch_point` — the same split +//! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand` +//! has no document access). + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::sketch_constraints::ConstraintKind; +use acadrust::types::Handle; + +pub mod center_point_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "CPCONSTRAINT", + label: "Center Point", + icon: IconKind::Glyph("⊕"), + event: ModuleEvent::Command("CPCONSTRAINT".to_string()), + } + } +} + +pub mod midpoint_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "MPCONSTRAINT", + label: "Midpoint", + icon: IconKind::Glyph("M"), + event: ModuleEvent::Command("MPCONSTRAINT".to_string()), + } + } +} + +pub mod point_on_curve_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "OCCONSTRAINT", + label: "Point on Curve", + icon: IconKind::Glyph("∈"), + event: ModuleEvent::Command("OCCONSTRAINT".to_string()), + } + } +} + +/// Shared by all three tools above — only `name`/`kind`/`label` differ. +pub struct PointOnEntityConstraintCommand { + name: &'static str, + kind: ConstraintKind, + target: Handle, + label: &'static str, +} + +impl PointOnEntityConstraintCommand { + pub fn new(name: &'static str, kind: ConstraintKind, target: Handle, label: &'static str) -> Self { + Self { name, kind, target, label } + } +} + +impl CadCommand for PointOnEntityConstraintCommand { + fn name(&self) -> &'static str { + self.name + } + + fn prompt(&self) -> String { + "Specify point:".to_string() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + CmdResult::AddPointOnEntityConstraint { point: pt, target: self.target, kind: self.kind, label: self.label } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index 9718fa541..bd3e8faff 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -73,3 +73,51 @@ pub mod tangent { } } } + +pub mod concentric { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "NCONSTRAINT", + label: "Concentric", + icon: IconKind::Glyph("◎"), + event: ModuleEvent::Command("NCONSTRAINT".to_string()), + } + } +} + +pub mod colinear { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "LCONSTRAINT", + label: "Colinear", + icon: IconKind::Glyph("L"), + event: ModuleEvent::Command("LCONSTRAINT".to_string()), + } + } +} + +pub mod fixed { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "FXCONSTRAINT", + label: "Fixed", + icon: IconKind::Glyph("F"), + event: ModuleEvent::Command("FXCONSTRAINT".to_string()), + } + } +} + +pub mod symmetric { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "SYCONSTRAINT", + label: "Symmetric", + icon: IconKind::Glyph("S"), + event: ModuleEvent::Command("SYCONSTRAINT".to_string()), + } + } +} diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index d91c83eb9..82882fb99 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -34,7 +34,11 @@ 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 constrain::{ + angle_tool, center_point_tool, coincident_tool, colinear, concentric, distance_tool, equal, + equal_distance_tool, fixed, horizontal, midpoint_tool, parallel, perpendicular, point_on_curve_tool, + symmetric, tangent, vertical, + }; use draw::{arc, circle, ellipse, hatch, line, polyline, shapes}; use groups::{group, ungroup}; use layers::{ @@ -134,6 +138,14 @@ impl CadModule for DrawModule { coincident_tool::tool().into(), distance_tool::tool().into(), angle_tool::tool().into(), + concentric::tool().into(), + colinear::tool().into(), + fixed::tool().into(), + symmetric::tool().into(), + center_point_tool::tool().into(), + midpoint_tool::tool().into(), + point_on_curve_tool::tool().into(), + equal_distance_tool::tool().into(), ], }, RibbonGroup { diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 3b97b4e89..dd29b8322 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -269,6 +269,18 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), + ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"), + ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"), + ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"), + ConstraintKind::Fixed => Some("ACFIXEDCONSTRAINT"), + ConstraintKind::Midpoint => Some("ACMIDPOINTCONSTRAINT"), + ConstraintKind::PointOnCurve => Some("ACPOINTCURVECONSTRAINT"), + ConstraintKind::Symmetric => Some("ACSYMMETRICCONSTRAINT"), + // Also a plain `Geometrical`-shaped node in AutoCAD's own class list + // (`is_plain_geometrical_constraint`) — unlike `Equal`, its class + // name never branches on entity type, so it belongs here rather + // than in the caller's per-entity-type dispatch. + ConstraintKind::EqualDistance => Some("ACEQUALDISTANCECONSTRAINT"), // `Equal` isn't one native kind — the line/circle split below // (design doc §5) needs the resolved entity types, not just the // ConstraintKind, so it's handled by the caller instead of here. @@ -664,6 +676,24 @@ fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { document.objects.remove(&root); } +/// Whether `owner`'s scope already carries a materialized native graph — +/// from an earlier save with the setting on, or from a file that came from +/// real AutoCAD/BricsCAD. Drives the "sync-if-present, create-only-if- +/// enabled" rule in [`Scene::materialize_dwg_native_constraints_for_save`]: +/// a scope that already has this graph keeps getting it rebuilt on every +/// save regardless of the current setting, so turning the setting off never +/// leaves a stale, out-of-sync graph behind — only a scope that doesn't have +/// one yet is gated by the setting. +fn has_native_network(document: &CadDocument, owner: Handle) -> bool { + let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { + return false; + }; + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dictionary_handle) else { + return false; + }; + dictionary.get(NETWORK_DICTIONARY_KEY).is_some() +} + /// One scope's worth of /// [`Scene::materialize_dwg_native_constraints_for_save`] — see that /// method's doc comment for the save-time contract this implements. @@ -784,14 +814,30 @@ impl Scene { /// "materialize right before save, from scratch every time" convention /// `sketch_persist.rs`'s `materialize_sketch_constraints_for_save` uses, /// called alongside it, not instead of it (design doc §6: additive). - pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self) { - ensure_associative_classes_registered(&mut self.document); + /// + /// `enabled` is the app's `write_dwg_native_constraints` Options toggle + /// (off by default — this graph adds file weight every save, whether or + /// not AutoCAD/BricsCAD interop is wanted). It only gates *creating* the + /// graph in a scope that doesn't have one yet: a scope that already + /// carries one — from an earlier save with the setting on, or from a + /// file that came from real AutoCAD — keeps getting it rebuilt in sync + /// regardless of the current setting ([`has_native_network`]), so + /// toggling this off never leaves a stale graph sitting in the file. + pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { + let mut classes_registered = false; for index in 0..self.sketch_constraints.len() { let set = self.sketch_constraints[index].clone(); let owner = set.scope.owner_handle(&self.document); if owner.is_null() { continue; } + if !enabled && !has_native_network(&self.document, owner) { + continue; + } + if !classes_registered { + ensure_associative_classes_registered(&mut self.document); + classes_registered = true; + } materialize_scope(&mut self.document, owner, &set, &self.named_parameters); } } @@ -801,7 +847,7 @@ impl Scene { mod tests { use super::super::sketch_constraints::SketchScope; use super::*; - use acadrust::entities::Line; + use acadrust::entities::{Circle, Line}; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -810,6 +856,10 @@ mod tests { ))) } + fn circle_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { + scene.add_entity(EntityType::Circle(Circle::from_center_radius(Vector3::new(center.0, center.1, 0.0), radius))) + } + /// Digs a scope's materialized native graph back out of `document`: /// `owner`'s extension dictionary -> `ACAD_ASSOCNETWORK` entry -> /// its one owned action, which (for this module) is always the @@ -854,7 +904,7 @@ mod tests { None, ); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; // Sanity before the round trip even happens: root + 2 geometry // nodes + 2 constraint nodes. @@ -915,7 +965,7 @@ mod tests { #[test] fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); assert!(has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization"); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) @@ -951,7 +1001,7 @@ mod tests { #[test] fn a_dxf_save_keeps_only_the_constraint_group_shell() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version) .unwrap_or_else(|e| panic!("save to dxf: {e}")); @@ -981,9 +1031,9 @@ mod tests { None, ); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let first_count = scene.document.objects.len(); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let second_count = scene.document.objects.len(); assert_eq!(first_count, second_count, "a resave with the same constraints must not accumulate new objects"); } @@ -992,7 +1042,7 @@ mod tests { fn an_empty_constraint_set_leaves_no_native_network() { let mut scene = Scene::new(); let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; let dict = scene.document.extension_dictionary_handle(owner); @@ -1005,4 +1055,141 @@ mod tests { } } } + + fn model_space_has_native_network(scene: &Scene) -> bool { + has_native_network(&scene.document, scene.document.header.model_space_block_handle) + } + + #[test] + fn the_setting_off_skips_a_scope_with_no_existing_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(false); + assert!( + !model_space_has_native_network(&scene), + "a scope with no prior native graph should stay untouched while the setting is off" + ); + } + + /// The "sync-if-present, create-only-if-enabled" rule from + /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment: + /// once a scope has the native graph (here, from an earlier save with + /// the setting on), turning the setting off must not freeze that graph + /// out of date — it should keep tracking the current constraints. + #[test] + fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + scene.materialize_dwg_native_constraints_for_save(true); + assert!(model_space_has_native_network(&scene), "sanity: the graph should exist after the first, enabled save"); + + let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Vertical, + vec![SketchRef::whole(b)], + None, + ); + scene.materialize_dwg_native_constraints_for_save(false); + + let owner = scene.document.header.model_space_block_handle; + let group = native_group(&scene.document, owner); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACVERTICALCONSTRAINT"), + "the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}", + group.nodes.iter().map(|n| &n.class_name).collect::>() + ); + } + + /// The other half of the same rule: disabling the setting after a scope + /// already has the graph must not make that graph disappear either — + /// only remove it by clearing the scope's constraints (existing + /// `an_empty_constraint_set_leaves_no_native_network` behavior), not by + /// toggling the setting off. + #[test] + fn the_setting_off_does_not_remove_an_existing_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + scene.materialize_dwg_native_constraints_for_save(true); + scene.materialize_dwg_native_constraints_for_save(false); + assert!( + model_space_has_native_network(&scene), + "an existing native graph must survive a disabled-setting resave, not be silently dropped" + ); + } + + /// The 8 new `ConstraintKind`s all reuse the same generic + /// `Geometrical`-shaped node path `geometrical_class_name` drives (see + /// its own doc comment) — one representative mix of ref shapes (2 whole + /// entities, an asymmetric point+whole pair, and a 3-ref case) is + /// enough to confirm that path handles them all, without one dedicated + /// test per kind. + #[test] + fn new_constraint_kinds_round_trip_with_their_own_dwg_native_class_names() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let circle_a = circle_entity(&mut scene, (0.0, 0.0), 3.0); + let circle_b = circle_entity(&mut scene, (5.0, 5.0), 1.0); + let line_a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let line_b = line_entity(&mut scene, (3.0, 4.0), (7.0, 6.0)); + let marker = line_entity(&mut scene, (20.0, 20.0), (21.0, 21.0)); + let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Concentric, vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], None); + set.add(ConstraintKind::Colinear, vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None); + set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], None); + set.add(ConstraintKind::Midpoint, vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], None); + set.add(ConstraintKind::PointOnCurve, vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], None); + set.add( + ConstraintKind::EqualDistance, + vec![SketchRef::point(line_b, 0), SketchRef::point(line_b, 1), SketchRef::point(line_a, 0), SketchRef::point(line_a, 1)], + None, + ); + set.add( + ConstraintKind::Symmetric, + vec![SketchRef::center(circle_a), SketchRef::center(circle_b), SketchRef::whole(axis)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("new_kinds.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + for expected in [ + "ACCONCENTRICCONSTRAINT", + "ACCOLINEARCONSTRAINT", + "ACFIXEDCONSTRAINT", + "ACCENTERPOINTCONSTRAINT", + "ACMIDPOINTCONSTRAINT", + "ACPOINTCURVECONSTRAINT", + "ACEQUALDISTANCECONSTRAINT", + "ACSYMMETRICCONSTRAINT", + ] { + assert!(class_names.contains(&expected), "{ext}: missing {expected} node, got {class_names:?}"); + } + } + } } diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index a3ece49ce..2700a3ea0 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -79,6 +79,41 @@ pub enum ConstraintKind { Angle, Radius, Tangent, + /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. + /// Solves identically to `Coincident` (`sketch_solve.rs` broadens that + /// match arm rather than duplicating it) — only the DWG-native class + /// name (`ACCONCENTRICCONSTRAINT` vs `ACPOINTCOINCIDENCECONSTRAINT`) + /// and the UI entry point differ. + Concentric, + /// A point sits at a circle/arc's center — `refs`: `[point, center(circle)]`. + /// Same solver math as `Coincident`/`Concentric`, different DWG class + /// name (`ACCENTERPOINTCONSTRAINT`). + CenterPoint, + /// Two lines share the same infinite line — `refs`: `[whole(a), whole(b)]`. + Colinear, + /// A point sits at another line's midpoint — `refs`: `[point, whole(line)]`. + Midpoint, + /// Locks a whole entity at its current position — `refs`: `[whole(entity)]`. + /// No `driving_param`: the target is the entity's own live geometry at + /// solve time, not a typed value (see `sketch_solve.rs`'s `Fixed` arm). + Fixed, + /// A point lies anywhere along a line's or circle's curve (not + /// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`. + PointOnCurve, + /// The distance between one point pair equals the distance between + /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). + /// AutoCAD's fourth `Equal` sub-kind (`ACEQUALDISTANCECONSTRAINT`) — + /// see the design doc for why `EqualCurvature`, the other missing + /// sub-kind, isn't modeled: for the only entity types this system + /// solves (Line, Circle), it would be mathematically identical to this + /// `Equal`'s existing circle/circle (radius) branch. + EqualDistance, + /// Two circles/arcs are mirror images of each other across a line — + /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the + /// circle-center-pair case (unambiguous with whole-entity selection); + /// point-symmetry about a point, and symmetry between two lines, aren't + /// modeled. + Symmetric, } pub type ConstraintId = u32; @@ -345,6 +380,14 @@ impl ConstraintKind { ConstraintKind::Angle => "∠", ConstraintKind::Radius => "R", ConstraintKind::Tangent => "T", + ConstraintKind::Concentric => "◎", + ConstraintKind::CenterPoint => "⊕", + ConstraintKind::Colinear => "L", + ConstraintKind::Midpoint => "M", + ConstraintKind::Fixed => "F", + ConstraintKind::PointOnCurve => "∈", + ConstraintKind::EqualDistance => "≐", + ConstraintKind::Symmetric => "S", } } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index cd9d3a755..e88519b50 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -29,9 +29,10 @@ 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::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; use ocs_gcs::constraints::point_line::{ - Equal, EqualLineLength, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, P2PDistance, + CenterOfGravity, Equal, EqualLineLength, MidpointOnLine, Parallel as ParallelConstraint, + Perpendicular as PerpendicularConstraint, PointOnLine, P2PDistance, }; use ocs_gcs::constraints::Constraint; use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint}; @@ -111,14 +112,18 @@ fn resolve_ref( 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 — or, for a dimensional kind, a `driving_param` that fails to -/// resolve: an undefined named-parameter reference or a division by zero in -/// its formula, design doc `named_parameters_design.md` stage 3). 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 +/// One [`SketchConstraint`]'s `ocs_gcs` construction: every system-level +/// constraint it contributes (most kinds contribute exactly one; a few — +/// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two +/// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, +/// `Fixed`'s per-coordinate pins — contribute more). Empty means 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 — or, +/// for a dimensional kind, a `driving_param` that fails to resolve: an +/// undefined named-parameter reference or a division by zero in its +/// formula, design doc `named_parameters_design.md` stage 3). 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. (A named-parameter *cycle* should be /// unreachable here — `ParameterTable::set` already refuses to create one — @@ -130,9 +135,9 @@ fn build_constraint( cache: &mut HashMap, params: &ParameterTable, c: &SketchConstraint, -) -> Option> { +) -> Vec> { if !c.enabled { - return None; + return Vec::new(); } let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? { @@ -148,64 +153,159 @@ fn build_constraint( resolve_ref(document, sys, cache, r)?.point_for_marker(marker) }; + // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two + // points (an endpoint, a circle's center via the existing `-3` marker, + // or a plain point) held equal on both axes. Only the DWG-native class + // name and the UI entry point that produces their `refs` differ. + let point_pair_equal = |sys: &mut System, cache: &mut HashMap<_, _>, refs: &[SketchRef]| -> Vec> { + let [a, b] = refs else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(Equal::new(pa.x, pb.x, 1.0)), Rc::new(Equal::new(pa.y, pb.y, 1.0))] + }; + 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::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { + point_pair_equal(sys, cache, &c.refs) } ConstraintKind::Horizontal => { - let l = whole_line(sys, cache, *c.refs.first()?)?; - Some(Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))) + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(l) = whole_line(sys, cache, *r) else { return Vec::new() }; + vec![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))) + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(l) = whole_line(sys, cache, *r) else { return Vec::new() }; + vec![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))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + vec![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))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(PerpendicularConstraint::new(sys.store(), moving, fixed))] } ConstraintKind::Equal => { - let [a, b] = c.refs.as_slice() else { return None }; + let [a, b] = c.refs.as_slice() else { return Vec::new() }; if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { - return Some(Rc::new(EqualLineLength::new(lb, la))); + return vec![Rc::new(EqualLineLength::new(lb, la))]; + } + let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] + } + ConstraintKind::EqualDistance => { + let [a, b, c2, d] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb), Some(pc), Some(pd)) = + (point_ref(sys, cache, *a), point_ref(sys, cache, *b), point_ref(sys, cache, *c2), point_ref(sys, cache, *d)) + else { + return Vec::new(); + }; + vec![Rc::new(EqualLineLength::new(GLine { p1: pc, p2: pd }, GLine { p1: pa, p2: pb }))] + } + ConstraintKind::Colinear => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(onto), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + // Pinning both of `moving`'s endpoints onto `onto`'s infinite + // line forces the two to coincide (as long as `moving`'s own + // two points stay distinct) — no dedicated "colinear" primitive + // needed, `PointOnLine` applied twice does it. + vec![Rc::new(PointOnLine::new(moving.p1, onto)), Rc::new(PointOnLine::new(moving.p2, onto))] + } + ConstraintKind::Midpoint => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + vec![ + Rc::new(CenterOfGravity::new(p.x, vec![l.p1.x, l.p2.x], vec![0.5, 0.5])), + Rc::new(CenterOfGravity::new(p.y, vec![l.p1.y, l.p2.y], vec![0.5, 0.5])), + ] + } + ConstraintKind::PointOnCurve => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(p), Some(geom)) = (point_ref(sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + return Vec::new(); + }; + match geom { + EntityGeom::Line(l) => vec![Rc::new(PointOnLine::new(p, l))], + EntityGeom::Circle(circ) => { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new(circ, p, zero))] + } + } + } + ConstraintKind::Symmetric => { + let [a, b, m] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb), Some(mirror)) = + (point_ref(sys, cache, *a), point_ref(sys, cache, *b), whole_line(sys, cache, *m)) + else { + return Vec::new(); + }; + let pair = GLine { p1: pa, p2: pb }; + vec![ + Rc::new(MidpointOnLine::new(pair, mirror)), + Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + ] + } + ConstraintKind::Fixed => { + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(geom) = resolve_ref(document, sys, cache, *r) else { + return Vec::new(); + }; + match geom { + EntityGeom::Line(l) => { + let (x1, y1, x2, y2) = { + let store = sys.store(); + (store.get(l.p1.x), store.get(l.p1.y), store.get(l.p2.x), store.get(l.p2.y)) + }; + vec![ + Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), + ] + } + EntityGeom::Circle(circ) => { + let (cx, cy, r) = { + let store = sys.store(); + (store.get(circ.center.x), store.get(circ.center.y), store.get(circ.rad)) + }; + vec![ + Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), + ] + } } - 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.as_ref()?.resolve(params).ok()?, true); - Some(Rc::new(P2PDistance::new(pa, pb, target))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![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.as_ref()?.resolve(params).ok()?.to_radians(), true); - Some(Rc::new(L2LAngle::new(fixed, moving, angle))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let angle = sys.add_param(resolved.to_radians(), true); + vec![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.as_ref()?.resolve(params).ok()?, true); - Some(Rc::new(Equal::new(circle.rad, target, 1.0))) + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(circle) = whole_circle(sys, cache, *r) else { + return Vec::new(); + }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![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)?); + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(ga), Some(gb)) = (resolve_ref(document, sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + return Vec::new(); + }; match (ga, gb) { (EntityGeom::Circle(c1), EntityGeom::Circle(c2)) => { let store = sys.store(); @@ -217,7 +317,7 @@ fn build_constraint( // 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))) + vec![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(); @@ -234,11 +334,11 @@ fn build_constraint( // (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))) + vec![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, + _ => Vec::new(), } } } @@ -265,35 +365,20 @@ fn solve_scope( ) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> { let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); - // Tracks which system-level `ocs_gcs` constraint came from which + // Tracks which system-level `ocs_gcs` constraint(s) 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. + // list, not `set.constraints`' indices, and several kinds contribute + // more than one system-level constraint per `SketchConstraint` (see + // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after + // partitioning resolves a row back to the `ConstraintId` the UI + // actually names. let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); for c in &set.constraints { - let Some(constraint) = build_constraint(document, &mut sys, &mut cache, params, 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` 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 = Rc::new(Equal::new(pa.y, pb.y, 1.0)); - owner.push((y_half.clone(), c.id)); - sys.add_constraint(y_half); - } - } + for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { + owner.push((constraint.clone(), c.id)); + sys.add_constraint(constraint); } } diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 0e3957701..873749af5 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -31,6 +31,7 @@ impl fmt::Display for Labelled { pub fn view_window<'a>( default_save_format: &'a str, file_assoc_enabled: bool, + write_dwg_native_constraints: bool, ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], language: crate::i18n::Language, @@ -190,6 +191,24 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), + Space::new().height(14), + row![ + iced::widget::checkbox(write_dwg_native_constraints) + .on_toggle(Message::WriteDwgNativeConstraintsChanged) + .size(15), + text(crate::t!("Write AutoCAD-compatible constraint objects on save")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Saves sketch constraints as AutoCAD/BricsCAD's own native objects too, alongside \ + this app's own format, so other CAD software recognizes them. Off by default — \ + adds some file size on every save. A drawing that already has these objects keeps \ + them up to date regardless of this setting." + )) + .size(11) + .width(sizing.width), ] .spacing(0) .width(sizing.width); diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index 2c9f025d2..d2838eb73 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -354,3 +354,212 @@ fn two_conflicting_distance_targets_are_reported_as_conflicting() { assert!(flagged_id == first || flagged_id == second, "the flagged id should be one of the two conflicting Distance constraints"); assert_eq!(kind, RedundancyKind::Conflicting); } + +fn set_circle_center(scene: &mut Scene, handle: Handle, center: Vector3) { + if let Some(EntityType::Circle(c)) = scene.document.get_entity_mut(handle) { + c.center = center; + } +} + +fn set_line_start(scene: &mut Scene, handle: Handle, start: Vector3) { + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(handle) { + l.start = start; + } +} + +#[test] +fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() { + let mut scene = Scene::new(); + let a = add_circle(&mut scene, 0.0, 0.0, 3.0); + let b = add_circle(&mut scene, 5.0, 5.0, 1.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(a), SketchRef::center(b)], + None, + ); + + set_circle_center(&mut scene, a, Vector3::new(2.0, -3.0, 0.0)); + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let (ca, ra) = circle_geom(&scene, a); + let (cb, rb) = circle_geom(&scene, b); + assert!((ca.x - cb.x).abs() < 1e-6 && (ca.y - cb.y).abs() < 1e-6, "centers should coincide: a={ca:?} b={cb:?}"); + // Radii are untouched by Concentric — only centers move. + assert!((ra - 3.0).abs() < 1e-6 && (rb - 1.0).abs() < 1e-6, "radii must not change: ra={ra} rb={rb}"); +} + +#[test] +fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 4.0); + let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(circle)], + None, + ); + + set_circle_center(&mut scene, circle, Vector3::new(-6.0, 9.0, 0.0)); + scene.bump_entities(&[(circle, ChangeKind::Modified)]); + + let (center, _) = circle_geom(&scene, circle); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (center.x - line_start.x).abs() < 1e-6 && (center.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the circle's center: center={center:?} start={line_start:?}" + ); +} + +#[test] +fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Colinear, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + // Both of b's endpoints should land on a's infinite line: the signed + // area of (a2-a1) × (bN-a1) is ~0 for a point exactly on that line. + let area = |p: Vector3| (a2.x - a1.x) * (p.y - a1.y) - (a2.y - a1.y) * (p.x - a1.x); + assert!(area(b1).abs() < 1e-5, "b.start should land on a's line, area={}", area(b1)); + assert!(area(b2).abs() < 1e-5, "b.end should land on a's line, area={}", area(b2)); +} + +#[test] +fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { + let mut scene = Scene::new(); + let base = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Midpoint, + vec![SketchRef::point(marker, 0), SketchRef::whole(base)], + None, + ); + + set_line_end(&mut scene, base, Vector3::new(10.0, 8.0, 0.0)); + scene.bump_entities(&[(base, ChangeKind::Modified)]); + + let (b1, b2) = line_endpoints(&scene, base); + let (marker_start, _) = line_endpoints(&scene, marker); + let expected = Vector3::new((b1.x + b2.x) / 2.0, (b1.y + b2.y) / 2.0, 0.0); + assert!( + (marker_start.x - expected.x).abs() < 1e-6 && (marker_start.y - expected.y).abs() < 1e-6, + "marker point should sit at base's midpoint: expected={expected:?} got={marker_start:?}" + ); +} + +#[test] +fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { + let mut scene = Scene::new(); + let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(fixed_line)], None); + set.add(ConstraintKind::Coincident, vec![SketchRef::point(fixed_line, 1), SketchRef::point(moving_line, 0)], None); + + let (before_start, before_end) = line_endpoints(&scene, fixed_line); + + // Drag the shared point — without Fixed this would pull fixed_line's + // end along with it; Fixed should hold fixed_line exactly in place and + // let moving_line's start follow back to it instead. + set_line_start(&mut scene, moving_line, Vector3::new(15.0, 5.0, 0.0)); + scene.bump_entities(&[(moving_line, ChangeKind::Modified)]); + + let (after_start, after_end) = line_endpoints(&scene, fixed_line); + assert_eq!(before_start, after_start, "Fixed entity's start must not move"); + assert_eq!(before_end, after_end, "Fixed entity's end must not move"); + let (moving_start, _) = line_endpoints(&scene, moving_line); + assert!( + (moving_start.x - before_end.x).abs() < 1e-6 && (moving_start.y - before_end.y).abs() < 1e-6, + "moving_line's start should have been pulled back to fixed_line's (unmoved) end" + ); +} + +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 5.0); + let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], + None, + ); + + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, radius) = circle_geom(&scene, circle); + let (marker_start, _) = line_endpoints(&scene, marker); + let dist = ((marker_start.x - center.x).powi(2) + (marker_start.y - center.y).powi(2)).sqrt(); + assert!((dist - radius).abs() < 1e-5, "point should land on the circumference: dist={dist} radius={radius}"); +} + +#[test] +fn equal_distance_constraint_matches_a_second_point_pairs_separation() { + let mut scene = Scene::new(); + // Reference pair: fixed 6 units apart. + let a = add_line(&mut scene, 0.0, 0.0, 6.0, 0.0); + // Tracked pair: starts at some other separation, should be pulled to 6. + let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::EqualDistance, + vec![SketchRef::point(b, 0), SketchRef::point(b, 1), SketchRef::point(a, 0), SketchRef::point(a, 1)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + let dist_a = ((a2.x - a1.x).powi(2) + (a2.y - a1.y).powi(2)).sqrt(); + let dist_b = ((b2.x - b1.x).powi(2) + (b2.y - b1.y).powi(2)).sqrt(); + assert!((dist_a - dist_b).abs() < 1e-6, "the two pairs' separations should match: dist_a={dist_a} dist_b={dist_b}"); +} + +#[test] +fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { + let mut scene = Scene::new(); + let axis = add_line(&mut scene, 0.0, 0.0, 0.0, 10.0); // the Y axis + let a = add_circle(&mut scene, 3.0, 4.0, 1.0); + // Starts well off the mirrored position (and not coincident with `a` — + // a zero-length a/b segment would leave `Perpendicular`'s precomputed + // scale dividing by zero at the very first solve iteration). + let b = add_circle(&mut scene, 8.0, 9.0, 1.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + // Pin the axis and `a` in place — otherwise they're just as free to move + // as `b`'s center, and with only 2 equations (MidpointOnLine + + // Perpendicular) against that many more unknowns the solver is free to + // converge on any of infinitely many valid configurations, not + // necessarily "only b moves, to a's exact mirror" (which is the one + // deterministic outcome this test actually wants to check). + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); + set.add(ConstraintKind::Symmetric, vec![SketchRef::center(a), SketchRef::center(b), SketchRef::whole(axis)], None); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (ca, _) = circle_geom(&scene, a); + let (cb, _) = circle_geom(&scene, b); + assert_eq!(ca, Vector3::new(3.0, 4.0, 0.0), "Fixed a's center must not have moved"); + // With both a and the axis pinned, b's center has a unique valid + // position left: a's exact mirror across the Y axis, (-3, 4). + assert!( + (cb.x - -3.0).abs() < 1e-5 && (cb.y - 4.0).abs() < 1e-5, + "b's center should have been pulled to a's mirror image across the axis: expected (-3, 4), got {cb:?}" + ); +}