//! 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, with the strings it owns (swatch and pattern names). fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; use vectorcraft_doc::color::{FreeformPoint, GradientStop}; let own = size_of::(); let name = |s: &Option| s.as_ref().map_or(0, String::len); let g = match paint { Paint::Solid { swatch, .. } => return own + name(swatch), Paint::Pattern { pattern, .. } => return own + pattern.len(), Paint::Gradient(g) => g, _ => return own, }; let freeform = g.freeform.as_ref().map_or(0, |f| { // Each line is a Vec: its header too, not only its indices. f.points.len() * size_of::() + f.lines.len() * size_of::>() + f.lines.iter().map(|l| l.len() * size_of::()).sum::() }); let stops: usize = g.gradient.stops.iter().map(|s| size_of::() + name(&s.swatch)).sum(); own + size_of::() + name(&g.swatch) + stops + freeform } /// Add `copies` copies of `paint` to the running byte total `used`; whether it still fits within /// [`MAX_PAINT_COPIES`] (for the solid surfaces, whose items' copies count together). pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -> bool { *used = used.saturating_add(copies.saturating_mul(paint_bytes(paint))); *used <= MAX_PAINT_COPIES } 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 { 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, 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 = 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 { 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 { 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 { art(doc, n, false) } /// [`expand_revolve_art_in`] without a document. #[cfg(test)] pub(crate) fn expand_revolve_art(n: &Node) -> Option { 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 { 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, 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 { 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, 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 { 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)>, level: usize) -> Vec> { fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option<&Scope>, level: usize, out: &mut Vec>) { 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, } impl Coverage { /// `None` for a face with no outline and no box. fn of(face: &Node) -> Option { let points: Vec = match face.path_data() { Some(path) => path .to_bezpath() .elements() .iter() .flat_map(|e| match *e { PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p], PathEl::QuadTo(a, b) => vec![a, b], PathEl::CurveTo(a, b, c) => vec![a, b, c], PathEl::ClosePath => vec![], }) .collect(), None => { let b = face.visual_bounds()?; vec![Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] } }; let hull = convex_hull(points); let first = *hull.first()?; let reach = SEAM_WIDTH / 2.0; let bounds = hull.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p)).inflate(reach, reach); Some(Coverage { bounds, hull }) } /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and /// sometimes near a corner when they don't. fn meets(&self, other: &Coverage) -> bool { fn normals(hull: &[Point]) -> impl Iterator + '_ { (0..hull.len()).filter_map(|i| { let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); Some(Vec2::new(a.y - b.y, b.x - a.x)) }) } let axes = [Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)].into_iter().chain(normals(&self.hull)).chain(normals(&other.hull)); let span = |hull: &[Point], axis: Vec2| { hull.iter().map(|p| p.to_vec2().dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) }; axes.filter(|axis| axis.hypot() > 0.0).all(|axis| { let ((a0, a1), (b0, b1)) = (span(&self.hull, axis), span(&other.hull, axis)); let gap = SEAM_WIDTH * axis.hypot(); b0 - a1 < gap && a0 - b1 < gap }) } } /// The convex hull of `points`, counterclockwise in a y-up frame, without repeated points. fn convex_hull(mut points: Vec) -> Vec { points.retain(|p| p.is_finite()); points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); points.dedup(); if points.len() < 3 { return points; } // One side of the hull from the first point to the last, without the last. fn side<'a>(points: impl Iterator) -> Vec { let mut chain: Vec = vec![]; for &p in points { while let [.., o, a] = chain[..] && (a - o).cross(p - o) <= 0.0 { chain.pop(); } chain.push(p); } chain.pop(); chain } let mut hull = side(points.iter()); hull.extend(side(points.iter().rev())); hull } /// Do boxes `a` and `b` overlap with some area? fn overlaps(a: Rect, b: Rect) -> bool { a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 } /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, /// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option { let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; 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, scopes: &'a [Scope], bounds: Rect, faces: Vec>, cover: Vec, opacity: f32, blend: vectorcraft_doc::color::BlendMode, } let mut runs: Vec = vec![]; let mut last_run = vec![None; count]; // Past this many tests a face starts a run of its own, which never draws out of order. let mut budget = MAX_OVERLAP_TESTS; for (_, member, opacity, blend, scopes, face) in faces { let paints = opacity > 0.0 && scopes.iter().all(|s| s.opacity > 0.0); let Some(cover) = Coverage::of(&face).filter(|_| paints) else { // A face that paints nothing goes in the order on its own and overlaps nothing. runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); continue; }; if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); continue; } let mut clear = |r: &Run| { budget = budget.saturating_sub(1); if !overlaps(r.bounds, cover.bounds) { return budget > 0; } budget = budget.saturating_sub(r.cover.len()); budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) }; let joins = last_run.get(member).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); match joins.and_then(|i| runs.get_mut(i)) { Some(run) => { run.bounds = run.bounds.union(cover.bounds); run.faces.push(face); run.cover.push(cover); } None => { if let Some(last) = last_run.get_mut(member) { *last = Some(runs.len()); } runs.push(Run { member: Some(member), scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); } } } let runs = runs.into_iter().filter_map(|mut r| { // 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) { 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 { 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>) -> Node { let mut g = Node::group(NodeId(9), children); g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms).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::>(), "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::>(), "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 { 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, 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::>(); 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::(); 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::(); 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::(); 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| { 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)); } }