Files
vectorcraft/crates/effects/src/revolve.rs
T
dotCooCoo 24155e1f75 3D: an opacity mask on a group member masks its solids
A 3D effect on a group built its solids from the members' paths and
paint and left out each member's opacity mask, and the mask of a group
inside the 3D group. Clipping groups, blends and symbol instances
resolved for the effect lost their masks the same way.

A member, or a group inside the 3D group, with an opacity mask now
gets a scope like one with its own opacity or blending mode, and the
mask goes on the group that holds that member's surfaces. Revolve
applies it with the member's opacity and blending mode; Extrude &
Bevel, Extrude & Bevel (Classic), Inflate and Rotate draw each solid
on its own when a mask is present and put the masked members' solids
in a group with the mask. A resolved clipping group, blend or symbol
instance keeps its mask. A disabled mask is ignored.
2026-11-10 14:30:10 -08:00

1271 lines
64 KiB
Rust

//! Revolve as live, shaded vector art, shared by the canvas and every exporter.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
use vectorcraft_three_d::{Edge, Revolve};
use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds};
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
pub const REVOLVE: &str = "threeD.revolve";
/// 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;
/// The most face and box tests a group's transparent members' faces take together to rejoin their
/// members' last runs; past them each starts a run of its own.
const MAX_OVERLAP_TESTS: usize = 1 << 22;
/// The width of the stroke around each face that covers the seams between faces.
const SEAM_WIDTH: f64 = 0.75;
/// About the bytes one copy of `paint` takes.
fn paint_bytes(paint: &Paint) -> usize {
use std::mem::size_of;
use vectorcraft_doc::color::{FreeformPoint, GradientStop};
let own = size_of::<Paint>();
let Paint::Gradient(g) = paint else { return own };
let freeform = g
.freeform
.as_ref()
.map_or(0, |f| f.points.len() * size_of::<FreeformPoint>() + f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>());
own + size_of::<vectorcraft_doc::color::GradientPaint>() + g.gradient.stops.len() * size_of::<GradientStop>() + freeform
}
fn visible(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.visible && e.id == REVOLVE)
}
pub fn has_revolve(n: &Node) -> bool {
visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects()))
}
/// Shared document-parameter conversion for rendering and interactive preview guides.
pub fn revolve_options(p: &Value) -> Revolve {
Revolve {
angle: num(p, "angle", 360.0),
offset: num(p, "offset", 0.0),
edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left },
rotation_x: num(p, "rotationX", 0.0),
rotation_y: num(p, "rotationY", 0.0),
rotation_z: num(p, "rotationZ", 0.0),
perspective: num(p, "perspectiveAngle", 0.0),
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_color: crate::lighting::light_color(p),
lights: crate::lighting::lights(p),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
smooth_shading: true,
cap_ends: flag(p, "cap", true),
curve_samples: None,
}
}
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
validate_revolve_in(None, n, p)
}
/// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now.
pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> {
validate_revolve_params(p)?;
let n = &*resolve_for(doc, n, "Revolve", false)?;
let effects = &n.appearance.effects;
// The profile is measured before the object's one Revolve, hidden or shown.
let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects);
if is_container(n) {
const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors";
let mut shapes = members(n, "Revolve")?;
if !shapes.iter().any(|s| has_two_anchors(&s.path)) {
return Err(EMPTY.into());
}
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or(EMPTY)?;
let options = shared_options(&shapes, revolve_options(p))?;
let mut copies = 0;
return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies));
}
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
return Err("Revolve requires a shape with at least two anchors".into());
}
let path = geometry(&path, before, &GeomContext::of(n));
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)
}
/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of
/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve,
/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level
/// Revolve draws in place of those surfaces.
pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> {
if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() {
return validate_revolve(n, p);
}
validate_revolve_params(p)?;
const EMPTY: &str = "Revolve requires a shape with at least two anchors";
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
return Err(EMPTY.into());
}
let ctx = GeomContext::of(n);
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut copies = 0;
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) };
let options = match at.and_then(|at| effects.get(at)) {
_ if i == target => revolve_options(p),
Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)),
_ => continue,
};
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
}
Ok(())
}
fn has_two_anchors(path: &PathData) -> bool {
path.subpaths.iter().any(|sp| sp.anchors.len() >= 2)
}
/// The bytes the copies of `paint` take on `faces` faces: a face's fill, its shadow and their seam
/// strokes make up to four copies.
fn paint_copies(faces: usize, paint: &Paint) -> usize {
faces.saturating_mul(4).saturating_mul(paint_bytes(paint))
}
/// An error when `path` does not revolve or when its surface in `item`'s paint takes `copies`, the
/// bytes of an object's paint copies so far, past [`MAX_PAINT_COPIES`].
fn check_surface(path: &PathData, b: Rect, options: Revolve, item: Option<&AppearanceItem>, copies: &mut usize) -> Result<(), String> {
let faces = vectorcraft_three_d::revolve(path, b, options)?;
*copies = copies.saturating_add(item.map_or(0, |i| paint_copies(faces.len(), &paint_of(i).0)));
if *copies > MAX_PAINT_COPIES {
return Err("Revolve paint is too complex for this surface; reduce Segments or simplify the gradient".into());
}
Ok(())
}
/// `options` with one number of samples for every curve of `shapes`, the members of one group: the
/// members share Revolve's limits.
fn shared_options(shapes: &[Profile], options: Revolve) -> Result<Revolve, String> {
let samples = vectorcraft_three_d::revolve_curve_samples(shapes.iter().map(|s| &s.path), options)?;
Ok(Revolve { curve_samples: Some(samples), ..options })
}
/// The item whose paint an object's surface takes: its top fill, else its top stroke.
fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> {
items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())
}
/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve cap must be a boolean".into());
}
Ok(())
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// the bytes of the paint copies of every surface of the object.
#[allow(clippy::too_many_arguments)]
fn surface(
n: &Node,
path: &PathData,
b: Rect,
options: Revolve,
fx: &Effect,
paint: Paint,
opacity: f32,
blend: vectorcraft_doc::color::BlendMode,
expand: bool,
copies: &mut usize,
) -> Option<(Vec<f64>, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
*copies = copies.saturating_add(paint_copies(faces.len(), &paint));
if *copies > MAX_PAINT_COPIES {
return None;
}
// Visibility is an expansion option, never a change to live geometry or export baking.
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
// failure also retains the complete surface rather than losing any of the user's art.
let visible = (expand
&& flag(&fx.params, "expandVisibleOnly", true)
&& paint.color().is_some()
&& opacity == 1.0
&& blend == Default::default()
&& n.has_default_transparency())
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
.flatten();
let faces = visible.unwrap_or_else(|| {
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect()
});
let depths: Vec<f64> = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect();
let children = faces
.into_iter()
.map(|face| {
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
return Arc::new(crate::lighting::rgb_gradient_art(n.id, &face.path, &paint, g));
}
if let (true, Paint::Gradient(_), Some(g)) = (colored, &paint, face.light_gradient) {
return Arc::new(crate::lighting::gradient_art(n.id, &face.path, &paint, g));
}
let mut fill = FillLayer::new(paint.clone());
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
fill.paint = if colored {
face.light_gradient
.map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c))
} else {
face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
};
items.push(AppearanceItem::Fill(fill));
} else if colored && matches!(paint, Paint::Gradient(_)) {
items = crate::lighting::fills(&paint, face.illumination, face.brightness, true);
} else {
items.push(AppearanceItem::Fill(fill));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
if let Some(g) = face.gradient {
shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32);
} else {
shadow.opacity = (1.0 - face.brightness) as f32;
}
items.push(AppearanceItem::Fill(shadow));
}
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
// Matching narrow edge paint overlaps their coverage in the canvas and exporters.
// Keep opacity on the surface group so these edges never accumulate transparency.
let items = items
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH);
// Acute face corners must stay within the overlap width. Miter joins
// amplify this seam stroke into visible spikes along the silhouette.
edge.join = LineJoin::Round;
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
.collect();
Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() }))
})
.collect();
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
Some((depths, group))
}
/// A surface of one object about the bounds of `path`, built with options derived from `fx`.
/// `copies` is the count [`surface`] adds to.
fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option<Node> {
let b = path.bounds()?;
let (paint, opacity, blend) = paint_of(item);
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops =
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
}
}
/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs
/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha.
pub fn revolve_art(n: &Node) -> Option<Node> {
revolve_art_in(None, n)
}
/// [`revolve_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone
/// or in a group, revolve the art they show ([`resolve_for`]). `None` too when that art can't be
/// resolved.
pub fn revolve_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
art(doc, n, false)
}
/// [`expand_revolve_art_in`] without a document.
#[cfg(test)]
pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
expand_revolve_art_in(None, n)
}
/// Revolve's own Expand Appearance path, with the symbols of `doc` ([`revolve_art_in`]). The shared
/// command and UI remain unchanged.
pub(crate) fn expand_revolve_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
art(doc, n, true)
}
/// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on
/// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for
/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes
/// `total` past `limit` stops with an error before the next is built.
pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
if is_container(n) {
return group_meshes(n, total, limit);
}
let (path, rule) = object_profile(n, "Revolve")?;
let ctx = GeomContext::of(n);
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> {
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let (paint, opacity, _) = paint_of(item);
let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) })
};
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
let before = n.appearance.effects.get(..index).unwrap_or_default();
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let path = geometry(&path, before, &ctx);
let paints = solid_paints(n);
let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?;
let mut out = vec![];
crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?;
return Ok(out);
}
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE)
&& let Some(fx) = item.effects().get(index)
{
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
crate::solids::keep_mesh(&mut out, total, mesh(&p, fx, item)?, limit)?;
}
}
Ok(out)
}
/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis.
fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
let index = n
.appearance
.effects
.iter()
.position(|e| e.visible && e.id == REVOLVE)
.ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?;
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let mut shapes = members(n, "Revolve")?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let mut out = vec![];
for s in &shapes {
let Some(item) = surface_item(&s.items) else { continue };
let (paint, opacity, _) = paint_of(item);
let mesh = vectorcraft_three_d::revolve_mesh(&s.path, s.rule, b, options)?;
crate::solids::keep_mesh(&mut out, total, crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity * s.opacity) }, limit)?;
}
Ok(out)
}
fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
if !has_revolve(n) {
return None;
}
let n = &*resolve_for(doc, n, "Revolve", false).ok()?;
if is_container(n) {
return group_art(n, expand);
}
let (path, _) = profile(n)?;
let ctx = GeomContext::of(n);
let mut children = vec![];
let mut copies = 0;
let mut remaining = n.appearance.effects.clone();
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
let fx = n.appearance.effects.get(index)?;
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
// An object's surface uses its top visible fill, falling back to its stroke.
let paints = solid_paints(n);
let item = surface_item(&paints)?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?));
// Later geometry acts on the projected art as a whole through container evaluation.
remaining = n
.appearance
.effects
.iter()
.enumerate()
.filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining.retain(|e| !is_geometry(&e.id));
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
let fx = item.effects().get(index)?;
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
children.push(Arc::new(art));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
}
}
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
/// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each
/// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its
/// opacity, blending mode and opacity mask, so each applies once, to the faces it holds together.
pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, level: usize) -> Vec<Arc<Node>> {
fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc<Node>)>, scope: Option<&Scope>, level: usize, out: &mut Vec<Arc<Node>>) {
if let Some(scope) = scope {
out.push(Arc::new(scope.group(id, scoped(id, std::mem::take(run), level + 1))));
}
}
let mut out = vec![];
let mut run = vec![];
let mut scope: Option<&'a Scope> = None;
for (scopes, s) in surfaces {
let at = scopes.get(level);
if at.map(|a| a.key) != scope.map(|s| s.key) {
close(id, &mut run, scope, level, &mut out);
scope = at;
}
if scope.is_some() {
run.push((scopes, s));
} else {
out.push(s);
}
}
close(id, &mut run, scope, level, &mut out);
out
}
/// 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,
hull: Vec<Point>,
}
impl Coverage {
/// `None` for a face with no outline and no box.
fn of(face: &Node) -> Option<Coverage> {
let points: Vec<Point> = match face.path_data() {
Some(path) => path
.to_bezpath()
.elements()
.iter()
.flat_map(|e| match *e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p],
PathEl::QuadTo(a, b) => vec![a, b],
PathEl::CurveTo(a, b, c) => vec![a, b, c],
PathEl::ClosePath => vec![],
})
.collect(),
None => {
let b = face.visual_bounds()?;
vec![Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]
}
};
let hull = convex_hull(points);
let first = *hull.first()?;
let reach = SEAM_WIDTH / 2.0;
let bounds = hull.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p)).inflate(reach, reach);
Some(Coverage { bounds, hull })
}
/// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the
/// separating axes of their edges and of the page's axes: `true` whenever they overlap, and
/// sometimes near a corner when they don't.
fn meets(&self, other: &Coverage) -> bool {
fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ {
(0..hull.len()).filter_map(|i| {
let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?);
Some(Vec2::new(a.y - b.y, b.x - a.x))
})
}
let axes = [Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)].into_iter().chain(normals(&self.hull)).chain(normals(&other.hull));
let span = |hull: &[Point], axis: Vec2| {
hull.iter().map(|p| p.to_vec2().dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
};
axes.filter(|axis| axis.hypot() > 0.0).all(|axis| {
let ((a0, a1), (b0, b1)) = (span(&self.hull, axis), span(&other.hull, axis));
let gap = SEAM_WIDTH * axis.hypot();
b0 - a1 < gap && a0 - b1 < gap
})
}
}
/// The convex hull of `points`, counterclockwise in a y-up frame, without repeated points.
fn convex_hull(mut points: Vec<Point>) -> Vec<Point> {
points.retain(|p| p.is_finite());
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
points.dedup();
if points.len() < 3 {
return points;
}
// One side of the hull from the first point to the last, without the last.
fn side<'a>(points: impl Iterator<Item = &'a Point>) -> Vec<Point> {
let mut chain: Vec<Point> = vec![];
for &p in points {
while let [.., o, a] = chain[..]
&& (a - o).cross(p - o) <= 0.0
{
chain.pop();
}
chain.push(p);
}
chain.pop();
chain
}
let mut hull = side(points.iter());
hull.extend(side(points.iter().rev()));
hull
}
/// Do boxes `a` and `b` overlap with some area?
fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
}
/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them,
/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when
/// the effect is only on the group's own fills or strokes or when its members can't be revolved.
fn group_art(n: &Node, expand: bool) -> Option<Node> {
let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?;
let fx = n.appearance.effects.get(index)?;
let mut shapes = members(n, "").ok()?;
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let b = union_bounds(&shapes)?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut copies = 0;
let mut surfaces = vec![];
let mut opaque = true;
for s in &shapes {
let Some(item) = surface_item(&s.items) else { continue };
// The opacity and blending of the member and the groups it is in apply through its scopes.
let (paint, opacity, blend) = paint_of(item);
opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty();
// A member with transparency of its own keeps every face, as a transparent object does.
let expand = expand && s.scopes.is_empty();
let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?;
surfaces.push((depths, art, s.scopes.as_slice()));
}
if surfaces.is_empty() {
return None;
}
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
let children = if !plain {
scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0)
} else {
let count = surfaces.len();
let mut faces = vec![];
for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() {
let (opacity, blend) = (s.opacity, s.blend);
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face)));
}
}
// A stable sort: faces at the same depth keep the members' order.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
if opaque {
vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))]
} else {
// An opaque member's face goes in the order on its own. The faces of a member with
// transparency share a run, one group with the opacity and blending mode of its fill or
// stroke, and its scopes group runs further. Such a face joins its member's last run
// unless a face drawn after that run overlaps it: only then does the member split,
// where one group can't be both behind and in front of another face.
struct Run<'a> {
member: Option<usize>,
scopes: &'a [Scope],
bounds: Rect,
faces: Vec<Arc<Node>>,
cover: Vec<Coverage>,
opacity: f32,
blend: vectorcraft_doc::color::BlendMode,
}
let mut runs: Vec<Run> = vec![];
let mut last_run = vec![None; count];
// Past this many tests a face starts a run of its own, which never draws out of order.
let mut budget = MAX_OVERLAP_TESTS;
for (_, member, opacity, blend, scopes, face) in faces {
let paints = opacity > 0.0 && scopes.iter().all(|s| s.opacity > 0.0);
let Some(cover) = Coverage::of(&face).filter(|_| paints) else {
// A face that paints nothing goes in the order on its own and overlaps nothing.
runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend });
continue;
};
if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() {
runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend });
continue;
}
let mut clear = |r: &Run| {
budget = budget.saturating_sub(1);
if !overlaps(r.bounds, cover.bounds) {
return budget > 0;
}
budget = budget.saturating_sub(r.cover.len());
budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover))
};
let joins =
last_run.get(member).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear)));
match joins.and_then(|i| runs.get_mut(i)) {
Some(run) => {
run.bounds = run.bounds.union(cover.bounds);
run.faces.push(face);
run.cover.push(cover);
}
None => {
if let Some(last) = last_run.get_mut(member) {
*last = Some(runs.len());
}
runs.push(Run { member: Some(member), scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend });
}
}
}
let runs = runs.into_iter().filter_map(|mut r| {
// A face on its own needs a group only for the opacity or blending mode of its fill or stroke.
if r.member.is_none() && r.opacity >= 1.0 && r.blend == Default::default() {
return r.faces.pop().map(|face| (r.scopes, face));
}
let mut group = Node::group(n.id, r.faces);
group.opacity = r.opacity;
group.blend = r.blend;
Some((r.scopes, Arc::new(group)))
});
scoped(n.id, runs.collect(), 0)
}
};
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
fn profile_node() -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 40.0));
b.line_to((130.0, 60.0));
b.line_to((130.0, 140.0));
b.line_to((100.0, 160.0));
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))
}
fn fx() -> Effect {
crate::new_effect(REVOLVE, &json!({})).unwrap()
}
#[test]
fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() {
use kurbo::Shape;
for shade in [false, true] {
for angle in [110.0, 360.0] {
let mut n = profile_node();
n.appearance.effects.push(
crate::new_effect(
REVOLVE,
&json!({
"offset":20,"rotationX":-18,"segments":24,"cap":true,
"shade":shade,"angle":angle
}),
)
.unwrap(),
);
for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] {
let mut checked = 0;
art.walk(&mut |face| {
let Some(path) = face.path_data() else { return };
for item in &face.appearance.items {
let AppearanceItem::Stroke(stroke) = item else { continue };
let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005);
let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box();
assert!(
bounds.contains_rect(ink),
"seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}"
);
checked += 1;
}
});
assert!(checked > 20);
}
}
}
}
#[test]
fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() {
let mut n = profile_node();
n.appearance.effects.push(fx());
assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
let smooth = revolve_art(&n).unwrap();
assert!(
smooth.children().unwrap()[0].children().unwrap().iter().any(|face| {
matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_)))
})
);
n.appearance.effects[0].params["smoothShading"] = json!(false);
assert_eq!(revolve_art(&n), Some(smooth.clone()));
n.appearance.effects[0].params["smoothShading"] = json!(true);
assert_eq!(revolve_art(&n), Some(smooth));
use vectorcraft_doc::color::{Gradient, GradientPaint};
let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
n.appearance.set_fill(base.clone());
let art = revolve_art(&n).unwrap();
let faces = art.children().unwrap()[0].children().unwrap();
assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base)));
assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| {
matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity))
})));
assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces");
}
#[test]
fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() {
let mut n = profile_node();
let mut effect = fx();
effect.params["offset"] = json!(20);
effect.params["rotationX"] = json!(-35);
effect.params.as_object_mut().unwrap().remove("cap");
n.appearance.effects.push(effect);
let capped = revolve_art(&n).unwrap();
n.appearance.effects[0].params["cap"] = json!(false);
let uncapped = revolve_art(&n).unwrap();
assert_ne!(uncapped, capped);
n.appearance.effects[0].params["cap"] = json!(true);
assert_eq!(revolve_art(&n), Some(capped));
n.appearance.effects[0].params["cap"] = json!(false);
assert_eq!(revolve_art(&n), Some(uncapped));
}
#[test]
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
let mut n = profile_node();
let before = n.path_data().unwrap().clone();
n.appearance.effects.push(fx());
let a = revolve_art(&n).unwrap();
assert!(!a.children().unwrap().is_empty());
assert_eq!(n.path_data().unwrap(), &before);
let b = a.visual_bounds().unwrap();
n.transform(Affine::translate((80.0, 20.0)), false);
let moved = revolve_art(&n).unwrap().visual_bounds().unwrap();
assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6);
n.appearance.effects[0].visible = false;
assert!(revolve_art(&n).is_none());
}
#[test]
fn item_effect_and_transparency_are_scoped_to_whole_surface() {
let mut n = profile_node();
n.opacity = 0.6;
n.appearance.items[0].effects_mut().push(fx());
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
f.opacity = 0.4;
}
let a = revolve_art(&n).unwrap();
assert_eq!(a.opacity, 0.6);
let surface = &a.children().unwrap()[0];
assert_eq!(surface.opacity, 0.4);
assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c)));
}
#[test]
fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() {
let mut d = Document::new(300.0, 300.0);
let mut n = profile_node();
n.id = d.alloc_id();
n.appearance.effects.push(fx());
let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
let mut ids = std::collections::HashSet::new();
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
assert!(ids.insert(n.id));
assert!(!has_revolve(n));
for c in n.children().into_iter().flatten() {
visit(c, ids);
}
}
visit(&art, &mut ids);
assert!(ids.len() > 50);
}
#[test]
fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() {
let mut n = profile_node();
n.appearance.effects.push(fx());
let source = n.path_data().unwrap().clone();
let live = revolve_art(&n).unwrap();
let expanded = expand_revolve_art(&n).unwrap();
let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len();
assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed");
assert_eq!(crate::bake_appearance(None, &n), Some(live.clone()), "other baking retains the full surface");
n.appearance.effects[0].params["expandVisibleOnly"] = json!(false);
assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting");
assert_eq!(expand_revolve_art(&n), Some(live));
assert_eq!(n.path_data(), Some(&source));
n.appearance.effects[0].params["expandVisibleOnly"] = json!(true);
assert_eq!(expand_revolve_art(&n), Some(expanded.clone()));
// Per-item Revolve uses the same option, without trimming another item's artwork.
let effect = n.appearance.effects.remove(0);
n.appearance.items[0].effects_mut().push(effect);
assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]);
}
/// A closed rectangle `x` to `x + 10` across, filled solid.
fn member(id: u64, x: f64) -> Arc<Node> {
let mut b = BezPath::new();
b.move_to((x, 0.0));
b.line_to((x + 10.0, 0.0));
b.line_to((x + 10.0, 40.0));
b.line_to((x, 40.0));
b.close_path();
Arc::new(Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)))
}
fn group_with(params: Value, children: Vec<Arc<Node>>) -> Node {
let mut g = Node::group(NodeId(9), children);
g.appearance.effects.push(crate::new_effect(REVOLVE, &params).unwrap());
g
}
#[test]
fn a_groups_members_share_the_axis_on_the_edge_of_them_all() {
let mut hidden = (*member(3, -500.0)).clone();
hidden.visible = false;
let nested = Arc::new(Node::group(NodeId(5), vec![member(4, 90.0)]));
let children = vec![member(1, 0.0), nested, Arc::new(hidden)];
// Left edge, 5 pt out: the axis at -5, the far wall (100) 105 from it.
let g = group_with(json!({"segments":32,"offset":5,"rotationX":0}), children.clone());
let b = revolve_art(&g).unwrap().visual_bounds().unwrap();
assert!((b.x0 + 110.0).abs() < 1.5 && (b.x1 - 100.0).abs() < 1.5, "{b:?}");
// Right edge, 5 pt out: the axis at 105, the far wall (0) 105 from it.
let g = group_with(json!({"segments":32,"offset":5,"edge":"right","rotationX":0}), children);
let b = revolve_art(&g).unwrap().visual_bounds().unwrap();
assert!((b.x1 - 210.0).abs() < 1.5 && (b.x0 - 0.0).abs() < 1.5, "{b:?}");
}
#[test]
fn opaque_members_share_one_back_to_front_order() {
let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]);
let art = revolve_art(&g).unwrap();
assert_eq!(art.children().unwrap().len(), 1, "one order for every face");
assert!(art.children().unwrap()[0].children().unwrap().len() > 16);
}
/// The opacities and blending modes of the groups a face is drawn in, outermost first, leaving
/// out groups with neither.
type Chain = Vec<(f32, vectorcraft_doc::color::BlendMode)>;
/// The faces of `art` in drawing order, each with its [`Chain`].
fn faces_in_order(art: &Node) -> Vec<(Node, Chain)> {
fn visit(n: &Node, mut scopes: Chain, out: &mut Vec<(Node, Chain)>) {
match n.children() {
Some(children) => {
if n.opacity != 1.0 || n.blend != Default::default() {
scopes.push((n.opacity, n.blend));
}
for c in children {
visit(c, scopes.clone(), out);
}
}
None => out.push((n.clone(), scopes)),
}
}
let mut out = vec![];
visit(art, vec![], &mut out);
out
}
#[test]
fn a_transparent_member_leaves_every_face_in_one_back_to_front_order() {
let faces_of = |members| faces_in_order(&revolve_art(&group_with(json!({"segments":16}), members)).unwrap());
let alone = faces_of(vec![member(1, 0.0), member(2, 50.0)]);
let mut faded = (*member(3, 20.0)).clone();
faded.opacity = 0.5;
let faces = faces_of(vec![member(1, 0.0), member(2, 50.0), Arc::new(faded)]);
let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect();
assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::<Vec<_>>(), "the opaque members' faces keep their shared order");
assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, Default::default())]), "the member's opacity applies once to each face");
let first = faces.iter().position(|(_, s)| !s.is_empty()).unwrap();
let last = faces.iter().rposition(|(_, s)| !s.is_empty()).unwrap();
assert!(first > 0 && last + 1 < faces.len(), "the ring between the others draws in front of the back ones and behind the front ones");
let groups = |art: &Node| {
let mut count = 0;
art.walk(&mut |n| count += usize::from(n.children().is_some() && n.opacity == 0.5));
count
};
// Rings one above the other: their faces alternate in the order but none overlaps the
// other ring, so each ring's opacity applies once, to all its faces.
let faded = |id, y: f64| {
let mut n = (*member(id, 40.0)).clone();
n.opacity = 0.5;
if let NodeKind::Path { path, .. } = &mut n.kind {
*path = PathData::from_bezpath(&(Affine::translate((0.0, y)) * path.to_bezpath()));
}
Arc::new(n)
};
let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0)])).unwrap();
assert!(faces_in_order(&art).iter().all(|(_, s)| s == &[(0.5, Default::default())]));
assert_eq!(groups(&art), 2, "one group for each ring");
// A tall cylinder inside both that paints nothing splits neither.
let mut hidden = (*member(3, 0.0)).clone();
hidden.opacity = 0.0;
if let NodeKind::Path { path, .. } = &mut hidden.kind {
*path = PathData::from_bezpath(&(Affine::translate((0.0, -20.0)) * Affine::scale_non_uniform(1.0, 7.0) * path.to_bezpath()));
}
let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0), Arc::new(hidden)])).unwrap();
assert_eq!(groups(&art), 2, "one group for each faded ring");
}
#[test]
fn a_member_whose_fill_or_stroke_paints_nothing_stays_invisible() {
let clear_fill = {
let mut n = (*member(3, 20.0)).clone();
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.opacity = 0.0;
}
n
};
let clear_stroke = {
let mut n = (*member(4, 20.0)).clone();
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into();
for item in n.appearance.items.iter_mut() {
if let AppearanceItem::Stroke(s) = item {
s.opacity = 0.0;
}
}
n
};
for clear in [clear_fill, clear_stroke] {
let art = revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0), Arc::new(clear)])).unwrap();
let faces = faces_in_order(&art);
let alone = faces_in_order(&revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)])).unwrap());
let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect();
assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::<Vec<_>>(), "only the opaque members' faces draw without transparency");
assert!(faces.len() > alone.len(), "the clear member's faces stay in the art");
assert!(faces.iter().all(|(_, s)| s.is_empty() || s.iter().any(|(o, _)| *o == 0.0)), "each clear face is drawn at 0% opacity");
}
}
fn star(id: u64) -> Arc<Node> {
let anchors = (0..1200)
.map(|i| {
let (s, c) = (i as f64 * std::f64::consts::PI / 600.0).sin_cos();
let r = if i % 2 == 0 { 100.0 } else { 60.0 };
vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(200.0 + r * c, 200.0 + r * s))
})
.collect();
Arc::new(Node::path(
NodeId(id),
PathData::new(vec![vectorcraft_geom::SubPath::new(anchors, true)]),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
))
}
#[test]
fn faces_overlap_by_their_painted_shapes_and_seams_not_only_their_boxes() {
let face = |points: &[(f64, f64)]| {
let mut b = BezPath::new();
b.move_to(points[0]);
for &p in &points[1..] {
b.line_to(p);
}
b.close_path();
Coverage::of(&Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::default())).unwrap()
};
let a = face(&[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]);
assert_eq!(a.hull.len(), 3);
assert_eq!(
face(&[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.0, 5.0), (0.0, 10.0)]).hull.len(),
4,
"a point on an edge or inside is left out"
);
// Across the diagonal 2 / sqrt(2) apart: their boxes overlap, their shapes and seams don't.
let b = face(&[(10.0, 10.0), (10.0, 2.0), (2.0, 10.0)]);
assert!(overlaps(a.bounds, b.bounds) && !a.meets(&b) && !b.meets(&a));
// 1 / sqrt(2) apart, within the two half seam strokes.
let c = face(&[(10.0, 10.0), (10.0, 1.0), (1.0, 10.0)]);
assert!(a.meets(&c) && c.meets(&a));
assert!(a.meets(&a), "a face meets itself");
}
#[test]
fn a_groups_members_share_revolves_limits() {
let alone = group_with(json!({}), vec![star(1)]);
assert!(validate_revolve(&alone, &json!({})).is_ok());
let both = group_with(json!({}), vec![star(1), star(2)]);
let refused = validate_revolve(&both, &json!({})).unwrap_err();
assert!(refused.contains("reduce Segments"), "{refused}");
assert_eq!(revolve_art(&both), None, "past the limits the source draws");
}
#[test]
fn blending_modes_apply_once_to_the_member_or_group_that_has_them() {
use vectorcraft_doc::color::BlendMode;
let blend = |n: Arc<Node>, mode: BlendMode| {
let mut n = (*n).clone();
n.blend = mode;
Arc::new(n)
};
let inner = blend(member(4, 60.0), BlendMode::Multiply);
let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen);
let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]);
let art = revolve_art(&group).expect("revolved");
let faces = faces_in_order(&art);
let modes = |s: &[(f32, BlendMode)]| s.iter().map(|(_, b)| *b).collect::<Vec<_>>();
let chains = [vec![BlendMode::Multiply], vec![], vec![BlendMode::Screen], vec![BlendMode::Screen, BlendMode::Multiply]];
for chain in &chains {
assert!(faces.iter().any(|(_, s)| &modes(s) == chain), "faces drawn in {chain:?}");
}
assert!(faces.iter().all(|(_, s)| chains.contains(&modes(s))), "each mode applies once, to the member or group that has it");
// Faces of the screened group's members that come together in the drawing order share one Screen group.
let runs = |faces: &[(Node, Chain)], inside: &dyn Fn(&Chain) -> bool| {
faces.windows(2).filter(|w| inside(&w[1].1) && !inside(&w[0].1)).count() + usize::from(faces.first().is_some_and(|f| inside(&f.1)))
};
let groups = |art: &Node, pick: &dyn Fn(&Node) -> bool| {
let mut count = 0;
art.walk(&mut |n| count += usize::from(n.children().is_some() && pick(n)));
count
};
let screened = |s: &Chain| s.first().is_some_and(|(_, b)| *b == BlendMode::Screen);
assert_eq!(groups(&art, &|n| n.blend == BlendMode::Screen), runs(&faces, &screened));
let plain = group_with(json!({}), vec![member(1, 0.0)]);
assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed");
let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]);
assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face");
// A nested group's opacity applies once, to its members' faces that come together.
let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]);
faded.opacity = 0.5;
let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved");
let faces = faces_in_order(&art);
assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, BlendMode::Normal)]), "the faded group's opacity applies once");
let half = |s: &Chain| !s.is_empty();
assert!(faces.iter().any(|(_, s)| half(s)) && faces.iter().any(|(_, s)| !half(s)));
assert_eq!(groups(&art, &|n| n.opacity == 0.5), runs(&faces, &half), "the faded group's members, together");
}
#[test]
fn a_3d_effect_on_a_groups_fill_is_refused() {
let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]);
group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
for id in [REVOLVE, crate::EXTRUDE] {
assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself");
let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err();
assert!(refused.contains("on the group itself"), "{id}: {refused}");
}
}
#[test]
fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() {
let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap();
let mut group = group_with(json!({}), vec![star(1)]);
assert!(validate_revolve(&group, &json!({})).is_ok());
group.appearance.effects.insert(0, roughen.clone());
assert!(validate_revolve(&group, &json!({})).is_err(), "the group's reshaped member");
assert_eq!(revolve_art(&group), None);
let mut object = (*star(1)).clone();
assert!(validate_revolve(&object, &json!({})).is_ok());
object.appearance.effects.insert(0, roughen);
assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved");
}
#[test]
fn validation_of_a_hidden_revolve_leaves_out_the_geometry_effects_after_it() {
let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap();
let mut hidden = crate::new_effect(REVOLVE, &json!({})).unwrap();
hidden.visible = false;
let mut object = (*star(1)).clone();
object.appearance.effects = vec![hidden.clone(), roughen.clone()];
assert_eq!(validate_revolve(&object, &json!({})), Ok(()), "an object whose Revolve is shown again");
let mut group = group_with(json!({}), vec![star(1)]);
group.appearance.effects = vec![hidden, roughen];
assert_eq!(validate_revolve(&group, &json!({})), Ok(()), "a group whose Revolve is shown again");
for n in [&mut object, &mut group] {
n.appearance.effects[0].visible = true;
assert!(revolve_art(n).is_some(), "shown, the Revolve draws");
}
}
#[test]
fn paint_copies_past_the_budget_are_refused_for_the_whole_group() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let painted = |id: u64, x: f64, stops: usize| {
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut node = (*member(id, x)).clone();
node.appearance = Appearance::basic(paint, Paint::None, 0.0).into();
Arc::new(node)
};
let plain = member(1, 0.0);
let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") };
let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len();
let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::<GradientStop>();
let one = group_with(json!({}), vec![painted(1, 0.0, stops)]);
assert_eq!(validate_revolve(&one, &json!({})), Ok(()));
let two = group_with(json!({}), vec![painted(1, 0.0, stops), painted(2, 20.0, stops)]);
let refused = validate_revolve(&two, &json!({})).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
assert_eq!(revolve_art(&two), None, "past the budget the source draws");
let object = painted(3, 0.0, stops * 2);
assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget");
}
#[test]
fn the_art_of_symbol_instances_and_blends_shares_the_budget_of_paint_copies() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::{BlendSpacing, BlendSpec, Symbol};
let plain = member(1, 0.0);
let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") };
let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len();
let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::<GradientStop>();
let painted = |id: u64, x: f64| {
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut node = (*member(id, x)).clone();
node.appearance = Appearance::basic(paint, Paint::None, 0.0).into();
Arc::new(node)
};
let mut d = Document::new(500.0, 500.0);
d.symbols.push(Symbol { dynamic: false, name: "P".into(), art: painted(0, 0.0) });
let instance = |id: u64| Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: "P".into(), xf: Affine::IDENTITY }));
// One instance's art fits; two in the same place, each of whose surfaces fits alone, pass
// the budget together, as two paths would.
assert_eq!(validate_revolve_in(Some(&d), &group_with(json!({}), vec![instance(1)]), &json!({})), Ok(()));
let two = group_with(json!({}), vec![instance(1), instance(2)]);
let refused = validate_revolve_in(Some(&d), &two, &json!({})).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
assert_eq!(revolve_art_in(Some(&d), &two), None, "past the budget the source draws");
// A blend's keys and step count together too.
let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() });
let blend = Arc::new(Node::new(NodeId(3), NodeKind::Blend { children: vec![painted(4, 0.0), painted(5, 0.0)], spec }));
let refused = validate_revolve_in(Some(&d), &group_with(json!({}), vec![blend]), &json!({})).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
}
#[test]
fn the_fills_of_one_object_share_the_budget_of_paint_copies() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let source = profile_node();
let NodeKind::Path { path: profile, .. } = &source.kind else { panic!("a path") };
let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len();
let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::<GradientStop>();
let fill = |stops: usize, revolved: bool| {
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut item = AppearanceItem::Fill(FillLayer::new(paint));
if revolved {
item.effects_mut().push(fx());
}
item
};
let with = |items: Vec<AppearanceItem>| {
let mut n = source.clone();
n.appearance = Appearance { items, ..Default::default() }.into();
n
};
let one = with(vec![fill(stops, true)]);
assert!(revolve_art(&one).is_some());
assert_eq!(validate_item_revolve(&one, &json!({}), 0, 0), Ok(()));
let two = with(vec![fill(stops, true), fill(stops, true)]);
assert_eq!(revolve_art(&two), None, "past the budget the source draws");
let refused = validate_item_revolve(&two, &json!({}), 1, 0).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
let second = with(vec![fill(stops, true), fill(stops, false)]);
assert!(validate_item_revolve(&second, &json!({}), 1, 0).is_err(), "the item the effect goes on counts");
assert_eq!(validate_item_revolve(&second, &json!({}), 0, 0), Ok(()), "an item without a Revolve does not");
let below = with(vec![fill(stops * 2, false), fill(1, false)]);
assert!(validate_item_revolve(&below, &json!({}), 0, 0).is_err(), "the item's own paint, not the top fill");
assert_eq!(validate_item_revolve(&below, &json!({}), 1, 0), Ok(()));
}
#[test]
fn an_items_revolve_is_validated_after_only_the_geometry_effects_before_it() {
let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap();
let mut object = (*star(1)).clone();
let mut hidden = fx();
hidden.visible = false;
let item = object.appearance.items.first_mut().unwrap();
item.effects_mut().push(hidden);
item.effects_mut().push(roughen);
assert_eq!(validate_item_revolve(&object, &json!({}), 0, 0), Ok(()), "the roughen after it is not swept");
assert!(validate_item_revolve(&object, &json!({}), 0, 2).is_err(), "a Revolve added after the roughen");
}
#[test]
fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() {
let mut n = profile_node();
n.appearance.effects.push(fx());
for change in 0..4 {
let mut sample = n.clone();
match change {
0 => sample.opacity = 0.5,
1 => {
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
f.opacity = 0.4;
}
}
2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply,
_ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()),
}
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}");
}
let mut sample = n;
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY };
}
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample));
}
}