Fix #7525: Extrude & Bevel cuts sharp corners square instead of spiking

An inset ring limited a corner's miter by moving the corner four times
the bevel width along its bisector. At a corner sharper than about 29°
that the bevel opens (a convex corner with the bevel outside, a notch
with it inside), the bevel still ran out to a spike up to four times
the width and no longer lined up with the art. With the bevel outside,
the wall behind it spiked the same way.

Such a corner is now given twice in the outline before the solid is
built, and each copy moves along its own edge's normal, so the bevel
(and, with the bevel outside, the wall) cuts the corner square, as a
bevel join cuts a stroke. Corners within the limit keep their miters,
and corners where the inset edges meet keep the four-times limit.

A cap's outline skips the straight segment of no length between the
two copies where they do not move apart.

Docs: docs/three-d.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-14 03:10:33 -08:00
parent edb228ac33
commit cdbd079ee9
6 changed files with 141 additions and 10 deletions
+12 -4
View File
@@ -3,7 +3,7 @@ use vectorcraft_geom::kurbo::ParamCurveDeriv;
use super::outline::{self, Along, Budget, CURVE_TOLERANCE};
use super::rings::{
CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, Ring, Span, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area,
solid_rings_traced,
solid_rings_traced, square_corners,
};
use super::{
Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View,
@@ -458,10 +458,15 @@ pub(super) fn check_bounds(b: Rect) -> Result<(), String> {
}
/// The art's outward direction at edge `i` of `ring` (before rotation), tilted towards the face
/// it borders by `z` (+1 front, -1 back, 0 a side wall).
/// it borders by `z` (+1 front, -1 back, 0 a side wall). An edge of no length (a corner cut
/// square, see [`square_corners`]) faces out between the edges on either side of it.
pub(super) fn outward(ring: &[Point], i: usize, z: f64) -> V3 {
let n = ring.len();
let (lx, ly) = left(ring[i], ring[(i + 1) % n]);
let (mut lx, mut ly) = left(ring[i], ring[(i + 1) % n]);
if lx == 0.0 && ly == 0.0 && n > 2 {
let (a, b) = (left(ring[(i + n - 1) % n], ring[i]), left(ring[(i + 1) % n], ring[(i + 2) % n]));
(lx, ly) = (a.0 + b.0, a.1 + b.1);
}
V3(-lx, -ly, z)
}
@@ -607,7 +612,10 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 });
let size = bounds.width().max(bounds.height());
let most = curve_pieces(path, size, bands, caps)?;
let traced = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?;
// Every inset of one extrusion goes the same way: into the shape for a bevel inside, out of it
// for one outside. The corners that way opens are cut square past the miter limit.
let side = steps.iter().map(|s| s.inset).find(|w| *w != 0.0).unwrap_or(0.0);
let traced: Vec<Ring> = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?.iter().map(|r| square_corners(r, side)).collect();
let rings: Vec<&[Point]> = traced.iter().map(|r| r.points.as_slice()).collect();
let edges = rings.iter().map(|r| r.len()).fold(0usize, usize::saturating_add);
if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES {
+2 -1
View File
@@ -123,7 +123,8 @@ fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budg
while i < n {
let end = |k: usize| points.get((k + 1) % n).copied().unwrap_or(first);
let Some(span) = ring.spans.get(i).copied().flatten() else {
if i + 1 < n {
// A corner cut square (see `square_corners`) whose two copies did not move apart.
if i + 1 < n && points.get(i).copied() != Some(end(i)) {
path.line_to(end(i));
}
i += 1;
+50 -3
View File
@@ -16,6 +16,10 @@ pub(super) const CURVE_PIECES: u32 = 32;
/// for fewer takes fewer.
pub(super) const MIN_CURVE_PIECES: u32 = 4;
/// How far an inset corner's miter reaches at most, as a multiple of the inset. [`square_corners`]
/// cuts a corner whose miter would reach further square across instead.
const MITER_LIMIT: f64 = 4.0;
const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect";
/// The error a solid past its limits returns, and a 3D effect whose solids together pass
/// [`crate::MAX_OBJECT_FACES`].
@@ -472,8 +476,10 @@ pub(super) fn left(a: Point, b: Point) -> (f64, f64) {
}
/// `ring` moved `w` points towards its filled side (negative: away from it), each corner along
/// its bisector, its mitre limited to four times `w` so sharp corners don't shoot off. Each point
/// stays the partner of its source point, so rings at different insets join face to face.
/// its bisector, its miter limited to [`MITER_LIMIT`] times `w` so sharp corners don't shoot off.
/// A point given twice (a corner [`square_corners`] cut) moves each copy along its one edge's
/// normal. Each point stays the partner of its source point, so rings at different insets join
/// face to face.
pub(super) fn inset(ring: &[Point], w: f64) -> Vec<Point> {
if w == 0.0 {
return ring.to_vec();
@@ -486,6 +492,12 @@ pub(super) fn inset(ring: &[Point], w: f64) -> Vec<Point> {
let next = ring[(i + 1) % n];
let (ax, ay) = left(prev, p);
let (bx, by) = left(p, next);
if ax == 0.0 && ay == 0.0 {
return Point::new(p.x + bx * w, p.y + by * w);
}
if bx == 0.0 && by == 0.0 {
return Point::new(p.x + ax * w, p.y + ay * w);
}
let (mut mx, mut my) = (ax + bx, ay + by);
let ml = mx.hypot(my);
if ml < 1e-9 {
@@ -493,8 +505,43 @@ pub(super) fn inset(ring: &[Point], w: f64) -> Vec<Point> {
} else {
(mx, my) = (mx / ml, my / ml);
}
let cos = (mx * ax + my * ay).max(0.25);
let cos = (mx * ax + my * ay).max(1.0 / MITER_LIMIT);
Point::new(p.x + mx * w / cos, p.y + my * w / cos)
})
.collect()
}
/// `ring` with each corner whose miter an inset towards `side` (positive: the filled side,
/// negative: away from it) would reach past [`MITER_LIMIT`] given twice, joined by an edge of no
/// length that follows no curve. [`inset`] moves the two copies apart along their own edges'
/// normals, so the inset ring cuts the corner square, as a bevel join does, instead of running out
/// to a point along the bisector. Corners on the other side of the turn, whose inset edges meet
/// before the corner, keep one point.
pub(super) fn square_corners(ring: &Ring, side: f64) -> Ring {
let n = ring.points.len();
if side == 0.0 || n < 3 || ring.spans.len() != n {
return ring.clone();
}
let mut out = Ring { points: Vec::with_capacity(n), spans: Vec::with_capacity(n) };
for (i, (&p, span)) in ring.points.iter().zip(&ring.spans).enumerate() {
let prev = ring.points[(i + n - 1) % n];
let next = ring.points[(i + 1) % n];
let (a, b) = (left(prev, p), left(p, next));
let (mx, my) = (a.0 + b.0, a.1 + b.1);
let ml = mx.hypot(my);
// The cosine of the half angle between the bisector and each edge's normal: the miter
// reaches 1 / cos times the inset.
let cos = if ml < 1e-9 { 0.0 } else { (mx * a.0 + my * a.1) / ml };
// Which way the outline turns at `p`: an inset towards `side` opens the corner when the
// turn is against it.
let turn = (p - prev).cross(next - p);
let edges = (a.0 != 0.0 || a.1 != 0.0) && (b.0 != 0.0 || b.1 != 0.0);
out.points.push(p);
if edges && cos < 1.0 / MITER_LIMIT && turn * side < 0.0 {
out.spans.push(None);
out.points.push(p);
}
out.spans.push(*span);
}
out
}
+72
View File
@@ -255,6 +255,78 @@ fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
assert!(inside.iter().any(|f| !close(f.brightness, inside[inside.len() - 1].brightness, 1e-3)));
}
/// A bevel past a sharp corner is cut square, as a bevel join cuts a stroke, instead of running
/// out to a spike along the corner's bisector: seen straight on, no point of the solid lies further
/// from the art than the miters of its corners of about 90° (14.6 pt for a 10 pt bevel), where the
/// sharp corner's spike reached 40 pt. The bevel opens the corner of a thin spike (with its
/// neighbor in the same compound path) when it is outside, and of a narrow notch when it is inside;
/// the other two cases, where the inset edges meet, keep their limited miters and are checked too.
#[test]
fn a_bevel_cuts_sharp_corners_square() {
let poly = |pts: &[(f64, f64)]| {
let mut b = BezPath::new();
b.move_to(pts[0]);
pts[1..].iter().for_each(|p| b.line_to(*p));
b.close_path();
b
};
let spike = [(50.0, 0.0), (56.0, 100.0), (44.0, 100.0)];
let block = [(80.0, 40.0), (120.0, 40.0), (120.0, 100.0), (80.0, 100.0)];
let notched = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (52.0, 100.0), (50.0, 20.0), (48.0, 100.0), (0.0, 100.0)];
let mut both = poly(&spike);
both.extend(poly(&block).elements().iter().copied());
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
let mut far_cases = vec![];
for (name, art, outline) in [("spike", both, vec![spike.to_vec(), block.to_vec()]), ("notch", poly(&notched), vec![notched.to_vec()])] {
let (p, b) = shape(&art);
let outline: Vec<Vec<Point>> = outline.iter().map(|r| r.iter().map(|q| Point::new(q.0, q.1)).collect()).collect();
for inside in [false, true] {
for shape in [BevelShape::Classic, BevelShape::Rounded] {
let o = Extrude { bevel: Some(Bevel { inside, shape, ..bevel }), ..Extrude::default() };
let f = extrude(&p, FillRule::NonZero, b, o, front()).unwrap();
let far = f.iter().flat_map(|f| f.contours.iter().flatten()).map(|q| edge_distance(&outline, *q)).fold(0.0, f64::max);
if far > 15.0 {
far_cases.push(format!("{name} inside {inside} {shape:?}: a point {far} pt from the art"));
}
assert!(f.iter().all(|f| f.normal.iter().all(|v| v.is_finite())), "{name} inside {inside} {shape:?}");
}
// Turned, every face still has a normal it can be lit and sorted by.
let o = Extrude { bevel: Some(Bevel { inside, ..bevel }), ..Extrude::default() };
let f = extrude(&p, FillRule::NonZero, b, o, View::default()).unwrap();
assert!(f.iter().all(|f| f.normal.iter().all(|v| v.is_finite()) && f.brightness.is_finite()), "{name} inside {inside}");
}
}
assert!(far_cases.is_empty(), "{far_cases:#?}");
}
/// With the bevel outside, the cap is the outline itself, so a corner cut square keeps its two
/// points together there: the cap's outline draws no straight segment of no length at that corner.
#[test]
fn a_cap_draws_no_empty_segment_at_a_corner_cut_square() {
use vectorcraft_geom::kurbo::PathEl;
let mut art = BezPath::new();
art.move_to((50.0, 0.0));
art.line_to((56.0, 100.0));
art.curve_to((56.0, 120.0), (44.0, 120.0), (44.0, 100.0));
art.close_path();
let (p, b) = shape(&art);
let o = Extrude { bevel: Some(Bevel { width: 10.0, height: 10.0, ..Bevel::default() }), ..Extrude::default() };
let f = extrude(&p, FillRule::NonZero, b, o, front()).unwrap();
let cap = f.iter().find(|f| f.depth == f64::MAX && f.outline.is_some()).unwrap();
let mut at = None;
for el in cap.path().to_bezpath().elements() {
match *el {
PathEl::MoveTo(q) => at = Some(q),
PathEl::LineTo(q) => {
assert_ne!(at, Some(q), "a segment of no length at {q:?}");
at = Some(q);
}
PathEl::QuadTo(_, q) | PathEl::CurveTo(_, _, q) => at = Some(q),
PathEl::ClosePath => {}
}
}
}
#[test]
fn bevel_shapes_repeats_and_both_sides_change_the_surface() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));