mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
3D: solid-color fills and strokes under colored lights draw their faces in one depth order
Under a light that is not white, an opaque Inflate fill or stroke in a solid color was drawn as one composite of all its facets, so the object's solids were drawn whole, one after another: the stroke's faces covered every face of the fill, nearer or not. When an object's solids share one order (all opaque with Normal blending, or one member's under its opacity mask), each facet of such a solid is now drawn on its own, with the composite's paint, channel light ramps and seam width, and takes its place in that order as the faces of a gradient already do. A lone solid, and the solids of an object with a translucent or blended fill or stroke, keep the one composite. A face drawn on its own is about 16 nodes, against about 6 per facet in the composite of a whole surface. These faces go in one order while they number at most 30,720 together, counted again after the cuts. Past that, each such solid is drawn as one composite. Docs: docs/three-d.md, docs/target-app-parity.md.
This commit is contained in:
@@ -416,6 +416,12 @@ const MAX_MEMBERS: usize = 4096;
|
||||
/// normals): past it the object is too complex to export.
|
||||
const MAX_MESH_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// The most facets of solid colors under colored lights that one 3D effect draws as faces of their
|
||||
/// own, to go in one order with other solids' faces. Each such face is about 16 nodes, a composite
|
||||
/// of a whole surface about 6 per facet, so at this count the faces hold about as many nodes as the
|
||||
/// composites do at the paint-copy allowance. Past it those solids draw as one composite each.
|
||||
const MAX_FACE_COMPOSITES: usize = 30_720;
|
||||
|
||||
/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path,
|
||||
/// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends
|
||||
/// and clipping groups), at any depth of its groups, each through its own geometry
|
||||
@@ -522,9 +528,12 @@ pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
|
||||
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
|
||||
/// blend mode (so overlapping facets don't add up their transparency). Under colored lights a solid
|
||||
/// color with smooth facets is one composite of all its facets ([`composites_facets`]) unless
|
||||
/// `faces`: then each facet is a face of its own, as in a gradient, so the faces can go in one order
|
||||
/// with the faces of other solids.
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool, faces: bool) -> Node {
|
||||
if !faces && composites_facets(&facets, paint, colored) {
|
||||
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
@@ -570,6 +579,12 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
group
|
||||
}
|
||||
|
||||
/// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it
|
||||
/// whole: a solid color under colored lights with smooth shading?
|
||||
fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool {
|
||||
colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some())
|
||||
}
|
||||
|
||||
/// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend.
|
||||
fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
|
||||
match item {
|
||||
@@ -587,10 +602,10 @@ fn paint_ref(item: &AppearanceItem) -> &Paint {
|
||||
}
|
||||
|
||||
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored
|
||||
/// lights give each facet derived paints (`lighting::solid_surface`).
|
||||
/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for
|
||||
/// each face).
|
||||
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
|
||||
let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some());
|
||||
facets.len().saturating_mul(if derived { 6 } else { 2 })
|
||||
facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 })
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
|
||||
@@ -1252,8 +1267,9 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// 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, their faces are drawn in one back-to-front order (where depths tie, as
|
||||
/// each in its own paint. When every one is opaque with normal blending, their faces are drawn in
|
||||
/// one back-to-front order, a solid color under colored lights included up to
|
||||
/// [`MAX_FACE_COMPOSITES`] facets (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, and its fill's faces and
|
||||
/// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the
|
||||
@@ -1508,7 +1524,8 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
|
||||
filled.push(Filled { at: out.len(), member, caps, opacity, blend, masked: masks_surface(&paint, colored) });
|
||||
}
|
||||
}
|
||||
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
|
||||
let art =
|
||||
if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored, false) };
|
||||
out.push((s.scopes.as_slice(), Arc::new(art)));
|
||||
}
|
||||
}
|
||||
@@ -1541,22 +1558,31 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
|
||||
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));
|
||||
// A solid color under colored lights with smooth shading draws as one composite of its facets
|
||||
// ([`surface`]) unless its facets go in one order with other solids' faces. They do while all
|
||||
// such facets together number at most MAX_FACE_COMPOSITES.
|
||||
let composite = |item: &AppearanceItem, f: &[Facet]| composites_facets(f, paint_ref(item), colored);
|
||||
let colored_faces: usize = solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum();
|
||||
let split = colored_faces <= MAX_FACE_COMPOSITES;
|
||||
// The faces are drawn in one order (below) when every solid is opaque and blends normally and
|
||||
// no composite draws a surface 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 && (split || !solids.iter().any(|(item, _, _, f, _)| composite(item, f)));
|
||||
let shape_order: Vec<bool> = (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)
|
||||
opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && (split || !composite(item, f))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order };
|
||||
// The solids whose faces share one order with another solid's: every solid in the one order of
|
||||
// the object, or of its member under a mask. A lone solid draws its facets in their own order,
|
||||
// so a solid color under colored lights stays one composite there ([`surface`]).
|
||||
let shared: Vec<bool> =
|
||||
(0..shapes.len()).map(|shape| ordered(shape) && solids.iter().filter(|s| !masks || s.4 == shape).nth(1).is_some()).collect();
|
||||
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
|
||||
@@ -1592,6 +1618,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
|
||||
.collect();
|
||||
placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks)
|
||||
};
|
||||
// With the pieces of the cuts, solid colors under colored lights past MAX_FACE_COMPOSITES draw
|
||||
// as one composite each, so their surfaces draw whole (below).
|
||||
let split =
|
||||
split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::<usize>() <= MAX_FACE_COMPOSITES;
|
||||
let members: Vec<usize> = solids.iter().map(|s| s.4).collect();
|
||||
let surfaces: Vec<BuiltSurface> = solids
|
||||
.into_iter()
|
||||
@@ -1600,7 +1630,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
|
||||
let depths: Vec<Placed> = 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);
|
||||
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
|
||||
let art = if classic {
|
||||
classic::surface(n, f, &paint, opacity, blend, &p)
|
||||
} else {
|
||||
surface(n, f, &paint, opacity, blend, colored, split && shared.get(shape).copied().unwrap_or(false))
|
||||
};
|
||||
(depths, art, scopes, ends)
|
||||
})
|
||||
.collect();
|
||||
@@ -1609,8 +1643,8 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
|
||||
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
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. A lone
|
||||
// solid's surface can instead be one clipped composite of all its facets ([`surface`]), which
|
||||
// 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 {
|
||||
|
||||
@@ -491,8 +491,8 @@ fn a_masked_members_fill_and_stroke_faces_go_in_one_order() {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// fill's and the dashed stroke's faces in one order, as it does under a white light, and so does
|
||||
/// Inflate in solid colors.
|
||||
#[test]
|
||||
fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
@@ -530,9 +530,111 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_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.
|
||||
// A solid color under the colored light: the fill's and the stroke's faces in one order too.
|
||||
let solid = stroked(&letter_c());
|
||||
assert_eq!(drawn(&solid, Some(colored)).0, 2);
|
||||
assert_eq!(drawn(&solid, Some(colored)).0, 1);
|
||||
}
|
||||
|
||||
/// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite
|
||||
/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt.
|
||||
fn drawn_face_bounds(f: &Node) -> Rect {
|
||||
match &f.kind {
|
||||
NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(),
|
||||
NodeKind::Group { clip: true, children } => {
|
||||
let cover = &children[1].children().unwrap()[0];
|
||||
cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0)
|
||||
}
|
||||
_ => panic!("a face is a path or a clipped composite"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a light that is not white, a solid-color fill and a solid-color stroke, plain, dashed or
|
||||
/// with a width profile, draw their faces in one order, the faces of both solids in the order of
|
||||
/// their depths where they overlap, in Inflate and in Extrude & Bevel, with and without perspective
|
||||
/// and in a member with an opacity mask. Drawing each solid whole, the fill's faces before the
|
||||
/// stroke's, shows farther faces on top.
|
||||
#[test]
|
||||
fn colored_solid_fills_and_strokes_share_face_depth_order() {
|
||||
use vectorcraft_doc::{Dash, WidthProfile};
|
||||
type Style<'a> = (&'a str, &'a dyn Fn(&mut StrokeLayer));
|
||||
let styles: [Style; 3] = [
|
||||
("plain", &|_| {}),
|
||||
("dashed", &|s| s.dash = Some(Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false })),
|
||||
("profiled", &|s| s.profile = Some(WidthProfile::lens())),
|
||||
];
|
||||
let views = [
|
||||
json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}),
|
||||
json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}),
|
||||
];
|
||||
let mut whole_misdrawn = 0;
|
||||
for id in [INFLATE, EXTRUDE] {
|
||||
for view in &views {
|
||||
for (style, set) in &styles {
|
||||
let mut params = view.clone();
|
||||
params["depth"] = json!(60);
|
||||
params["lights"] = json!([{"color": "#ff8080"}, {"color": "#4060ff", "rotation": 120}]);
|
||||
let mut n = stroked(&letter_c());
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
set(s);
|
||||
}
|
||||
let what = format!("{id} {view} {style}");
|
||||
let parts = drawn_parts(&n, id, ¶ms);
|
||||
if id == INFLATE {
|
||||
assert!(parts.iter().any(|(f, ..)| f.rgb_gradient.is_some()), "{what}: smooth colored facets");
|
||||
}
|
||||
let mut with = n.clone();
|
||||
with.appearance.effects.push(fx(id, params.clone()));
|
||||
let art = solid_art(&with).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 1, "{what}: one order");
|
||||
let faces = surfaces[0].children().unwrap();
|
||||
assert_eq!(faces.len(), parts.len(), "{what}: one face per facet");
|
||||
for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() {
|
||||
let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap());
|
||||
let close = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6);
|
||||
assert!(close, "{what}: face {k} drawn out of the order of the depths");
|
||||
}
|
||||
let allowed = if id == INFLATE { 5 } else { 0 };
|
||||
let wrong = misdrawn(&parts);
|
||||
assert!(wrong.len() <= allowed, "{what}: {} points show a farther face, first {:?}", wrong.len(), wrong.first());
|
||||
// The same faces drawn as two whole surfaces, the fill's then the stroke's.
|
||||
let mut whole = parts.clone();
|
||||
whole.sort_by_key(|(_, part, _)| *part);
|
||||
whole_misdrawn += misdrawn(&whole).len();
|
||||
// In a group, under the member's opacity mask, the faces keep the same order.
|
||||
if id == INFLATE && *style == "plain" {
|
||||
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 = n.clone();
|
||||
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true)));
|
||||
let mut plain = Node::group(NodeId(2), vec![Arc::new(n.clone())]);
|
||||
plain.appearance.effects.push(fx(id, params.clone()));
|
||||
let mut g = Node::group(NodeId(2), vec![Arc::new(masked)]);
|
||||
g.appearance.effects.push(fx(id, params.clone()));
|
||||
let bounds = |art: &Node| {
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |n| {
|
||||
if let NodeKind::Group { clip: true, .. } = &n.kind {
|
||||
out.push(drawn_face_bounds(n));
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap());
|
||||
let mut masks = 0;
|
||||
scoped.walk(&mut |n| masks += usize::from(n.extras().mask.is_some()));
|
||||
assert!(masks > 0, "{what}: drawn in the member's mask");
|
||||
let ordered = bounds(&plain);
|
||||
assert!(ordered.len() > 100, "{what}: clipped faces");
|
||||
assert_eq!(bounds(&scoped), ordered, "{what}: the member's faces in one order");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(whole_misdrawn > 0, "whole surfaces show farther faces");
|
||||
}
|
||||
|
||||
/// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they
|
||||
@@ -1322,11 +1424,12 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||||
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
|
||||
}
|
||||
|
||||
/// A solid color under colored lights is one composite of all its facets, with one clip and a fixed
|
||||
/// number of groups, where its solid's faces draw in no order shared with another solid's: a lone
|
||||
/// fill, or a fill and a translucent stroke. A fill and an opaque stroke draw each face on its own,
|
||||
/// in one order.
|
||||
#[test]
|
||||
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
fn count(n: &Node) -> (usize, usize, usize) {
|
||||
let (mut groups, mut clips, mut paths) = match &n.kind {
|
||||
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
|
||||
@@ -1341,13 +1444,125 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
}
|
||||
(groups, clips, paths)
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
// The fill's solid and the stroke's, each one composite with its own clip.
|
||||
let surfaces = art.children().unwrap().len();
|
||||
assert_eq!(surfaces, 2);
|
||||
assert_eq!(clips, surfaces);
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
|
||||
let lights = json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]});
|
||||
let lone = |n: &mut Node| n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let translucent = |n: &mut Node| {
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.opacity = 0.5;
|
||||
}
|
||||
};
|
||||
for (what, set, surfaces) in [("a lone fill", &lone as &dyn Fn(&mut Node), 1), ("a translucent stroke", &translucent, 2)] {
|
||||
let mut n = square_node();
|
||||
set(&mut n);
|
||||
n.appearance.effects.push(fx(INFLATE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "{what}: exercise a tessellated surface");
|
||||
// Each solid one composite with its own clip.
|
||||
assert_eq!(art.children().unwrap().len(), surfaces, "{what}");
|
||||
assert_eq!(clips, surfaces, "{what}");
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "{what}: group count must not grow with triangle count");
|
||||
}
|
||||
// An opaque fill and stroke: one order of faces, each its own clipped composite or shaded path.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, lights));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 1, "one order");
|
||||
let faces = surfaces[0].children().unwrap();
|
||||
assert!(faces.len() > 100, "one face per facet");
|
||||
assert!(faces.iter().all(|f| matches!(&f.kind, NodeKind::Path { .. } | NodeKind::Group { clip: true, .. })));
|
||||
assert!(faces.iter().any(|f| matches!(&f.kind, NodeKind::Group { clip: true, .. })), "colored ramps");
|
||||
}
|
||||
|
||||
/// Solid colors under colored lights go in one order of faces while their facets together, with the
|
||||
/// pieces of the cuts, number at most `MAX_FACE_COMPOSITES`. Past it each solid draws whole, as one
|
||||
/// composite.
|
||||
#[test]
|
||||
fn colored_solids_past_the_face_limit_draw_as_composites() {
|
||||
let lights = json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff","rotation":90}]});
|
||||
let mut base = square_node();
|
||||
base.appearance.effects.push(fx(INFLATE, lights.clone()));
|
||||
let stroke = base.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned().unwrap();
|
||||
// The facets each solid draws under these lights. Strokes alike each build the same solid.
|
||||
let shapes = [Profile { path: profile(&base).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&base), opacity: 1.0, scopes: vec![] }];
|
||||
let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap());
|
||||
let counts: Vec<usize> = built_solids(INFLATE, &crate::merged_params(INFLATE, &lights), &parts, bounds, MAX_OBJECT_FACES)
|
||||
.map(|f| f.unwrap().map_or(0, |f| f.len()))
|
||||
.collect();
|
||||
let [fill, each] = counts[..] else { panic!("a fill's solid and a stroke's") };
|
||||
let fits = (MAX_FACE_COMPOSITES - fill) / each;
|
||||
assert!(fits >= 4, "{fill} + {fits} × {each}");
|
||||
// Under the limit; under it before the cuts but past it with their pieces; past it before them.
|
||||
let mut seen = vec![];
|
||||
for strokes in [fits / 2, fits, fits + 1] {
|
||||
let mut n = base.clone();
|
||||
n.appearance.items.extend(std::iter::repeat_n(stroke.clone(), strokes - 1));
|
||||
let uncut = fill + strokes * each;
|
||||
let cut = drawn_parts(&n, INFLATE, &lights).len();
|
||||
let one_order = cut <= MAX_FACE_COMPOSITES;
|
||||
seen.push((uncut <= MAX_FACE_COMPOSITES, one_order));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
if one_order {
|
||||
assert_eq!(surfaces.len(), 1, "{uncut} faces, {cut} cut: one order");
|
||||
} else {
|
||||
assert_eq!(surfaces.len(), 1 + strokes, "{uncut} faces, {cut} cut: each solid whole");
|
||||
assert!(surfaces.iter().all(|s| s.children().is_some_and(|c| c.len() < 10)), "each solid one composite");
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, [(true, true), (true, false), (false, false)], "every case");
|
||||
}
|
||||
|
||||
/// A solid-color face drawn on its own under colored lights takes the same colors as in the
|
||||
/// surface's one composite: the paint, multiplied by the same red, green and blue light ramps, and
|
||||
/// a seam of the same width.
|
||||
#[test]
|
||||
fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() {
|
||||
let n = square_node();
|
||||
let p = crate::merged_params(INFLATE, &json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff","rotation":90}]}));
|
||||
let (path, rule) = profile(&n).unwrap();
|
||||
let all = facets(INFLATE, &path, rule, path.bounds().unwrap(), &p).unwrap();
|
||||
let face = all
|
||||
.iter()
|
||||
.find(|f| f.rgb_gradient.is_some_and(|g| g.channels[0] != g.channels[1] || g.channels[1] != g.channels[2]))
|
||||
.expect("a facet with colored ramps")
|
||||
.clone();
|
||||
let paint = Paint::solid(RED);
|
||||
// Each node's own fill paint, and its seam stroke's width, for every node under `n`.
|
||||
let paints = |n: &Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |n| {
|
||||
for item in &n.appearance.items {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => out.push((Some(f.paint.clone()), None)),
|
||||
AppearanceItem::Stroke(s) => out.push((None, Some(s.width))),
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let alone = surface(&n, vec![face.clone()], &paint, 1.0, BlendMode::Normal, true, true);
|
||||
let batch = surface(&n, vec![face.clone()], &paint, 1.0, BlendMode::Normal, true, false);
|
||||
let [single] = alone.children().unwrap().as_slice() else { panic!("one face") };
|
||||
assert!(matches!(&single.kind, NodeKind::Group { clip: true, .. }), "a clipped face");
|
||||
// The face's paint cover and its three channel ramps.
|
||||
let fills = |n: &Node| paints(n).into_iter().filter_map(|(f, _)| f).collect::<Vec<_>>();
|
||||
let one = fills(single);
|
||||
assert_eq!(one.len(), 4);
|
||||
assert_eq!(one[0], paint, "the paint");
|
||||
// The composite: its paint cover, then each channel's black backdrop and the face's ramp.
|
||||
let all_fills = fills(&batch);
|
||||
let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect();
|
||||
assert_eq!(all_fills.first(), Some(&paint));
|
||||
assert_eq!(ramps, one.iter().skip(1).collect::<Vec<_>>(), "the same light ramps");
|
||||
// The single face's clip holds its seam: the clip reaches past the facet by half the seam.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
let widths: Vec<f64> = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect();
|
||||
assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}");
|
||||
let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") };
|
||||
let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap());
|
||||
assert!((clip.x0 - (facet.x0 - seam / 2.0)).abs() < 0.1 && (clip.x1 - (facet.x1 + seam / 2.0)).abs() < 0.1, "{clip:?} {facet:?}");
|
||||
}
|
||||
|
||||
/// `square_node` moved right by `dx`, with id `id` and fill `fill`.
|
||||
|
||||
@@ -502,3 +502,80 @@ fn batched_colored_inflate_preserves_compound_holes_and_surface_opacity() {
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(image.pixels, render(doc(&s)).pixels);
|
||||
}
|
||||
|
||||
/// Under colored lights, an Inflate fill and stroke in solid colors draw each face on its own, in
|
||||
/// one depth order: zoomed in, the fill's faces show the colors the one composite of the surface
|
||||
/// shows (drawn when the stroke is translucent), with no seam between faces; at 1x, where a pixel
|
||||
/// holds several faces, all but a few pixels show the average of the zoomed-in colors; and the art
|
||||
/// keeps its colors through SVG export and Expand Appearance.
|
||||
#[test]
|
||||
fn colored_solid_inflate_faces_in_one_order_keep_the_composites_colors_and_seams() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
s.execute("path.create", &json!({"d":"M100 100H300V300H100Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#e0c080"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color":"#204060","width":4})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.inflate","item":null,"params":{
|
||||
"rotationX":0,"rotationY":0,"rotationZ":0,"lights":[{"color":"#ff4040"},{"color":"#4040ff","rotation":120}]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let at = |d: &Document, scale: f64| {
|
||||
vectorcraft_render::Renderer::new().render(
|
||||
d,
|
||||
400,
|
||||
400,
|
||||
kurbo::Affine::scale(scale) * kurbo::Affine::translate((-140.0, -140.0)),
|
||||
&vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() },
|
||||
)
|
||||
};
|
||||
let ordered = doc(&s).clone();
|
||||
s.execute("transparency.set", &json!({"item":1,"opacity":99})).unwrap();
|
||||
let whole = doc(&s).clone();
|
||||
// The fill's puff, inside the stroke's: 150 to 250 pt across, 10 to 110 px at 1x.
|
||||
let (near, near_whole, far) = (at(&ordered, 3.0), at(&whole, 3.0), at(&ordered, 1.0));
|
||||
let (mut off, mut open) = (0, 0);
|
||||
for y in 10..110u32 {
|
||||
for x in 10..110u32 {
|
||||
// At 3x each 1x pixel is nine pixels: the faces against the composite, pixel by pixel.
|
||||
let mut sum = [0u32; 4];
|
||||
for (dx, dy) in (0..9).map(|k| (k % 3, k / 3)) {
|
||||
let (p, q) = (near.pixel(x * 3 + dx, y * 3 + dy), near_whole.pixel(x * 3 + dx, y * 3 + dy));
|
||||
assert!(p[3] >= 254, "no seam at ({}, {}) at 3x: {p:?}", x * 3 + dx, y * 3 + dy);
|
||||
assert!(
|
||||
p.into_iter().zip(q).all(|(p, q)| p.abs_diff(q) <= 1),
|
||||
"({}, {}) at 3x: {p:?} vs the composite's {q:?}",
|
||||
x * 3 + dx,
|
||||
y * 3 + dy
|
||||
);
|
||||
for (s, v) in sum.iter_mut().zip(p) {
|
||||
*s += u32::from(v);
|
||||
}
|
||||
}
|
||||
// At 1x, where a pixel holds several faces, a pixel has about the average of its nine:
|
||||
// a few pixels where thin faces meet are off by more than 3 or let a little through.
|
||||
let p = far.pixel(x, y);
|
||||
assert!(p[3] >= 240, "a seam at ({x},{y}) at 1x: {p:?}");
|
||||
open += usize::from(p[3] < 255);
|
||||
off += usize::from(p.into_iter().zip(sum).any(|(p, s)| u32::from(p).abs_diff(s / 9) > 3));
|
||||
}
|
||||
}
|
||||
assert!(open <= 500, "{open} of 10,000 pixels at 1x let the background through");
|
||||
assert!(off <= 300, "{off} of 10,000 pixels at 1x are off the zoomed-in colors");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &ordered);
|
||||
let live = render(doc(&s));
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(!svg.contains("<image"));
|
||||
let roundtrip = render(&vectorcraft_svg::import(&svg).unwrap());
|
||||
for y in (110..290).step_by(9) {
|
||||
for x in (110..290).step_by(9) {
|
||||
let (a, b) = (live.pixel(x, y), roundtrip.pixel(x, y));
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 6), "SVG ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, live.pixels);
|
||||
}
|
||||
|
||||
@@ -1215,6 +1215,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- 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_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`.
|
||||
- Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`.
|
||||
- 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`).
|
||||
|
||||
+9
-7
@@ -102,7 +102,7 @@ middle.
|
||||
| Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. |
|
||||
| Both sides | The back puffs out too; otherwise it is flat. |
|
||||
|
||||
The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading and paint composition. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds, and every facet's seam has round joins ([Seams](#seams)). Shading is automatic, with no additional dialog option.
|
||||
The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading and paint composition. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. A solid fill or stroke drawn alone, or drawn whole because a fill or stroke of the object is translucent or blended, batches those triangles into three channel groups and one coverage clip for the whole surface; when its faces share one depth order with another solid's, each triangle has its own clip and channel ramps. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds, and every facet's seam has round joins ([Seams](#seams)). Shading is automatic, with no additional dialog option.
|
||||
|
||||
## Seams
|
||||
|
||||
@@ -115,9 +115,10 @@ How a facet carries its seam depends on the lights and the paint:
|
||||
- Extrude & Bevel, Inflate and Rotate, when no light is colored (one white light or several): a path filled with
|
||||
the facet's shading and stroked with its seam. With a colored light, Extrude & Bevel and Rotate (planar shading)
|
||||
are drawn the same way.
|
||||
- Inflate with a colored light and shading on: a solid-color fill draws each facet's light ramps with its seam in three channel
|
||||
groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill fills each facet's
|
||||
ramps through a clip of the facet and its seam, outlined with round joins.
|
||||
- Inflate with a colored light and shading on: a solid-color fill or stroke drawn as one composite draws each facet's light ramps with its seam in three channel
|
||||
groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill, and a solid-color
|
||||
fill or stroke whose faces share one depth order with another solid's, fills each facet's ramps through a clip of
|
||||
the facet and its seam, outlined with round joins.
|
||||
- Extrude & Bevel (Classic), Rotate (Classic) and Revolve (Classic): a path and a seam for every face; gradient and
|
||||
pattern faces seam only the base paint, and the shading and highlight layers cover the face alone. The Wireframe
|
||||
surface draws edges only, with no seam.
|
||||
@@ -128,7 +129,7 @@ How a facet carries its seam depends on the lights and the paint:
|
||||
A facet seen almost edge-on is a sliver, and a seam with mitered joins would reach past the solid as a thorn; a round
|
||||
one reaches no more than half its width past its facet. So where a facet has no neighbor (Rotate's plane, every
|
||||
silhouette corner) its convex corners are drawn rounded by up to 0.375 pt (half the largest seam), except a
|
||||
solid-color Inflate under a colored light, whose clip keeps its corners sharp.
|
||||
solid-color Inflate surface under a colored light drawn as one composite, whose clip keeps its corners sharp.
|
||||
|
||||
## Fills and strokes
|
||||
|
||||
@@ -155,8 +156,9 @@ The rest is our reading:
|
||||
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).
|
||||
lower item's first. With a light that is not white, a puffed, shaded Inflate surface in a solid color takes its
|
||||
place in this order too, each of its faces drawn with its own light ramps; a lone solid of that kind is still
|
||||
drawn as one composite, and so is each such solid when they hold more than 30,720 faces together.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user