forked from 1b-agent/opencadstudio-upstream
1630 lines
60 KiB
Rust
1630 lines
60 KiB
Rust
use codec::entities::EntityType;
|
||
use codec::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(codec::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(
|
||
codec::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(
|
||
codec::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(
|
||
codec::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(
|
||
codec::entities::LwPolyline::from_points(vec![
|
||
codec::types::Vector2::new(0.0, 0.0),
|
||
codec::types::Vector2::new(5.0, 2.0),
|
||
codec::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 = codec::entities::LwPolyline::new();
|
||
source.add_point_with_bulge(codec::types::Vector2::new(0.0, 0.0), 0.5);
|
||
source.add_point(codec::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 kernel_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 kernel_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(codec::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(codec::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_keeps_a_point_within_an_arcs_sweep() {
|
||
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, 0.0, -20.0, 1.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, start_angle, end_angle) =
|
||
match scene.document.get_entity(arc).expect("entity exists") {
|
||
EntityType::Arc(arc) => (arc.center, arc.radius, arc.start_angle, arc.end_angle),
|
||
other => panic!("expected an Arc, got {other:?}"),
|
||
};
|
||
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: dist={dist} radius={radius}"
|
||
);
|
||
assert!(
|
||
kernel::geom2d::angle_within_arc(
|
||
(p.y - center.y).atan2(p.x - center.x),
|
||
start_angle,
|
||
end_angle,
|
||
),
|
||
"point must stay within the solved arc sweep: {p:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn point_on_curve_constraint_keeps_a_point_within_a_polyline_arc_segment() {
|
||
let mut source = codec::entities::LwPolyline::new();
|
||
source.add_point_with_bulge(codec::types::Vector2::new(0.0, 0.0), 1.0);
|
||
source.add_point(codec::types::Vector2::new(10.0, 0.0));
|
||
let mut scene = Scene::new();
|
||
let polyline = scene.add_entity(EntityType::LwPolyline(source));
|
||
let marker = add_line(&mut scene, 5.0, 20.0, 6.0, 20.0);
|
||
|
||
scene
|
||
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
|
||
.add(
|
||
ConstraintKind::PointOnCurve,
|
||
vec![
|
||
ParametricRef::point(marker, 0),
|
||
ParametricRef::segment(polyline, 0),
|
||
],
|
||
None,
|
||
);
|
||
scene.bump_entities(&[(marker, ChangeKind::Modified)]);
|
||
|
||
let (point, _) = line_endpoints(&scene, marker);
|
||
let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else {
|
||
panic!("expected a lightweight polyline");
|
||
};
|
||
let arc = kernel::geom2d::BulgeArc::from_bulge(
|
||
[
|
||
polyline.vertices[0].location.x,
|
||
polyline.vertices[0].location.y,
|
||
],
|
||
[
|
||
polyline.vertices[1].location.x,
|
||
polyline.vertices[1].location.y,
|
||
],
|
||
polyline.vertices[0].bulge,
|
||
)
|
||
.unwrap();
|
||
let distance = (point.x - arc.center[0]).hypot(point.y - arc.center[1]);
|
||
assert!((distance - arc.radius).abs() < 1e-6);
|
||
assert!(
|
||
kernel::geom2d::angle_within_arc(
|
||
(point.y - arc.center[1]).atan2(point.x - arc.center[0]),
|
||
arc.start_angle,
|
||
arc.start_angle + arc.sweep,
|
||
),
|
||
"point must stay within the solved polyline arc segment: {point:?}"
|
||
);
|
||
}
|
||
|
||
#[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 `opencadkernel_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 opencadcodec'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 opencadcodec's center +
|
||
// major-axis-*vector* + minor/major ratio into `opencadkernel_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 `kernel_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
|
||
);
|
||
}
|