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)); +}