Files
cadcraft/crates/engine/tests/it/explode_stretched.rs
T
5ed192555c EXPLODE: a block reference with different X and Y scales gives ellipses, not circles (#2649)
* EXPLODE: a block reference with different X and Y scales gives ellipses, not circles

EXPLODE of a block reference whose X and Y scales differ kept its circles
as circles with the mean scale, its arcs as circular arcs, its ellipses
with their old ratio and its polyline arcs as circular bulges: not the
shape the reference draws. BURST and INSERT with explode go the same way.

When the reference's transform isn't a similarity, a circle now becomes a
full ELLIPSE, an arc an elliptical arc, an ellipse gets its true axes, and
a polyline with arc segments (and no widths) its lines and elliptical
arcs. Everything else, and every evenly scaled or mirrored reference,
transforms as before.

The exact ellipse transform and the similarity test move unchanged from
snap.rs to cadcraft-geom (Ellipse::transformed, Mat3::similarity_scale)
and are shared by object snaps and EXPLODE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* contributors: add Tyler-Chiriboga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:54:03 -05:00

141 lines
7.1 KiB
Rust

//! EXPLODE of a block reference whose X and Y scales differ: its circles, arcs, ellipses and
//! polyline arcs come out as the ellipses and elliptical arcs the reference draws, not as circles
//! scaled by the mean of the two scales.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Entity, EntityKind};
use serde_json::json;
const AT: [f64; 2] = [100.0, 50.0];
const ROTATION: f64 = 30.0;
/// Block S (base 0,0): a circle (r 5 at 0,0), an arc (r 4 at 20,0, 0°..90°), an elliptical arc
/// (centre 40,0, major 6 along X, ratio 0.5, 0°..180°), a slot polyline (two lines, two
/// semicircles of r 2 at 60,0 and 70,0) and a line. One reference to it at `AT`, rotated by
/// `ROTATION`, scaled by `scale`, exploded.
fn exploded(scale: [f64; 2]) -> Vec<Entity> {
let mut s = Session::new();
let mut hs = Vec::new();
for (id, p) in [
("circle", json!({"center": [0, 0], "radius": 5})),
("arc", json!({"center": [20, 0], "radius": 4, "start": 0, "end": 90})),
("ellipse", json!({"center": [40, 0], "major": [6, 0], "ratio": 0.5, "start": 0, "end": 180})),
("pline", json!({"vertices": [[60, -2], {"p": [70, -2], "bulge": 1}, [70, 2], {"p": [60, 2], "bulge": 1}], "closed": true})),
("line", json!({"points": [[0, 10], [10, 10]]})),
] {
s.execute(id, &p).unwrap();
hs.push(s.doc().unwrap().model.iter().last().unwrap().handle.hex());
}
s.execute("block", &json!({"name": "S", "base": [0, 0], "keep": "delete", "handles": hs})).unwrap();
let r = s.execute("insert", &json!({"name": "S", "at": AT, "scale": scale, "rotation": ROTATION})).unwrap();
s.execute("explode", &json!({"handles": [r["handle"]]})).unwrap();
s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect()
}
/// A drawing point back in block coordinates.
fn to_block(p: [f64; 2], scale: [f64; 2]) -> [f64; 2] {
let (s, c) = (-ROTATION.to_radians()).sin_cos();
let (dx, dy) = (p[0] - AT[0], p[1] - AT[1]);
[(dx * c - dy * s) / scale[0], (dx * s + dy * c) / scale[1]]
}
/// Points along an ELLIPSE from its start to its end parameter.
fn samples(e: &cadcraft_engine::doc::Ellipse) -> Vec<[f64; 2]> {
let mut sweep = e.end - e.start;
if sweep <= 0.0 {
sweep += std::f64::consts::TAU;
}
let (mx, my) = (e.major.x, e.major.y);
let (nx, ny) = (-my * e.ratio, mx * e.ratio);
(0..=16)
.map(|i| {
let t = e.start + sweep * f64::from(i) / 16.0;
[e.center.x + mx * t.cos() + nx * t.sin(), e.center.y + my * t.cos() + ny * t.sin()]
})
.collect()
}
fn ellipses(v: &[Entity]) -> Vec<cadcraft_engine::doc::Ellipse> {
v.iter()
.filter_map(|e| match &e.kind {
EntityKind::Ellipse(el) => Some(el.clone()),
_ => None,
})
.collect()
}
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).hypot(a[1] - b[1]) < 1e-9
}
/// Every point of `el`, taken back into the block, lies on the circle of radius `r` about `c`
/// in block coordinates, and its ends are `ends` (the whole circle when `None`).
fn on_circle(el: &cadcraft_engine::doc::Ellipse, scale: [f64; 2], c: [f64; 2], r: f64, ends: Option<([f64; 2], [f64; 2])>) {
let pts: Vec<[f64; 2]> = samples(el).into_iter().map(|p| to_block(p, scale)).collect();
for q in &pts {
let d = (q[0] - c[0]).hypot(q[1] - c[1]);
assert!((d - r).abs() < 1e-9, "{q:?} is {d} from {c:?}, not {r}: {el:?}");
}
if let Some((a, b)) = ends {
let (first, last) = (pts[0], pts[pts.len() - 1]);
assert!(near(first, a) && near(last, b) || near(first, b) && near(last, a), "ends {first:?} {last:?}, expected {a:?} {b:?}");
}
}
#[test]
fn explode_of_a_stretched_reference_draws_circles_and_arcs_as_ellipses() {
for scale in [[2.0, 0.5], [-2.0, 0.5], [1.0, 3.0]] {
let v = exploded(scale);
assert!(!v.iter().any(|e| matches!(e.kind, EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_))), "{scale:?}: {v:?}");
let els = ellipses(&v);
// Circle, arc, elliptical arc and the slot's two semicircles.
assert_eq!(els.len(), 5, "{scale:?}: {els:?}");
let at = |c: [f64; 2]| els.iter().filter(move |e| near(to_block([e.center.x, e.center.y], scale), c));
let circle = at([0.0, 0.0]).next().unwrap();
on_circle(circle, scale, [0.0, 0.0], 5.0, None);
// Its axes are the scaled radii: 10 and 2.5 for 2 x 0.5.
let major = circle.major.x.hypot(circle.major.y);
let (big, small) = (5.0 * scale[0].abs().max(scale[1].abs()), 5.0 * scale[0].abs().min(scale[1].abs()));
assert!((major - big).abs() < 1e-9 && (major * circle.ratio - small).abs() < 1e-9, "{circle:?}");
on_circle(at([20.0, 0.0]).next().unwrap(), scale, [20.0, 0.0], 4.0, Some(([24.0, 0.0], [20.0, 4.0])));
on_circle(at([60.0, 0.0]).next().unwrap(), scale, [60.0, 0.0], 2.0, Some(([60.0, 2.0], [60.0, -2.0])));
on_circle(at([70.0, 0.0]).next().unwrap(), scale, [70.0, 0.0], 2.0, Some(([70.0, -2.0], [70.0, 2.0])));
// The elliptical arc: its points, back in the block, are on the block's ellipse
// ((x-40)/6)^2 + (y/3)^2 = 1, from (46,0) over the top to (34,0).
let ell = at([40.0, 0.0]).next().unwrap();
let pts: Vec<[f64; 2]> = samples(ell).into_iter().map(|p| to_block(p, scale)).collect();
for q in &pts {
let f = ((q[0] - 40.0) / 6.0).powi(2) + (q[1] / 3.0).powi(2);
assert!((f - 1.0).abs() < 1e-9 && q[1] > -1e-9, "{q:?}: {ell:?}");
}
let (first, last) = (pts[0], pts[pts.len() - 1]);
assert!(near(first, [46.0, 0.0]) && near(last, [34.0, 0.0]) || near(first, [34.0, 0.0]) && near(last, [46.0, 0.0]), "{pts:?}");
// The slot's straight sides and the line are lines, where the reference draws them.
let lines: Vec<[[f64; 2]; 2]> = v
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(l) => Some([to_block([l.a.x, l.a.y], scale), to_block([l.b.x, l.b.y], scale)]),
_ => None,
})
.collect();
assert_eq!(lines.len(), 3);
for [a, b] in [[[60.0, -2.0], [70.0, -2.0]], [[70.0, 2.0], [60.0, 2.0]], [[0.0, 10.0], [10.0, 10.0]]] {
assert!(lines.iter().any(|l| near(l[0], a) && near(l[1], b)), "{a:?}-{b:?} not in {lines:?}");
}
}
}
#[test]
fn explode_of_an_evenly_scaled_or_mirrored_reference_keeps_circles_arcs_and_polylines() {
for scale in [[2.0, 2.0], [-1.0, 1.0]] {
let v = exploded(scale);
let count = |f: fn(&EntityKind) -> bool| v.iter().filter(|e| f(&e.kind)).count();
assert_eq!(count(|k| matches!(k, EntityKind::Circle(_))), 1, "{scale:?}");
assert_eq!(count(|k| matches!(k, EntityKind::Arc(_))), 1, "{scale:?}");
assert_eq!(count(|k| matches!(k, EntityKind::LwPolyline(_))), 1, "{scale:?}");
assert_eq!(count(|k| matches!(k, EntityKind::Ellipse(_))), 1, "{scale:?}");
let r = v.iter().find_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None }).unwrap();
assert!((r - 5.0 * scale[0].abs()).abs() < 1e-9);
}
}