From 3d0a41b69a237548281288f4fa15b894dc3399fe Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 10:58:21 +0200 Subject: [PATCH] Extend sketch constraints toward FreeCAD parity, staying DWG-native-compatible Arcs are now full constraint participants (center/radius via the existing Circle-shaped solver path, plus real endpoints kept consistent by arc-rules CurveValue constraints); adds Diameter/DistanceX/DistanceY/ArcLength/Normal constraint kinds and initial Ellipse entity support (center-based constraints only). DWG-native class mappings and gaps (ArcLength, Ellipse) are grounded against real ObjectARX headers rather than guessed, so files saved with DWG-native constraints stay honest about what AutoCAD can actually represent. Also fixes a case-sensitivity bug where a lowercase named-parameter reference typed at a dimensional constraint's value prompt was rejected (the command line uppercases SingleToken input before the parameter-name lookup), and adds a "Used by" column to the Named Parameters panel. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 60 ++++ src/app/commands/draw.rs | 3 +- src/app/view/modal.rs | 4 +- src/modules/draw/constrain/mod.rs | 6 +- src/modules/draw/constrain/tools.rs | 20 ++ src/modules/draw/constrain/value.rs | 253 +++++++++++++++-- src/modules/draw/mod.rs | 3 +- src/scene/dwg_native_constraints.rs | 341 +++++++++++++++++++++- src/scene/sketch_constraints.rs | 139 +++++++++ src/scene/sketch_solve.rs | 339 ++++++++++++++++++++-- src/ui/window/named_parameters.rs | 126 +++++++- tests/sketch_constraints_solve.rs | 426 ++++++++++++++++++++++++++++ 12 files changed, 1660 insertions(+), 60 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d40cbc73d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +Notable changes on `feature/parametric-constraint-system` since each push to +GitHub. Newest entries first. "Unreleased" covers local changes not yet +pushed. + +## Unreleased (since `eb96e8b6`) + +- **Fix:** a dimensional constraint's value prompt (e.g. `DCONSTRAINT`'s + "Specify distance:") rejected a named parameter typed in its own + lowercase name — the command line uppercases typed text before it + reaches the parser, so `r` became `R` and no longer matched the + parameter table's case-sensitive lookup. `parse_driving_value` now + matches parameter names case-insensitively and resolves to the + parameter's canonical name. + ([value.rs](src/modules/draw/constrain/value.rs)) +- **Feature:** Named Parameters panel gains a "Used by" column showing + which constraints (kind + entity handles) currently reference each + parameter, with a hover tooltip listing the full per-constraint entity + breakdown when more than a couple of constraints use it. Backed by a new + `Scene::parameter_usage(name)` query that searches every sketch scope + (model space and blocks) and deduplicates an entity referenced by more + than one of a constraint's point markers. + ([named_parameters.rs](src/ui/window/named_parameters.rs), + [sketch_constraints.rs](src/scene/sketch_constraints.rs)) +- Named Parameters modal widened (620 → 820) to fit the new column. + ([modal.rs](src/app/view/modal.rs)) +- **Feature:** constraint-solver support extended well beyond Line/Circle, + closing most of the gap with FreeCAD's Sketcher (grounded against real + ObjectARX headers so DWG-native persistence stays honest about what + AutoCAD itself can represent): + - Arcs are now full constraint participants: center/radius (Concentric, + Tangent, Equal, CenterPoint, Radius, Diameter, Fixed, Normal) via the + same solver representation a Circle uses, *and* actual endpoints + (Coincident/PointOnCurve/Midpoint/EqualDistance/Symmetric on an arc's + real start/end point, marker 0/1) — kept numerically consistent with + center/radius/angle by "arc rules" constraints added for every + registered arc, reusing an already-ported-but-unused `ocs_gcs` + primitive (`CurveValue`) rather than new low-level math. + - New dimensional kinds: `Diameter`, `DistanceX`, `DistanceY`, + `ArcLength` — reusing existing DWG classes (Diameter/DistanceX/Y ride + the same `ACRADIUSDIAMETERCONSTRAINT`/`ACDISTANCECONSTRAINT` objects + Radius/Distance already use, with a different mode/direction byte, + matching how AutoCAD itself represents them). `ArcLength` has no DWG + equivalent at all (confirmed against ObjectARX's own headers) and is + XRecord-only by design, not omission. + - New geometric kind: `Normal` (a line perpendicular to a circle/arc's + tangent), matching AutoCAD's real `kNormal` constraint type. + - New entity: `Ellipse` (center/Concentric/CenterPoint/Fixed only — + major/minor axis dimensional constraints and `PointOnEllipse`-based + Coincident/Tangent remain a follow-up). DWG-native persistence for + ellipse-referencing constraints is deliberately not implemented yet — + `acadrust`'s `Ellipse`/`BoundedEllipse` node variants have a different + field shape than the geometry-dependency role Circle/Arc play, and + guessing the semantics risked writing a plausible-but-wrong object + graph, so it degrades to XRecord-only instead. + ([sketch_solve.rs](src/scene/sketch_solve.rs), + [sketch_constraints.rs](src/scene/sketch_constraints.rs), + [dwg_native_constraints.rs](src/scene/dwg_native_constraints.rs), + [value.rs](src/modules/draw/constrain/value.rs)) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6b1bc0ca0..6bb19741f 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1000,7 +1000,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" => { + "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1019,6 +1019,7 @@ impl OpenCADStudio { "QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"), "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), + "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), _ => (ConstraintKind::Equal, "Equal constraint"), }; return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index bf450e75f..60cd94d7f 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -175,13 +175,15 @@ impl OpenCADStudio { ) } super::super::ModalKind::NamedParameters => { + let scene = &self.tabs[self.active_tab].scene; sized_flow( ex, - 620, + 820, 520, |flow| { crate::ui::window::named_parameters::view_window( &self.named_parameter_editor_rows, + scene, flow, ) }, diff --git a/src/modules/draw/constrain/mod.rs b/src/modules/draw/constrain/mod.rs index 652166409..e66832368 100644 --- a/src/modules/draw/constrain/mod.rs +++ b/src/modules/draw/constrain/mod.rs @@ -7,8 +7,8 @@ //! (`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 +//! Concentric, Normal (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. @@ -31,6 +31,6 @@ pub use coincident::{coincident_tool, CoincidentConstraintCommand}; 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, + concentric, equal, fixed, horizontal, colinear, normal, parallel, perpendicular, symmetric, tangent, vertical, }; pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand}; diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index bd3e8faff..6ed14b260 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -86,6 +86,26 @@ pub mod concentric { } } +/// A line perpendicular to a circle/arc's tangent at their point of +/// contact — for the Line/Circle-only entity model this is equivalent to +/// "the line passes through the circle's center" (a circle's radius is +/// always normal to its own tangent), so it's built from the same +/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line +/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only +/// covers line-to-line — AutoCAD's own `GeomConstraintType` enum lists +/// `kNormal` and `kPerpendicular` separately for exactly this reason. +pub mod normal { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "NRCONSTRAINT", + label: "Normal", + icon: IconKind::Glyph("⊾"), + event: ModuleEvent::Command("NRCONSTRAINT".to_string()), + } + } +} + pub mod colinear { use super::*; pub fn tool() -> ToolDef { diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index fa0afa064..256253f23 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -40,7 +40,15 @@ fn parse_driving_value(text: &str, known_names: &[String]) -> Option() { return Some(DrivingValue::Literal(value)); } - known_names.iter().find(|n| n.as_str() == text).map(|n| DrivingValue::Named(n.clone())) + // Case-insensitive: the command line uppercases `SingleToken` input + // (InputKind::SingleToken) before it reaches this parser, so a typed + // lowercase parameter name like "hole_dia" arrives here as "HOLE_DIA". + // Matching case-insensitively and returning the canonically-cased name + // keeps the driving link pointed at the actual parameter. + known_names + .iter() + .find(|n| n.eq_ignore_ascii_case(text)) + .map(|n| DrivingValue::Named(n.clone())) } pub mod distance_tool { @@ -67,35 +75,62 @@ pub mod angle_tool { } } -/// Constrains a single line's length (between its two endpoints) or a -/// circle's radius, to a typed target value. +/// Which flavor of dimensional constraint the typed value ultimately +/// drives — selectable via a prompt keyword before the value itself is +/// typed, mirroring how real AutoCAD's own `DCCONSTRAINT` offers +/// `[Xdistance/Ydistance]` or `[Diameter]` as options on the same command +/// rather than as separate tools. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DistanceMode { + /// Line length (two-point distance) or a circle/arc's radius — + /// whichever `is_circle` selects. + Auto, + X, + Y, + Diameter, + /// Arc length — only offered when `is_arc` (not a plain Circle, which + /// has no "arc length" distinct from its circumference). + ArcLength, +} + +/// Constrains a single line's length (or its X/Y-only component) between +/// its two endpoints, or a circle/arc's radius (or diameter, or — for an +/// arc specifically — its swept arc length), 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`). + /// Whether `handle` is a circle/arc (Radius/Diameter) or a line + /// (Distance/DistanceX/DistanceY) — decided once at construction from + /// the entity's type. is_circle: bool, + /// Whether `handle` is specifically an Arc (as opposed to a full + /// Circle) — gates the `[Arclength]` prompt option. + is_arc: bool, + mode: DistanceMode, default_value: f64, + /// The entity's current arc length, for `ArcLength` mode's `` + /// — meaningless (left at 0) unless `is_arc`. + default_arc_length: f64, /// Snapshot of `Scene::named_parameters`' names at construction time — /// see `parse_driving_value`'s doc comment for why a snapshot. known_param_names: Vec, } impl DistanceConstraintCommand { - /// `None` if `handle` isn't a Line or Circle. + /// `None` if `handle` isn't a Line, Circle, or Arc. pub fn new(scene: &Scene, handle: Handle) -> Option { let entity = scene.document.get_entity(handle)?; - let (is_circle, default_value) = match entity { + let (is_circle, is_arc, default_value, default_arc_length) = 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()) + (false, false, (dx * dx + dy * dy).sqrt(), 0.0) } - EntityType::Circle(c) => (true, c.radius), + EntityType::Circle(c) => (true, false, c.radius, 0.0), + EntityType::Arc(a) => (true, true, a.radius, a.arc_length()), _ => return None, }; let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); - Some(Self { handle, is_circle, default_value, known_param_names }) + Some(Self { handle, is_circle, is_arc, mode: DistanceMode::Auto, default_value, default_arc_length, known_param_names }) } fn build(&self, target: DrivingValue) -> Option { @@ -104,14 +139,27 @@ impl DistanceConstraintCommand { return None; } } - let (kind, refs, label) = if self.is_circle { - (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint") - } else { - ( + let (kind, refs, label) = match (self.is_circle, self.mode) { + (true, DistanceMode::Diameter) => (ConstraintKind::Diameter, vec![SketchRef::whole(self.handle)], "Diameter constraint"), + (true, DistanceMode::ArcLength) if self.is_arc => { + (ConstraintKind::ArcLength, vec![SketchRef::whole(self.handle)], "Arc length constraint") + } + (true, _) => (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint"), + (false, DistanceMode::X) => ( + ConstraintKind::DistanceX, + vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + "DistanceX constraint", + ), + (false, DistanceMode::Y) => ( + ConstraintKind::DistanceY, + vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + "DistanceY constraint", + ), + (false, _) => ( 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 }) } @@ -123,7 +171,17 @@ impl CadCommand for DistanceConstraintCommand { } fn prompt(&self) -> String { - format!("Specify distance <{:.4}>: ", self.default_value) + match (self.is_circle, self.mode) { + (true, DistanceMode::Diameter) => format!("Specify diameter <{:.4}> or [Radius]: ", self.default_value * 2.0), + (true, DistanceMode::ArcLength) if self.is_arc => { + format!("Specify arc length <{:.4}> or [Radius]: ", self.default_arc_length) + } + (true, _) if self.is_arc => format!("Specify distance <{:.4}> or [Diameter/Arclength]: ", self.default_value), + (true, _) => format!("Specify distance <{:.4}> or [Diameter]: ", self.default_value), + (false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_value), + (false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_value), + (false, _) => format!("Specify distance <{:.4}> or [Xdistance/Ydistance]: ", self.default_value), + } } fn input_kind(&self) -> InputKind { @@ -135,10 +193,45 @@ impl CadCommand for DistanceConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) + let default = match self.mode { + DistanceMode::Diameter => self.default_value * 2.0, + DistanceMode::ArcLength if self.is_arc => self.default_arc_length, + _ => self.default_value, + }; + self.build(DrivingValue::Literal(default)).unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { + // Mode-switch keywords re-prompt instead of building — consumed + // inputs return `Some(NeedPoint)` so the driver doesn't re-offer + // the same token a second time (matches e.g. `trim.rs`'s + // `T`/`B`/`F` keyword handling). Defensively uppercased same as + // `trim.rs` even though `SingleToken` input already arrives + // uppercased from the command line. + let keyword = text.trim().to_uppercase(); + match (self.is_circle, keyword.as_str()) { + (true, "D" | "DIAMETER") => { + self.mode = DistanceMode::Diameter; + return Some(CmdResult::NeedPoint); + } + (true, "A" | "ARCLENGTH") if self.is_arc => { + self.mode = DistanceMode::ArcLength; + return Some(CmdResult::NeedPoint); + } + (true, "R" | "RADIUS") => { + self.mode = DistanceMode::Auto; + return Some(CmdResult::NeedPoint); + } + (false, "X" | "XDISTANCE") => { + self.mode = DistanceMode::X; + return Some(CmdResult::NeedPoint); + } + (false, "Y" | "YDISTANCE") => { + self.mode = DistanceMode::Y; + return Some(CmdResult::NeedPoint); + } + _ => {} + } let value = parse_driving_value(text, &self.known_param_names)?; self.build(value) } @@ -239,4 +332,128 @@ mod tests { assert_eq!(parse_driving_value("bogus", &known), None); assert_eq!(parse_driving_value("", &known), None); } + + #[test] + fn parse_driving_value_matches_a_known_parameter_regardless_of_typed_case() { + // The command line uppercases `SingleToken` input before it reaches + // here, so a lowercase-named parameter must still resolve — and + // resolve to its canonically-cased name, not the uppercased token. + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("HOLE_DIA", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + assert_eq!(parse_driving_value("Hole_Dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + } + + fn add_line(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(6.0, 8.0, 0.0), + ))) + } + + fn add_circle(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + ))) + } + + fn add_arc(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + 0.0, + std::f64::consts::FRAC_PI_2, + ))) + } + + #[test] + fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + + // The keyword itself only switches mode and re-prompts — it must + // not build a constraint yet. + assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("X distance"), "prompt should reflect the new mode: {}", cmd.prompt()); + + match cmd.on_text_input("10") { + Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + assert_eq!(kind, ConstraintKind::DistanceX); + assert_eq!(v, 10.0); + } + _ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"), + } + } + + #[test] + fn d_keyword_switches_a_circles_command_to_diameter_mode() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("diameter"), "prompt should reflect Diameter mode: {}", cmd.prompt()); + + match cmd.on_text_input("16") { + Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + assert_eq!(kind, ConstraintKind::Diameter); + assert_eq!(v, 16.0); + } + _ => panic!("expected an AddSketchConstraint{{Diameter}}"), + } + } + + #[test] + fn default_mode_still_builds_a_plain_radius_constraint() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + match cmd.on_text_input("5") { + Some(CmdResult::AddSketchConstraint { kind, .. }) => assert_eq!(kind, ConstraintKind::Radius), + _ => panic!("expected an AddSketchConstraint{{Radius}}"), + } + } + + #[test] + fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + + assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("arc length"), "prompt should reflect ArcLength mode: {}", cmd.prompt()); + + match cmd.on_text_input("8") { + Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + assert_eq!(kind, ConstraintKind::ArcLength); + assert_eq!(v, 8.0); + } + _ => panic!("expected an AddSketchConstraint{{ArcLength}}"), + } + } + + #[test] + fn arc_length_keyword_is_not_offered_for_a_plain_circle() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(!cmd.prompt().contains("Arclength"), "a plain circle has no arc length option: {}", cmd.prompt()); + // "A" isn't a recognized keyword here and doesn't parse as a + // number or a named parameter either, so it's rejected outright. + assert!(cmd.on_text_input("A").is_none()); + } + + #[test] + fn arc_length_default_is_the_arcs_current_swept_length() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 + let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + cmd.on_text_input("A"); + + let expected = 3.0 * std::f64::consts::FRAC_PI_2; + assert!(cmd.prompt().contains(&format!("{expected:.4}")), "prompt should default to the arc's current length: {}", cmd.prompt()); + } } diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 82882fb99..23f8f5bb0 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -36,7 +36,7 @@ impl CadModule for DrawModule { use clipboard::{copy_clip, cut, paste}; 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, + equal_distance_tool, fixed, horizontal, midpoint_tool, normal, parallel, perpendicular, point_on_curve_tool, symmetric, tangent, vertical, }; use draw::{arc, circle, ellipse, hatch, line, polyline, shapes}; @@ -135,6 +135,7 @@ impl CadModule for DrawModule { perpendicular::tool().into(), equal::tool().into(), tangent::tool().into(), + normal::tool().into(), coincident_tool::tool().into(), distance_tool::tool().into(), angle_tool::tool().into(), diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dd29b8322..033dcdced 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -121,7 +121,20 @@ impl<'a> GroupBuilder<'a> { /// (well: `BoundedLine`, §4 — nothing we draw is an infinite /// construction line), `Circle`, or `Arc` node the first time this /// entity is referenced. Returns `None` for an entity kind this pass - /// doesn't support as constraint geometry. + /// doesn't support as constraint geometry — currently including + /// `Ellipse` (constraint-parity Phase 5): the solver + /// (`sketch_solve.rs`) and this app's own XRecord format both support + /// ellipse-referencing constraints already, but `acadrust`'s + /// `AssocConstraintNodeData::Ellipse`/`BoundedEllipse` variants have a + /// different field shape from `Circle`/`Arc`'s here (`owner_id`/ + /// `is_implied`/`is_active` instead of `geometry_dependency`/ + /// `geometry_node_id`, i.e. they don't obviously look like the same + /// "geometry dependency for an entity reference" role) — without + /// confirming that against a real AutoCAD-written file, guessing would + /// risk writing a wrong-but-plausible-looking object graph, which is + /// worse than the honest "not persisted to DWG yet" gap this already + /// degrades to (same contract as `ArcLength`, which has no native + /// representation at all). fn geometry_node(&mut self, handle: Handle) -> Option { if let Some(existing) = self.entities.get(&handle) { if existing.geometry_node_id != 0 { @@ -269,6 +282,10 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), + // AutoCAD's own `GeomConstraintType` enum (ObjectARX's + // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` + // as a distinct, real geometric-constraint kind. + ConstraintKind::Normal => Some("ACNORMALCONSTRAINT"), ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"), ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"), ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"), @@ -285,15 +302,29 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { // (design doc §5) needs the resolved entity types, not just the // ConstraintKind, so it's handled by the caller instead of here. ConstraintKind::Equal | ConstraintKind::Parallel | ConstraintKind::Distance - | ConstraintKind::Angle | ConstraintKind::Radius => None, + | ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter + | ConstraintKind::DistanceX | ConstraintKind::DistanceY => None, + // No native DWG representation exists at all: neither + // `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no + // ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData` + // has anything for it. `constraint_node` below falls through to + // its own `_ => None` for this kind, same "can't persist to DWG, + // XRecord-only" gap the dependency-chain DXF omission already + // documents. + ConstraintKind::ArcLength => None, } } +/// `Diameter`/`DistanceX`/`DistanceY` reuse `Radius`'/`Distance`'s own DWG +/// class — real AutoCAD represents them as the same +/// `AcRadiusDiameterConstraint`/`AcDistanceConstraint` object with a +/// different `RadiusDiameterConstrType`/`DirectionType` mode byte (set in +/// `constraint_node` below), not a distinct native class. const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { match kind { - ConstraintKind::Distance => "ACDISTANCECONSTRAINT", + ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => "ACDISTANCECONSTRAINT", ConstraintKind::Angle => "ACANGLECONSTRAINT", - ConstraintKind::Radius => "ACRADIUSDIAMETERCONSTRAINT", + ConstraintKind::Radius | ConstraintKind::Diameter => "ACRADIUSDIAMETERCONSTRAINT", _ => "", } } @@ -361,9 +392,10 @@ fn constraint_node( builder.connect(node_id, nb); Some(node_id) } - ConstraintKind::Distance | ConstraintKind::Angle | ConstraintKind::Radius => { + ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY + | ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter => { let target_refs: Vec = match constraint.kind { - ConstraintKind::Radius => vec![builder.ref_node(*refs.first()?)?], + ConstraintKind::Radius | ConstraintKind::Diameter => vec![builder.ref_node(*refs.first()?)?], _ => { let [a, b] = refs else { return None }; vec![builder.ref_node(*a)?, builder.ref_node(*b)?] @@ -372,6 +404,7 @@ fn constraint_node( let owner_id = *target_refs.first()?; let node_id = builder.alloc_node_id(); let data = match constraint.kind { + // Plain two-point distance: undirected (`kNotDirected`). ConstraintKind::Distance => AssocConstraintNodeData::Distance { owner_id, is_implied: false, @@ -381,6 +414,28 @@ fn constraint_node( direction_type: 0, distance: None, }, + // X-only/Y-only: `kFixedDirection` with the fixed unit + // vector the distance is measured along — AutoCAD's own + // representation of DistanceX/DistanceY, not a distinct + // constraint class. + ConstraintKind::DistanceX => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 1, + distance: Some(Vector3::new(1.0, 0.0, 0.0)), + }, + ConstraintKind::DistanceY => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 1, + distance: Some(Vector3::new(0.0, 1.0, 0.0)), + }, ConstraintKind::Angle => AssocConstraintNodeData::Angle { owner_id, is_implied: false, @@ -397,6 +452,16 @@ fn constraint_node( dimension_dependency: Handle::NULL, mode: 0, }, + // `kCircleDiameter` — same class as Radius (`mode: 0` / + // `kCircleRadius`), different mode byte. + ConstraintKind::Diameter => AssocConstraintNodeData::RadiusDiameter { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + mode: 1, + }, _ => unreachable!(), }; builder.push_node(node_id, dimensional_class_name(constraint.kind), data); @@ -847,7 +912,7 @@ impl Scene { mod tests { use super::super::sketch_constraints::SketchScope; use super::*; - use acadrust::entities::{Circle, Line}; + use acadrust::entities::{Arc, Circle, Line}; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -860,6 +925,15 @@ mod tests { scene.add_entity(EntityType::Circle(Circle::from_center_radius(Vector3::new(center.0, center.1, 0.0), radius))) } + fn arc_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { + scene.add_entity(EntityType::Arc(Arc::from_center_radius_angles( + Vector3::new(center.0, center.1, 0.0), + radius, + 0.0, + std::f64::consts::PI, + ))) + } + /// 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 @@ -1133,7 +1207,7 @@ mod tests { ); } - /// The 8 new `ConstraintKind`s all reuse the same generic + /// These `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 @@ -1167,6 +1241,7 @@ mod tests { vec![SketchRef::center(circle_a), SketchRef::center(circle_b), SketchRef::whole(axis)], None, ); + set.add(ConstraintKind::Normal, vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], None); scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; @@ -1178,6 +1253,7 @@ mod tests { 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", @@ -1187,9 +1263,258 @@ mod tests { "ACPOINTCURVECONSTRAINT", "ACEQUALDISTANCECONSTRAINT", "ACSYMMETRICCONSTRAINT", + "ACNORMALCONSTRAINT", ] { assert!(class_names.contains(&expected), "{ext}: missing {expected} node, got {class_names:?}"); } } } + + /// Constraint-parity Phase 1: an Arc registers in the solver as its own + /// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and + /// this module already had `ACCONSTRAINEDARC` geometry-dependency + /// support in place before that. This confirms the two sides actually + /// meet: a Concentric/Tangent/Radius constraint referencing an arc + /// still gets its expected class names AND its geometry node is the + /// arc-specific one, through a real DWG/DXF byte round trip — not just + /// the in-memory graph this test module builds directly. + #[test] + fn constraints_referencing_an_arc_round_trip_with_the_arc_geometry_node() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Concentric, vec![SketchRef::center(arc), SketchRef::center(circle)], None); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(crate::scene::named_parameters::DrivingValue::Literal(9.0)), + ); + + 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!("arc_ref.{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(); + assert!(class_names.contains(&"ACCONCENTRICCONSTRAINT"), "{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}"); + assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}"); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"), + "{ext}: the arc should have its own geometry node, got {class_names:?}" + ); + } + } + + /// Constraint-parity Phase 4b: a Coincident constraint referencing an + /// arc's actual *endpoint* (marker 0, not its center via `-3` or its + /// whole curve) round-trips correctly too — `SketchRef`'s marker is + /// serialized generically regardless of what entity type or point it + /// addresses, so this needs no dedicated DWG-side wiring beyond what + /// already existed; this test is here to actually confirm that rather + /// than assume it. + #[test] + fn a_coincident_constraint_on_an_arcs_endpoint_round_trips() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0)); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + 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!("arc_endpoint_ref.{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(); + assert!(class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), "{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}"); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"), + "{ext}: the arc should still have its own geometry node, got {class_names:?}" + ); + } + } + + /// Constraint-parity Phase 5: an ellipse isn't a supported geometry + /// node type in `geometry_node` yet (see its own doc comment for why), + /// so a Concentric constraint referencing one degrades to "not in the + /// native graph" — the same "skip, don't error" contract every other + /// unbuildable native-constraint case gets — while the other + /// constraint in the same scope (Fixed, on a plain circle) still + /// persists normally, and the ellipse constraint itself still survives + /// in this app's own XRecord format. + #[test] + fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let ellipse = scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + 0.5, + ))); + let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Concentric, vec![SketchRef::center(ellipse), SketchRef::center(circle)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None); + + scene.materialize_sketch_constraints_for_save(); + 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_doc = + crate::io::load_bytes(&format!("ellipse_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded_doc, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!(class_names.contains(&"ACFIXEDCONSTRAINT"), "{ext}: the other constraint should still persist, got {class_names:?}"); + assert!( + !class_names.contains(&"ACCONCENTRICCONSTRAINT"), + "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" + ); + + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = reloaded_doc; + reloaded_scene.load_sketch_constraints_from_document(); + let restored = reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip")); + let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&ConstraintKind::Concentric), "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}"); + } + } + + /// Constraint-parity Phase 4a: `ArcLength` has no ObjectARX class at + /// all (confirmed against `AcExplicitConstr.h`) — this asserts the + /// degradation is exactly as documented: the native DWG/DXF graph + /// gets every *other* constraint on the arc (Radius, here) but no + /// ArcLength-shaped node, while the app's own constraint set (what + /// XRecord persistence actually serializes, per `sketch_persist.rs`'s + /// fully-generic encode/decode) still carries it untouched. + #[test] + fn arc_length_has_no_native_class_but_survives_in_the_apps_own_constraint_set() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(4.0))); + set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0))); + + // Both materializers, "called alongside, not instead of" each + // other (`materialize_dwg_native_constraints_for_save`'s own + // doc comment) — the native graph and this app's own XRecord + // format are independent, additive persistence paths. + scene.materialize_sketch_constraints_for_save(); + 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_doc = + crate::io::load_bytes(&format!("arc_length.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded_doc, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}"); + assert!( + !class_names.iter().any(|n| n.contains("ARCLENGTH")), + "{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}" + ); + + // Still present in this app's own (XRecord-backed) constraint + // set — reload into a fresh `Scene` and read it back the same + // way `sketch_persist.rs`'s own round-trip tests do. + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = reloaded_doc; + reloaded_scene.load_sketch_constraints_from_document(); + let restored = reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip")); + let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&ConstraintKind::ArcLength), "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}"); + } + } + + /// Constraint-parity Phase 2: `Diameter`/`DistanceX`/`DistanceY` reuse + /// `Radius`'/`Distance`'s own DWG class with a different + /// `RadiusDiameterConstrType`/`DirectionType` mode byte, matching real + /// AutoCAD's representation (this module's own research, folded into + /// `dimensional_class_name`'s doc comment). Confirms that byte actually + /// survives a real DWG/DXF write+read, not just the in-memory node this + /// module built. + #[test] + fn diameter_and_directed_distance_constraints_round_trip_with_their_mode_byte() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let circle = circle_entity(&mut scene, (0.0, 0.0), 3.0); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Diameter, vec![SketchRef::whole(circle)], Some(DrivingValue::Literal(16.0))); + set.add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); + set.add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(3.0)), + ); + + 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!("diameter_directed.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let radius_diameter_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|n| match &n.data { + AssocConstraintNodeData::RadiusDiameter { mode, .. } => Some(*mode), + _ => None, + }) + .collect(); + assert_eq!(radius_diameter_nodes, vec![1], "{ext}: Diameter should round-trip as mode=1 (kCircleDiameter), got {radius_diameter_nodes:?}"); + + let distance_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|n| match &n.data { + AssocConstraintNodeData::Distance { direction_type, distance, .. } => Some((*direction_type, *distance)), + _ => None, + }) + .collect(); + assert_eq!(distance_nodes.len(), 2, "{ext}: expected DistanceX and DistanceY nodes, got {distance_nodes:?}"); + assert!( + distance_nodes.contains(&(1, Some(Vector3::new(1.0, 0.0, 0.0)))), + "{ext}: DistanceX should round-trip as direction_type=1 with a (1,0,0) direction, got {distance_nodes:?}" + ); + assert!( + distance_nodes.contains(&(1, Some(Vector3::new(0.0, 1.0, 0.0)))), + "{ext}: DistanceY should round-trip as direction_type=1 with a (0,1,0) direction, got {distance_nodes:?}" + ); + } + } } diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index 2700a3ea0..2623fdcb6 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -114,6 +114,42 @@ pub enum ConstraintKind { /// point-symmetry about a point, and symmetry between two lines, aren't /// modeled. Symmetric, + /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: + /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` + /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the + /// `RadiusDiameterConstrType` mode byte + /// (`dwg_native_constraints.rs`), matching how real AutoCAD represents + /// it — not a distinct native object type. + Diameter, + /// The X-only (resp. Y-only) component of the distance between two + /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG + /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its + /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction, again + /// matching AutoCAD's own representation rather than inventing a new + /// class. + DistanceX, + DistanceY, + /// A line perpendicular to a circle/arc's tangent at their point of + /// contact — `refs`: `[whole(a), whole(b)]`, either order. For the + /// Line/Circle-only entity model this is equivalent to "the line + /// passes through the circle's center" (a circle's radius is always + /// normal to its own tangent), so it solves via the same `PointOnLine` + /// primitive `PointOnCurve` already uses. Distinct from + /// `Perpendicular` (line-to-line only) — AutoCAD's own + /// `GeomConstraintType` enum lists `kNormal` and `kPerpendicular` + /// separately for exactly this reason. Line-Line has no meaning here + /// (that's plain `Perpendicular`) and isn't buildable. + Normal, + /// An arc's arc length (`radius * sweep angle`) — `refs`: + /// `[whole(arc)]`. Solves against the arc's own `start_angle`/ + /// `end_angle`, registered separately from its center/radius + /// (`sketch_solve.rs`'s `arc_angles` cache) — no native DWG + /// representation exists for this (neither `AcExplicitConstr.h` nor + /// `acadrust`'s `AssocConstraintNodeData` has an ArcLength-shaped + /// class/variant), so it persists in this app's own XRecord format + /// only, the same "DWG can't carry everything" gap the dependency-chain + /// DXF-omission precedent already documents. + ArcLength, } pub type ConstraintId = u32; @@ -388,6 +424,11 @@ impl ConstraintKind { ConstraintKind::PointOnCurve => "∈", ConstraintKind::EqualDistance => "≐", ConstraintKind::Symmetric => "S", + ConstraintKind::Diameter => "⌀", + ConstraintKind::DistanceX => "↔ₓ", + ConstraintKind::DistanceY => "↔ᵧ", + ConstraintKind::Normal => "⊾", + ConstraintKind::ArcLength => "⌢", } } } @@ -494,6 +535,40 @@ impl super::Scene { let changes: Vec<(Handle, super::ChangeKind)> = touched.into_iter().map(|h| (h, super::ChangeKind::Modified)).collect(); self.bump_entities(&changes); } + + /// Every persistent constraint, in any scope, currently driven by the + /// named parameter `name` — what the Named Parameters panel's "used by" + /// column shows. `entities` is the constraint's own referenced handles + /// (deduplicated; a two-point constraint on the same entity's own two + /// markers would otherwise list it twice), not resolved against the + /// live document — a caller wanting an entity's current type/position + /// still needs `Scene::document.get_entity`. + pub fn parameter_usage(&self, name: &str) -> Vec { + let mut out = Vec::new(); + for set in &self.sketch_constraints { + for c in &set.constraints { + let Some(DrivingValue::Named(n)) = &c.driving_param else { continue }; + if n != name { + continue; + } + let mut entities: Vec = c.refs.iter().map(|r| r.entity).collect(); + entities.sort(); + entities.dedup(); + out.push(ParameterUsage { scope: set.scope, constraint_id: c.id, kind: c.kind, entities }); + } + } + out + } +} + +/// One persistent constraint driven by a named parameter — [`Scene::parameter_usage`]'s +/// result type. +#[derive(Debug, Clone)] +pub struct ParameterUsage { + pub scope: SketchScope, + pub constraint_id: ConstraintId, + pub kind: ConstraintKind, + pub entities: Vec, } #[cfg(test)] @@ -586,4 +661,68 @@ mod tests { assert_eq!(restored.constraints[1].driving_param, Some(DrivingValue::Literal(12.5))); assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); } + + #[test] + fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { + let mut scene = super::super::Scene::new(); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("gap".to_string())), + ); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(2))], + Some(DrivingValue::Named("gap".to_string())), + ); + // Unrelated: a literal-driven constraint and one driven by a + // different name must not show up. + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(3))], + Some(DrivingValue::Literal(5.0)), + ); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], + Some(DrivingValue::Named("other".to_string())), + ); + + let usage = scene.parameter_usage("gap"); + assert_eq!(usage.len(), 2, "exactly the two constraints driven by 'gap', got {usage:?}"); + assert!(usage.iter().any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); + assert!(usage.iter().any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); + + assert_eq!(scene.parameter_usage("nonexistent").len(), 0); + } + + #[test] + fn parameter_usage_searches_every_scope_not_just_model_space() { + let mut scene = super::super::Scene::new(); + let block = h(99); + scene.sketch_constraint_set_mut(SketchScope::Block(block)).add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(1))], + Some(DrivingValue::Named("r".to_string())), + ); + let usage = scene.parameter_usage("r"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].scope, SketchScope::Block(block)); + } + + #[test] + fn parameter_usage_deduplicates_an_entity_referenced_by_two_markers() { + let mut scene = super::super::Scene::new(); + // A Distance constraint whose two points are both on the same + // entity (e.g. a line's own start and end) must list that entity + // once, not twice. + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("len".to_string())), + ); + let usage = scene.parameter_usage("len"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].entities, vec![h(1)]); + } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index e88519b50..3959c1db1 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -13,14 +13,43 @@ //! 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 +//! (`TangentCircumf`) — Line-Line has no meaning, so that ref shape builds +//! 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. +//! +//! An `Arc` registers as a full [`EntityGeom::Arc`]: center, radius, +//! `start_angle`/`end_angle`, *and* real `start`/`end` points (marker `0`/ +//! `1`, matching `Line`'s own convention — see `sketch_constraints:: +//! resolve_point`'s read-side counterpart). Naively, `start`/`end` would be +//! independent free params that drift away from "actually on the circle at +//! that angle" the moment anything pulls on them — planegcs itself avoids +//! that by adding "arc rules" constraints internally whenever an arc is +//! created; `ocs_gcs` has no dedicated helper for that, but it turns out not +//! to need one: `ocs_gcs::constraints::curve_generic::CurveValue` already +//! ties a point's `x`/`y` to *any* `Curve`'s value at a parameter, and +//! `geo::Arc` already implements `Curve` (its `value(u)` is exactly +//! "point on the circle at angle `u`"). So `solve_scope` adds four +//! `CurveValue` constraints per registered arc — `start.x`/`start.y` tied +//! to the curve at `start_angle`, `end.x`/`end.y` tied to it at +//! `end_angle` — right after building every `SketchConstraint`'s own +//! system-level constraints, unconditionally, the same way planegcs's own +//! arc rules apply regardless of which constraints actually reference the +//! arc. That's what makes Coincident/PointOnCurve/Midpoint/EqualDistance/ +//! Symmetric on an arc's actual endpoint (as opposed to its whole curve or +//! its center) work: they resolve through the ordinary `point_ref`/ +//! `point_for_marker` machinery once markers `0`/`1` return real, +//! consistency-anchored points, no per-kind special-casing needed. +//! +//! Every whole-circle-shaped kind (Concentric, Tangent, Equal, CenterPoint, +//! Radius, Diameter, Fixed, Normal) still works on an arc via its +//! `.circle` field — `whole_circle` extracts it generically; `Tangent`/ +//! `Normal`'s own inline matches (which don't go through `whole_circle`) +//! have explicit `EntityGeom::Arc` arms alongside their `Circle` ones for +//! the same reason. use std::collections::HashMap; use std::rc::Rc; @@ -29,13 +58,14 @@ use acadrust::entities::EntityType; use acadrust::types::Handle; use ocs_gcs::constraints::angle_distance::L2LAngle; -use ocs_gcs::constraints::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; +use ocs_gcs::constraints::circle_arc::{ArcLength, C2LDistance, P2CDistance, TangentCircumf}; +use ocs_gcs::constraints::curve_generic::CurveValue; use ocs_gcs::constraints::point_line::{ - CenterOfGravity, Equal, EqualLineLength, MidpointOnLine, Parallel as ParallelConstraint, + CenterOfGravity, Difference, 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}; +use ocs_gcs::geo::{Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint}; use ocs_gcs::solvers::dogleg::solve_dl; use ocs_gcs::system::System; @@ -44,14 +74,22 @@ use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, 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. +/// parameter store. #[derive(Clone, Copy)] enum EntityGeom { Line(GLine), Circle(GCircle), + /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ + /// `end_angle`, and real `start`/`end` points kept consistent with + /// those by the arc-rules `CurveValue` constraints `solve_scope` adds + /// for every registered arc. See this module's doc comment. + Arc(GArc), + /// Center-and-shape support only — see `register_entity`'s `Ellipse` + /// arm for the acadrust-to-`ocs_gcs` parametrization conversion, and + /// this module's doc comment for what isn't wired up yet (major/minor + /// axis dimensional constraints, `PointOnEllipse`-based Coincident/ + /// Tangent). + Ellipse(GEllipse), } impl EntityGeom { @@ -64,11 +102,34 @@ impl EntityGeom { (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), (EntityGeom::Circle(c), -3) => Some(c.center), + (EntityGeom::Arc(a), 0) => Some(a.start), + (EntityGeom::Arc(a), 1) => Some(a.end), + (EntityGeom::Arc(a), -3) => Some(a.circle.center), + (EntityGeom::Ellipse(e), -3) => Some(e.center), _ => None, } } } +/// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or +/// "a whole line" — an `Arc` collapses to its `.circle` here exactly like +/// `whole_circle` does, so those two constraints' own inline matches don't +/// need every Circle/Arc combination spelled out separately. +enum CircleOrLine { + Circle(GCircle), + Line(GLine), + Other, +} + +fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { + match g { + EntityGeom::Circle(c) => CircleOrLine::Circle(c), + EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), + EntityGeom::Line(l) => CircleOrLine::Line(l), + EntityGeom::Ellipse(_) => CircleOrLine::Other, + } +} + /// 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- @@ -91,6 +152,44 @@ fn register_entity(document: &acadrust::CadDocument, sys: &mut System, handle: H let rad = sys.add_param(c.radius, false); Some(EntityGeom::Circle(GCircle { center, rad })) } + EntityType::Arc(a) => { + // Full registration: center/radius, both angles, and real + // start/end points seeded from acadrust's own `start_point`/ + // `end_point` (already consistent with center/radius/angle at + // registration time) — `solve_scope` adds the arc-rules + // constraints that keep them that way under solving. See this + // module's doc comment. + let center = GPoint::new(sys.add_param(a.center.x, false), sys.add_param(a.center.y, false)); + let rad = sys.add_param(a.radius, false); + let start_angle = sys.add_param(a.start_angle, false); + let end_angle = sys.add_param(a.end_angle, false); + let start_seed = a.start_point(); + let end_seed = a.end_point(); + let start = GPoint::new(sys.add_param(start_seed.x, false), sys.add_param(start_seed.y, false)); + let end = GPoint::new(sys.add_param(end_seed.x, false), sys.add_param(end_seed.y, false)); + Some(EntityGeom::Arc(GArc { circle: GCircle { center, rad }, start, end, start_angle, end_angle })) + } + EntityType::Ellipse(el) => { + // acadrust's `Ellipse` is center + major-axis vector (its length + // is the major radius) + minor/major ratio; `ocs_gcs::geo:: + // Ellipse` is center + one focus + minor radius. Converting: + // minor_radius = major_radius * ratio, then the focus distance + // c follows from a² = b² + c² (standard ellipse identity), and + // focus1 sits `c` along the major-axis direction from center. + let major_radius = el.major_axis.length(); + let minor_radius = major_radius * el.minor_axis_ratio; + let focus_dist = (major_radius * major_radius - minor_radius * minor_radius).max(0.0).sqrt(); + // Direction is meaningless once major_radius is ~0 (a + // degenerate point-ellipse) — arbitrarily fall back to +X + // rather than dividing by ~0 in `normalize`. + let unit_major = + if major_radius > 1e-9 { el.major_axis.normalize() } else { acadrust::types::Vector3::UNIT_X }; + let focus1_point = el.center + unit_major * focus_dist; + let center = GPoint::new(sys.add_param(el.center.x, false), sys.add_param(el.center.y, false)); + let focus1 = GPoint::new(sys.add_param(focus1_point.x, false), sys.add_param(focus1_point.y, false)); + let radmin = sys.add_param(minor_radius, false); + Some(EntityGeom::Ellipse(GEllipse { center, focus1, radmin })) + } _ => None, } } @@ -142,11 +241,12 @@ fn build_constraint( 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, + EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => 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, + EntityGeom::Arc(a) => Some(a.circle), + EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, }; let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { let marker = r.marker?; @@ -232,6 +332,18 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(circ, p, zero))] } + // Same "distance to the underlying circle is zero" relation + // as the `Circle` arm — doesn't restrict the point to + // within the arc's own sweep, matching that same + // pre-existing simplification for `Circle`. + EntityGeom::Arc(a) => { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new(a.circle, p, zero))] + } + // `PointOnEllipse` (`ocs_gcs::constraints::conic`) isn't + // wired up yet — deliberately deferred alongside + // ellipse-tangency, same as this module's doc comment. + EntityGeom::Ellipse(_) => Vec::new(), } } ConstraintKind::Symmetric => { @@ -276,6 +388,43 @@ fn build_constraint( Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), ] } + // Pinning the 5 intrinsic params (center, radius, both + // angles) is enough — `start`/`end` are already tied to + // those via the arc-rules `CurveValue` constraints + // `solve_scope` adds for every arc, so pinning them too + // would just be redundant. + EntityGeom::Arc(a) => { + let (cx, cy, r, sa, ea) = { + let store = sys.store(); + ( + store.get(a.circle.center.x), + store.get(a.circle.center.y), + store.get(a.circle.rad), + store.get(a.start_angle), + store.get(a.end_angle), + ) + }; + vec![ + Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), + Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), + Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), + ] + } + EntityGeom::Ellipse(el) => { + let (cx, cy, fx, fy, b) = { + let store = sys.store(); + (store.get(el.center.x), store.get(el.center.y), store.get(el.focus1.x), store.get(el.focus1.y), store.get(el.radmin)) + }; + vec![ + Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(el.focus1.x, sys.add_param(fx, true), 1.0)), + Rc::new(Equal::new(el.focus1.y, sys.add_param(fy, true), 1.0)), + Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), + ] + } } } ConstraintKind::Distance => { @@ -301,13 +450,50 @@ fn build_constraint( let target = sys.add_param(resolved, true); vec![Rc::new(Equal::new(circle.rad, target, 1.0))] } + // Same math as `Radius`, just `radius = target / 2` instead of + // `radius = target` — `Equal`'s `ratio` param already supports + // this, matching how AutoCAD itself stores Diameter as the same + // `ACRADIUSDIAMETERCONSTRAINT` class with a different mode byte + // rather than a distinct one (`dwg_native_constraints.rs`). + ConstraintKind::Diameter => { + 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, 0.5))] + } + // Signed X-only/Y-only component of the distance between two + // points — `Difference::new(p1, p2, d)` is `p2 - p1 == d`, matching + // AutoCAD's own `ACDISTANCECONSTRAINT` with a fixed-direction + // vector rather than a distinct native class + // (`dwg_native_constraints.rs`). + ConstraintKind::DistanceX => { + 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(Difference::new(pa.x, pb.x, target))] + } + ConstraintKind::DistanceY => { + 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(Difference::new(pa.y, pb.y, target))] + } ConstraintKind::Tangent => { 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)) => { + // An `Arc`'s `.circle` behaves identically to a plain `Circle` + // for tangency math — normalize both refs down first so the + // match below doesn't need every Circle/Arc combination + // written out separately. + match (as_circle_or_line(ga), as_circle_or_line(gb)) { + (CircleOrLine::Circle(c1), CircleOrLine::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)); @@ -319,7 +505,7 @@ fn build_constraint( let internal = center_dist < (r1 - r2).abs(); 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)) => { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::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)); @@ -336,11 +522,38 @@ fn build_constraint( let zero = sys.add_param(0.0, true); 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). + // Line-Line tangency has no meaning; either side being an + // Ellipse falls here too (`PointOnEllipse`/tangency isn't + // wired up yet). _ => Vec::new(), } } + // A circle's radius is always normal to its own tangent, so + // "line normal to circle/arc" reduces to "line passes through the + // circle's center" — the same `PointOnLine` primitive + // `PointOnCurve` already uses for a point-on-line case. + ConstraintKind::Normal => { + 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 (as_circle_or_line(ga), as_circle_or_line(gb)) { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + vec![Rc::new(PointOnLine::new(circ.center, line))] + } + // Line-Line has no meaning here (that's `Perpendicular`); + // Circle-Circle "normal" (orthogonal circles) needs a + // different, not-yet-implemented relation. + _ => Vec::new(), + } + } + ConstraintKind::ArcLength => { + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(EntityGeom::Arc(arc)) = resolve_ref(document, 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(ArcLength::new(arc, target))] + } } } @@ -386,6 +599,22 @@ fn solve_scope( return None; } + // Arc rules (this module's doc comment): keep every registered arc's + // `start`/`end` points consistent with its center/radius/angle, + // unconditionally — planegcs itself adds these the moment an arc + // exists, not only when some `SketchConstraint` happens to reference + // its endpoint. Not tracked in `owner`: these are solver-internal + // bookkeeping, never redundant with anything a user-facing constraint + // could name, so there's no `ConstraintId` for them to report against. + for geom in cache.values() { + let EntityGeom::Arc(arc) = geom else { continue }; + let curve = Rc::new(*arc); + sys.add_constraint(Rc::new(CurveValue::new(arc.start, arc.start.x, curve.clone(), arc.start_angle))); + sys.add_constraint(Rc::new(CurveValue::new(arc.start, arc.start.y, curve.clone(), arc.start_angle))); + sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.x, curve.clone(), arc.end_angle))); + sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.y, curve, arc.end_angle))); + } + let partitions = sys.partition(); for sub in &partitions { solve_dl(sub, sys.store_mut()); @@ -404,6 +633,15 @@ fn solve_scope( .map(|g| match g { EntityGeom::Line(_) => 4, EntityGeom::Circle(_) => 3, + // Raw param count (center×2, rad, start×2, end×2, both + // angles) — same "raw, not netted against its own + // constraints" convention as every other arm here. The four + // arc-rules `CurveValue` constraints (always present, added + // just above) already reduce this to 5 *effective* DOF through + // the normal `touched_free`/`diag.dof` accounting below, the + // same way any other constraint would. + EntityGeom::Arc(_) => 9, + EntityGeom::Ellipse(_) => 5, }) .sum(); let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); @@ -458,6 +696,65 @@ fn solve_scope( results.push((handle, EntityType::Circle(updated))); } } + (EntityType::Arc(a), EntityGeom::Arc(g)) => { + // Center/radius/angles only — `start`/`end` are derived + // (acadrust's `Arc` has no separate stored fields for them; + // `start_point()`/`end_point()` compute them from these + // same four), so nothing further needs writing back for + // them specifically. + let (cx, cy, r) = (store.get(g.circle.center.x), store.get(g.circle.center.y), store.get(g.circle.rad)); + let (new_start_angle, new_end_angle) = (store.get(g.start_angle), store.get(g.end_angle)); + if (cx - a.center.x).abs() > MOVE_EPS + || (cy - a.center.y).abs() > MOVE_EPS + || (r - a.radius).abs() > MOVE_EPS + || (new_start_angle - a.start_angle).abs() > MOVE_EPS + || (new_end_angle - a.end_angle).abs() > MOVE_EPS + { + let mut updated = a.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.radius = r; + updated.start_angle = new_start_angle; + updated.end_angle = new_end_angle; + results.push((handle, EntityType::Arc(updated))); + } + } + (EntityType::Ellipse(el), EntityGeom::Ellipse(g)) => { + // Converting back to acadrust's center + major-axis-vector + + // minor/major-ratio parametrization — the inverse of + // `register_entity`'s `Ellipse` arm. `rad_maj_at` (already + // provided by `ocs_gcs::geo::Ellipse`) does the a²=b²+c² + // algebra; only the major-axis *direction* needs deriving + // here, from the (possibly moved) focus relative to center. + let (cx, cy) = (store.get(g.center.x), store.get(g.center.y)); + let (fx, fy) = (store.get(g.focus1.x), store.get(g.focus1.y)); + let minor_radius = store.get(g.radmin); + let (major_radius, _) = g.rad_maj_at(store, None); + let focus_dist = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt(); + // Direction is ill-defined once the ellipse is (near) + // circular — keep whatever direction it already had rather + // than snapping to an arbitrary axis. + let unit_major = if focus_dist > 1e-9 { + acadrust::types::Vector3::new(fx - cx, fy - cy, 0.0).normalize() + } else { + el.major_axis.normalize() + }; + let new_major_axis = unit_major * major_radius; + let new_ratio = if major_radius > 1e-9 { minor_radius / major_radius } else { 0.0 }; + if (cx - el.center.x).abs() > MOVE_EPS + || (cy - el.center.y).abs() > MOVE_EPS + || (new_major_axis.x - el.major_axis.x).abs() > MOVE_EPS + || (new_major_axis.y - el.major_axis.y).abs() > MOVE_EPS + || (new_ratio - el.minor_axis_ratio).abs() > MOVE_EPS + { + let mut updated = el.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.major_axis = new_major_axis; + updated.minor_axis_ratio = new_ratio; + results.push((handle, EntityType::Ellipse(updated))); + } + } _ => {} } } diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index cbcb97667..bfe494f02 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -17,10 +17,76 @@ use crate::app::Message; use crate::scene::named_parameters::ParameterTable; +use crate::scene::Scene; use crate::t; -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use acadrust::types::Handle; +use acadrust::EntityType; +use iced::widget::tooltip::Position as TipPos; +use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space}; use iced::{Background, Element, Length, Theme}; +/// Short "Kind handle" label for one referenced entity — the same handle +/// display (`0x…`) AutoCAD/this app's own conventions use, since entities +/// have no user-facing name to show instead. +fn entity_label(scene: &Scene, handle: Handle) -> String { + match scene.document.get_entity(handle) { + Some(EntityType::Line(_)) => format!("Line {handle}"), + Some(EntityType::Circle(_)) => format!("Circle {handle}"), + Some(EntityType::Arc(_)) => format!("Arc {handle}"), + Some(_) => format!("Entity {handle}"), + None => format!("(erased {handle})"), + } +} + +/// One line per constraint currently driven by `name`, e.g. +/// `"Distance: Line 0x2A, Line 0x2B"` — empty when nothing references it +/// yet (a parameter defined but not yet used by any constraint). +fn usage_lines(scene: &Scene, name: &str) -> Vec { + scene + .parameter_usage(name) + .iter() + .map(|u| { + let entities = u.entities.iter().map(|h| entity_label(scene, *h)).collect::>().join(", "); + format!("{:?}: {entities}", u.kind) + }) + .collect() +} + +/// The "Used by" column's cell for one row: a compact kind-count summary +/// (e.g. `"Distance ×2, Radius ×1"`), with the full per-constraint entity +/// list (`usage_lines`) as a hover tooltip — the column is too narrow to +/// show entity lists inline once a parameter drives more than one or two +/// constraints. A blank name (a fresh, not-yet-named row) or a name driving +/// nothing shows a muted em dash instead. +fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> { + if name.is_empty() { + return Space::new().into(); + } + let lines = usage_lines(scene, name); + if lines.is_empty() { + return text("—").size(11).style(muted_style).into(); + } + let mut counts: Vec<(String, usize)> = Vec::new(); + for line in &lines { + let kind = line.split(':').next().unwrap_or(line).to_string(); + match counts.iter_mut().find(|(k, _)| *k == kind) { + Some((_, n)) => *n += 1, + None => counts.push((kind, 1)), + } + } + let summary = counts.iter().map(|(k, n)| format!("{k} ×{n}")).collect::>().join(", "); + let mut body = column![].spacing(2); + for line in &lines { + body = body.push(text(line.clone()).size(11)); + } + tooltip( + text(summary).size(11).style(muted_style), + container(body).style(container::bordered_box).padding([4, 8]), + TipPos::Top, + ) + .into() +} + /// Which column of a parameter row a text edit targets. #[derive(Clone, Copy, Debug)] pub enum ParamField { @@ -113,8 +179,12 @@ fn preview(rows: &[ParamEditorRow]) -> Vec>> { } /// Build the named-parameter editor content. `rows` is the live working -/// buffer. -pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { +/// buffer; `scene` supplies the "Used by" column, computed against the +/// *live*, already-applied constraint set (`Scene::parameter_usage`) — +/// unapplied edits to a row's name in the buffer don't retroactively +/// relabel what's shown, same as the rest of this editor only takes effect +/// on Apply. +pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { let title = text(t!("Named Parameters")).size(15); let hint = text(t!( "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." @@ -124,13 +194,15 @@ pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizin let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 }; const NAME_WIDTH: f32 = 120.0; - const VALUE_WIDTH: f32 = 140.0; + const VALUE_WIDTH: f32 = 100.0; + const USED_BY_WIDTH: f32 = 160.0; let head = container( row![ container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)), container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width), container(text(t!("Value")).size(11).style(muted_style)).width(Length::Fixed(VALUE_WIDTH)), + container(text(t!("Used by")).size(11).style(muted_style)).width(Length::Fixed(USED_BY_WIDTH)), Space::new().width(Length::Fixed(30.0)), ] .spacing(8), @@ -155,14 +227,21 @@ pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizin Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(), None => Space::new().into(), }; + let used_by_cell = used_by_cell(scene, row.name.trim()); let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) .on_press(Message::NamedParametersRemove(idx)) .padding([2, 6]) .style(button::danger); list = list.push( - row![name_box, formula_box, container(value_cell).width(Length::Fixed(VALUE_WIDTH)), del] - .spacing(8) - .align_y(iced::Center), + row![ + name_box, + formula_box, + container(value_cell).width(Length::Fixed(VALUE_WIDTH)), + container(used_by_cell).width(Length::Fixed(USED_BY_WIDTH)), + del + ] + .spacing(8) + .align_y(iced::Center), ); } @@ -255,4 +334,37 @@ mod tests { assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate"))); assert_eq!(results[2], Some(Ok(3.0)), "an unrelated row's name must not be affected"); } + + #[test] + fn usage_lines_names_the_constraint_kind_and_its_entities() { + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + let mut scene = Scene::new(); + let line = scene.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(crate::scene::named_parameters::DrivingValue::Named("gap".to_string())), + ); + + let lines = usage_lines(&scene, "gap"); + assert_eq!(lines.len(), 1); + assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}"); + assert!(lines[0].contains(&format!("{line}")), "should name the actual entity handle, got {lines:?}"); + } + + #[test] + fn usage_lines_is_empty_for_a_parameter_nothing_references() { + let scene = Scene::new(); + assert!(usage_lines(&scene, "unused").is_empty()); + } + + #[test] + fn entity_label_reports_erased_for_a_dangling_handle() { + let scene = Scene::new(); + let label = entity_label(&scene, acadrust::types::Handle::new(999)); + assert!(label.starts_with("(erased "), "got {label}"); + } } diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index d2838eb73..194bcd597 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -42,6 +42,44 @@ fn circle_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { } } +fn add_arc(scene: &mut Scene, cx: f64, cy: f64, radius: f64, start_angle: f64, end_angle: f64) -> Handle { + scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( + Vector3::new(cx, cy, 0.0), + radius, + start_angle, + end_angle, + ))) +} + +fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => (a.center, a.radius), + other => panic!("expected an Arc, got {other:?}"), + } +} + +fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => (a.radius, a.start_angle, a.end_angle), + other => panic!("expected an Arc, got {other:?}"), + } +} + +fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { + scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes( + Vector3::new(cx, cy, 0.0), + Vector3::new(major_axis.0, major_axis.1, 0.0), + ratio, + ))) +} + +fn ellipse_geom(scene: &Scene, handle: Handle) -> (Vector3, Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Ellipse(e) => (e.center, e.major_axis, e.minor_axis_ratio), + other => panic!("expected an Ellipse, got {other:?}"), + } +} + #[test] fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { let mut scene = Scene::new(); @@ -563,3 +601,391 @@ fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { "b's center should have been pulled to a's mirror image across the axis: expected (-3, 4), got {cb:?}" ); } + +// Phase 1 of docs' constraint-parity plan: an Arc registers in the solver +// as its own center/radius (an `EntityGeom::Circle`), so every whole-circle +// constraint kind — Radius, Tangent, Concentric among them — already works +// on it. These mirror the equivalent Circle tests above one-for-one. + +#[test] +fn radius_constraint_on_an_arc_solves_to_the_target() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(7.5)), + ); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (_, radius) = arc_geom(&scene, arc); + assert!((radius - 7.5).abs() < 1e-6, "arc radius should have solved to the driven target: got {radius}"); +} + +#[test] +fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Tangent, vec![SketchRef::whole(arc), SketchRef::whole(line)], None); + scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, radius) = arc_geom(&scene, arc); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!((signed_dist.abs() - radius).abs() < 1e-6, "center-to-line distance should equal the arc's radius after solving: dist={signed_dist} radius={radius}"); +} + +#[test] +fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 4.0, 0.0, std::f64::consts::PI); + let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(arc), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + + let (arc_center, _) = arc_geom(&scene, arc); + let (circle_center, _) = circle_geom(&scene, circle); + assert!( + (arc_center.x - circle_center.x).abs() < 1e-6 && (arc_center.y - circle_center.y).abs() < 1e-6, + "arc and circle centers should coincide after solving: arc={arc_center:?} circle={circle_center:?}" + ); +} + +// Phase 4b of the same plan: an arc's actual endpoints (marker 0/1, not +// just its center or whole curve) are now real, solvable points — kept +// consistent with center/radius/angle by the "arc rules" `CurveValue` +// constraints `solve_scope` adds for every registered arc. Coincident/ +// PointOnCurve/etc. touching an arc endpoint need no kind-specific code at +// all: they resolve through the same generic `point_ref`/`point_for_marker` +// path every other point-ref constraint already uses. + +fn arc_start_point(scene: &Scene, handle: Handle) -> Vector3 { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => a.start_point(), + other => panic!("expected an Arc, got {other:?}"), + } +} + +#[test] +fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); + let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); + + // Move the arc directly (bypassing the solver), same pattern as the + // equivalent Circle/Ellipse CenterPoint tests — the constraint is a + // symmetric point-equality, so what's checked is that both sides end + // up equal to *each other*, not at some hardcoded absolute position. + if let Some(EntityType::Arc(a)) = scene.document.get_entity_mut(arc) { + a.center = Vector3::new(-8.0, 3.0, 0.0); + } + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let arc_start = arc_start_point(&scene, arc); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (arc_start.x - line_start.x).abs() < 1e-6 && (arc_start.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the arc's start point: arc_start={arc_start:?} line_start={line_start:?}" + ); +} + +#[test] +fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solving() { + // The actual "arc rules" guarantee: after a solve that moved a + // constrained endpoint, recomputing the arc's start point from its + // (possibly also moved) center/radius/start_angle must land exactly on + // the same point the constraint pulled the endpoint to — not just "some + // point near it". This is what would drift if the `CurveValue` ties + // were missing or wrong. + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + set.add(ConstraintKind::Coincident, vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], None); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (center, radius, start_angle, _) = { + match scene.document.get_entity(arc).expect("entity exists") { + EntityType::Arc(a) => (a.center, a.radius, a.start_angle, a.end_angle), + other => panic!("expected an Arc, got {other:?}"), + } + }; + let recomputed_start = Vector3::new(center.x + radius * start_angle.cos(), center.y + radius * start_angle.sin(), 0.0); + let actual_start = arc_start_point(&scene, arc); + assert!( + (recomputed_start.x - actual_start.x).abs() < 1e-6 && (recomputed_start.y - actual_start.y).abs() < 1e-6, + "start_point() must match center+radius*angle exactly: recomputed={recomputed_start:?} actual={actual_start:?}" + ); + assert!( + (actual_start.x - 12.0).abs() < 1e-6 && (actual_start.y - 7.0).abs() < 1e-6, + "the constrained endpoint itself should have reached the fixed anchor: {actual_start:?}" + ); +} + +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, radius) = arc_geom(&scene, arc); + let (p, _) = line_endpoints(&scene, marker); + let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt(); + assert!((dist - radius).abs() < 1e-6, "point should land exactly on the arc's circle: dist={dist} radius={radius}"); +} + +#[test] +fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { + // Exercises both registration paths for the same arc in one solve: the + // whole-circle path (`Radius`, via `.circle`) and the endpoint path + // (`Coincident` on marker 0, via the arc-rules-anchored `start` point). + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); + let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + set.add(ConstraintKind::Coincident, vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], None); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0))); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (_, radius) = arc_geom(&scene, arc); + assert!((radius - 6.0).abs() < 1e-6, "Radius constraint should still hold: got {radius}"); + let actual_start = arc_start_point(&scene, arc); + assert!( + (actual_start.x - 9.0).abs() < 1e-6 && (actual_start.y - -4.0).abs() < 1e-6, + "the arc's start point should have reached the fixed anchor despite the simultaneous Radius constraint: {actual_start:?}" + ); +} + +// Phase 2 of the same plan: `Diameter`/`DistanceX`/`DistanceY` reuse +// existing `ocs_gcs` primitives (`Equal`'s `ratio`, `Difference`) with no +// new solver-crate code. + +#[test] +fn diameter_constraint_drives_the_radius_to_half_the_target() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Diameter, + vec![SketchRef::whole(circle)], + Some(DrivingValue::Literal(16.0)), + ); + scene.bump_entities(&[(circle, ChangeKind::Modified)]); + + let (_, radius) = circle_geom(&scene, circle); + assert!((radius - 8.0).abs() < 1e-6, "radius should be half the diameter target: got {radius}"); +} + +#[test] +fn distance_x_constraint_pins_only_the_x_component() { + let mut scene = Scene::new(); + // Deliberately not axis-aligned so DistanceX and DistanceY are + // distinguishable from a plain Distance/Horizontal outcome. + let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!((end.x - start.x - 10.0).abs() < 1e-6, "x component should equal the target: dx={}", end.x - start.x); +} + +// Phase 3 of the same plan: `Normal` reduces to "line passes through the +// circle's center" for the Line/Circle-only entity model, reusing +// `PointOnLine` (the same primitive `PointOnCurve` already uses). + +#[test] +fn normal_constraint_pulls_the_line_through_the_circles_center() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 5.0, 5.0, 2.0); + // A horizontal line well clear of the circle's center. + let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Normal, vec![SketchRef::whole(circle), SketchRef::whole(line)], None); + scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, _) = circle_geom(&scene, circle); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!(signed_dist.abs() < 1e-6, "the circle's center should lie on the (infinite) line after solving: dist={signed_dist}"); +} + +// Phase 4a of the same plan: `ArcLength` registers an arc's +// `start_angle`/`end_angle` separately from its center/radius (which still +// flow through the existing `EntityGeom::Circle` aliasing), so it can +// combine with a `Radius` constraint on the same arc in one solve. + +#[test] +fn arc_length_constraint_drives_the_swept_length_to_the_target() { + let mut scene = Scene::new(); + // radius 5, a 90° span (length ≈ 7.85) — deliberately not the target. + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(5.0))); + set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(10.0))); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (radius, start_angle, end_angle) = arc_angles(&scene, arc); + assert!((radius - 5.0).abs() < 1e-6, "Radius constraint should still hold: got {radius}"); + let length = radius * (end_angle - start_angle); + assert!((length - 10.0).abs() < 1e-5, "swept length should equal the ArcLength target: got {length}"); +} + +// Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- +// radius (converted from acadrust's center + major-axis-vector + ratio +// parametrization). Only center-based whole-entity constraints are wired +// up so far — Concentric, CenterPoint, Fixed — mirroring how Arc started +// at "center/radius only" in Phase 1. Axis-length constraints and +// `PointOnEllipse`-based Coincident/Tangent are a further follow-up. + +#[test] +fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); + let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(ellipse, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + + let (ellipse_center, _, _) = ellipse_geom(&scene, ellipse); + let (circle_center, _) = circle_geom(&scene, circle); + assert!( + (ellipse_center.x - circle_center.x).abs() < 1e-6 && (ellipse_center.y - circle_center.y).abs() < 1e-6, + "ellipse and circle centers should coincide after solving: ellipse={ellipse_center:?} circle={circle_center:?}" + ); +} + +#[test] +fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + 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(ellipse)], + None, + ); + + // Move the ellipse directly (bypassing the solver) before the first + // solve, same as the equivalent Circle test — the constraint is a + // symmetric point-equality, so what's checked is that both sides end + // up equal to *each other*, not at some hardcoded absolute position. + if let Some(EntityType::Ellipse(e)) = scene.document.get_entity_mut(ellipse) { + e.center = Vector3::new(-6.0, 9.0, 0.0); + } + scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); + + let (center, _, _) = ellipse_geom(&scene, ellipse); + 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 ellipse's center: center={center:?} start={line_start:?}" + ); +} + +#[test] +fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { + // The riskiest part of Ellipse support: converting acadrust's center + + // major-axis-*vector* + minor/major ratio into `ocs_gcs`'s center + + // focus + minor-radius, then back, for a major axis that ISN'T + // axis-aligned (an axis-aligned one wouldn't exercise the direction + // math at all). A solve with nothing actually pulling on it should + // reproduce the same ellipse, not drift. + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 2.0, -3.0, (4.0, 3.0), 0.6); // major radius 5, tilted + let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); + + let (center, major_axis, ratio) = ellipse_geom(&scene, ellipse); + assert!((center.x - 2.0).abs() < 1e-6 && (center.y - -3.0).abs() < 1e-6, "center should not have drifted: {center:?}"); + assert!((major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 3.0).abs() < 1e-6, "major axis should not have drifted: {major_axis:?}"); + assert!((ratio - 0.6).abs() < 1e-6, "minor/major ratio should not have drifted: {ratio}"); +} + +#[test] +fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); + let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); + set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], None); + + let before = ellipse_geom(&scene, ellipse); + + // Drag the line's start away — without Fixed this would pull the + // ellipse's center along with it; Fixed should hold the ellipse + // exactly in place and let the line's start follow back to it instead. + set_line_start(&mut scene, line, Vector3::new(20.0, -8.0, 0.0)); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let after = ellipse_geom(&scene, ellipse); + assert_eq!(before, after, "Fixed ellipse must not move at all (center, major axis, or ratio)"); + let (start, _) = line_endpoints(&scene, line); + assert!( + (start.x - before.0.x).abs() < 1e-6 && (start.y - before.0.y).abs() < 1e-6, + "line's start should have been pulled back to the (unmoved) ellipse center" + ); +} + +#[test] +fn distance_y_constraint_pins_only_the_y_component() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(-3.0)), + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!((end.y - start.y - -3.0).abs() < 1e-6, "y component should equal the (signed) target: dy={}", end.y - start.y); +}