From 9bd8b69613ae63823a156f58beab9e323b0052ad Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sat, 7 Nov 2026 22:16:50 -0700 Subject: [PATCH] 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. --- crates/three-d/src/solid/build.rs | 8 +++++--- crates/three-d/src/solid/inflate.rs | 5 +++-- crates/three-d/src/solid/rings.rs | 12 ++++++++++++ crates/three-d/src/solid/tests.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 7fbeb3e8c..adba3bde3 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -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 { 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> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 9f1f5c3f7..a220884b1 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -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, 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); } diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 44a8590ac..ac63fe2fa 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -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 diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 0f78aa712..859bf703d 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -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)); +}