mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user