diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 99a6e053e..1a491daa5 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{PathData, Rect}; +use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; @@ -18,6 +18,13 @@ pub const REVOLVE: &str = "threeD.revolve"; /// thousands of faces took gigabytes; past this the source path is drawn. const MAX_PAINT_COPIES: usize = 256 << 20; +/// The most face and box tests a group's transparent members' faces take together to rejoin their +/// members' last runs; past them each starts a run of its own. +const MAX_OVERLAP_TESTS: usize = 1 << 22; + +/// The width of the stroke around each face that covers the seams between faces. +const SEAM_WIDTH: f64 = 0.75; + /// About the bytes one copy of `paint` takes. fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; @@ -265,7 +272,7 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); // Acute face corners must stay within the overlap width. Miter joins // amplify this seam stroke into visible spikes along the silhouette. edge.join = LineJoin::Round; @@ -431,11 +438,11 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } -/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` -/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity -/// and blending mode, so each applies once, to the members it holds together. -fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option, level: usize, out: &mut Vec>) { +/// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each +/// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its +/// opacity and blending mode, so each applies once, to the faces it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Arc)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option, level: usize, out: &mut Vec>) { if let Some(scope) = scope { let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); group.opacity = scope.opacity; @@ -455,15 +462,104 @@ fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec, +} + +impl Coverage { + /// `None` for a face with no outline and no box. + fn of(face: &Node) -> Option { + let points: Vec = match face.path_data() { + Some(path) => path + .to_bezpath() + .elements() + .iter() + .flat_map(|e| match *e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p], + PathEl::QuadTo(a, b) => vec![a, b], + PathEl::CurveTo(a, b, c) => vec![a, b, c], + PathEl::ClosePath => vec![], + }) + .collect(), + None => { + let b = face.visual_bounds()?; + vec![Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] + } + }; + let hull = convex_hull(points); + let first = *hull.first()?; + let reach = SEAM_WIDTH / 2.0; + let bounds = hull.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p)).inflate(reach, reach); + Some(Coverage { bounds, hull }) + } + + /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the + /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and + /// sometimes near a corner when they don't. + fn meets(&self, other: &Coverage) -> bool { + fn normals(hull: &[Point]) -> impl Iterator + '_ { + (0..hull.len()).filter_map(|i| { + let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); + Some(Vec2::new(a.y - b.y, b.x - a.x)) + }) + } + let axes = [Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)].into_iter().chain(normals(&self.hull)).chain(normals(&other.hull)); + let span = |hull: &[Point], axis: Vec2| { + hull.iter().map(|p| p.to_vec2().dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) + }; + axes.filter(|axis| axis.hypot() > 0.0).all(|axis| { + let ((a0, a1), (b0, b1)) = (span(&self.hull, axis), span(&other.hull, axis)); + let gap = SEAM_WIDTH * axis.hypot(); + b0 - a1 < gap && a0 - b1 < gap + }) + } +} + +/// The convex hull of `points`, counterclockwise in a y-up frame, without repeated points. +fn convex_hull(mut points: Vec) -> Vec { + points.retain(|p| p.is_finite()); + points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + points.dedup(); + if points.len() < 3 { + return points; + } + // One side of the hull from the first point to the last, without the last. + fn side<'a>(points: impl Iterator) -> Vec { + let mut chain: Vec = vec![]; + for &p in points { + while let [.., o, a] = chain[..] + && (a - o).cross(p - o) <= 0.0 + { + chain.pop(); + } + chain.push(p); + } + chain.pop(); + chain + } + let mut hull = side(points.iter()); + hull.extend(side(points.iter().rev())); + hull +} + +/// Do boxes `a` and `b` overlap with some area? +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, -/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when +/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option { let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; @@ -490,18 +586,86 @@ fn group_art(n: &Node, expand: bool) -> Option { return None; } let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - let children = if opaque && plain { + let children = if !plain { + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) + } else { + let count = surfaces.len(); let mut faces = vec![]; - for (depths, s, _) in surfaces { + for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children)); + faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); } } // A stable sort: faces at the same depth keep the members' order. faces.sort_by(|a, b| a.0.total_cmp(&b.0)); - vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] - } else { - scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0) + if opaque { + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] + } else { + // An opaque member's face goes in the order on its own. The faces of a member with + // transparency share a run, one group with the opacity and blending mode of its fill or + // stroke, and its scopes group runs further. Such a face joins its member's last run + // unless a face drawn after that run overlaps it: only then does the member split, + // where one group can't be both behind and in front of another face. + struct Run<'a> { + member: Option, + scopes: &'a [Scope], + bounds: Rect, + faces: Vec>, + cover: Vec, + opacity: f32, + blend: vectorcraft_doc::color::BlendMode, + } + let mut runs: Vec = vec![]; + let mut last_run = vec![None; count]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = MAX_OVERLAP_TESTS; + for (_, member, opacity, blend, scopes, face) in faces { + let paints = opacity > 0.0 && scopes.iter().all(|s| s.opacity > 0.0); + let Some(cover) = Coverage::of(&face).filter(|_| paints) else { + // A face that paints nothing goes in the order on its own and overlaps nothing. + runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); + continue; + }; + if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { + runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = + last_run.get(member).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(member) { + *last = Some(runs.len()); + } + runs.push(Run { member: Some(member), scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); + } + } + } + let runs = runs.into_iter().filter_map(|mut r| { + if r.member.is_none() { + return r.faces.pop().map(|face| (r.scopes, face)); + } + let mut group = Node::group(n.id, r.faces); + group.opacity = r.opacity; + group.blend = r.blend; + Some((r.scopes, Arc::new(group))) + }); + scoped(n.id, runs.collect(), 0) + } }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -712,18 +876,76 @@ mod tests { } #[test] - fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() { + fn opaque_members_share_one_back_to_front_order() { let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]); let art = revolve_art(&g).unwrap(); assert_eq!(art.children().unwrap().len(), 1, "one order for every face"); assert!(art.children().unwrap()[0].children().unwrap().len() > 16); - let mut faded = (*member(2, 50.0)).clone(); + } + + /// The opacities and blending modes of the groups a face is drawn in, outermost first, leaving + /// out groups with neither. + type Chain = Vec<(f32, vectorcraft_doc::color::BlendMode)>; + + /// The faces of `art` in drawing order, each with its [`Chain`]. + fn faces_in_order(art: &Node) -> Vec<(Node, Chain)> { + fn visit(n: &Node, mut scopes: Chain, out: &mut Vec<(Node, Chain)>) { + match n.children() { + Some(children) => { + if n.opacity != 1.0 || n.blend != Default::default() { + scopes.push((n.opacity, n.blend)); + } + for c in children { + visit(c, scopes.clone(), out); + } + } + None => out.push((n.clone(), scopes)), + } + } + let mut out = vec![]; + visit(art, vec![], &mut out); + out + } + + #[test] + fn a_transparent_member_leaves_every_face_in_one_back_to_front_order() { + let faces_of = |members| faces_in_order(&revolve_art(&group_with(json!({"segments":16}), members)).unwrap()); + let alone = faces_of(vec![member(1, 0.0), member(2, 50.0)]); + let mut faded = (*member(3, 20.0)).clone(); faded.opacity = 0.5; - let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); - let art = revolve_art(&g).unwrap(); - let surfaces = art.children().unwrap(); - assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); - assert_eq!(surfaces[1].opacity, 0.5); + let faces = faces_of(vec![member(1, 0.0), member(2, 50.0), Arc::new(faded)]); + let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect(); + assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::>(), "the opaque members' faces keep their shared order"); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, Default::default())]), "the member's opacity applies once to each face"); + let first = faces.iter().position(|(_, s)| !s.is_empty()).unwrap(); + let last = faces.iter().rposition(|(_, s)| !s.is_empty()).unwrap(); + assert!(first > 0 && last + 1 < faces.len(), "the ring between the others draws in front of the back ones and behind the front ones"); + let groups = |art: &Node| { + let mut count = 0; + art.walk(&mut |n| count += usize::from(n.children().is_some() && n.opacity == 0.5)); + count + }; + // Rings one above the other: their faces alternate in the order but none overlaps the + // other ring, so each ring's opacity applies once, to all its faces. + let faded = |id, y: f64| { + let mut n = (*member(id, 40.0)).clone(); + n.opacity = 0.5; + if let NodeKind::Path { path, .. } = &mut n.kind { + *path = PathData::from_bezpath(&(Affine::translate((0.0, y)) * path.to_bezpath())); + } + Arc::new(n) + }; + let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0)])).unwrap(); + assert!(faces_in_order(&art).iter().all(|(_, s)| s == &[(0.5, Default::default())])); + assert_eq!(groups(&art), 2, "one group for each ring"); + // A tall cylinder inside both that paints nothing splits neither. + let mut hidden = (*member(3, 0.0)).clone(); + hidden.opacity = 0.0; + if let NodeKind::Path { path, .. } = &mut hidden.kind { + *path = PathData::from_bezpath(&(Affine::translate((0.0, -20.0)) * Affine::scale_non_uniform(1.0, 7.0) * path.to_bezpath())); + } + let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0), Arc::new(hidden)])).unwrap(); + assert_eq!(groups(&art), 2, "one group for each faded ring"); } fn star(id: u64) -> Arc { @@ -741,6 +963,33 @@ mod tests { )) } + #[test] + fn faces_overlap_by_their_painted_shapes_and_seams_not_only_their_boxes() { + let face = |points: &[(f64, f64)]| { + let mut b = BezPath::new(); + b.move_to(points[0]); + for &p in &points[1..] { + b.line_to(p); + } + b.close_path(); + Coverage::of(&Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::default())).unwrap() + }; + let a = face(&[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]); + assert_eq!(a.hull.len(), 3); + assert_eq!( + face(&[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.0, 5.0), (0.0, 10.0)]).hull.len(), + 4, + "a point on an edge or inside is left out" + ); + // Across the diagonal 2 / sqrt(2) apart: their boxes overlap, their shapes and seams don't. + let b = face(&[(10.0, 10.0), (10.0, 2.0), (2.0, 10.0)]); + assert!(overlaps(a.bounds, b.bounds) && !a.meets(&b) && !b.meets(&a)); + // 1 / sqrt(2) apart, within the two half seam strokes. + let c = face(&[(10.0, 10.0), (10.0, 1.0), (1.0, 10.0)]); + assert!(a.meets(&c) && c.meets(&a)); + assert!(a.meets(&a), "a face meets itself"); + } + #[test] fn a_groups_members_share_revolves_limits() { let alone = group_with(json!({}), vec![star(1)]); @@ -763,28 +1012,37 @@ mod tests { let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen); let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]); let art = revolve_art(&group).expect("revolved"); - let modes = |n: &Node| match &n.kind { - NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::>(), - _ => panic!("a group"), + let faces = faces_in_order(&art); + let modes = |s: &[(f32, BlendMode)]| s.iter().map(|(_, b)| *b).collect::>(); + let chains = [vec![BlendMode::Multiply], vec![], vec![BlendMode::Screen], vec![BlendMode::Screen, BlendMode::Multiply]]; + for chain in &chains { + assert!(faces.iter().any(|(_, s)| &modes(s) == chain), "faces drawn in {chain:?}"); + } + assert!(faces.iter().all(|(_, s)| chains.contains(&modes(s))), "each mode applies once, to the member or group that has it"); + // Faces of the screened group's members that come together in the drawing order share one Screen group. + let runs = |faces: &[(Node, Chain)], inside: &dyn Fn(&Chain) -> bool| { + faces.windows(2).filter(|w| inside(&w[1].1) && !inside(&w[0].1)).count() + usize::from(faces.first().is_some_and(|f| inside(&f.1))) }; - assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); - let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; - let screened = &children[2]; - assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); - let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; - assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + let groups = |art: &Node, pick: &dyn Fn(&Node) -> bool| { + let mut count = 0; + art.walk(&mut |n| count += usize::from(n.children().is_some() && pick(n))); + count + }; + let screened = |s: &Chain| s.first().is_some_and(|(_, b)| *b == BlendMode::Screen); + assert_eq!(groups(&art, &|n| n.blend == BlendMode::Screen), runs(&faces, &screened)); let plain = group_with(json!({}), vec![member(1, 0.0)]); assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); - // A nested group's opacity applies once, to its members' surfaces together. + // A nested group's opacity applies once, to its members' faces that come together. let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]); faded.opacity = 0.5; let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved"); - let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; - let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::>(); - assert_eq!(children.iter().map(|c| c.opacity).collect::>(), [1.0, 0.5]); - assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together"); + let faces = faces_in_order(&art); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, BlendMode::Normal)]), "the faded group's opacity applies once"); + let half = |s: &Chain| !s.is_empty(); + assert!(faces.iter().any(|(_, s)| half(s)) && faces.iter().any(|(_, s)| !half(s))); + assert_eq!(groups(&art, &|n| n.opacity == 0.5), runs(&faces, &half), "the faded group's members, together"); } #[test] diff --git a/docs/revolve.md b/docs/revolve.md index fd0f36f16..6ecd4957e 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -6,6 +6,11 @@ Select a path, compound path, type, or a group or layer of them, then choose **E On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. +The faces of all members draw in one back-to-front order. The opacity and blending mode of a member, or of a group +inside the group, apply once to all its faces. Where a face of another member lies between its faces in that order +and overlaps them on the page, its faces are split there, and the opacity and blending mode apply once to each part. +Two faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. After +4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface.