Files
OneCAD-Draft/tests/parametric_constraints_solve.rs
T

1569 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use acadrust::entities::EntityType;
use acadrust::types::{Handle, Vector3};
use OpenCADStudio::scene::named_parameters::DrivingValue;
use OpenCADStudio::scene::parametric_constraints::{
ConstraintKind, ParametricRef, ParametricScope,
};
use OpenCADStudio::scene::{ChangeKind, Scene};
fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle {
scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(x1, y1, 0.0),
Vector3::new(x2, y2, 0.0),
)))
}
fn line_endpoints(scene: &Scene, handle: Handle) -> (Vector3, Vector3) {
match scene.document.get_entity(handle).expect("entity exists") {
EntityType::Line(l) => (l.start, l.end),
other => panic!("expected a Line, got {other:?}"),
}
}
fn set_line_end(scene: &mut Scene, handle: Handle, end: Vector3) {
if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(handle) {
l.end = end;
}
}
fn add_circle(scene: &mut Scene, cx: f64, cy: f64, radius: f64) -> Handle {
scene.add_entity(EntityType::Circle(
acadrust::entities::Circle::from_center_radius(Vector3::new(cx, cy, 0.0), radius),
))
}
fn circle_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) {
match scene.document.get_entity(handle).expect("entity exists") {
EntityType::Circle(c) => (c.center, c.radius),
other => panic!("expected a Circle, got {other:?}"),
}
}
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 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();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(line)],
None,
);
// Drag the endpoint off-axis — a real edit, the kind of thing a Move
// command or grip-drag commit would do.
set_line_end(&mut scene, line, Vector3::new(10.0, 4.0, 0.0));
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, line);
assert!(
(start.y - end.y).abs() < 1e-6,
"line should have been re-leveled: start={start:?} end={end:?}"
);
// A line is 4 raw coordinates; Horizontal removes exactly 1 (y1 == y2),
// leaving 3 — not 1: the two X coordinates are registered but never
// referenced by any constraint, so they must still count as free
// (regression guard for the undercounting `solve_scope` bug found while
// live-testing this stage: `System::partition`'s subsystems only see
// constraint-referenced params, so an untouched one was invisible to
// `diagnose` entirely before this was fixed).
let dof = scene
.parametric_constraint_set(ParametricScope::ModelSpace)
.and_then(|s| s.dof);
assert_eq!(
dof,
Some(3),
"DOF should count the two untouched X coordinates too"
);
}
#[test]
fn horizontal_constraint_levels_a_polyline_segment() {
let mut scene = Scene::new();
let polyline = scene.add_entity(EntityType::LwPolyline(
acadrust::entities::LwPolyline::from_points(vec![
acadrust::types::Vector2::new(0.0, 0.0),
acadrust::types::Vector2::new(5.0, 2.0),
acadrust::types::Vector2::new(10.0, 7.0),
]),
));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::segment(polyline, 1)],
None,
);
scene.bump_entities(&[(polyline, ChangeKind::Modified)]);
let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else {
panic!("expected a lightweight polyline");
};
assert!(
(polyline.vertices[1].location.y - polyline.vertices[2].location.y).abs() < 1e-6,
"the selected segment should be horizontal"
);
}
#[test]
fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() {
let mut source = acadrust::entities::LwPolyline::new();
source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 0.5);
source.add_point(acadrust::types::Vector2::new(5.0, 2.0));
let mut scene = Scene::new();
let polyline = scene.add_entity(EntityType::LwPolyline(source));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::segment(polyline, 0)],
None,
);
scene.bump_entities(&[(polyline, ChangeKind::Modified)]);
let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else {
panic!("expected a lightweight polyline");
};
assert_eq!(polyline.vertices[0].location.y, 0.0);
assert_eq!(polyline.vertices[1].location.y, 2.0);
assert_eq!(polyline.vertices[0].bulge, 0.5);
}
#[test]
fn horizontal_constraint_aligns_two_selected_points() {
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 1.0, 4.0, 3.0);
let b = add_line(&mut scene, 8.0, 7.0, 12.0, 9.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (a_start, _) = line_endpoints(&scene, a);
let (b_start, _) = line_endpoints(&scene, b);
assert!((a_start.y - b_start.y).abs() < 1e-6);
}
#[test]
fn parallel_constraint_rotates_the_other_line_when_one_moves() {
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Parallel,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
// Rotate `a`; `b` (parallel to it) should follow.
set_line_end(&mut scene, a, Vector3::new(10.0, 6.0, 0.0));
scene.bump_entities(&[(a, ChangeKind::Modified)]);
let (a1, a2) = line_endpoints(&scene, a);
let (b1, b2) = line_endpoints(&scene, b);
let dir_a = ((a2.x - a1.x), (a2.y - a1.y));
let dir_b = ((b2.x - b1.x), (b2.y - b1.y));
// Parallel: cross product of directions is ~0.
let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0;
assert!(
cross.abs() < 1e-6,
"lines should be parallel after the solve: dir_a={dir_a:?} dir_b={dir_b:?}"
);
}
#[test]
fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit() {
let mut scene = Scene::new();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Literal(20.0)),
);
set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, line);
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!(
(len - 20.0).abs() < 1e-6,
"length should have been solved to the driving value, got {len}"
);
}
/// `named_parameters_design.md` stage 3: a `Distance` constraint's driving
/// value can be a named-parameter reference instead of a literal, resolved
/// through `Scene::named_parameters` at solve time.
#[test]
fn distance_constraint_resolves_a_named_parameter_reference() {
let mut scene = Scene::new();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene
.named_parameters_mut()
.set("target_len", "20")
.unwrap();
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Named("target_len".to_string())),
);
set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, line);
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!(
(len - 20.0).abs() < 1e-6,
"length should have resolved the named parameter, got {len}"
);
// Editing the parameter itself and touching the scope again should
// ripple through, same as editing a literal driving value would.
scene.named_parameters_mut().set("target_len", "8").unwrap();
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, line);
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!(
(len - 8.0).abs() < 1e-6,
"length should track the redefined parameter value, got {len}"
);
}
/// A `driving_param` referencing a named parameter that doesn't (or no
/// longer) exists must not panic the solve — same "skip, don't panic"
/// contract `build_constraint`'s doc comment gives every other unbuildable
/// constraint. The referenced line's two endpoint coordinates are still
/// registered (via `point_ref`) even though the constraint itself can't be
/// built, so they're just left free — the edit that triggered this solve is
/// not undone or altered.
#[test]
fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked() {
let mut scene = Scene::new();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Named("does_not_exist".to_string())),
);
let edited_end = Vector3::new(5.0, 5.0, 0.0);
set_line_end(&mut scene, line, edited_end);
scene.bump_entities(&[(line, ChangeKind::Modified)]); // must not panic
let (_, end) = line_endpoints(&scene, line);
assert_eq!(end, edited_end, "an unresolvable driving reference must leave the edit alone, not move it toward a phantom target");
}
#[test]
fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene, 0.0, 0.0, 5.0);
// A horizontal line well clear of the circle (distance 10, radius 5) —
// deliberately not tangent yet.
let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Tangent,
vec![ParametricRef::whole(circle), ParametricRef::whole(line)],
None,
);
scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]);
let (center, radius) = 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();
// Signed distance from the circle's center to the (infinite) line.
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 radius after solving: dist={signed_dist} radius={radius}");
}
#[test]
fn tangent_constraint_between_two_circles_solves_to_external_tangency() {
let mut scene = Scene::new();
let a = add_circle(&mut scene, 0.0, 0.0, 3.0);
let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Tangent,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]);
let (ca, ra) = circle_geom(&scene, a);
let (cb, rb) = circle_geom(&scene, b);
let center_dist = ((ca.x - cb.x).powi(2) + (ca.y - cb.y).powi(2)).sqrt();
assert!(
(center_dist - (ra + rb)).abs() < 1e-6,
"circles should sit exactly (r1+r2) apart after solving: dist={center_dist} r1+r2={}",
ra + rb
);
}
#[test]
fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() {
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 5.0, 0.0);
let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Coincident,
vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)],
None,
);
// Move a's end away — b's start should follow to stay coincident.
set_line_end(&mut scene, a, Vector3::new(8.0, 3.0, 0.0));
scene.bump_entities(&[(a, ChangeKind::Modified)]);
let (_, a_end) = line_endpoints(&scene, a);
let (b_start, _) = line_endpoints(&scene, b);
assert!(
(a_end.x - b_start.x).abs() < 1e-6 && (a_end.y - b_start.y).abs() < 1e-6,
"a.end={a_end:?} b.start={b_start:?}"
);
}
#[test]
fn unrelated_entity_edit_does_not_touch_constrained_geometry() {
let mut scene = Scene::new();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); // deliberately not horizontal
let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(line)],
None,
);
let (start_before, end_before) = line_endpoints(&scene, line);
set_line_end(&mut scene, unrelated, Vector3::new(200.0, 150.0, 0.0));
scene.bump_entities(&[(unrelated, ChangeKind::Modified)]);
let (start_after, end_after) = line_endpoints(&scene, line);
assert_eq!(
start_before, start_after,
"unrelated edit must not trigger a re-solve of unrelated geometry"
);
assert_eq!(end_before, end_after);
}
#[test]
fn erasing_a_constrained_entity_drops_its_constraints_instead_of_dangling() {
// Deleting an entity drops its constraints instead of leaving dangling refs.
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0);
let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint
let parallel_id = scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Parallel,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
let horizontal_id = scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(c)],
None,
);
scene.erase_entities(&[a]);
let set = scene
.parametric_constraint_set(ParametricScope::ModelSpace)
.expect("scope still exists");
assert!(
set.get(parallel_id).is_none(),
"the Parallel constraint referencing the erased line must be gone"
);
assert!(
set.get(horizontal_id).is_some(),
"an unrelated constraint on a different entity must survive"
);
// And the surviving constraint set still functions normally afterward.
set_line_end(&mut scene, c, Vector3::new(30.0, 25.0, 0.0));
scene.bump_entities(&[(c, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, c);
assert!(
(start.y - end.y).abs() < 1e-6,
"remaining Horizontal constraint should still solve: start={start:?} end={end:?}"
);
}
#[test]
fn copying_two_constrained_entities_carries_their_constraint_along() {
// A constraint entirely between copied entities follows the copy.
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Parallel,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
// Solve once so the source pair is actually parallel before copying.
scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]);
let constraints_before = scene
.parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap()
.constraints
.len();
let transform =
OpenCADStudio::command::EntityTransform::Translate(glam::DVec3::new(100.0, 100.0, 0.0));
let new_handles = scene.copy_entities(&[a, b], &transform);
assert_eq!(new_handles.len(), 2);
let (new_a, new_b) = (new_handles[0], new_handles[1]);
let constraints_after = scene
.parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap()
.constraints
.len();
assert_eq!(
constraints_after,
constraints_before + 1,
"the copy should have gained exactly one new Parallel constraint"
);
// Break the copy's parallelism, then confirm its own (copied) constraint
// re-solves it — proof this is a real, independent constraint on the
// copy, not just coincidentally-parallel geometry from the translate.
set_line_end(&mut scene, new_b, Vector3::new(110.0, 108.0, 0.0));
scene.bump_entities(&[(new_b, ChangeKind::Modified)]);
let (a1, a2) = line_endpoints(&scene, new_a);
let (b1, b2) = line_endpoints(&scene, new_b);
let dir_a = (a2.x - a1.x, a2.y - a1.y);
let dir_b = (b2.x - b1.x, b2.y - b1.y);
let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0;
assert!(cross.abs() < 1e-6, "the copied pair should still be held parallel by its own constraint: dir_a={dir_a:?} dir_b={dir_b:?}");
// The original pair's own constraint must be untouched (still its own
// record, independently referencing the original handles).
let (oa1, oa2) = line_endpoints(&scene, a);
let (ob1, ob2) = line_endpoints(&scene, b);
let odir_a = (oa2.x - oa1.x, oa2.y - oa1.y);
let odir_b = (ob2.x - ob1.x, ob2.y - ob1.y);
let ocross = odir_a.0 * odir_b.1 - odir_a.1 * odir_b.0;
assert!(
ocross.abs() < 1e-6,
"the original pair should remain parallel too"
);
}
// The "one edit that ripples through a constraint still records as one undo
// step" case needs `Scene::record_undo_before`, which is `pub(crate)` (an
// integration test crate can't reach it) — covered instead as an internal
// unit test in `src/scene/parametric_solve.rs`.
#[test]
fn a_duplicated_horizontal_constraint_is_reported_as_redundant() {
// `solve_scope` resolves the redundant row back to the
// `ConstraintId` a `ConflictResolverPanel` would name.
use cadkernel_constraints::diagnosis::RedundancyKind;
use OpenCADStudio::scene::parametric_constraints::ConstraintKind;
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0);
let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
let first = set.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(a)],
None,
);
let second = set.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(a)],
None,
); // exact duplicate
scene.bump_entities(&[(a, ChangeKind::Modified)]);
let set = scene
.parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap();
assert_eq!(
set.conflicts.len(),
1,
"exactly one of the two identical constraints should be flagged"
);
let (flagged_id, kind) = set.conflicts[0];
assert!(
flagged_id == first || flagged_id == second,
"the flagged id should be one of the two duplicates"
);
assert_eq!(kind, RedundancyKind::Redundant);
}
#[test]
fn two_conflicting_distance_targets_are_reported_as_conflicting() {
use cadkernel_constraints::diagnosis::RedundancyKind;
use OpenCADStudio::scene::parametric_constraints::ConstraintKind;
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
let first = set.add(
ConstraintKind::Distance,
vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)],
Some(DrivingValue::Literal(20.0)),
);
let second = set.add(
ConstraintKind::Distance,
vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)],
Some(DrivingValue::Literal(50.0)),
);
scene.bump_entities(&[(a, ChangeKind::Modified)]);
let set = scene
.parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap();
assert_eq!(set.conflicts.len(), 1);
let (flagged_id, kind) = set.conflicts[0];
assert!(
flagged_id == first || flagged_id == second,
"the flagged id should be one of the two conflicting Distance constraints"
);
assert_eq!(kind, RedundancyKind::Conflicting);
}
fn set_circle_center(scene: &mut Scene, handle: Handle, center: Vector3) {
if let Some(EntityType::Circle(c)) = scene.document.get_entity_mut(handle) {
c.center = center;
}
}
fn set_line_start(scene: &mut Scene, handle: Handle, start: Vector3) {
if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(handle) {
l.start = start;
}
}
#[test]
fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() {
let mut scene = Scene::new();
let a = add_circle(&mut scene, 0.0, 0.0, 3.0);
let b = add_circle(&mut scene, 5.0, 5.0, 1.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Concentric,
vec![ParametricRef::center(a), ParametricRef::center(b)],
None,
);
set_circle_center(&mut scene, a, Vector3::new(2.0, -3.0, 0.0));
scene.bump_entities(&[(a, ChangeKind::Modified)]);
let (ca, ra) = circle_geom(&scene, a);
let (cb, rb) = circle_geom(&scene, b);
assert!(
(ca.x - cb.x).abs() < 1e-6 && (ca.y - cb.y).abs() < 1e-6,
"centers should coincide: a={ca:?} b={cb:?}"
);
// Radii are untouched by Concentric — only centers move.
assert!(
(ra - 3.0).abs() < 1e-6 && (rb - 1.0).abs() < 1e-6,
"radii must not change: ra={ra} rb={rb}"
);
}
#[test]
fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene, 0.0, 0.0, 4.0);
let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::CenterPoint,
vec![ParametricRef::point(line, 0), ParametricRef::center(circle)],
None,
);
set_circle_center(&mut scene, circle, Vector3::new(-6.0, 9.0, 0.0));
scene.bump_entities(&[(circle, ChangeKind::Modified)]);
let (center, _) = circle_geom(&scene, circle);
let (line_start, _) = line_endpoints(&scene, line);
assert!(
(center.x - line_start.x).abs() < 1e-6 && (center.y - line_start.y).abs() < 1e-6,
"the line's start should sit exactly at the circle's center: center={center:?} start={line_start:?}"
);
}
#[test]
fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() {
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Colinear,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (a1, a2) = line_endpoints(&scene, a);
let (b1, b2) = line_endpoints(&scene, b);
// Both of b's endpoints should land on a's infinite line: the signed
// area of (a2-a1) × (bN-a1) is ~0 for a point exactly on that line.
let area = |p: Vector3| (a2.x - a1.x) * (p.y - a1.y) - (a2.y - a1.y) * (p.x - a1.x);
assert!(
area(b1).abs() < 1e-5,
"b.start should land on a's line, area={}",
area(b1)
);
assert!(
area(b2).abs() < 1e-5,
"b.end should land on a's line, area={}",
area(b2)
);
}
#[test]
fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() {
let mut scene = Scene::new();
let base = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Midpoint,
vec![ParametricRef::point(marker, 0), ParametricRef::whole(base)],
None,
);
set_line_end(&mut scene, base, Vector3::new(10.0, 8.0, 0.0));
scene.bump_entities(&[(base, ChangeKind::Modified)]);
let (b1, b2) = line_endpoints(&scene, base);
let (marker_start, _) = line_endpoints(&scene, marker);
let expected = Vector3::new((b1.x + b2.x) / 2.0, (b1.y + b2.y) / 2.0, 0.0);
assert!(
(marker_start.x - expected.x).abs() < 1e-6 && (marker_start.y - expected.y).abs() < 1e-6,
"marker point should sit at base's midpoint: expected={expected:?} got={marker_start:?}"
);
}
#[test]
fn midpoint_constraint_supports_a_point_entity() {
let mut scene = Scene::new();
let base = add_line(&mut scene, 0.0, 0.0, 10.0, 6.0);
let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at(
Vector3::new(20.0, 20.0, 0.0),
)));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Midpoint,
vec![ParametricRef::point(point, 0), ParametricRef::whole(base)],
None,
);
scene.bump_entities(&[(base, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, base);
let EntityType::Point(point) = scene.document.get_entity(point).unwrap() else {
panic!("expected a point");
};
assert!((point.location.x - (start.x + end.x) / 2.0).abs() < 1e-6);
assert!((point.location.y - (start.y + end.y) / 2.0).abs() < 1e-6);
}
#[test]
fn coincident_constraint_supports_a_block_insertion_point() {
let mut scene = Scene::new();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 4.0);
let insert = scene.add_entity(EntityType::Insert(acadrust::entities::Insert::new(
"fixture",
Vector3::new(30.0, 20.0, 0.0),
)));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Coincident,
vec![
ParametricRef::point(line, 1),
ParametricRef::point(insert, 0),
],
None,
);
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (_, end) = line_endpoints(&scene, line);
let EntityType::Insert(insert) = scene.document.get_entity(insert).unwrap() else {
panic!("expected a block reference");
};
assert!((insert.insert_point.x - end.x).abs() < 1e-6);
assert!((insert.insert_point.y - end.y).abs() < 1e-6);
}
#[test]
fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() {
let mut scene = Scene::new();
let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0);
let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(fixed_line)],
None,
);
set.add(
ConstraintKind::Coincident,
vec![
ParametricRef::point(fixed_line, 1),
ParametricRef::point(moving_line, 0),
],
None,
);
let (before_start, before_end) = line_endpoints(&scene, fixed_line);
// Drag the shared point — without Fixed this would pull fixed_line's
// end along with it; Fixed should hold fixed_line exactly in place and
// let moving_line's start follow back to it instead.
set_line_start(&mut scene, moving_line, Vector3::new(15.0, 5.0, 0.0));
scene.bump_entities(&[(moving_line, ChangeKind::Modified)]);
let (after_start, after_end) = line_endpoints(&scene, fixed_line);
assert_eq!(
before_start, after_start,
"Fixed entity's start must not move"
);
assert_eq!(before_end, after_end, "Fixed entity's end must not move");
let (moving_start, _) = line_endpoints(&scene, moving_line);
assert!(
(moving_start.x - before_end.x).abs() < 1e-6
&& (moving_start.y - before_end.y).abs() < 1e-6,
"moving_line's start should have been pulled back to fixed_line's (unmoved) end"
);
}
#[test]
fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene, 0.0, 0.0, 5.0);
let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::PointOnCurve,
vec![
ParametricRef::point(marker, 0),
ParametricRef::whole(circle),
],
None,
);
scene.bump_entities(&[(marker, ChangeKind::Modified)]);
let (center, radius) = circle_geom(&scene, circle);
let (marker_start, _) = line_endpoints(&scene, marker);
let dist = ((marker_start.x - center.x).powi(2) + (marker_start.y - center.y).powi(2)).sqrt();
assert!(
(dist - radius).abs() < 1e-5,
"point should land on the circumference: dist={dist} radius={radius}"
);
}
#[test]
fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() {
let mut scene = Scene::new();
let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6);
let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::PointOnCurve,
vec![
ParametricRef::point(marker, 0),
ParametricRef::whole(ellipse),
],
None,
);
scene.bump_entities(&[(marker, ChangeKind::Modified)]);
let (center, major, ratio) = ellipse_geom(&scene, ellipse);
let (point, _) = line_endpoints(&scene, marker);
let a = major.length();
let b = a * ratio;
let u = major / a;
let v = Vector3::new(-u.y, u.x, 0.0);
let offset = point - center;
let equation = (offset.dot(&u) / a).powi(2) + (offset.dot(&v) / b).powi(2);
assert!(
(equation - 1.0).abs() < 1e-5,
"point should satisfy the solved ellipse equation: {equation}"
);
}
#[test]
fn tangent_constraint_solves_an_ellipse_and_line() {
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, -8.0, 8.0, 8.0, 8.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Tangent,
vec![ParametricRef::whole(ellipse), ParametricRef::whole(line)],
None,
);
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (center, major, ratio) = ellipse_geom(&scene, ellipse);
let (start, end) = line_endpoints(&scene, line);
let direction = (end - start).normalize();
let normal = Vector3::new(-direction.y, direction.x, 0.0);
let unit_major = major.normalize();
let unit_minor = Vector3::new(-unit_major.y, unit_major.x, 0.0);
let a = major.length();
let b = a * ratio;
let support =
((a * normal.dot(&unit_major)).powi(2) + (b * normal.dot(&unit_minor)).powi(2)).sqrt();
let distance = (center - start).dot(&normal).abs();
assert!(
(distance - support).abs() < 1e-5,
"line should be tangent to the solved ellipse: distance={distance} support={support}"
);
}
#[test]
fn equal_constraint_matches_ellipse_major_axes() {
let mut scene = Scene::new();
let a = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6);
let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Equal,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (_, major_a, _) = ellipse_geom(&scene, a);
let (_, major_b, _) = ellipse_geom(&scene, b);
assert!(
(major_a.length() - major_b.length()).abs() < 1e-5,
"ellipse major radii should match: {} vs {}",
major_a.length(),
major_b.length()
);
}
#[test]
fn equal_distance_constraint_matches_a_second_point_pairs_separation() {
let mut scene = Scene::new();
// Reference pair: fixed 6 units apart.
let a = add_line(&mut scene, 0.0, 0.0, 6.0, 0.0);
// Tracked pair: starts at some other separation, should be pulled to 6.
let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::EqualDistance,
vec![
ParametricRef::point(b, 0),
ParametricRef::point(b, 1),
ParametricRef::point(a, 0),
ParametricRef::point(a, 1),
],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (a1, a2) = line_endpoints(&scene, a);
let (b1, b2) = line_endpoints(&scene, b);
let dist_a = ((a2.x - a1.x).powi(2) + (a2.y - a1.y).powi(2)).sqrt();
let dist_b = ((b2.x - b1.x).powi(2) + (b2.y - b1.y).powi(2)).sqrt();
assert!(
(dist_a - dist_b).abs() < 1e-6,
"the two pairs' separations should match: dist_a={dist_a} dist_b={dist_b}"
);
}
#[test]
fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() {
let mut scene = Scene::new();
let axis = add_line(&mut scene, 0.0, 0.0, 0.0, 10.0); // the Y axis
let a = add_circle(&mut scene, 3.0, 4.0, 1.0);
// Starts well off the mirrored position (and not coincident with `a` —
// a zero-length a/b segment would leave `Perpendicular`'s precomputed
// scale dividing by zero at the very first solve iteration).
let b = add_circle(&mut scene, 8.0, 9.0, 1.0);
let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
// Pin the axis and `a` in place — otherwise they're just as free to move
// as `b`'s center, and with only 2 equations (MidpointOnLine +
// Perpendicular) against that many more unknowns the solver is free to
// converge on any of infinitely many valid configurations, not
// necessarily "only b moves, to a's exact mirror" (which is the one
// deterministic outcome this test actually wants to check).
set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(axis)],
None,
);
set.add(ConstraintKind::Fixed, vec![ParametricRef::whole(a)], None);
set.add(
ConstraintKind::Symmetric,
vec![
ParametricRef::center(a),
ParametricRef::center(b),
ParametricRef::whole(axis),
],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (ca, _) = circle_geom(&scene, a);
let (cb, _) = circle_geom(&scene, b);
assert_eq!(
ca,
Vector3::new(3.0, 4.0, 0.0),
"Fixed a's center must not have moved"
);
// With both a and the axis pinned, b's center has a unique valid
// position left: a's exact mirror across the Y axis, (-3, 4).
assert!(
(cb.x - -3.0).abs() < 1e-5 && (cb.y - 4.0).abs() < 1e-5,
"b's center should have been pulled to a's mirror image across the axis: expected (-3, 4), got {cb:?}"
);
}
// 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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Radius,
vec![ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Tangent,
vec![ParametricRef::whole(arc), ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Concentric,
vec![ParametricRef::center(arc), ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Coincident,
vec![ParametricRef::point(arc, 0), ParametricRef::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.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(anchor)],
None,
);
set.add(
ConstraintKind::Coincident,
vec![
ParametricRef::point(arc, 0),
ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::PointOnCurve,
vec![ParametricRef::point(marker, 0), ParametricRef::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.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(anchor)],
None,
);
set.add(
ConstraintKind::Coincident,
vec![
ParametricRef::point(arc, 0),
ParametricRef::point(anchor, 0),
],
None,
);
set.add(
ConstraintKind::Radius,
vec![ParametricRef::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 `cadkernel_constraints` 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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Diameter,
vec![ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::DistanceX,
vec![ParametricRef::point(line, 0), ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Normal,
vec![ParametricRef::whole(circle), ParametricRef::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 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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Concentric,
vec![
ParametricRef::center(ellipse),
ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::CenterPoint,
vec![
ParametricRef::point(line, 0),
ParametricRef::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 `cadkernel_constraints`'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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Concentric,
vec![
ParametricRef::center(ellipse),
ParametricRef::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 concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_moves() {
// Regression test for a real solver bug: `a_tilted_ellipse_round_trips_
// through_the_solver_without_drifting` (above) uses a circle that's
// ALREADY concentric with the ellipse, so Concentric's equations start
// out satisfied and the ellipse's center never actually has to move —
// it doesn't exercise the bug at all. Here the circle starts elsewhere,
// forcing a real center move, which is exactly when `cadkernel_constraints::geo::
// Ellipse`'s absolute `focus1` point used to get left behind: `center`
// moved to meet the circle, `focus1` didn't (nothing else referenced
// it), so the derived major-axis direction/length and minor/major
// ratio changed even though only a center-to-center constraint was
// ever applied. The fix ties `focus1`'s offset from `center` to its
// seeded value, so it translates along with `center` instead.
let mut scene = Scene::new();
let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (4.0, 0.0), 0.5);
let circle = add_circle(&mut scene, 10.0, 7.0, 1.5);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Concentric,
vec![
ParametricRef::center(ellipse),
ParametricRef::center(circle),
],
None,
);
scene.bump_entities(&[
(ellipse, ChangeKind::Modified),
(circle, ChangeKind::Modified),
]);
let (ellipse_center, major_axis, ratio) = 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,
"concentric constraint should still hold: ellipse={ellipse_center:?} circle={circle_center:?}"
);
// The whole point: the center was free to move (nothing else pinned
// either entity), so it's not expected to still sit at (0,0) — but the
// ellipse's *shape* must be exactly what it was before solving.
assert!(
(major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 0.0).abs() < 1e-6,
"major axis must not rotate or resize just because the center moved: {major_axis:?}"
);
assert!(
(ratio - 0.5).abs() < 1e-6,
"minor/major ratio must not change just because the center moved: {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.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(ellipse)],
None,
);
set.add(
ConstraintKind::CenterPoint,
vec![
ParametricRef::point(line, 0),
ParametricRef::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
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::DistanceY,
vec![ParametricRef::point(line, 0), ParametricRef::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
);
}