From 57d37a6dae48fdc0231dbd8f7ed14df0575a1558 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:23:42 -0800 Subject: [PATCH] 3D: fill walls and gapped strokes draw in depth order where they overlap Extrude & Bevel, Extrude & Bevel (Classic) and Inflate sorted the fill walls and the faces of a stroke that leaves gaps (dashed, profiled, brushed) by one depth each, so a far stroke face could draw over a near fill wall and the reverse. Two such strokes on one shape drew over each other the same way, and Inflate drew a plain stroke as if it covered the fill's edge, which an inflated fill can rise in front of. - Each flat facet now carries its plane, so its depth can be measured at any point under it, with orthographic and perspective views. - Where faces of one shape's fill and strokes overlap in the wrong order, the overlapping faces are ordered by their depths where they overlap. A fill wall and its covering stroke keep their order. - A face of a stroke that leaves gaps that passes through a fill wall, a covering stroke's face or an earlier such stroke's face is cut along the line where their depths meet, keeping its curved outline, within the face limit and the paint allowance. - Under Inflate, no stroke counts as covering the fill's edge. - The faces go in one order when the fill and strokes are at full opacity with Normal blending. A member of a 3D group with an opacity mask or a blending mode of its own orders its faces inside it when its own paints qualify. A gradient with transparent stops keeps the one order with graphics on its cap, and so does Inflate in gradients under a colored light. A solid color under a colored light, drawn as one composite per surface, still draws each surface whole. --- crates/effects/src/revolve.rs | 2 +- crates/effects/src/solids.rs | 625 ++++++++++++++++++-- crates/effects/src/solids/tests.rs | 436 ++++++++++++++ crates/effects/src/solids/tests_graphics.rs | 104 ++++ crates/three-d/src/lib.rs | 4 +- crates/three-d/src/solid.rs | 42 ++ crates/three-d/src/solid/build.rs | 10 +- crates/three-d/src/solid/tests.rs | 23 + docs/target-app-parity.md | 1 + docs/three-d.md | 19 +- 10 files changed, 1202 insertions(+), 64 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e9d64864d0..1677937f17 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -27,7 +27,7 @@ pub fn is_revolve(id: &str) -> bool { /// Most bytes the copies of the object's paint in the faces may take together. Each face and the /// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of /// thousands of faces took gigabytes; past this the source path is drawn. -const MAX_PAINT_COPIES: usize = 256 << 20; +pub(crate) 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. diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 52739983a5..bd1e6e34ae 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; +use vectorcraft_pathops::BoolOp; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -678,7 +679,8 @@ fn built_solids<'a>( /// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's /// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of /// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its -/// strokes that covers the fill's whole edge. +/// strokes that covers the fill's whole edge. A face that [`convex_face`] takes also keeps its +/// polygon and plane, for [`local_order`]; every other face has an empty `outline` and no `plane`. struct Placed { depth: f64, side: bool, @@ -686,6 +688,8 @@ struct Placed { bounds: Rect, member: usize, paints: Paints, + outline: Vec, + plane: Option, } /// The part of its shape a face's solid is built from ([`Placed`]). @@ -694,13 +698,22 @@ enum Paints { Fill, /// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without /// dashes, a width profile or a brush, painted with a solid color or a gradient without - /// transparent stops, on a shape whose subpaths are all closed. + /// transparent stops, on a shape whose subpaths are all closed, extruded (an inflated fill can + /// rise in front of its stroke). Stroke, Other, } +/// Can a stroke's solid on a shape with outline `path` hold the fill's edge ([`Paints::Stroke`]) +/// under the effect `effect`? Only when the subpaths are all closed and the effect extrudes: an +/// inflated fill can rise in front of its stroke. +fn holds_edge(effect: &str, path: &PathData) -> bool { + effect != INFLATE && path.subpaths.iter().all(|p| p.closed) +} + impl Paints { - /// What `item` of a shape is, the shape's subpaths all closed when `closed`. + /// What `item` of a shape is, its stroke's solid able to hold the fill's edge when `closed` + /// ([`holds_edge`]). fn of(item: &AppearanceItem, closed: bool) -> Self { match item { AppearanceItem::Fill(_) => Paints::Fill, @@ -729,10 +742,434 @@ impl Placed { fn of(f: &Facet, member: usize, paints: Paints) -> Self { let mut points = f.contours.iter().flatten(); let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q))); - Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints } + let local = convex_face(f).is_some(); + let outline = f.contours.first().filter(|_| local).cloned().unwrap_or_default(); + let plane = f.plane.filter(|_| local); + Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints, outline, plane } } } +/// The most pairs of faces [`local_order`] compares, and the most [`cut_stroke_faces`] compares; +/// the pairs they have not reached by then keep the order of their depths. +const MAX_LOCAL_CHECKS: usize = 4_000_000; + +/// The polygon of `f` and the sign of its turns, when `f` is a face turned toward the viewer other +/// than a planar end, with a plane and one convex contour: a face [`local_order`] and +/// [`cut_stroke_faces`] work on. +fn convex_face(f: &Facet) -> Option<(&[Point], f64)> { + if f.away || f.end.is_some() || f.plane.is_none() || f.contours.len() != 1 { + return None; + } + let c = f.contours.first()?; + Some((c, convex(c)?)) +} + +/// The bounds of polygon `p` (`Rect::ZERO` when it has no points). +fn polygon_bounds(p: &[Point]) -> Rect { + let mut points = p.iter(); + points.next().map_or(Rect::ZERO, |q| points.fold(Rect::from_points(*q, *q), |r, q| r.union_pt(*q))) +} + +/// Do rectangles `a` and `b` overlap (more than along an edge)? +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + +/// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces +/// and whose paint copies take `paint_bytes` so far: as many as keep the faces within +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies +/// of the costliest stroke's paint. +fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { + let per_face = items + .filter(|item| matches!(item, AppearanceItem::Stroke(_))) + .map(|item| { + let mut copies = 0usize; + crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + copies + }) + .max() + .unwrap_or(0) + .max(1); + let affordable = crate::revolve::MAX_PAINT_COPIES.saturating_sub(paint_bytes) / per_face; + MAX_OBJECT_FACES.saturating_sub(faces).min(affordable) +} + +/// In `solids`, each solid's part ([`Paints`]), its shape's index and its faces, cut each face of a +/// stroke that does not cover the fill's edge ([`Paints::Other`]) that passes through the plane of a +/// face of the same shape's fill, or of a face of another of the shape's strokes, where the two +/// overlap on the drawing ([`cut_through`]). Of two such strokes, the later one's faces are cut +/// against the earlier one's; a covering stroke's faces ([`Paints::Stroke`]) are never cut, and an +/// other stroke's faces are cut against them. At most `room` pieces are added, the object's solids +/// together. +fn cut_stroke_faces(solids: &mut [(Paints, usize, &mut Vec)], mut room: usize) { + let mut checks = 0usize; + for k in 0..solids.len() { + let Some((Paints::Other, shape)) = solids.get(k).map(|(p, s, _)| (*p, *s)) else { continue }; + let walls: Vec = (0..solids.len()) + .filter(|&j| { + solids.get(j).is_some_and(|(p, s, _)| { + *s == shape + && match p { + Paints::Fill | Paints::Stroke => true, + Paints::Other => j < k, + } + }) + }) + .collect(); + for j in walls { + let (wall, stroke) = if j < k { + let (before, from) = solids.split_at_mut(k); + (&**before[j].2, &mut *from[0].2) + } else { + let (before, from) = solids.split_at_mut(j); + (&**from[0].2, &mut *before[k].2) + }; + cut_through(wall, stroke, &mut room, &mut checks); + } + } +} + +/// Cut each face of `stroke` that passes through the plane of a face of `solid` (the fill's or +/// another stroke's) where their polygons overlap on the drawing, along the line where their depths +/// meet, so that each piece is wholly in front of or behind that face there ([`local_order`] can +/// then order them). A dash's end, which crosses the edge of the fill it is drawn on, is cut into its +/// part outside the fill, in front of the fill's wall, and its part inside, behind it. A piece keeps +/// the part of the face's curved outline on its side of the cut. The first piece of a face takes its +/// place, the others go at the end. Each cut adds a face out of `room`, and each pair compared counts +/// in `checks` ([`MAX_LOCAL_CHECKS`]); cutting stops when either runs out. +fn cut_through(solid: &[Facet], stroke: &mut Vec, room: &mut usize, checks: &mut usize) { + // The faces from left to right: a face is compared with those that start left of its end. + let mut walls: Vec<(&Facet, &[Point], f64, Rect)> = + solid.iter().filter_map(|f| convex_face(f).map(|(c, turn)| (f, c, turn, polygon_bounds(c)))).collect(); + if walls.is_empty() { + return; + } + walls.sort_by(|a, b| a.3.x0.total_cmp(&b.3.x0)); + let mut k = 0; + while k < stroke.len() { + let mut extent = stroke.get(k).and_then(convex_face).map(|(c, _)| polygon_bounds(c)); + for &(wall, outline, turn, bounds) in &walls { + let Some(at) = extent.filter(|e| bounds.x0 < e.x1) else { break }; + if *checks >= MAX_LOCAL_CHECKS || *room == 0 { + return; + } + *checks += 1; + if !rects_overlap(at, bounds) { + continue; + } + let Some(s) = stroke.get(k) else { break }; + let Some((c, _)) = convex_face(s) else { break }; + if let Some((near, far)) = cut(s, c, wall, outline, turn) { + *room -= 1; + extent = convex_face(&near).map(|(c, _)| polygon_bounds(c)); + stroke[k] = near; + stroke.push(far); + } + } + k += 1; + } +} + +/// Face `s` (polygon `c`) cut where its depth meets that of face `wall` (convex polygon `outline`, +/// turning with sign `turn`): its piece nearer the viewer than the wall's plane and its piece +/// farther. `None` when the two do not overlap on the drawing or `s` is on one side of the wall's +/// plane wherever they do. +fn cut(s: &Facet, c: &[Point], wall: &Facet, outline: &[Point], turn: f64) -> Option<(Facet, Facet)> { + let ahead = |p: Point| Some(s.depth_at(p)? - wall.depth_at(p)?); + let overlap = clip_convex(c, outline, turn); + centroid(&overlap)?; + let tolerance = 1e-6; + let (mut nearer, mut farther) = (false, false); + for p in &overlap { + let d = ahead(*p)?; + nearer |= d > tolerance; + farther |= d < -tolerance; + } + if !(nearer && farther) { + return None; + } + let (a, b) = (s.plane?, wall.plane?); + let values: Vec = match (a.eye, b.eye) { + (Some(e), Some(f)) if e == f && a.center == b.center => { + // With perspective the depths themselves are not linear on the drawing, but where they + // meet is a line: (dA - aA e)(nzB - aB) = (dB - aB e)(nzA - aA), with aX = (nX · p) / e, + // whose difference is linear. Its sign is that of the depths' difference over the + // product of the two denominators, taken at the middle of the face. + let at = |p: Point| { + let (x, y) = (p.x - a.center.x, p.y - a.center.y); + let (aa, ab) = ((a.normal[0] * x + a.normal[1] * y) / e, (b.normal[0] * x + b.normal[1] * y) / e); + let (da, db) = (a.normal[2] - aa, b.normal[2] - ab); + ((a.offset - aa * e) * db - (b.offset - ab * e) * da, da * db) + }; + let sign = at(centroid(c)?).1.signum(); + c.iter().map(|p| at(*p).0 * sign).collect() + } + _ => c.iter().map(|p| ahead(*p)).collect::>()?, + }; + let piece = |sign: f64| { + let points = half(c, &values, sign); + let mid = centroid(&points)?; + let mut f = s.clone(); + f.depth = s.depth_at(mid).unwrap_or(s.depth); + f.contours = vec![points]; + f.outline = s.outline.as_ref().and_then(|o| clip_to_side(o, c, &values, sign)); + Some(f) + }; + Some((piece(1.0)?, piece(-1.0)?)) +} + +/// Curved outline `outline` of a face with polygon `c`, cut along the line where `values` (one per +/// point of `c`, varying linearly along each edge) is 0, keeping the side where `sign` times them is +/// at least 0. `None` when the line does not cross `c` at two points or nothing is left. +fn clip_to_side(outline: &PathData, c: &[Point], values: &[f64], sign: f64) -> Option { + let mut crossings = vec![]; + for (j, (&a, &va)) in c.iter().zip(values).enumerate() { + let k = (j + 1) % c.len(); + let (Some(&b), Some(&vb)) = (c.get(k), values.get(k)) else { continue }; + if (va >= 0.0) != (vb >= 0.0) { + crossings.push(a.lerp(b, va / (va - vb))); + } + } + let &[q0, q1] = crossings.as_slice() else { return None }; + let along = q1 - q0; + let length = along.hypot(); + if length.is_nan() || length <= 1e-9 { + return None; + } + let along = along / length; + let (inside, _) = c.iter().zip(values).find(|(_, v)| **v * sign > 0.0)?; + let mut across = Vec2::new(-along.y, along.x); + if (*inside - q0).dot(across) < 0.0 { + across = -across; + } + let bounds = outline.bounds()?.union(polygon_bounds(c)); + let reach = 2.0 * (bounds.width() + bounds.height() + (q0 - bounds.center()).hypot()) + 1.0; + let mut side = BezPath::new(); + side.move_to(q0 - along * reach); + side.line_to(q0 + along * reach); + side.line_to(q0 + along * reach + across * reach); + side.line_to(q0 - along * reach + across * reach); + side.close_path(); + let kept = vectorcraft_pathops::boolean(outline, FillRule::NonZero, &PathData::from_bezpath(&side), FillRule::NonZero, BoolOp::Intersect); + kept.bounds().is_some().then_some(kept) +} + +/// The part of polygon `p` where `sign` times `values` (one per point, varying linearly along each +/// edge) is at least 0. +fn half(p: &[Point], values: &[f64], sign: f64) -> Vec { + let mut out = vec![]; + for (j, (&a, &va)) in p.iter().zip(values).enumerate() { + let k = (j + 1) % p.len(); + let (Some(&b), Some(&vb)) = (p.get(k), values.get(k)) else { continue }; + let (sa, sb) = (va * sign, vb * sign); + if sa >= 0.0 { + out.push(a); + } + if (sa >= 0.0) != (sb >= 0.0) { + out.push(a.lerp(b, sa / (sa - sb))); + } + } + out +} + +/// `order` (back to front), when it draws a face of one of a shape's strokes and an overlapping face +/// of the same shape's fill or strokes the wrong way round for their depths where they overlap on +/// the drawing, with every pair of overlapping faces that keep a polygon and a plane +/// ([`convex_face`]: turned toward the viewer, not a planar end), of any shape, put in the order of +/// their depths where they overlap: the face nearer the viewer there goes after the other. A fill +/// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each +/// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays +/// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`. +/// Otherwise `order` as it is. +fn local_order(faces: &[Placed], order: Vec) -> Vec { + use std::cmp::Reverse; + // The faces of each shape that take part, from left to right for a sweep along x. + let mut shapes: Vec> = vec![]; + for (k, f) in faces.iter().enumerate() { + if f.outline.len() < 3 || f.plane.is_none() { + continue; + } + if shapes.len() <= f.member { + shapes.resize_with(f.member + 1, Vec::new); + } + shapes[f.member].push(k); + } + if !shapes.iter().any(|list| list.iter().any(|&k| faces[k].paints != Paints::Fill)) { + return order; + } + for list in &mut shapes { + list.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0)); + } + // A fill wall and a face of the same shape's covering stroke keep the order [`draw_order`] gave + // them: the stroke's solid holds the wall, which never shows. + let covered = |f: &Placed, s: &Placed| f.paints == Paints::Fill && f.side && s.paints == Paints::Stroke && f.member == s.member; + // Is there a face of a shape's strokes and a face of the same shape's fill or strokes that the + // order of their depths draws the wrong way round where they overlap? + let mut position = vec![0usize; faces.len()]; + for (at, &k) in order.iter().enumerate() { + position[k] = at; + } + let mut checks = 0usize; + let mut misordered = false; + 'shapes: for list in &shapes { + for (i, &f) in list.iter().enumerate() { + let a = &faces[f]; + for &s in list.iter().skip(i + 1).take_while(|&&s| faces[s].bounds.x0 < a.bounds.x1) { + if checks >= MAX_LOCAL_CHECKS { + break 'shapes; + } + checks += 1; + let b = &faces[s]; + if (a.paints == Paints::Fill && b.paints == Paints::Fill) || covered(a, b) || covered(b, a) || !rects_overlap(a.bounds, b.bounds) { + continue; + } + if nearer_where_overlapping(a, b).is_some_and(|a_nearer| a_nearer != (position[f] > position[s])) { + misordered = true; + break 'shapes; + } + } + } + } + if !misordered { + return order; + } + // Moving those faces must not carry them past any other face they overlap, so every pair of + // faces with a polygon and a plane that overlap goes in the order of their depths there. + let mut sweep: Vec = (0..faces.len()).filter(|&k| faces[k].outline.len() >= 3 && faces[k].plane.is_some()).collect(); + sweep.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0)); + // For each face, the faces drawn after it, and for each, how many go before it. + let mut after: Vec> = vec![vec![]; faces.len()]; + let mut waits = vec![0usize; faces.len()]; + 'pairs: for (i, &k) in sweep.iter().enumerate() { + let a = &faces[k]; + for &j in sweep.iter().skip(i + 1).take_while(|&&j| faces[j].bounds.x0 < a.bounds.x1) { + if checks >= MAX_LOCAL_CHECKS { + break 'pairs; + } + checks += 1; + let b = &faces[j]; + if !rects_overlap(a.bounds, b.bounds) { + continue; + } + let a_later = if covered(a, b) || covered(b, a) { + position[k] > position[j] + } else { + let Some(a_nearer) = nearer_where_overlapping(a, b) else { continue }; + a_nearer + }; + let (back, front) = if a_later { (j, k) } else { (k, j) }; + after[back].push(front); + waits[front] += 1; + } + } + // Each step draws the face that comes first in `order` of those whose faces before are drawn. + let mut ready: std::collections::BinaryHeap> = + (0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect(); + let mut drawn = vec![false; faces.len()]; + let mut out = Vec::with_capacity(order.len()); + let mut first = 0usize; + while out.len() < order.len() { + let k = match ready.pop() { + Some(Reverse((_, k))) => k, + None => { + // A cycle: the first face in `order` not drawn yet goes next. + while order.get(first).is_some_and(|&k| drawn[k]) { + first += 1; + } + let Some(&k) = order.get(first) else { break }; + k + } + }; + if drawn[k] { + continue; + } + drawn[k] = true; + out.push(k); + for &j in &after[k] { + waits[j] = waits[j].saturating_sub(1); + if waits[j] == 0 && !drawn[j] { + ready.push(Reverse((position[j], j))); + } + } + } + out +} + +/// Whether face `a` is nearer the viewer than face `b` where their polygons overlap on the drawing, +/// measured at the middle of the overlap. `None` when they do not overlap, when either polygon is +/// not convex, or when they are at the same depth there. +fn nearer_where_overlapping(a: &Placed, b: &Placed) -> Option { + let (pa, pb) = (a.plane?, b.plane?); + let turn = convex(&b.outline)?; + convex(&a.outline)?; + let overlap = clip_convex(&a.outline, &b.outline, turn); + let mid = centroid(&overlap)?; + let (da, db) = (pa.depth_at(mid)?, pb.depth_at(mid)?); + let tolerance = 1e-6 * (1.0 + da.abs().max(db.abs())); + ((da - db).abs() > tolerance).then_some(da > db) +} + +/// The sign of the turns of convex polygon `p` (`None` when it is not convex or has no area). +fn convex(p: &[Point]) -> Option { + let n = p.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b, c) = (p[i], p[(i + 1) % n], p[(i + 2) % n]); + let cross = (b - a).cross(c - b); + if cross.abs() <= 1e-12 { + continue; + } + if sign == 0.0 { + sign = cross.signum(); + } else if cross.signum() != sign { + return None; + } + } + (sign != 0.0).then_some(sign) +} + +/// The part of polygon `subject` inside convex polygon `clip`, whose turns have sign `turn` +/// (Sutherland–Hodgman). +fn clip_convex(subject: &[Point], clip: &[Point], turn: f64) -> Vec { + let mut out = subject.to_vec(); + for i in 0..clip.len() { + if out.is_empty() { + break; + } + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + let side = |p: Point| (e1 - e0).cross(p - e0) * turn; + let input = std::mem::take(&mut out); + for j in 0..input.len() { + let (p, q) = (input[j], input[(j + 1) % input.len()]); + let (sp, sq) = (side(p), side(q)); + if sp >= 0.0 { + out.push(p); + } + if (sp >= 0.0) != (sq >= 0.0) { + let t = sp / (sp - sq); + out.push(p.lerp(q, t)); + } + } + } + out +} + +/// The centroid of polygon `p`, `None` when it has (almost) no area. It is measured from the +/// polygon's first point, so a small polygon far from the origin keeps its area. +fn centroid(p: &[Point]) -> Option { + let origin = *p.first()?; + let n = p.len(); + let (mut area, mut cx, mut cy) = (0.0, 0.0, 0.0); + for i in 0..n { + let (a, b) = (p[i] - origin, p[(i + 1) % n] - origin); + let cross = a.x * b.y - b.x * a.y; + area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + (area.abs() > 1e-9).then(|| origin + Vec2::new(cx / (3.0 * area), cy / (3.0 * area))) +} + /// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep /// their depths. const MAX_OVERLAP_CHECKS: usize = 4_000_000; @@ -742,7 +1179,8 @@ const MAX_OVERLAP_CHECKS: usize = 4_000_000; /// of a shape's fill turned toward the viewer is drawn before each face of the same shape's /// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own. /// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under -/// any other face. +/// any other face. A shape's fill faces and the faces of its other strokes then go in the order of +/// their depths where they overlap ([`local_order`]). fn draw_order(faces: &[Placed], sealed: bool) -> Vec { // Each face's depth to sort by, and whether it was moved before a stroke face at that depth. let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect(); @@ -781,7 +1219,7 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { // A stable sort: faces at the same depth keep the order of the appearance, and a fill's side // face moved to a stroke face's depth goes before it. order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1))); - order + local_order(faces, order) } /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` @@ -806,7 +1244,9 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as /// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's -/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and +/// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the +/// order of their depths where they overlap: [`draw_order`]), the /// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask /// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and @@ -995,18 +1435,22 @@ type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec> /// [`with_graphics`] of built surfaces, each drawn whole. fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { - with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), arts) + with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts) } -/// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each -/// right after its surface, with that surface's scopes and its opacity mask (a gradient's -/// transparency), so a graphic is hidden where its cap is. -fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { +/// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts` +/// ([`placed_graphics`]) of those surfaces each right after its surface, with that surface's scopes +/// and its opacity mask (a gradient's transparency), so a graphic is hidden where its cap is. +fn with_graphics<'s>( + surfaces: Vec<(&'s [Scope], Arc)>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], +) -> Vec<(&'s [Scope], Arc)> { let mut out = Vec::with_capacity(surfaces.len() + arts.len()); for (k, (scopes, s)) in surfaces.into_iter().enumerate() { let mask = s.extras().mask.clone(); out.push((scopes, s)); - out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| { + out.extend(arts.iter().filter(|(at, ..)| *at == first + k).map(|(.., art)| { let mut art = art.clone(); if let Some(mask) = &mask { Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); @@ -1058,7 +1502,7 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile } } let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); - let out = with_graphics(out, &arts); + let out = with_graphics(out, 0, &arts); return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); @@ -1085,6 +1529,43 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); + let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path)); + // A solid whose surface is one composite of all its facets ([`surface`]: a solid color under + // colored lights with smooth shading) has no faces to order. + let composite = |item: &AppearanceItem, f: &[Facet]| colored && paint_ref(item).color().is_some() && f.iter().any(|f| f.rgb_gradient.is_some()); + // The faces are drawn in one order (below) unless a composite draws the surfaces whole. Under + // a member's opacity mask or blending, each member's faces go in one order of their own when its + // fills and strokes are opaque, blend normally, carry no opacity mask and are not composites. + let one_order = opaque && !solids.iter().any(|(item, _, _, f, _)| composite(item, f)); + let shape_order: Vec = (0..shapes.len()) + .map(|shape| { + solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| { + let (paint, opacity, blend) = paint_of(item, *member); + opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && !composite(item, f) + }) + }) + .collect(); + let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order }; + if solids.iter().any(|s| ordered(s.4)) { + // A stroke face that passes through a fill wall or another stroke's face is cut where it + // does. The pieces count against the limits as the faces did: cutting stops before the + // faces pass MAX_OBJECT_FACES or their paint copies pass the allowance, so an effect that + // draws uncut still draws. + let counts: Vec = solids.iter().map(|(.., f, _)| f.len()).collect(); + let room = cut_room(solids.iter().map(|(item, ..)| *item), counts.iter().sum(), paint_bytes); + let mut cuts: Vec<(Paints, usize, &mut Vec)> = solids + .iter_mut() + .filter(|(.., shape)| ordered(*shape)) + .map(|(item, _, _, f, shape)| (Paints::of(item, closed(*shape)), *shape, f)) + .collect(); + cut_stroke_faces(&mut cuts, room); + for ((item, _, _, f, _), before) in solids.iter().zip(counts) { + let added = f.get(before..).unwrap_or_default(); + if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(added, paint_ref(item), colored), paint_ref(item)) { + return None; + } + } + } let arts = if !graphics_on { vec![] } else { @@ -1100,11 +1581,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile .collect(); placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) }; + let members: Vec = solids.iter().map(|s| s.4).collect(); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { - let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); - let paints = Paints::of(item, closed); + let paints = Paints::of(item, closed(shape)); let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); @@ -1112,50 +1593,86 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile (depths, art, scopes, ends) }) .collect(); - if masks { - return Some(masked(n.id, whole_surfaces(surfaces, &arts))); - } - // One order takes a plain group of faces from each solid, one face for each facet. With - // colored lights a surface can instead be one clipped composite of all its facets. A composite - // has no faces to order, so when any surface is one, every surface of the solid, masked ones - // included, draws whole in member order. - let plain = - surfaces.iter().all(|(depths, s, _, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - // A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs - // ([`in_runs`]), and each run carries the mask. With graphics as well, the surfaces draw whole, - // each graphic right after its surface. - let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); - let masked_faces = masks.iter().any(Option::is_some); - if !opaque || !plain || (masked_faces && !arts.is_empty()) { - return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); - } - let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); - for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { - if let NodeKind::Group { children, .. } = s.kind { - placed.extend(depths); - faces.extend(children.into_iter().map(|face| Some((surface, face)))); - ends.extend(e); - } - } let sealed = match fx.id.as_str() { INFLATE => inflate_options(&p).cap, CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, _ => extrude_options(&p).cap, }; + // One order takes a plain group of faces from each solid, one face for each facet. With + // colored lights a surface can instead be one clipped composite of all its facets. A composite + // has no faces to order, so it draws whole. + let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()); + if masks { + // Each member's surfaces, in the scopes of its opacity mask or blending: its faces in one + // order when it is opaque and plain, otherwise each surface whole. + let mut out = vec![]; + let mut surfaces = surfaces.into_iter().zip(members).enumerate().peekable(); + while let Some((first, (s, member))) = surfaces.next() { + let mut run = vec![s]; + while let Some((_, (s, _))) = surfaces.next_if(|(_, (_, m))| *m == member) { + run.push(s); + } + let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + if ordered(member) && flat { + let scopes = run.first().map_or(&[][..], |s| s.2); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + } else { + out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + } + } + return Some(masked(n.id, out)); + } + if !opaque || !surfaces.iter().all(plain) { + return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + } + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) +} + +/// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in +/// one order ([`draw_order`], `sealed` for solids with caps), each of the graphics `arts` of those +/// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops +/// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of +/// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. +fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { + let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); + let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); + let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); + for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { + if let NodeKind::Group { children, .. } = s.kind { + placed.extend(depths); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); + ends.extend(e.into_iter().map(|end| end.map(|end| (surface, end)))); + } + } let order = draw_order(&placed, sealed); - if masked_faces { - let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); - return Some(vec![Arc::new(Node::group(n.id, in_runs(n.id, faces, &masks)))]); - } - // Each face's graphics go right after the last of the faces they are drawn on. - let last: Vec> = - arts.iter().map(|(_, face, _)| order.iter().rposition(|k| ends.get(*k).copied().flatten() == Some(*face))).collect(); - let mut children = Vec::with_capacity(order.len() + arts.len()); + // Each graphic goes right after the last of the faces it is drawn on, among the surfaces that + // share it ([`placed_graphics`]): a masked surface's own, or else the run of surfaces without a + // mask that ends at its surface. + let last: Vec> = arts + .iter() + .map(|(at, face, _)| { + let at = at - first; + let from = if masks.get(at).is_some_and(Option::is_some) { + at + } else { + masks.get(..at).and_then(|before| before.iter().rposition(Option::is_some)).map_or(0, |k| k + 1) + }; + order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface))) + }) + .collect(); + let mut drawn: Vec<(usize, Arc)> = Vec::with_capacity(order.len() + arts.len()); for (at, k) in order.into_iter().enumerate() { - children.extend(faces.get_mut(k).and_then(Option::take).map(|(_, face)| face)); - children.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (.., art))| art.clone())); + drawn.extend(faces.get_mut(k).and_then(Option::take)); + drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (surface, .., art))| { + let mut art = art.clone(); + if let Some(mask) = masks.get(surface - first).cloned().flatten() { + Arc::make_mut(&mut art).extras_mut().mask = Some(mask); + } + // An index past the surfaces: the graphic goes in a run of its own, with its own mask. + (masks.len(), art) + })); } - Some(vec![Arc::new(Node::group(n.id, children))]) + if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index b4e012f977..3c2795dd1b 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -253,6 +253,437 @@ fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() { } } +/// The faces of the solids effect `id` with `params` builds for `n`, in the order they are drawn, +/// as [`surfaces_for`] orders them, each with the index of the part ([`solid_parts`]) it was built +/// from and whether it is a stroke's. +fn drawn_parts(n: &Node, id: &str, params: &Value) -> Vec<(Facet, usize, bool)> { + let (path, rule) = profile(n).unwrap(); + let shapes = [Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]; + let p = crate::merged_params(id, params); + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + let mut solids = vec![]; + for (part, ((item, _, _, _, _, member), f)) in parts.iter().zip(built_solids(id, &p, &parts, bounds, MAX_OBJECT_FACES)).enumerate() { + let Some(f) = f.unwrap() else { continue }; + let closed = holds_edge(id, &shapes[*member].path); + solids.push((Paints::of(item, closed), *member, f, part, matches!(item, AppearanceItem::Stroke(_)))); + } + let mut cuts: Vec<(Paints, usize, &mut Vec)> = solids.iter_mut().map(|(paints, member, f, _, _)| (*paints, *member, f)).collect(); + cut_stroke_faces(&mut cuts, MAX_OBJECT_FACES); + let (mut placed, mut all) = (vec![], vec![]); + for (paints, member, f, part, stroke) in solids { + for facet in f { + placed.push(Placed::of(&facet, member, paints)); + all.push((facet, part, stroke)); + } + } + let sealed = match id { + INFLATE => inflate_options(&p).cap, + CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, + _ => extrude_options(&p).cap, + }; + draw_order(&placed, sealed).into_iter().map(|k| all[k].clone()).collect() +} + +/// Is `p` inside the polygon of `f` (nonzero winding)? +fn facet_contains(f: &Facet, p: Point) -> bool { + let mut winding = 0i32; + for c in &f.contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let cross = (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y); + if a.y <= p.y && b.y > p.y && cross > 0.0 { + winding += 1; + } else if a.y > p.y && b.y <= p.y && cross < 0.0 { + winding -= 1; + } + } + } + winding != 0 +} + +/// The points of a 1 pt grid over `faces` (drawn in that order, each with whether it is a stroke's) +/// where the face drawn on top is the fill's while a face of the stroke turned toward the viewer is +/// nearer the viewer there by more than 0.5 pt, or the other way round. Points within 0.3 pt of an +/// edge are left out. +fn wrong_on_top(faces: &[(Facet, bool)]) -> Vec { + let polygons: Vec<&Facet> = faces.iter().map(|(f, _)| f).collect(); + let extent = + polygons.iter().flat_map(|f| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let mut wrong = vec![]; + let mut y = extent.y0.floor() + 0.5; + while y < extent.y1 { + let mut x = extent.x0.floor() + 0.5; + while x < extent.x1 { + let p = Point::new(x, y); + let well_inside = |f: &Facet| { + [(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy))) + }; + let top = faces.iter().rposition(|(f, _)| facet_contains(f, p)); + if let Some((f, stroke)) = top.and_then(|k| faces.get(k)).filter(|(f, _)| !f.away && well_inside(f)) { + let nearest = |of_stroke: bool| { + faces.iter().filter(|(g, s)| *s == of_stroke && !g.away && well_inside(g)).filter_map(|(g, _)| g.depth_at(p)).reduce(f64::max) + }; + if let (Some(ds), Some(df)) = (nearest(true), nearest(false)) + && (ds - df).abs() > 0.5 + && (ds > df) != *stroke + && f.depth_at(p).is_some() + { + wrong.push(p); + } + } + x += 1.0; + } + y += 1.0; + } + wrong +} + +/// The points of a 1 pt grid over `faces` (drawn in that order) where the face drawn on top is +/// farther from the viewer than another face turned toward the viewer, of any part, by more than +/// 0.5 pt. Points within 0.3 pt of an edge, and faces without a plane, are left out. +fn misdrawn(faces: &[(Facet, usize, bool)]) -> Vec { + let extent = + faces.iter().flat_map(|(f, ..)| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let mut wrong = vec![]; + let mut y = extent.y0.floor() + 0.5; + while y < extent.y1 { + let mut x = extent.x0.floor() + 0.5; + while x < extent.x1 { + let p = Point::new(x, y); + let well_inside = |f: &Facet| { + [(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy))) + }; + let top = faces.iter().rposition(|(f, ..)| facet_contains(f, p)).and_then(|k| faces.get(k)).filter(|(f, ..)| !f.away && well_inside(f)); + if let Some(on_top) = top.and_then(|(f, ..)| f.depth_at(p)) { + let nearest = faces.iter().filter(|(g, ..)| !g.away && well_inside(g)).filter_map(|(g, ..)| g.depth_at(p)).reduce(f64::max); + if nearest.is_some_and(|d| d - on_top > 0.5) { + wrong.push(p); + } + } + x += 1.0; + } + y += 1.0; + } + wrong +} + +/// The faces of a shape's fill and strokes are drawn in the order of their depths where they +/// overlap: on a turned C, with or without perspective, under a stroke that does not cover the +/// fill's whole edge (dashed, with a width profile, or with a brush), under a plain stroke and +/// under two strokes whose faces pass through each other, no point shows a face farther from the +/// viewer than another, in Extrude & Bevel and Extrude & Bevel (Classic), and only a few in +/// Inflate, where curved puffs cross. +#[test] +fn fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap() { + use vectorcraft_doc::{Dash, WidthProfile}; + let views = [ + json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), + json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10}), + json!({"rotationX": -30, "rotationY": 50, "rotationZ": 5, "perspectiveAngle": 40}), + json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}), + ]; + type Style<'a> = (&'a str, &'a dyn Fn(&mut Node)); + let first = |n: &mut Node, set: &dyn Fn(&mut StrokeLayer)| { + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + set(s); + } + }; + let dash = |pattern: Vec, offset: f64| Some(Dash { pattern, offset, align_corners: false }); + let styles: [Style; 6] = [ + ("plain", &|_| {}), + ("dashed", &|n| first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0))), + ("profiled", &|n| first(n, &|s| s.profile = Some(WidthProfile::lens()))), + ("brushed", &|n| first(n, &|s| s.brush = Some("brush".into()))), + ("dashed with perspective", &|n| first(n, &|s| s.dash = dash(vec![20.0, 6.0], 3.0))), + ("two strokes", &|n| { + first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0)); + let mut wide = StrokeLayer::new(Paint::solid(RED), 14.0); + wide.profile = Some(WidthProfile::lens()); + n.appearance.items.push(AppearanceItem::Stroke(wide)); + }), + ]; + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + for (v, view) in views.iter().enumerate() { + for (style, set) in &styles { + // The last two views, for the plain and the two strokes only. + if v >= 2 && !["plain", "two strokes"].contains(style) { + continue; + } + let mut params = view.clone(); + params["depth"] = json!(60); + if style.contains("perspective") { + params["perspectiveAngle"] = json!(40); + } + let mut n = stroked(&letter_c()); + set(&mut n); + let parts = drawn_parts(&n, id, ¶ms); + let faces: Vec<(Facet, bool)> = parts.iter().map(|(f, _, s)| (f.clone(), *s)).collect(); + assert!(faces.iter().any(|(_, s)| *s) && faces.iter().any(|(_, s)| !*s), "{id} {view} {style}: faces of both"); + // Inflate's curved puffs cross each other along curves its flat facets only follow + // roughly: a few points next to those crossings may still show the farther face. + let allowed = if id == INFLATE { 5 } else { 0 }; + let wrong = wrong_on_top(&faces); + assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points drawn in the wrong paint, first {:?}", wrong.len(), wrong.first()); + let wrong = misdrawn(&parts); + assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points show a farther face, first {:?}", wrong.len(), wrong.first()); + } + } + } +} + +/// The paths of `art`'s faces in drawing order, leaving out the graphics (clip groups). +fn face_paths(art: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { clip: true, .. } => {} + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)), + _ => out.extend(n.path_data().cloned()), + } + } + let mut out = vec![]; + walk(art, &mut out); + out +} + +/// A member of a 3D group with an opacity mask or a blending mode of its own draws its fill's and +/// its strokes' faces in one order inside that mask, the order the same member draws them in +/// without it, its dashes cut where they pass through the fill's walls. A member whose stroke is +/// translucent still draws each surface whole. +#[test] +fn a_masked_members_fill_and_stroke_faces_go_in_one_order() { + let mut member = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = member.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + let solid = |member: Node| { + let mut g = Node::group(NodeId(2), vec![Arc::new(member)]); + g.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}))); + solid_art(&g).unwrap() + }; + let cover = Node::path( + NodeId(900), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + let mut masked = member.clone(); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true))); + let mut multiplied = member.clone(); + multiplied.blend = BlendMode::Multiply; + let unmasked = face_paths(&solid(member.clone())); + for (what, scoped) in [("masked", masked.clone()), ("multiplied", multiplied)] { + let art = solid(scoped); + let mut scopes = 0; + art.walk(&mut |n| scopes += usize::from(n.extras().mask.is_some() || n.blend == BlendMode::Multiply)); + assert!(scopes > 0, "{what}: drawn in its scope"); + assert_eq!(face_paths(&art), unmasked, "{what}: the faces in the member's one order"); + } + // A translucent stroke keeps its surface whole: the fill's faces, then the stroke's. + if let Some(AppearanceItem::Stroke(s)) = masked.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.opacity = 0.5; + } + let art = solid(masked); + let mut groups = vec![]; + art.walk(&mut |n| { + if n.opacity == 0.5 { + groups.push(n.children().map_or(0, Vec::len)); + } + }); + assert_eq!(groups.len(), 1, "the stroke's surface is one translucent group"); +} + +/// Under a light that is not white, Inflate in gradients, whose faces are drawn one by one, draws the +/// fill's and the dashed stroke's faces in one order, as it does under a white light. A solid color, +/// drawn as one composite of each surface's facets, still draws each surface whole. +#[test] +fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + // How many surfaces the art draws, and how many faces the first holds. + let drawn = |n: &Node, lights: Option| { + let mut n = n.clone(); + let mut p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + if let Some(lights) = lights { + p["lights"] = lights; + } + n.appearance.effects.push(fx(INFLATE, p)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + (surfaces.len(), surfaces[0].children().map_or(0, Vec::len)) + }; + let colored = json!([{"color": "#ff8080"}]); + let white = drawn(&n, None); + assert_eq!(white.0, 1, "one order"); + let cut = drawn_parts(&n, INFLATE, &json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})).len(); + assert_eq!(white.1, cut, "the white light's faces, cut"); + assert_eq!(drawn(&n, Some(colored.clone())).0, 1, "one order under the colored light"); + // A solid color under the colored light: a composite for each of the fill and the stroke. + let solid = stroked(&letter_c()); + assert_eq!(drawn(&solid, Some(colored)).0, 2); +} + +/// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they +/// overlap is cut along the line where their depths meet, into the piece in front of the wall and +/// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke +/// and a cut past the room left are left whole. +#[test] +fn a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet() { + let facet = |x: f64, normal: [f64; 3], offset: f64| Facet { + contours: vec![vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]], + depth: 0.0, + brightness: 1.0, + illumination: [1.0; 3], + normal, + rgb_gradient: None, + outline: None, + side: true, + away: false, + end: None, + plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }), + }; + let wall = facet(0.0, [0.0, 0.0, 1.0], 0.0); + // z = x - 8: behind the wall left of x = 8, in front of it right of it. + let r = std::f64::consts::FRAC_1_SQRT_2; + let mut through = facet(5.0, [-r, 0.0, r], -8.0 * r); + // Its curved outline: the top edge bows up to y = -2. + let mut curved = BezPath::new(); + curved.move_to((5.0, 0.0)); + curved.curve_to((8.0, -8.0 / 3.0), (12.0, -8.0 / 3.0), (15.0, 0.0)); + curved.line_to((15.0, 10.0)); + curved.line_to((5.0, 10.0)); + curved.close_path(); + through.outline = Some(PathData::from_bezpath(&curved)); + let extent = |f: &Facet| polygon_bounds(&f.contours[0]); + let cut_with = |paints: Paints, member: usize, room: usize| { + let (mut fill, mut stroke) = (vec![wall.clone()], vec![through.clone()]); + let mut solids: Vec<(Paints, usize, &mut Vec)> = vec![(Paints::Fill, 0, &mut fill), (paints, member, &mut stroke)]; + cut_stroke_faces(&mut solids, room); + stroke + }; + let pieces = cut_with(Paints::Other, 0, 10); + assert_eq!(pieces.len(), 2); + let (near, far) = (extent(&pieces[0]), extent(&pieces[1])); + assert!((near.x0 - 8.0).abs() < 1e-9 && near.x1 == 15.0 && far.x0 == 5.0 && (far.x1 - 8.0).abs() < 1e-9, "{near:?} {far:?}"); + assert!(pieces[0].depth > 0.0 && pieces[1].depth < 0.0); + // Each piece keeps its side of the curved outline, the bowed edge included. + let curve = |f: &Facet| f.outline.as_ref().and_then(PathData::bounds).unwrap(); + let (near, far) = (curve(&pieces[0]), curve(&pieces[1])); + assert!((near.x0 - 8.0).abs() < 1e-6 && (near.x1 - 15.0).abs() < 1e-6 && near.y0 < -1.5, "{near:?}"); + assert!((far.x0 - 5.0).abs() < 1e-6 && (far.x1 - 8.0).abs() < 1e-6 && far.y0 < -1.5, "{far:?}"); + let whole = std::slice::from_ref(&through); + assert_eq!(cut_with(Paints::Other, 1, 10), whole, "another shape's stroke"); + assert_eq!(cut_with(Paints::Stroke, 0, 10), whole, "a covering stroke"); + assert_eq!(cut_with(Paints::Other, 0, 0), whole, "no room"); + let (mut fill, mut stroke) = (vec![wall.clone()], vec![facet(5.0, [0.0, 0.0, 1.0], 1.0)]); + cut_stroke_faces(&mut [(Paints::Fill, 0, &mut fill), (Paints::Other, 0, &mut stroke)], 10); + assert_eq!(stroke.len(), 1, "in front of the wall wherever they overlap"); + // Of two of a shape's strokes whose faces pass through each other, the later stroke's face is + // cut against the earlier one's; a covering stroke's face is never cut, and the other stroke's + // face is cut against it. Two shapes' strokes are left whole. + for (case, (first, second, cut_first)) in + [(Paints::Other, Paints::Other, false), (Paints::Stroke, Paints::Other, false), (Paints::Other, Paints::Stroke, true)].into_iter().enumerate() + { + let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]); + cut_stroke_faces(&mut [(first, 0, &mut a), (second, 0, &mut b)], 10); + assert_eq!((a.len(), b.len()), if cut_first { (2, 1) } else { (1, 2) }, "case {case}"); + } + let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]); + cut_stroke_faces(&mut [(Paints::Other, 0, &mut a), (Paints::Other, 1, &mut b)], 10); + assert_eq!((a.len(), b.len()), (1, 1), "two shapes"); +} + +/// A small polygon far from the origin keeps its area and centroid. +#[test] +fn a_small_polygon_far_away_has_a_centroid() { + let far = 100_000_000.0; + let square = [Point::new(far, far), Point::new(far + 1.0, far), Point::new(far + 1.0, far + 1.0), Point::new(far, far + 1.0)]; + let c = centroid(&square).unwrap(); + assert!((c.x - far - 0.5).abs() < 1e-6 && (c.y - far - 0.5).abs() < 1e-6, "{c:?}"); + assert!(centroid(&[]).is_none() && centroid(&square[..2]).is_none()); +} + +/// Cutting adds no more faces than the object's face limit and the paint-copy allowance leave room +/// for, so an effect that draws with its faces uncut still draws. +#[test] +fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { + let items = stroked(&letter_c()).appearance.items.clone(); + let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); + let mut one = 0usize; + crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + let max = crate::revolve::MAX_PAINT_COPIES; + assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); + assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); + assert_eq!(cut_room(items.iter(), 10, max - 3 * one), 3); + assert_eq!(cut_room(items.iter(), 10, max + 1), 0); +} + +/// Where a shape's fill wall and a face of its stroke that leaves gaps overlap, the one nearer the +/// viewer there is drawn after the other, whichever their depths put first; a stroke face that +/// overlaps no fill wall keeps its place. +#[test] +fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + let at = |depth: f64, x: f64, z: f64, paints: Paints| Placed { + depth, + side: true, + away: false, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member: 0, + paints, + outline: square(x), + plane: Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }), + }; + // 0 a fill wall at z 0; 1 a stroke face over half of it, nearer, sorted before it; 2 a stroke + // face elsewhere; 3 a stroke face over it, farther, sorted after it. + let faces = + [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 1.0, Paints::Other), at(1.0, 20.0, 1.0, Paints::Other), at(6.0, 2.0, -1.0, Paints::Other)]; + assert_eq!(draw_order(&faces, true), [2, 3, 0, 1]); + assert_eq!(draw_order(&faces, false), [2, 3, 0, 1]); + // A covering stroke's faces keep the wall's own rule. + let covering: Vec = faces + .iter() + .map(|f| Placed { paints: if f.paints == Paints::Other { Paints::Stroke } else { f.paints }, outline: vec![], plane: None, ..*f }) + .collect(); + assert_eq!(draw_order(&covering, false), [2, 1, 0, 3]); + // Another shape's stroke, and a stroke face at the same depth where they overlap, move nothing. + let other_shape: Vec = + faces[..3].iter().map(|f| Placed { member: usize::from(f.paints == Paints::Other), outline: f.outline.clone(), ..*f }).collect(); + assert_eq!(draw_order(&other_shape, true), [2, 1, 0]); + let level = [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 0.0, Paints::Other)]; + assert_eq!(draw_order(&level, true), [1, 0]); + // A stroke face moved after its fill's wall does not pass a nearer face of another shape that + // it overlaps: 0 a stroke face, 1 another shape's face in front of both, 2 the fill's wall. + let in_front = Placed { member: 1, ..at(2.0, 5.0, 2.0, Paints::Fill) }; + let shapes = [at(1.0, 5.0, 1.0, Paints::Other), in_front, at(3.0, 0.0, 0.0, Paints::Fill)]; + assert_eq!(draw_order(&shapes, true), [2, 0, 1]); + // A shape with a covering stroke as well keeps its fill wall before the covering stroke's face, + // even where that face is the farther: 0 the fill's wall, 1 the covering stroke's face behind + // it, 2 the other stroke's face in front. + let both = [at(3.0, 0.0, 0.0, Paints::Fill), at(3.0, 0.0, -1.0, Paints::Stroke), at(2.0, 5.0, 1.0, Paints::Other)]; + assert_eq!(draw_order(&both, true), [0, 1, 2]); + // Faces of a shape's strokes go in the order of their depths where they overlap too, with no + // fill face among them: 1 is nearer than 0 there, though sorted before it. + for (case, (back, front)) in + [(Paints::Other, Paints::Other), (Paints::Stroke, Paints::Other), (Paints::Stroke, Paints::Stroke)].into_iter().enumerate() + { + let strokes = [at(5.0, 0.0, 0.0, back), at(2.0, 5.0, 1.0, front)]; + assert_eq!(draw_order(&strokes, true), [0, 1], "case {case}"); + } +} + /// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a /// face of the same shape's stroke drawn before it: the wall then goes just before that face. A /// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an @@ -266,6 +697,8 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { bounds: Rect::new(x, 0.0, x + 10.0, 10.0), member, paints, + outline: vec![], + plane: None, }; // 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke // wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a @@ -292,6 +725,9 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { }; assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke); assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape"); + let closed = profile(&stroked(&letter_c())).unwrap().0; + assert!(holds_edge(EXTRUDE, &closed) && holds_edge(CLASSIC_EXTRUDE, &closed)); + assert!(!holds_edge(INFLATE, &closed), "an inflated fill can rise in front of its stroke"); assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width"); let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false }; assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes"); diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index aa73535fc5..bf219353cf 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -620,6 +620,110 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); } +/// The paths of `art`'s faces in drawing order (a face lit by a colored light is a clip group: its +/// clipping path), leaving out the graphics (clip groups of solid-color paths). +fn face_paths(art: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { children, clip: true } => { + let graphic = children.get(1).is_some_and(|inner| match &inner.kind { + NodeKind::Group { children: paths, clip: false } => paths.iter().all(|p| { + p.path_data().is_some() + && p.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint.color().is_some())) + }), + _ => false, + }); + if !graphic { + out.extend(children.first().and_then(|c| c.path_data()).cloned()); + } + } + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)), + _ => out.extend(n.path_data().cloned()), + } + } + let mut out = vec![]; + walk(art, &mut out); + out +} + +/// A turned solid in a gradient with transparent stops under a colored light, with a dashed stroke +/// and a graphic, draws its fill's and its stroke's faces in the one order it draws them in without +/// the graphic, and the graphic keeps the cap's mask and goes after the fill's cap, not the stroke's. +#[test] +fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + let solid = |graphics: Value| { + let mut n = object( + EXTRUDE, + json!({"rotationX": -20.0, "rotationY": -40.0, "depth": 60.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": graphics}), + ); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient.clone())); + } + // A gradient stroke: a solid color under the colored light would be one composite. + let blues = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.5))], + ); + let mut stroke = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(blues))), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + n.appearance.items.push(AppearanceItem::Stroke(stroke)); + solid_art_in(Some(&d), &n).unwrap() + }; + let with = solid(json!([{"symbol": "Mark"}])); + let without = solid(json!([])); + assert!(face_paths(&without).len() > 20, "the faces are found"); + assert_eq!(face_paths(&with), face_paths(&without), "the faces in one order"); + // The graphic is drawn on the fill's cap and on the stroke's (the clip groups holding the + // symbol's red square); the fill's, whose surface carries the mask, carries it too. + let mut masked = vec![]; + with.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let [_, inner] = children.as_slice() + && let NodeKind::Group { children: paths, clip: false } = &inner.kind + && paths.iter().any(|p| { + p.appearance.items.iter().any(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3), + AppearanceItem::Stroke(_) => false, + }) + }) + { + masked.push(c.extras().mask.is_some()); + } + }); + masked.sort_unstable(); + assert_eq!(masked, [false, true], "the fill's graphic carries the cap's mask"); + // The fill's graphic goes right after the fill's cap, so the dashes on the cap's edge, drawn + // after it, cover it. Drawn in order: the fill's graphic (the masked clip group), and each face, + // the fill's or the stroke's by its gradient's first stop (red or blue), with its bounds. + let mut drawn: Vec<(&str, Rect)> = vec![]; + with.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && c.extras().mask.is_some() + && let Some(bounds) = children.first().and_then(|clip| clip.path_data()).and_then(PathData::bounds) + { + drawn.push(("graphic", bounds)); + } + let first_stop = c.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_rgb()), + _ => None, + }, + AppearanceItem::Stroke(_) => None, + }); + if let (Some(rgb), Some(bounds)) = (first_stop, c.path_data().and_then(PathData::bounds)) { + drawn.push((if rgb[0] > rgb[2] { "fill" } else { "stroke" }, bounds)); + } + }); + let graphic = drawn.iter().position(|(kind, _)| *kind == "graphic").unwrap(); + let covering = |(kind, b): &(&str, Rect)| *kind == "stroke" && b.intersect(drawn[graphic].1).area() > 0.0; + assert!(drawn[graphic..].iter().any(covering), "a dash on the cap's edge is drawn over the fill's graphic"); +} + #[test] fn a_cap_with_a_hole_keeps_the_hole_out_of_the_graphic() { let d = document(); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 7b778498e6..bab5849c1a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -9,8 +9,8 @@ mod solid; mod visibility; pub use solid::{ - Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each, - inflate_together, plane, plane_face_map, + Bevel, BevelShape, DepthPlane, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, + inflate_each, inflate_together, plane, plane_face_map, }; pub use visibility::{VisibleFace, visible_faces}; mod shading; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index f3da6f866f..49b0738d9e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -292,12 +292,53 @@ pub struct Facet { /// The planar end of the solid the facet is: Extrude & Bevel's front or back cap, or a plane /// (its front). `None` for every other facet. pub end: Option, + /// The face's plane in view space and the projection it was drawn with, for its depth at a + /// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose + /// corners lie more than 0.001 pt off one plane (a piece of a twisted wall). + pub plane: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, } +/// A facet's plane in view space (x right, y down, z toward the viewer): the points `v` with +/// `normal · v = offset`, drawn by a projection about `center` with the eye `eye` in front of the +/// object's center plane (`None` without perspective). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DepthPlane { + pub normal: [f64; 3], + pub offset: f64, + pub center: Point, + pub eye: Option, +} + +impl DepthPlane { + /// The view-space depth (larger is nearer the viewer) of the point of the plane that projects + /// to `p`, a point of the drawing. `None` when the plane is seen edge-on there or the point is + /// not in front of the eye. + pub fn depth_at(&self, p: Point) -> Option { + let [nx, ny, nz] = self.normal; + let (x, y) = (p.x - self.center.x, p.y - self.center.y); + let z = match self.eye { + None => (nz.abs() > 1e-9).then(|| (self.offset - nx * x - ny * y) / nz)?, + Some(e) => { + // On the ray through `p`, the point at depth z is at (x, y) × (e - z) / e. + let a = (nx * x + ny * y) / e; + let den = nz - a; + let z = (den.abs() > 1e-9).then(|| (self.offset - a * e) / den)?; + (z < e).then_some(z)? + } + }; + z.is_finite().then_some(z) + } +} + impl Facet { + /// The view-space depth of the facet at `p`, a point of the drawing ([`DepthPlane::depth_at`]). + pub fn depth_at(&self, p: Point) -> Option { + self.plane.as_ref()?.depth_at(p) + } + /// About how thick the facet is across: twice its area over its perimeter (a w × l rectangle /// long against its width gives about w). pub fn thickness(&self) -> f64 { @@ -339,6 +380,7 @@ impl Facet { // A revolved cap is not one of the planar faces graphics map onto. end: None, away: false, + plane: None, #[cfg(test)] view: vec![], } diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index bd4030dfc4..1263582df9 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -6,8 +6,8 @@ use super::rings::{ solid_rings_traced, }; use super::{ - Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, eye_distance, - perspective_scale, + Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, + eye_distance, perspective_scale, }; use crate::shadow::Projection; @@ -129,6 +129,11 @@ impl Builder<'_> { } else { (V3(-n.0, -n.1, -n.2), false) }; + // A face whose corners lie off one plane (a piece of a twisted wall bent into a saddle) has + // no plane to measure its depth on. + let offset = n.dot(centre); + let flat = contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); + let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] let view = contours.clone(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); @@ -156,6 +161,7 @@ impl Builder<'_> { side: false, away: behind, end: None, + plane, #[cfg(test)] view, }); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index be887180b2..b0c26ccc0a 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -154,6 +154,29 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(open.len() > 2 * 8, "{}", open.len()); } +/// A flat facet carries its plane: the depth it gives at each corner of its drawing is the corner's +/// view-space depth, with and without perspective. A piece of a twisted wall whose corners lie off +/// one plane has none. +#[test] +fn a_flat_facet_measures_its_depth_on_its_plane() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let o = Extrude { depth: 60.0, cap: true, ..Extrude::default() }; + for perspective in [0.0, 50.0] { + let view = View { rotation_x: -20.0, rotation_y: 30.0, perspective, ..front() }; + let flat = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap(); + assert!(!flat.is_empty()); + for f in &flat { + assert!(f.plane.is_some(), "{perspective}"); + for (q, v) in f.contours.iter().flatten().zip(f.view.iter().flatten()) { + let d = f.depth_at(*q); + assert!(d.is_some_and(|d| close(d, v.2, 1e-6)), "{perspective}: {d:?} against {}", v.2); + } + } + let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 90.0, ..o }, view).unwrap(); + assert!(twisted.iter().any(|f| f.side && f.plane.is_none()), "{perspective}"); + } +} + #[test] fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..f447b00c84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1176,6 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. + - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_cuts_its_dashes_as_a_white_one_does`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index c701585b88..e9d5a696ff 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -117,11 +117,20 @@ The rest is our reading: each side face of the fill's solid that is turned toward the viewer is drawn before every face of the same shape's strokes, turned toward the viewer, whose bounds overlap it, so the stroke's walls cover the fill's walls at concave corners. This applies to strokes on closed paths without dashes, a width profile or a brush, painted - with a solid color or a gradient without transparent stops; with other strokes, and without caps, the faces keep - their depth order. Otherwise - each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not - white, each solid is also drawn on its own when one of them is a puffed, shaded Inflate surface in a solid color - (drawn as one composite) or is painted with a gradient that has transparent stops (drawn with an opacity mask). + with a solid color or a gradient without transparent stops, under Extrude & Bevel (both kinds). Under Inflate + the fill can rise in front of such a stroke, so it is drawn like the other strokes. Other strokes (dashed, with a + width profile or a brush, on an open subpath, or in a paint that may show through) leave parts of the fill's edge + uncovered, such as the gaps between dashes. Each face of such a stroke that passes through a face of the fill's + solid, or through a face of another of the shape's strokes, where the two overlap in the drawing is cut where + their depths meet, and the faces of the shape's fill and strokes that overlap are drawn in the order of their + depths where they overlap, with caps on or off. A fill wall in a gap stays in its own depth order. Inflate's + curved surfaces follow this only as closely as their flat facets do. A solid painted with a gradient that has + transparent stops under a light that is not white is drawn with an opacity mask: its faces keep their places in + this order, in runs that each carry the mask, and so do its graphics. A member of a 3D group with its own opacity + mask or blending mode draws its fill's and strokes' faces in this order inside that mask or blending mode when + they are opaque with Normal blending. Otherwise each solid is drawn on its own with its opacity and blending, the + lower item's first. With a light that is not white, each solid is also drawn on its own when one of them is a + puffed, shaded Inflate surface in a solid color (drawn as one composite). - The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's grid stays flat.