diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 780099e062..b705ea3262 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -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. diff --git a/crates/doc/src/project_order.rs b/crates/doc/src/project_order.rs index f59cdfa799..979cdcf4ac 100644 --- a/crates/doc/src/project_order.rs +++ b/crates/doc/src/project_order.rs @@ -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 diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 5565554574..c76c364e72 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -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::().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}"); +}