3D effects: resolve overlapping contours before building a solid

Extrude & Bevel, Inflate and Rotate broke on shapes whose contours cross.
Type often draws a glyph that way: Roboto Slab's "k" has its arm and leg
as one contour that crosses both the stem and the leg's foot serif.

rings() decided each contour's role (outline, hole, or no edge at all
under nonzero) from the contour's first point only. A contour that starts
inside another was dropped or flipped into a hole, cutting part of the
glyph away. A contour that starts outside was kept whole, so its walls and
bevels ran through the overlap and the overlapping parts drew as separate
boxes.

The profile now goes through vectorcraft_pathops::try_normalize first, so
rings() only ever sees simple contours that do not cross. A path the
boolean refuses is used as it was.
This commit is contained in:
Drew Dibble
2026-11-09 02:24:47 +07:00
committed by bflatastic
parent 90d5692d80
commit 9bd8b69613
4 changed files with 48 additions and 5 deletions
+5 -3
View File
@@ -1,4 +1,4 @@
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area};
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, resolved, rings, signed_area};
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
/// Turns 3D points into document points and shades faces for one view.
@@ -403,7 +403,8 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
if o.depth <= 0.0 {
return plane_faces(path, rule, bounds, view, mesh);
}
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let (path, rule) = resolved(path, rule);
let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
let steps = steps(&o);
@@ -461,7 +462,8 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
/// [`plane`], or with `mesh` its face before projection instead of its facet.
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
check_bounds(bounds)?;
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let (path, rule) = resolved(path, rule);
let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
+3 -2
View File
@@ -1,6 +1,6 @@
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material};
use super::rings::{MAX_SOLID_POINTS, rings, signed_area};
use super::rings::{MAX_SOLID_POINTS, resolved, rings, signed_area};
use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View};
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
@@ -18,7 +18,8 @@ struct Puff {
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let (path, rule) = resolved(path, rule);
let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
if rings.is_empty() {
return Ok(None);
}
+12
View File
@@ -19,6 +19,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 }
}
/// `path` with its overlaps resolved under `rule`: simple contours that do not cross,
/// holes running against their outlines. Type often draws one glyph as overlapping contours (a
/// "k"'s arm across its stem), and [`rings`] tells a contour's side from one point of it, so a
/// contour that crosses another must be cut where they meet first. A path the boolean refuses is
/// used as it is.
pub(super) fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) {
match vectorcraft_pathops::try_normalize(path, rule) {
Ok(p) if !p.subpaths.is_empty() => (p, FillRule::NonZero),
_ => (path.clone(), rule),
}
}
/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
+28
View File
@@ -754,3 +754,31 @@ fn many_small_rings_far_apart_are_too_complex() {
let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err();
assert!(e.contains("too complex"), "{e}");
}
/// Type often draws a glyph as contours that cross (a "k"'s arm across its stem, its leg across
/// the arm). Each contour's side was told from its first point, so one starting inside another
/// was dropped or cut as a hole, and the parts left were walled where they overlapped. The
/// outline is the union the art fills, under either rule.
#[test]
fn crossing_contours_make_one_outline() {
let rect = |r: Rect| {
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
};
// The stem, an arm starting inside it, and a leg starting inside the arm.
let k = PathData::new(vec![
rect(Rect::new(0.0, 0.0, 20.0, 100.0)),
rect(Rect::new(10.0, 40.0, 60.0, 60.0)),
rect(Rect::new(50.0, 50.0, 70.0, 100.0)),
]);
let b = k.bounds().unwrap();
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
let filled = vectorcraft_pathops::area(&k, rule);
assert!(close(area(&plane(&k, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate");
let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap();
assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows");
assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap");
assert!(inflate(&k, rule, b, Inflate::default(), front()).is_ok_and(|f| close(extent(&f).width(), 70.0, 1e-6)), "{rule:?}: Inflate");
}
// The filled area is the union's: the stem, the arm past it and the leg past the arm.
assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6));
}