fix: Projection d'une silhouette à congés coniques fermée en un profil (#10773)

- snap_tangent_arcs : chaque extrémité d'arc prend la ligne la plus proche
- une ligne ne prend qu'une extrémité d'un arc ; l'autre passe à sa ligne suivante
- une ligne plus loin sur le contour est ignorée quand l'extrémité touche déjà une autre pièce
- tests : 4 unitaires (project_order.rs) et the_silhouette_of_conic_rounds_closes

Co-authored-by: P'tite Tête <asso@ptitetete.com>
This commit is contained in:
P'tite Tête
2026-11-01 19:41:32 +00:00
committed by GitHub
parent 17b9a8e67f
commit 3017641b41
3 changed files with 187 additions and 10 deletions
+33 -10
View File
@@ -1543,8 +1543,9 @@ fn snap_tangent_arcs(out: &mut [LinkGeom], tol: f64) {
for i in 0..out.len() {
let Some(LinkGeom::Arc { c, a, b }) = out.get(i).cloned() else { continue };
let r = (c.dist(a) + c.dist(b)) / 2.0;
// (arc end, line index, line end index, tangent point, unit normal towards the centre, centre distance)
let mut hits: Vec<(usize, usize, usize, Vec2, Vec2, f64)> = Vec::new();
// Per arc end: (gap to the line's end, line index, line end index, tangent point, unit
// normal towards the centre, centre distance)
let mut hits: [Vec<(f64, usize, usize, Vec2, Vec2, f64)>; 2] = [Vec::new(), Vec::new()];
for (end, e) in [a, b].into_iter().enumerate() {
for (j, g) in out.iter().enumerate() {
let LinkGeom::Line(p, q) = g else { continue };
@@ -1562,17 +1563,39 @@ fn snap_tangent_arcs(out: &mut [LinkGeom], tol: f64) {
if (foot - far).dot(near_pt - far) <= 0.0 || near_pt.dist(foot) > 0.25 * r {
continue;
}
hits.push((end, j, near, foot, n, d));
// When this end of the arc already meets another piece of the outline and the line
// doesn't end there, the line lies further along (past that piece, as along a
// conic fitted with several arcs): moving the end onto it would leave a gap.
let gap = near_pt.dist(e);
let meets = |g: &LinkGeom| match *g {
LinkGeom::Line(s, t) | LinkGeom::Arc { a: s, b: t, .. } => s.dist(e) <= tol || t.dist(e) <= tol,
_ => false,
};
if gap > tol && out.iter().enumerate().any(|(k, g)| k != i && k != j && meets(g)) {
continue;
}
if let Some(h) = hits.get_mut(end) {
h.push((gap, j, near, foot, n, d));
}
}
}
let mut hits_by_end: [Option<(usize, usize, Vec2, Vec2, f64)>; 2] = [None, None];
for (end, j, near, foot, n, d) in hits {
if let Some(slot) = hits_by_end.get_mut(end)
&& slot.is_none()
{
*slot = Some((j, near, foot, n, d));
}
for h in &mut hits {
h.sort_by(|x, y| x.0.total_cmp(&y.0));
}
// Each end takes the line ending nearest it. Both ends on one line would pull the arc down
// to a point: the end nearer that line's end keeps it, the other takes its next line.
let [ha, hb] = &hits;
let (ta, tb) = match (ha.first(), hb.first()) {
(Some(x), Some(y)) if x.1 == y.1 => {
if x.0 <= y.0 {
(Some(x), hb.iter().find(|h| h.1 != x.1))
} else {
(ha.iter().find(|h| h.1 != y.1), Some(y))
}
}
pair => pair,
};
let hits_by_end: [Option<(usize, usize, Vec2, Vec2, f64)>; 2] = [ta, tb].map(|h| h.map(|&(_, j, near, foot, n, d)| (j, near, foot, n, d)));
let (mut c, mut r) = (c, r);
if let [Some((_, _, _, n1, d1)), Some((_, _, _, n2, d2))] = hits_by_end {
// One circle tangent to both lines: equal distance to each.
+111
View File
@@ -235,6 +235,117 @@ mod tests {
let (Some(LinkGeom::Arc { a, .. }), Some(LinkGeom::Line(p, _))) = (out.get(1), out.get(2)) else { panic!("{out:?}") };
assert!(a.dist(*p) < 1e-9, "{out:?}");
}
#[test]
fn tangent_snapping_leaves_a_line_further_along_alone() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
let y = |x: f64| 2.0 - (4.0 - x * x).sqrt();
// The arc's end at x = 0.5 already meets the next piece; the line along y = 0 ends at
// x = 0.3, further along the outline the other way. Snapping onto it would pull the end
// off the piece it meets.
let mut out = vec![
LinkGeom::Arc { c: v(0., 2.), a: v(0.5, y(0.5)), b: v(-1.5, y(-1.5)) },
LinkGeom::Line(v(0.3, 0.), v(10., 0.)),
LinkGeom::Line(v(0.5, y(0.5)), v(3., 3.)),
];
let before = out.clone();
super::super::snap_tangent_arcs(&mut out, 0.05);
assert_eq!(out.get(1), before.get(1), "{out:?}");
let (Some(LinkGeom::Arc { a, .. }), Some(LinkGeom::Line(p, _))) = (out.first(), out.get(2)) else { panic!("{out:?}") };
assert!(a.dist(*p) < 1e-9, "{out:?}");
}
#[test]
fn tangent_snapping_gives_one_line_to_one_end_of_an_arc() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
let y = |x: f64| 2.0 - (4.0 - x * x).sqrt();
// A short arc whose two ends both lie near the end of one line: both snapping onto it
// would shrink the arc to the tangent point.
let mut out = vec![LinkGeom::Arc { c: v(0., 2.), a: v(-0.2, y(-0.2)), b: v(0.2, y(0.2)) }, LinkGeom::Line(v(-10., 0.), v(0., 0.))];
super::super::snap_tangent_arcs(&mut out, 0.05);
let Some(LinkGeom::Arc { a, b, .. }) = out.first() else { panic!("{out:?}") };
assert!(a.dist(*b) > 0.1, "{out:?}");
}
#[test]
fn tangent_snapping_moves_the_other_end_of_an_arc_to_its_next_line() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
let y = |x: f64| 2.0 - (4.0 - x * x).sqrt();
let (a, b) = (v(-0.2, y(-0.2)), v(0.2, y(0.2)));
// The line along y = 0 ends nearest both ends of the arc but nearer b, which keeps it; a
// takes the next line, tangent to the circle at a and ending a little past it.
let back = (v(0., 2.) - a).normalized().map(|n| v(-n.y, n.x)).unwrap_or(Vec2::X);
let mut out =
vec![LinkGeom::Arc { c: v(0., 2.), a, b }, LinkGeom::Line(v(0.1, 0.), v(10., 0.)), LinkGeom::Line(a + back * 10., a + back * 0.35)];
super::super::snap_tangent_arcs(&mut out, 0.05);
let (Some(LinkGeom::Arc { a, b, .. }), Some(LinkGeom::Line(p, _)), Some(LinkGeom::Line(_, q))) = (out.first(), out.get(1), out.get(2)) else {
panic!("{out:?}")
};
assert!(b.dist(*p) < 1e-9 && a.dist(*q) < 1e-9 && a.dist(*b) > 0.1, "{out:?}");
}
#[test]
fn tangent_snapping_closes_a_conic_corner_in_any_order() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
// A square of side 150 with a conic round of 10 mm legs at each corner, fitted with lines
// and arcs the way a silhouette is, then snapped with the pieces in each order.
for rho in [0.1, 0.15, 0.5, 0.8, 0.9] {
let w = rho / (1. - rho);
let conic = |s: Vec2, apex: Vec2, e: Vec2, t: f64| {
let (b0, b1, b2) = ((1. - t) * (1. - t), 2. * t * (1. - t) * w, t * t);
(s * b0 + apex * b1 + e * b2) * (1. / (b0 + b1 + b2))
};
let v = Vec2::new;
let corners = [
(v(-75., 65.), v(-75., 75.), v(-65., 75.)),
(v(65., 75.), v(75., 75.), v(75., 65.)),
(v(75., -65.), v(75., -75.), v(65., -75.)),
(v(-65., -75.), v(-75., -75.), v(-75., -65.)),
];
let mut chain = Vec::new();
for (s, apex, e) in corners {
chain.extend((0..=40).map(|k| conic(s, apex, e, f64::from(k) / 40.)));
}
if let Some(&first) = chain.first() {
chain.push(first);
}
let mut fitted = Vec::new();
super::super::fit_chain_with_arcs(&chain, Some(212. * 1e-5), &mut fitted, true);
assert!(fitted.iter().any(|g| matches!(g, LinkGeom::Arc { .. })), "{fitted:?}");
let ends = |g: &LinkGeom| match *g {
LinkGeom::Line(s, t) | LinkGeom::Arc { a: s, b: t, .. } => vec![s, t],
_ => Vec::new(),
};
for k in 0..fitted.len() {
let mut out = fitted.clone();
out.rotate_left(k);
super::super::snap_tangent_arcs(&mut out, 212. * 1e-3);
// Every end still meets another piece's end. An end left without a line moves a little
// when the arc's radius is set from the lines (well under 0.01 mm here); before, ends
// pulled onto lines further along left gaps of 0.25 to 0.55 mm.
for (i, g) in out.iter().enumerate() {
for e in ends(g) {
let gap = out
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.flat_map(|(_, h)| ends(h))
.map(|x| x.dist(e))
.fold(f64::INFINITY, f64::min);
assert!(gap < 212. * 1e-3 * 0.02, "rho {rho}, rotation {k}: gap {gap} at {e:?}: {out:?}");
}
}
}
}
}
#[test]
fn boundaries_touching_at_a_vertex_project() {
// Two squares of one face family meet only at the corner (1, 1): four boundary edges meet
@@ -1298,3 +1298,46 @@ fn a_thicker_angle_moves_the_projected_face_of_the_sketch_on_it() {
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
}
/// The silhouette of a square with a conic round at each corner (rho 0.19, 0.4, 0.5 and 0.7) is
/// one closed outline of the square's true area. Fitted arcs along a conic were snapped onto lines
/// that ended further along the outline, which left gaps of up to 3.6 mm: no profile.
#[test]
fn the_silhouette_of_conic_rounds_closes() {
let mut s = Session::default();
let corners = [
([-75., 65.], [-75., 75.], [-65., 75.], 0.19),
([65., 75.], [75., 75.], [75., 65.], 0.4),
([75., -65.], [75., -75.], [65., -75.], 0.5),
([-65., -75.], [-75., -75.], [-75., -65.], 0.7),
];
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
for (i, (start, apex, end, rho)) in corners.iter().enumerate() {
run(&mut s, "sketch.conic", json!({"start": start, "apex": apex, "end": end, "rho": rho}));
let next = corners[(i + 1) % 4].0;
run(&mut s, "sketch.line", json!({"points": [end, next]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 55, "body_name": "Part"}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "offset": -10}));
run(&mut s, "sketch.project", json!({"refs": [{"body": "Part"}]}));
// The true area: the outline through the four rational quadratic Béziers (apex weight
// rho / (1 - rho)), finely sampled, and the straight sides between them.
let mut outline: Vec<[f64; 2]> = Vec::new();
for (start, apex, end, rho) in corners {
let w = rho / (1. - rho);
for k in 0..=2000 {
let t = f64::from(k) / 2000.;
let (b0, b1, b2) = ((1. - t) * (1. - t), 2. * t * (1. - t) * w, t * t);
let sum = b0 + b1 + b2;
outline.push([(b0 * start[0] + b1 * apex[0] + b2 * end[0]) / sum, (b0 * start[1] + b1 * apex[1] + b2 * end[1]) / sum]);
}
}
let n = outline.len();
let exact = (0..n).map(|i| outline[i][0] * outline[(i + 1) % n][1] - outline[(i + 1) % n][0] * outline[i][1]).sum::<f64>().abs() / 2.;
let si = inspect(&mut s);
let profiles = si["profiles"].as_array().unwrap();
assert_eq!(profiles.len(), 1, "{}", si["profiles"]);
let area = profiles[0]["area_mm2"].as_f64().unwrap();
assert!((area - exact).abs() < exact * 2e-4, "area {area}, exact {exact}");
}