mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
3D: a solid in a transparent gradient keeps the shared face order
With Extrude & Bevel, Extrude & Bevel (Classic) or Inflate on a group or an object with fills and strokes, a solid whose gradient has transparent stops carries an opacity mask, and it was drawn as one whole surface in the order of the members. It could cover a solid in front of it or hide behind one it is in front of. Its faces now take their places in the one back-to-front order of every solid's faces, in runs that each carry the solid's opacity mask. A run splits only where a face drawn after it overlaps the next face.
This commit is contained in:
@@ -20,7 +20,7 @@ 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;
|
||||
pub(crate) 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;
|
||||
@@ -576,14 +576,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, le
|
||||
/// What a face can paint, to tell whether two faces overlap: the convex hull of the points and
|
||||
/// control points of its outline, which holds the outline, and the hull's box grown by half the
|
||||
/// seam stroke.
|
||||
struct Coverage {
|
||||
bounds: Rect,
|
||||
pub(crate) struct Coverage {
|
||||
pub(crate) bounds: Rect,
|
||||
hull: Vec<Point>,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// `None` for a face with no outline and no box.
|
||||
fn of(face: &Node) -> Option<Coverage> {
|
||||
pub(crate) fn of(face: &Node) -> Option<Coverage> {
|
||||
let points: Vec<Point> = match face.path_data() {
|
||||
Some(path) => path
|
||||
.to_bezpath()
|
||||
@@ -611,7 +611,7 @@ impl Coverage {
|
||||
/// 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 {
|
||||
pub(crate) fn meets(&self, other: &Coverage) -> bool {
|
||||
fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ {
|
||||
(0..hull.len()).filter_map(|i| {
|
||||
let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?);
|
||||
@@ -658,7 +658,7 @@ fn convex_hull(mut points: Vec<Point>) -> Vec<Point> {
|
||||
}
|
||||
|
||||
/// Do boxes `a` and `b` overlap with some area?
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
pub(crate) fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
|
||||
}
|
||||
|
||||
|
||||
@@ -686,10 +686,11 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, 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`]);
|
||||
/// 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
|
||||
/// 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
|
||||
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
|
||||
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
|
||||
@@ -841,19 +842,20 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect()));
|
||||
}
|
||||
// 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, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s, _)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, and then it
|
||||
// stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
|
||||
}
|
||||
// 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.
|
||||
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect();
|
||||
let (mut placed, mut faces) = (vec![], vec![]);
|
||||
for (depths, s, _) in surfaces {
|
||||
for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
placed.extend(depths);
|
||||
faces.extend(children.into_iter().map(Some));
|
||||
faces.extend(children.into_iter().map(|face| Some((surface, face))));
|
||||
}
|
||||
}
|
||||
let sealed = match fx.id.as_str() {
|
||||
@@ -862,7 +864,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
_ => extrude_options(&p).cap,
|
||||
};
|
||||
let order = draw_order(&placed, sealed);
|
||||
Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))])
|
||||
let faces: Vec<(usize, Arc<Node>)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect();
|
||||
let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) };
|
||||
Some(vec![Arc::new(Node::group(n.id, children))])
|
||||
}
|
||||
|
||||
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
|
||||
/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order
|
||||
/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a
|
||||
/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does
|
||||
/// the surface split, where one group can't be both behind and in front of another face.
|
||||
fn in_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, masks: &[Option<Box<OpacityMask>>]) -> Vec<Arc<Node>> {
|
||||
use crate::revolve::{Coverage, overlaps};
|
||||
struct Run {
|
||||
surface: Option<usize>,
|
||||
bounds: Rect,
|
||||
faces: Vec<Arc<Node>>,
|
||||
cover: Vec<Coverage>,
|
||||
}
|
||||
let mut runs: Vec<Run> = vec![];
|
||||
let mut last_run = vec![None; masks.len()];
|
||||
// Past this many tests a face starts a run of its own, which never draws out of order.
|
||||
let mut budget = crate::revolve::MAX_OVERLAP_TESTS;
|
||||
for (surface, face) in faces {
|
||||
let Some(cover) = Coverage::of(&face) else {
|
||||
// A face with no outline and no box overlaps nothing.
|
||||
runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] });
|
||||
continue;
|
||||
};
|
||||
if masks.get(surface).is_none_or(Option::is_none) {
|
||||
runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
|
||||
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(surface).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(surface) {
|
||||
*last = Some(runs.len());
|
||||
}
|
||||
runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
|
||||
}
|
||||
}
|
||||
}
|
||||
runs.into_iter()
|
||||
.filter_map(|mut r| {
|
||||
let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() };
|
||||
let mut group = Node::group(id, r.faces);
|
||||
group.extras_mut().mask = Some(mask);
|
||||
Some(Arc::new(group))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() {
|
||||
}
|
||||
|
||||
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
|
||||
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
|
||||
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
|
||||
/// opacity mask: its faces still take their places in the one order of the fill's and the
|
||||
/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the
|
||||
/// gradient itself and no mask.
|
||||
#[test]
|
||||
fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.paint = Paint::Gradient(Box::new(gradient.clone()));
|
||||
@@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let drawn = art.children().unwrap();
|
||||
assert_eq!(drawn.len(), surfaces, "{lights}");
|
||||
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
|
||||
assert_eq!(drawn.len(), 1, "{lights}: one order of faces");
|
||||
let runs: Vec<&Arc<Node>> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect();
|
||||
assert_eq!(!runs.is_empty(), masked, "{lights}");
|
||||
assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() {
|
||||
}
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surface = &art.children().unwrap()[0];
|
||||
assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
|
||||
let mut has_mask = false;
|
||||
surface.walk(&mut |c| has_mask |= c.extras().mask.is_some());
|
||||
assert_eq!(has_mask, masked, "{end}");
|
||||
let mut opaque = true;
|
||||
surface.walk(&mut |c| {
|
||||
for item in &c.appearance.items {
|
||||
@@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group member in a gradient with transparent stops keeps the shared back-to-front order of
|
||||
/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's
|
||||
/// faces are drawn between them where they are in front of some and behind others. Here a square
|
||||
/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square
|
||||
/// and its front cap is in front of the square's walls.
|
||||
#[test]
|
||||
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| {
|
||||
let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)];
|
||||
let mut points = corners.to_vec();
|
||||
if !clockwise {
|
||||
points.reverse();
|
||||
}
|
||||
b.move_to(points[0]);
|
||||
for p in &points[1..] {
|
||||
b.line_to(*p);
|
||||
}
|
||||
b.close_path();
|
||||
};
|
||||
let art = |id: &str, params: &Value, end_opacity: f32| {
|
||||
let mut end = GradientStop::new(1.0, Color::BLACK);
|
||||
end.opacity = end_opacity;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end]))));
|
||||
let mut b = BezPath::new();
|
||||
square(&mut b, 100.0, 300.0, true);
|
||||
square(&mut b, 150.0, 250.0, false);
|
||||
let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
let mut b = BezPath::new();
|
||||
square(&mut b, 185.0, 215.0, true);
|
||||
let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0));
|
||||
let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]);
|
||||
g.appearance.effects.push(fx(id, params.clone()));
|
||||
solid_art(&g).unwrap_or_else(|| panic!("{id}"))
|
||||
};
|
||||
// The drawing order: `M` for a group with an opacity mask, `F` for a face outside one.
|
||||
fn visit(n: &Node, order: &mut String) {
|
||||
if n.extras().mask.is_some() {
|
||||
order.push('M');
|
||||
return;
|
||||
}
|
||||
if n.path_data().is_some() {
|
||||
order.push('F');
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, order);
|
||||
}
|
||||
}
|
||||
let order = |art: &Node| {
|
||||
let mut order = String::new();
|
||||
visit(art, &mut order);
|
||||
order
|
||||
};
|
||||
for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] {
|
||||
assert!(!order(&art(id, ¶ms, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask");
|
||||
let masked = order(&art(id, ¶ms, 0.5));
|
||||
let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap());
|
||||
assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_parameters_are_checked() {
|
||||
let n = classic(json!({}));
|
||||
|
||||
Reference in New Issue
Block a user