2D prism route: the same letters cut again are one exact boolean (#1644)

Thanks @phanoob.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-16 06:26:19 -04:00
1 parent 3087363c44
commit 64b0b674d2
3 files changed
+76 -8

No files matched your search

@@ -1151,3 +1151,32 @@ fn a_line_of_text_cut_through_or_joined_in_relief_is_one_boolean() {
assert_eq!(pieces, 1);
assert!(rel(relief - before, 2.0 * (before - one)) < 2e-3, "{} vs {}", relief - before, 2.0 * (before - one));
}
/// The same line of text cut again, deeper: the new walls lie on the letters' walls. The 2D route
/// takes it as one exact boolean: a point a hair from a segment's end is that end, a sliver of a
/// letter's curve shorter than the tolerance is dropped, and a piece of a letter so short that the
/// engraved walls read it as an arc while the tool has the curve itself is one edge. Before, the
/// sewing found a degenerate edge, then the faces did not close, and the cut went to the B-rep
/// boolean's retries (90 s).
#[test]
fn a_line_of_text_cut_again_deeper_is_one_exact_boolean() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
let cut = |s: &mut Session, depth: f64| {
let before = volume(s);
run(s, "sketch.create", json!({"plane":"XY","offset":40}));
run(s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
run(s, "sketch.finish", json!({}));
let id = run(s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{depth}: {r:?}");
before - volume(s)
};
let first = cut(&mut s, 1.0);
// Cut again 2 mm deep: one more millimetre of the letters.
let again = cut(&mut s, 2.0);
assert!(rel(again, first) < 1e-3, "{first} {again}");
}
+44 -6
View File
@@ -1300,10 +1300,15 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
};
let (t0, t1) = (at(seg.start()), at(last.end()));
if (t1 - t0).abs() < 1e-9 {
// A sliver of the curve: its arcs as they are.
for m in k..=j {
let q = (first + m) % n;
pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q)));
// A sliver of the curve: its arcs as they are, or, shorter than the tolerance (where
// a curve lies on another, as when the same letters are cut again), nothing: its
// neighbours meet where it was, and as an edge it would have no length.
let length: f64 = (k..=j).filter_map(|m| lp.segs.get((first + m) % n)).map(Seg2::length).sum();
if length > near {
for m in k..=j {
let q = (first + m) % n;
pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q)));
}
}
} else {
let t = Tag { end: tag((first + j) % n).end, ..tag(i) };
@@ -1313,6 +1318,9 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
}
// Each joint at an exact curve: both sides end at the same point, the exact crossing where
// the curve is cut, its own end point where it ends.
if pieces.is_empty() {
return plain();
}
let m = pieces.len();
let inner = |t: f64| t > 1e-9 && t < 1.0 - 1e-9;
for i in 0..m {
@@ -1690,6 +1698,11 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
{
return None;
}
// A point within `tol` of an end is that end (the sewing welds them into one): a cut
// there would leave a piece with no length.
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
return None;
}
let t = seg.closest_param(*p);
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
})
@@ -1951,13 +1964,38 @@ fn sew_connected_sections(
};
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z);
// One edge between two ends, whichever way a wall or a cap reads the curve there: a piece of
// a font's curve so short that one section reads it as an arc within the tolerance, where
// another has the curve itself (the same letters cut again), is one edge. The first reading
// stands for all of them.
let readings: std::cell::RefCell<Vec<(Vec3, Vec3, Vec3, EdgeSpec)>> = std::cell::RefCell::new(Vec::new());
let same_edge = |spec: EdgeSpec, a: Vec3, b: Vec3, mid: Vec3| -> EdgeSpec {
let mut known = readings.borrow_mut();
for (ka, kb, km, k) in known.iter() {
if km.dist(mid) <= tol * 10.0 {
if *ka == a && *kb == b {
return k.clone();
}
if *ka == b && *kb == a {
return k.reversed();
}
}
}
known.push((a, b, mid, spec.clone()));
spec
};
let edge = |sg: &Seg2, z: f64| -> Option<EdgeSpec> {
Some(match sg {
Seg2::Line { .. } => EdgeSpec::Line { a: welded(sg.start(), z), b: welded(sg.end(), z) },
Seg2::Arc { .. } => EdgeSpec::Arc { a: welded(sg.start(), z), b: welded(sg.end(), z), mid: at(sg.point_at(0.5), z) },
Seg2::Arc { .. } => {
let (a, b, mid) = (welded(sg.start(), z), welded(sg.end(), z), at(sg.point_at(0.5), z));
same_edge(EdgeSpec::Arc { a, b, mid }, a, b, mid)
}
_ => {
let [p0, p1, p2, p3] = cubic_of(sg)?;
EdgeSpec::Chain { ctrl: same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]).to_vec().into() }
let c = same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]);
let mid = (c[0] + (c[1] + c[2]) * 3.0 + c[3]) * 0.125;
same_edge(EdgeSpec::Chain { ctrl: c.to_vec().into() }, c[0], c[3], mid)
}
})
};
+3 -2
View File
@@ -54,7 +54,7 @@
"50": {"area":9387.513708,"bodies":2,"edges":48,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["extruded profile","fillet","join cylinder","join cylinder","fillet","cut box","cut cylinder"],"unsupported":1,"volume":40128.140215},
"51": {"area":4221.874863,"bodies":1,"edges":75,"error":null,"faces":31,"outcome":"ok","rejected":1,"shells":2,"steps":["box","cut box","hole","rectangular pattern","chamfer","join box"],"unsupported":1,"volume":8682.025064},
"52": {"area":8628.608654,"bodies":1,"edges":45,"error":null,"faces":18,"outcome":"ok","rejected":0,"shells":1,"steps":["box","shell","cut cylinder","chamfer","join cylinder"],"unsupported":1,"volume":9001.505742},
"53": {"area":17168.464938,"bodies":1,"edges":110,"error":null,"faces":46,"outcome":"ok","rejected":1,"shells":4,"steps":["extruded profile","cut cylinder","shell","join cylinder","circular pattern","fillet"],"unsupported":0,"volume":16607.030819},
"53": {"area":17463.451761,"bodies":1,"edges":116,"error":null,"faces":48,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","cut cylinder","shell","fillet","join cylinder","circular pattern"],"unsupported":0,"volume":16954.551768},
"54": {"area":7080.555724,"bodies":4,"edges":90,"error":null,"faces":38,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","join box","circular pattern","cut cylinder","cut box","join cylinder","cut cylinder"],"unsupported":3,"volume":15737.224446},
"55": {"area":6796.974648,"bodies":1,"edges":60,"error":"Body","faces":23,"outcome":"invalid","rejected":0,"shells":1,"steps":["box","fillet","cut sphere","circular pattern","rectangular pattern","cut sphere"],"unsupported":0,"volume":24741.239331},
"56": {"area":4453.31728,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","chamfer"],"unsupported":0,"volume":11829.375},
@@ -126,6 +126,7 @@
{"reason":"seed 15 now cuts around a sealed void","seeds":[15]},
{"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]},
{"reason":"a prism cut in a column round the tool (prism_local): seed 65's rectangular pattern (step 4, a second half-cylinder groove overlapping the first, in a block with a rounded vertical edge) is now built by the exact 2D route in a column clear of the round, where the B-rep tries left it with split edges. The step's part has the 10 faces, 24 edges and 16 vertices it should (28 edges and 18 vertices before), and its volume is 45145.383 mm³ against 45145.214 (exactly 45145.103, both within the mesh measure); the generator then picks other edges for its step 7 fillet, so the run ends on another design (45143.48 mm³, 15 faces)","seeds":[65]},
{"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]}
{"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]},
{"reason":"the 2D route sews a cut whose outline has a point a hair from a segment's end (text-recut): seed 53's shell (step 3, a 2 mm shell of an extruded L with an arc side, opened at the notch's inner face) builds its cavity by the 2D route in 77 ms, where it took the B-rep path (2.4 s) and gave a part 336 mm³ lighter, which a box can't then be intersected with (no result). Its section at mid height is 338.27 mm² (a distance-rule raster of the profile gives 343.7, rounding the cavity's concave corners); the generator then builds the step 4 fillet it refused before, so the run goes on another way (16954.55 mm³, 48 faces)","seeds":[53]}
]
}