diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 0650e09c..410d923f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -113,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { } } -fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result, String> { +fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { let view = view_options(p); match id { - EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view), - INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view), - _ => vectorcraft_three_d::plane(path, bounds, view), + EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view), + INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view), + _ => vectorcraft_three_d::plane(path, rule, bounds, view), } } /// Validate before a command changes the document; the evaluator bounds loaded files too. pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); - let (path, _) = + let (path, rule) = profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; if let Some(v) = p.get("bevel") @@ -134,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect(); return Err(format!("bevel must be none or one of {}", names.join(", "))); } - facets(id, &path, b, p).map(|_| ()) + facets(id, &path, rule, b, p).map(|_| ()) } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and @@ -185,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) { } } -/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke. -fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData { +/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the +/// stroke's outline (whose contours nest as its holes do) for a stroke. +fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) { match item { - AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s), - _ => path.clone(), + AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd), + _ => (path.clone(), rule), } } @@ -204,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App let mut out = vec![]; for item in items { let (paint, opacity, blend) = paint_of(item); - let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?; + let (area, rule) = region(path, rule, item); + let f = facets(ROTATE, &area, rule, b, &p).ok()?; out.push(Arc::new(surface(n, f, &paint, opacity, blend))); } return Some(out); @@ -213,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App let (paint, opacity, blend) = paint_of(item); // A loaded file or a shape edited past the limits keeps drawing its source path instead of // failing the frame; commands validate the same evaluator and report its errors. - let f = facets(&fx.id, path, b, &p).ok()?; + let f = facets(&fx.id, path, rule, b, &p).ok()?; Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))]) } diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index de9ca38d..bb16efa2 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -9,7 +9,7 @@ //! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away //! from the viewer are left out, the rest are drawn back to front. -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect}; use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; @@ -290,7 +290,11 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { /// 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 /// few points and large ones more; more than `limit` points in all is refused. -fn rings(path: &PathData, size: f64, limit: usize) -> Result>, String> { +/// +/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and +/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would +/// if nesting alone decided (#1724). +fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -325,6 +329,18 @@ fn rings(path: &PathData, size: f64, limit: usize) -> Result>, St out.push(ring); } } + if rule == FillRule::NonZero { + let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; + let mut edge = Vec::with_capacity(out.len()); + for (i, r) in out.iter().enumerate() { + let p = r.first().copied().unwrap_or_default(); + // The winding number just outside the ring, and just inside it. + let outside: i32 = out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).map(|(_, o)| turn(o)).sum(); + edge.push((outside != 0) != (outside + turn(r) != 0)); + } + let mut edge = edge.into_iter(); + out.retain(|_| edge.next().unwrap_or(true)); + } // A ring inside an odd number of others is a hole. let holes: Vec = out .iter() @@ -634,14 +650,14 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. -pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result, String> { +pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane(path, bounds, view); + return plane(path, rule, bounds, view); } - let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + 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![] }; let steps = steps(&o); @@ -675,9 +691,9 @@ pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> R /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). -pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result, String> { +pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { check_bounds(bounds)?; - let rings = rings(path, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + 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![] }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); @@ -997,18 +1013,18 @@ fn material(outline: &PathData, cell: Rect) -> Option>> { &[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)], true, )); - let rule = vectorcraft_geom::FillRule::NonZero; + let rule = FillRule::NonZero; let part = vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION) .ok()?; - rings(&part, cell.width(), MAX_PLANE_POINTS).ok() + rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok() } /// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is. -pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> Result, String> { +pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result, String> { check_bounds(bounds)?; let o = options.sanitized(); - let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; if rings.is_empty() { return Ok(vec![]); } diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/tests_solid.rs index 657fba65..72f007ce 100644 --- a/crates/three-d/src/tests_solid.rs +++ b/crates/three-d/src/tests_solid.rs @@ -1,4 +1,4 @@ -use vectorcraft_geom::{BezPath, PathData, Point, Rect}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; use super::*; @@ -41,7 +41,7 @@ fn close(a: f64, b: f64, tol: f64) -> bool { #[test] fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() { let (p, b) = shape(&square(10.0, 20.0, 100.0)); - let f = extrude(&p, b, Extrude::default(), front()).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap(); assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows"); assert_eq!(f[0].contours.len(), 1); assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0)); @@ -51,7 +51,7 @@ fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() { fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let o = Extrude { depth: 40.0, ..Extrude::default() }; - let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_y: 30.0, ..front() }).unwrap(); // Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°. let w = extent(&f).width(); assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}"); @@ -66,10 +66,10 @@ fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() { fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() }; - let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap(); - assert_eq!(flat, plane(&p, b, v).unwrap()); - let solid = extrude(&p, b, Extrude::default(), v).unwrap(); - let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap(); + let flat = extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap(); + assert_eq!(flat, plane(&p, FillRule::EvenOdd, b, v).unwrap()); + let solid = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap(); + let hollow = extrude(&p, FillRule::EvenOdd, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap(); // Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps. assert_eq!(solid.len(), 3); assert_eq!(hollow.len(), 4); @@ -81,10 +81,10 @@ fn taper_shrinks_the_back_and_twist_turns_it() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let side = View { rotation_y: 90.0, ..front() }; let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() }; - let straight = extent(&extrude(&p, b, o, side).unwrap()); + let straight = extent(&extrude(&p, FillRule::EvenOdd, b, o, side).unwrap()); assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6)); // Seen from the side, a 50 % taper keeps the front's full height and halves the back's. - let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap(); + let tapered = extrude(&p, FillRule::EvenOdd, b, Extrude { taper: 0.5, ..o }, side).unwrap(); let back: Vec = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect(); let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y))); let at_front: Vec = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect(); @@ -93,11 +93,11 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}"); // Seen from behind, a 45° twist shows the back cap turned into a diamond. let back = View { rotation_y: 180.0, ..front() }; - let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap(); + let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap(); assert_eq!(twisted.len(), 1, "only the back cap faces the viewer"); assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted)); // Its walls are cut into slices that follow the turn. - let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); + let open = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); assert!(open.len() > 2 * 8, "{}", open.len()); } @@ -105,13 +105,13 @@ fn taper_shrinks_the_back_and_twist_turns_it() { fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() }; - let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap(); + let inside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap(); // Straight on: the cap inset by the width, and the chamfer around it filling the outline. let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6)); assert!(cap.is_some(), "an 80 × 80 cap"); assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0)); assert!(close(area(&inside), 100.0 * 100.0, 1e-6)); - let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); + let outside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art"); assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art"); // The chamfer's faces lean towards the light differently from the cap. @@ -123,12 +123,16 @@ fn bevel_shapes_repeats_and_both_sides_change_the_surface() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() }; - let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap(); + let classic = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap(); for shape in BevelShape::ALL.into_iter().skip(1) { - assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}"); + assert_ne!( + classic, + extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), + "{shape:?}" + ); } for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] { - assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}"); + assert_ne!(classic, extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}"); } // The step shape keeps its corners: half the width at half the height. assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5)); @@ -147,11 +151,11 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() { // The hole drawn the same way round as the outline: even-odd nesting still makes it a hole. b.extend(square(30.0, 30.0, 40.0)); let (p, r) = shape(&b); - let f = extrude(&p, r, Extrude::default(), front()).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap(); assert_eq!(f.len(), 1); assert_eq!(f[0].contours.len(), 2); assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6)); - let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap(); + let turned = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap(); // Front cap, the outline's right wall and the hole's left wall (seen through the hole). assert_eq!(turned.len(), 3); } @@ -159,15 +163,15 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() { #[test] fn a_plane_turns_about_the_given_centre() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); - let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap(); + let f = plane(&p, FillRule::EvenOdd, b, View { rotation_y: 60.0, ..front() }).unwrap(); assert_eq!(f.len(), 1); let e = extent(&f); assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9)); // Turned about the centre of a larger box, the square moves as well as narrowing. - let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap(); + let wide = plane(&p, FillRule::EvenOdd, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap(); assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6)); // Its back shows when turned past 90°: a plane is two-sided. - assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1); + assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1); } #[test] @@ -175,7 +179,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() { let mut c = BezPath::new(); c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); - let f = inflate(&p, b, Inflate::default(), front()).unwrap(); + let f = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), front()).unwrap(); // Straight on, the cushion covers the circle (sampled as a polygon) and nothing else. assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f)); assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6); @@ -184,7 +188,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() { assert!(hi - lo > 0.3, "{lo}..{hi}"); // From the side: 50 % volume rounds a disc into a half sphere on a flat back. let side = View { rotation_y: 90.0, ..front() }; - let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width(); + let thick = |o: Inflate| extent(&inflate(&p, FillRule::EvenOdd, b, o, side).unwrap()).width(); assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default())); assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0)); assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0)); @@ -197,7 +201,7 @@ fn inflate_leaves_holes_open() { let mut b = square(0.0, 0.0, 100.0); b.extend(square(30.0, 30.0, 40.0)); let (p, r) = shape(&b); - let f = inflate(&p, r, Inflate::default(), front()).unwrap(); + let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap(); assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f)); } @@ -205,9 +209,9 @@ fn inflate_leaves_holes_open() { fn malformed_and_degenerate_input_is_bounded() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] { - assert!(extrude(&p, r, Extrude::default(), front()).is_err()); - assert!(inflate(&p, r, Inflate::default(), front()).is_err()); - assert!(plane(&p, r, front()).is_err()); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_err()); + assert!(inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).is_err()); + assert!(plane(&p, FillRule::EvenOdd, r, front()).is_err()); } let wild = Extrude { depth: f64::INFINITY, @@ -216,18 +220,24 @@ fn malformed_and_degenerate_input_is_bounded() { cap: true, bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }), }; - let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite())); assert!( - inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front()) - .is_ok() + inflate( + &p, + FillRule::EvenOdd, + b, + Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, + front() + ) + .is_ok() ); - assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty()); + assert!(extrude(&PathData::default(), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty()); // A line has no area to extrude. let mut line = BezPath::new(); line.move_to((0.0, 0.0)); line.line_to((100.0, 0.0)); - assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty()); + assert!(extrude(&PathData::from_bezpath(&line), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty()); // Too many anchors: refused, not truncated. let mut many = BezPath::new(); many.move_to((0.0, 0.0)); @@ -235,15 +245,15 @@ fn malformed_and_degenerate_input_is_bounded() { many.line_to((f64::from(i), f64::from(i % 7))); } many.close_path(); - assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err()); + assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err()); } #[test] fn perspective_makes_the_near_face_larger() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let o = Extrude { depth: 100.0, ..Extrude::default() }; - let flat = extent(&extrude(&p, b, o, front()).unwrap()); - let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap()); + let flat = extent(&extrude(&p, FillRule::EvenOdd, b, o, front()).unwrap()); + let deep = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: 30.0, ..front() }).unwrap()); assert!(deep.width() > flat.width() + 1.0); assert!(close(deep.center().x, flat.center().x, 1e-6)); } @@ -257,7 +267,7 @@ fn a_wider_perspective_brings_the_eye_nearer() { let mut last = 100.0; for angle in [20.0, 60.0, 110.0, 160.0] { // The front face alone shows: its width is the cube's on the page. - let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width(); + let width = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: angle, ..front() }).unwrap()).width(); assert!(width > last, "{angle}°: {width} after {last}"); last = width; } @@ -274,10 +284,10 @@ fn the_inflate_membrane_matches_known_solutions() { let mut c = BezPath::new(); c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); - let disc = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49); + let disc = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49); assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak); let (p, b) = shape(&square(0.0, 0.0, 100.0)); - let sq = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); + let sq = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak); // Smooth across the middle: no crease where a distance-to-the-edge height would have one // (the diagonals). The second difference along a diagonal stays small and even. @@ -303,10 +313,10 @@ fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() { b.close_path(); let (p, r) = shape(&b); let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() }; - let f = inflate(&p, r, Inflate::default(), v).unwrap(); + let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), v).unwrap(); assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface"); // Straight on, the facets still cover exactly the shape. - let flat = inflate(&p, r, Inflate::default(), front()).unwrap(); + let flat = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap(); assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat)); } @@ -332,19 +342,19 @@ fn bumps(count: usize) -> (PathData, Rect) { fn small_curves_take_few_points_and_rotate_takes_large_outlines() { // 300 curves were 4,800 points at sixteen each, over the old limit of 1,024. let (p, r) = bumps(300); - assert!(extrude(&p, r, Extrude::default(), front()).is_ok()); - assert!(inflate(&p, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); - assert!(plane(&p, r, front()).is_ok()); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); + assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); + assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok()); // 4,000 curves: too many for a solid, not for Rotate. let (p, r) = bumps(4000); - assert!(extrude(&p, r, Extrude::default(), front()).unwrap_err().contains("too complex")); - assert_eq!(plane(&p, r, front()).unwrap().len(), 1); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex")); + assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1); // Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt // here (1/2000 of its 100 pt size is less). let mut c = BezPath::new(); c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01)); let (p, r) = shape(&c); - let ring = &rings(&p, 100.0, MAX_SOLID_POINTS).unwrap()[0]; + let ring = &rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap()[0]; for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) { let mid = a.midpoint(*b); assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}"); @@ -352,7 +362,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { assert!(r.width() > 0.0); // A small bump takes a few points; a large curve more than sixteen where it needs them. let (p, r) = bumps(300); - let small = rings(&p, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len(); + let small = rings(&p, FillRule::EvenOdd, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len(); assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps"); assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len()); } @@ -362,7 +372,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { #[test] fn the_edge_index_finds_the_nearest_edge() { let (p, r) = bumps(200); - let rings = rings(&p, r.width(), MAX_SOLID_POINTS).unwrap(); + let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap(); let edges = Edges::new(&rings); for k in 0..400 { let t = f64::from(k); @@ -376,7 +386,7 @@ fn the_edge_index_finds_the_nearest_edge() { /// straight ahead: the search doesn't stop before it has looked there. #[test] fn the_edge_index_looks_along_the_side_from_outside() { - let ring = |b: BezPath| rings(&shape(&b).0, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); + let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; let edges = Edges::new(&rings); @@ -390,7 +400,7 @@ fn the_edge_index_looks_along_the_side_from_outside() { fn twist_holds_between_0_and_360_degrees() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; - let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap(); + let at = |twist: f64| extrude(&p, FillRule::EvenOdd, b, Extrude { twist, ..Extrude::default() }, v).unwrap(); assert_eq!(at(-45.0), at(0.0)); assert_eq!(at(500.0), at(360.0)); assert_ne!(at(45.0), at(0.0)); @@ -408,11 +418,11 @@ fn twisted_walls_face_the_way_they_turn() { for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] { let v = View { rotation_x: x, rotation_y: y, ..front() }; let o = Extrude { twist, ..Extrude::default() }; - let got = extrude(&p, b, o, v).unwrap(); + let got = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap(); // Expected: walls (and caps) whose outward normal faces the viewer. let o = o.sanitized(); let camera = Camera::new(v, b, o.depth); - let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0]; + let ring = &rings(&p, FillRule::EvenOdd, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0]; let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64); let z = |t: f64| o.depth / 2.0 - o.depth * t; let mut expected = 0; @@ -456,7 +466,7 @@ fn inflate_twists_tapers_and_opens_its_back() { let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() }; // From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50 // at a 50 % taper. - let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap(); + let tapered = inflate(&p, FillRule::EvenOdd, b, Inflate { taper: 0.5, ..o }, side).unwrap(); let e = extent(&tapered); assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}"); let back: Vec = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect(); @@ -464,18 +474,19 @@ fn inflate_twists_tapers_and_opens_its_back() { assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo); // From behind, a 45° twist shows the flat back as a diamond. let behind = View { rotation_y: 180.0, ..front() }; - let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap(); + let twisted = inflate(&p, FillRule::EvenOdd, b, Inflate { twist: 45.0, ..o }, behind).unwrap(); assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted)); // Hollow: no back; from behind the inside of the inflated front shows instead of a flat face. - let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap(); + let solid = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, ..o }, behind).unwrap(); assert_eq!(solid.len(), 1, "the flat back alone"); - let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap(); + let hollow = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap(); assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12)); // With both sides puffed, the back puff is scaled with the taper: lower and smaller. - let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap()); + let both = + |taper: f64| extent(&inflate(&p, FillRule::EvenOdd, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap()); assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6); // Out of range values hold at the documented ends. - let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); + let at = |i: Inflate| inflate(&p, FillRule::EvenOdd, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o)); assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o })); } @@ -487,10 +498,43 @@ fn a_flat_twisted_inflate_matches_the_extrusion() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] { let v = View { rotation_x: x, rotation_y: y, ..front() }; - let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap()); - let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap()); + let e = extent(&extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap()); + let i = extent( + &inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap(), + ); for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] { assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}"); } } } + +/// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole +/// (the middle is filled, as the art is drawn); one running the other way does, and so does +/// either under the even-odd rule. +#[test] +fn nonzero_shapes_keep_the_middle_a_same_way_contour_encloses() { + let rect = |r: Rect, clockwise: bool| { + let mut pts = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + if clockwise { + pts.reverse(); + } + vectorcraft_geom::SubPath::polyline(&pts, true) + }; + let (outer, inner) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(25.0, 25.0, 75.0, 75.0)); + let nested = |inner_clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, inner_clockwise)]); + let flat = |p: &PathData, rule: FillRule| area(&plane(p, rule, outer, front()).unwrap()); + let (solid, holed) = (100.0 * 100.0, 100.0 * 100.0 - 50.0 * 50.0); + assert!(close(flat(&nested(false), FillRule::NonZero), solid, 1e-6), "the same way round: filled"); + assert!(close(flat(&nested(true), FillRule::NonZero), holed, 1e-6), "the other way round: a hole"); + assert!(close(flat(&nested(false), FillRule::EvenOdd), holed, 1e-6)); + assert!(close(flat(&nested(true), FillRule::EvenOdd), holed, 1e-6)); + // A third contour inside the hole is an island again, whichever way it runs. + let island = |clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, true), rect(Rect::new(40.0, 40.0, 60.0, 60.0), clockwise)]); + for clockwise in [false, true] { + assert!(close(flat(&island(clockwise), FillRule::NonZero), holed + 20.0 * 20.0, 1e-6), "{clockwise}"); + } + // Extrude's caps and Inflate follow the same outline. + let caps = |p: &PathData| area(&extrude(p, FillRule::NonZero, outer, Extrude::default(), front()).unwrap()); + assert!(caps(&nested(false)) > caps(&nested(true)) + 1000.0); + assert!(inflate(&nested(false), FillRule::NonZero, outer, Inflate::default(), front()).is_ok()); +}