diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index d28ab56e5..d4c461f07 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -212,8 +212,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, } /// The 3D art of one effect `fx` on `path` (already through the effects before it). -/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill -/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint. +/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a +/// solid from the path for the top visible fill and one from the outline of each visible stroke, +/// each in its own paint. When every one is opaque with normal blending and no colored light makes +/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where +/// depths tie, as for the caps turned toward the viewer, in the order of the appearance); +/// otherwise each solid is a surface of its own, in that order. fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option>> { let b = path.bounds()?; let p = crate::merged_params(&fx.id, &fx.params); @@ -229,12 +233,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App } return Some(out); } - let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?; - let (paint, opacity, blend) = paint_of(item); - // A loaded file or a shape edited past the limits keeps drawing its source path instead of - // failing the frame; commands validate the same evaluator and report its errors. - let f = facets(&fx.id, path, rule, b, &p).ok()?; - Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))]) + let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items + .iter() + .enumerate() + .filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))) + .map(|(_, i)| { + let (area, rule) = region(path, rule, i); + (*i, area, rule) + }) + .collect(); + // The solids share the bounds of all their shapes: one center to turn, twist and taper about, + // and Inflate's grid reaches over the strokes' outlines. + let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b); + let built = if fx.id == INFLATE { + // One eye for every puff, as far away as the highest needs. + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) + } else { + parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect() + }; + let mut solids = vec![]; + for ((item, _, _), f) in parts.iter().zip(built) { + let item: &AppearanceItem = item; + match f { + Ok(f) => solids.push((item, f)), + // A stroke's outline past the limits is left out; the fill's solid still draws. + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + // A loaded file or a shape edited past the limits keeps drawing its source path + // instead of failing the frame; commands validate the same evaluator and report its + // errors. + Err(_) => return None, + } + } + if solids.is_empty() { + return None; + } + let opaque = solids.iter().all(|(item, _)| { + let (_, opacity, blend) = paint_of(item); + opacity >= 1.0 && blend == BlendMode::Normal + }); + let surfaces: Vec<(Vec, Node)> = solids + .into_iter() + .map(|(item, f)| { + let depths: Vec = f.iter().map(|f| f.depth).collect(); + let (paint, opacity, blend) = paint_of(item); + (depths, surface(n, f, &paint, opacity, blend, colored)) + }) + .collect(); + // One order takes a plain group of faces from each solid, one face for each facet. With + // colored lights a surface can instead be one clipped composite of all its facets, or carry an + // opacity mask, and then it stays whole. + let plain = surfaces.iter().all(|(depths, s)| { + s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()) + }); + if !opaque || !plain { + return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect()); + } + let mut faces = vec![]; + for (depths, s) in surfaces { + if let NodeKind::Group { children, .. } = s.kind { + faces.extend(depths.into_iter().zip(children)); + } + } + // A stable sort: faces at the same depth keep the order of the appearance. + faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))]) } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { diff --git a/crates/effects/src/tests_solids.rs b/crates/effects/src/tests_solids.rs index 335c0b8b6..61b5a7f57 100644 --- a/crates/effects/src/tests_solids.rs +++ b/crates/effects/src/tests_solids.rs @@ -1,6 +1,6 @@ use serde_json::json; use vectorcraft_doc::{Document, NodeId}; -use vectorcraft_geom::{Affine, BezPath}; +use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() { let art = solid_art(&n).unwrap_or_else(|| panic!("{id}")); assert_eq!(n.path_data(), Some(&source), "{id}"); assert!(!faces(&art).is_empty(), "{id}"); - // The faces are the fill's red, shaded: never brighter, never another hue. + // The faces are the fill's red or the stroke's black, shaded: never brighter, never another + // hue. for f in faces(&art) { let [r, g, b] = fill_rgb(f); assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}"); @@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() { n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}); let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap(); assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}"); - // Extrude & Bevel paints one surface with the top fill. + // Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface. let mut n = square_node(); n.appearance.effects.push(fx(EXTRUDE, json!({}))); assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1); } +const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 }; + +/// `b` with a red fill and a 6 pt blue stroke above it, on the path. +fn stroked(b: &BezPath) -> Node { + Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0)) +} + +/// A `width` × 100 pt rectangle at (100, 100), stroked. +fn stroked_rect(width: f64) -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 100.0)); + b.line_to((100.0 + width, 100.0)); + b.line_to((100.0 + width, 200.0)); + b.line_to((100.0, 200.0)); + b.close_path(); + stroked(&b) +} + +/// Is `face` in the stroke's blue (rather than the fill's red)? +fn blue(face: &Node) -> bool { + let [r, _, b] = fill_rgb(face); + b > r +} + +/// The bounds of the faces' own paths (without their seam strokes). +fn extent(faces: &[&Node]) -> Rect { + faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap() +} + +fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) { + assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}"); +} + +/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path +/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the +/// fill's, and they are drawn in the stroke's paint. +#[test] +fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() { + for id in [EXTRUDE, INFLATE] { + let mut n = stroked_rect(100.0); + n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0}))); + let art = solid_art(&n).unwrap(); + let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f)); + assert!(!strokes.is_empty(), "{id}: the stroke draws"); + assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0); + assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0); + // Its own blue, shaded: never brighter, never another hue. + for f in &strokes { + let [r, g, b] = fill_rgb(f); + assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}"); + } + } +} + +/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either +/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without +/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap +/// drawn below the fill's. +#[test] +fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() { + // A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the + // caps; the mean of its outline's points is not the mean of the stroke's. + let mut b = BezPath::new(); + b.move_to((100.0, 100.0)); + b.line_to((400.0, 100.0)); + b.line_to((100.0, 200.0)); + b.close_path(); + for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] { + let mut n = stroked(&b); + n.appearance.effects.push(fx(EXTRUDE, params.clone())); + n.appearance.items.reverse(); + let art = solid_art(&n).unwrap(); + let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") }; + assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below"); + n.appearance.items.reverse(); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + let flat = params["depth"] == json!(0); + assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len()); + let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") }; + assert!(!blue(fill_cap) && blue(stroke_cap), "{params}"); + // The stroke's cap is a ring: its outline and the hole the fill shows through. + assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}"); + assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}"); + let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of; + assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light"); + if !flat { + assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both"); + } + } +} + +/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose +/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner. +#[test] +fn a_stroked_open_path_builds_a_ribbon() { + let mut b = BezPath::new(); + b.move_to((100.0, 100.0)); + b.line_to((200.0, 100.0)); + b.line_to((200.0, 200.0)); + for id in [EXTRUDE, INFLATE] { + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0)); + n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0}))); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + assert!(faces.iter().all(|f| blue(f)), "{id}"); + assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0); + let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum(); + // 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter. + assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}"); + } +} + +/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid +/// still draws. +#[test] +fn a_stroke_past_the_limits_is_left_out() { + // 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..1500) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos(); + let r = if k % 2 == 0 { 100.0 } else { 96.0 }; + Point::new(200.0 + r * c, 200.0 + r * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + for id in [EXTRUDE, INFLATE] { + let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0)); + n.appearance.effects.push(fx(id, json!({}))); + let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws")); + assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}"); + } +} + +/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's, +/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to +/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side. +#[test] +fn a_flat_inflate_draws_the_strokes_front_above_the_fills() { + // On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a + // 1 pt stroke, so the stroke stays flat. + for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] { + let mut n = stroked_rect(length); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.width = width; + } + n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume }))); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + let first_blue = faces.iter().position(|f| blue(f)).unwrap(); + let last_red = faces.iter().rposition(|f| !blue(f)).unwrap(); + assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len()); + } +} + +/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises: +/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly. +#[test] +fn inflate_sees_the_fill_and_the_stroke_from_one_eye() { + let mut n = stroked_rect(100.0); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.align = vectorcraft_doc::StrokeAlign::Outside; + } + let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0}); + n.appearance.effects.push(fx(INFLATE, params)); + let art = solid_art(&n).unwrap(); + let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") }; + assert!(!blue(fill_back) && blue(stroke_back)); + let fill = fill_back.path_data().unwrap().bounds().unwrap(); + // The ring's inner contour: the smaller of its two. + let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds()); + let hole = rims.reduce(|a, b| a.intersect(b)).unwrap(); + assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1); +} + +/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its +/// opacity applies to the stroke as a whole. +#[test] +fn a_translucent_stroke_is_a_surface_of_its_own() { + for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] { + let mut n = stroked_rect(100.0); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.opacity = opacity; + s.blend = blend; + } + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 2); + assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal)); + assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend)); + assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f))); + } +} + +/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an +/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their +/// own, and the mask stays on the fill's. Under a white light the same object is one surface. +#[test] +fn a_masked_surface_under_a_colored_light_stays_whole() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let mut half = GradientStop::new(1.0, RED); + half.opacity = 0.5; + let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] }); + for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { + let mut n = stroked_rect(100.0); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.paint = Paint::Gradient(Box::new(gradient.clone())); + } + n.appearance.effects.push(fx(EXTRUDE, lights.clone())); + let art = solid_art(&n).unwrap(); + let drawn = art.children().unwrap(); + assert_eq!(drawn.len(), surfaces, "{lights}"); + assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}"); + } +} + #[test] fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() { let mut n = square_node(); @@ -230,9 +448,9 @@ fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() { let mut n = square_node(); n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20}))); n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0}))); - // Straight on, the extrusion shows its front: the offset square. + // Straight on, the extrusion shows its front: the offset square and its 4 pt stroke. let b = solid_art(&n).unwrap().visual_bounds().unwrap(); - assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}"); + assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}"); n.appearance.effects.push(fx("distort.twist", json!({"angle": 30}))); let art = solid_art(&n).unwrap(); assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole"); @@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { } let (groups, clips, paths) = count(&art); assert!(paths > 1000, "exercise a tessellated surface"); - assert_eq!(clips, 1); - assert_eq!(groups, 7, "group count must not grow with triangle count"); + // The fill's solid and the stroke's, each one composite with its own clip. + let surfaces = art.children().unwrap().len(); + assert_eq!(surfaces, 2); + assert_eq!(clips, surfaces); + assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count"); } diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 3a7069b4e..5589c3d0c 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -8,7 +8,7 @@ mod solid; mod visibility; -pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane}; +pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane}; pub use visibility::{VisibleFace, visible_faces}; mod shading; pub use shading::{LightGradient, RgbGradient, ShadeGradient}; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index b4a11cdb8..216513f2e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -433,8 +433,7 @@ struct Camera { origin: Point, /// The eye's distance in front of the centre; `None` without perspective. eye: Option, - /// Deeper than any part of the object: faces drawn first go this far behind it, and faces - /// drawn last go this far in front of it. + /// Deeper than any part of the object: faces drawn first go this far behind it. far: f64, light: V3, } @@ -487,7 +486,9 @@ enum Order { /// a piece of a twisted wall at the solid's outline covers the walls behind it. Twisted, /// The face is drawn after every other face when it is turned toward the viewer. A flat end of - /// the solid has the whole solid behind its plane, so it covers whatever it overlaps. + /// the solid has the whole solid behind its plane, so it covers whatever it overlaps. Its depth + /// is `f64::MAX`, so the faces drawn last by several solids sorted together keep the order they + /// were added in. Last, } @@ -526,8 +527,8 @@ impl Builder<'_> { } let depth = if behind && order != Order::Twisted { centre.2 - self.camera.far - } else if order == Order::Last { - centre.2 + self.camera.far + } else if order == Order::Last && facing > 0.0 { + f64::MAX } else { centre.2 }; @@ -840,12 +841,11 @@ pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, } } if o.cap { - // A cap turned toward the viewer covers the twisted walls it overlaps, even those nearer - // than its center. - let ends = if o.twist != 0.0 { Order::Last } else { Order::Depth }; + // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its + // center: twisted walls, and the walls of a fill inside the solid of its stroke. for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] { let Some(s) = s else { continue }; - b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), ends)?; + b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; } } Ok(b.finish()) @@ -859,7 +859,11 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); - b.face(contours, camera.rotate(V3(0.0, 0.0, 1.0)), Order::Depth)?; + // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so + // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. + let up = camera.rotate(V3(0.0, 0.0, 1.0)); + let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; + b.face(contours, n, Order::Last)?; Ok(b.finish()) } @@ -1258,39 +1262,94 @@ fn cell_triangles(pieces: &[Vec]) -> Option> { Some(triangles) } -/// The part of `outline` inside `cell`, as rings (holes running the other way). -fn material(outline: &PathData, cell: Rect) -> Option>> { +/// The part of `outline` (its rings, each with its bounds) inside `cell`, as rings (holes running +/// the other way). Only the rings whose bounds meet the cell's go into the boolean. +fn material(outline: &[(Rect, vectorcraft_geom::SubPath)], cell: Rect) -> Option>> { let r = PathData::single(vectorcraft_geom::SubPath::polyline( &[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)], true, )); + let near = PathData::new( + outline.iter().filter(|(b, _)| b.x0 <= cell.x1 && cell.x0 <= b.x1 && b.y0 <= cell.y1 && cell.y0 <= b.y1).map(|(_, s)| s.clone()).collect(), + ); let rule = FillRule::NonZero; let part = - vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION) + vectorcraft_pathops::try_boolean(&r, rule, &near, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION) .ok()?; rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok() } -/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is. -pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result, String> { - check_bounds(bounds)?; - let o = options.sanitized(); +/// A shape to inflate: its rings and the membrane under them on the grid of the bounds. +struct Puff { + rings: Vec>, + /// The index of the rings' edges. + edges: Edges, + cell: f64, + nx: usize, + ny: usize, + m: Membrane, + /// How high the front rises at its peak. + height: f64, +} + +/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. +fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; if rings.is_empty() { - return Ok(vec![]); + return Ok(None); } let size = bounds.width().max(bounds.height()); if size <= 0.0 { - return Ok(vec![]); + return Ok(None); } const CELLS: usize = 48; let cell = size / CELLS as f64; let (nx, ny) = (((bounds.width() / cell).ceil() as usize).max(1), ((bounds.height() / cell).ceil() as usize).max(1)); let edges = Edges::new(&rings); let m = Membrane::solve(&rings, &edges, bounds.origin(), cell, nx + 1, ny + 1); - let outline = PathData::new(rings.iter().map(|r| vectorcraft_geom::SubPath::polyline(r, true)).collect()); // A circle of radius r puffs into a half sphere of radius r at 50 % (its membrane peaks at r²/4). let height = 2.0 * m.peak.sqrt() * o.volume / 50.0; + Ok(Some(Puff { rings, edges, cell, nx, ny, m, height })) +} + +/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is. +pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result, String> { + inflate_together(&[(path, rule)], bounds, options, view).pop().unwrap_or(Ok(vec![])) +} + +/// Inflate for several shapes of one object (a fill and the outlines of its strokes): each puffs +/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest +/// puff needs with Perspective. One result for each shape, in order. +pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { + let o = options.sanitized(); + let puffs: Vec, String>> = + shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); + let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); + puffs + .into_iter() + .map(|p| match p { + Ok(Some(p)) => inflated(p, bounds, o, view, reach), + Ok(None) => Ok(vec![]), + Err(e) => Err(e), + }) + .collect() +} + +/// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result, String> { + let Puff { rings, edges, cell, nx, ny, m, height } = p; + let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings + .iter() + .filter_map(|r| { + let b = r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)); + Some((b, vectorcraft_geom::SubPath::polyline(r, true))) + }) + .collect(); + // When no shape puffs (no volume, or shapes too narrow for the grid), every front (and a back + // that puffs too) lies flat in one plane: its cells are drawn last, as caps are. If one shape + // puffs, a flat front can be behind its puff, so the cells keep their depths. + let flat = reach <= 0.0; let lift = |p: Point| { if m.peak <= 0.0 || edges.near(p, 1e-9) { return 0.0; @@ -1315,7 +1374,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, if n.dot(n) > 0.5 { n } else { V3(0.0, 0.0, 1.0) } }; let smooth = view.shade && view.has_colored_lights(); - let camera = Camera::new(view, bounds, o.depth + 2.0 * height); + let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; let half = o.depth / 2.0; @@ -1464,7 +1523,8 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, let front: Vec> = pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, half + h)).collect()).collect(); let n = newell(&front); - b.face(front, n, Order::Depth)?; + let order = if flat { Order::Last } else { Order::Depth }; + b.face(front, n, order)?; if o.both_sides && o.cap { // The back winds the same way round as the front, so its outward normal is // reversed. @@ -1474,7 +1534,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, .map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(back_turn.apply(*q), -half - h * back_turn.scale)).collect()) .collect(); let n = newell(&back); - b.face(back, V3(-n.0, -n.1, -n.2), Order::Depth)?; + b.face(back, V3(-n.0, -n.1, -n.2), order)?; } } } @@ -1482,9 +1542,9 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, } if !o.both_sides && o.cap { let back: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(back_turn.apply(*p), -half)).collect()).collect(); - // The back turned toward the viewer covers the twisted walls it overlaps, even those - // nearer than its center. - b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)), if o.twist != 0.0 { Order::Last } else { Order::Depth })?; + // The back turned toward the viewer covers the walls it overlaps, even those nearer than + // its center: twisted walls, and the walls of a fill inside the solid of its stroke. + b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)), Order::Last)?; } if o.depth > 0.0 { // The sides, cut into slices that follow a twist or taper. diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/tests_solid.rs index ba4ddde3f..cdbfa7054 100644 --- a/crates/three-d/src/tests_solid.rs +++ b/crates/three-d/src/tests_solid.rs @@ -176,6 +176,15 @@ fn a_plane_turns_about_the_given_centre() { assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6)); // Its back shows when turned past 90°: a plane is two-sided. assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1); + // From either side it is drawn last, so the planes of several parts sorted together keep the + // order they are given in, and it is lit as the side seen. + for (y, seen) in [(30.0, 1.0), (150.0, -1.0)] { + let v = View { rotation_x: -20.0, rotation_y: y, ..front() }; + let f = plane(&p, FillRule::EvenOdd, b, v).unwrap(); + let camera = Camera::new(v, b, 0.0); + let lit = camera.brightness(camera.rotate(V3(0.0, 0.0, seen))); + assert!(f.len() == 1 && f[0].depth == f64::MAX && close(f[0].brightness, lit, 1e-12), "{y}: {:?}", f[0]); + } } #[test] @@ -661,6 +670,21 @@ fn inflate_twists_tapers_and_opens_its_back() { assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o })); } +/// A flat front is drawn after every other face only when it is turned toward the viewer: an open +/// Inflate at 0 % volume seen from behind shows the inside of its front, the far side, and the +/// walls nearer the eye are drawn over it. +#[test] +fn an_open_flat_front_seen_from_behind_is_drawn_under_the_walls() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let o = Inflate { depth: 60.0, volume: 0.0, cap: false, ..Inflate::default() }; + let ahead = inflate(&p, FillRule::EvenOdd, b, o, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); + assert!(ahead.last().is_some_and(|f| f.depth == f64::MAX), "from in front, the front is drawn last"); + let behind = inflate(&p, FillRule::EvenOdd, b, o, View { rotation_x: -20.0, rotation_y: 150.0, ..front() }).unwrap(); + assert!(behind.iter().all(|f| f.depth < f64::MAX)); + let (gap, at) = worst_misorder(&behind, 120); + assert!(gap < 1.0, "{gap} pt behind at {at:?}"); +} + /// At 0 % volume an inflated shape is the extruded one: twisted and tapered, their outlines /// agree from every side (no wall goes missing in either). #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b961e9c1f..070331291 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -605,6 +605,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. + - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. diff --git a/docs/three-d.md b/docs/three-d.md index b8278818d..c95ab9309 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -52,6 +52,24 @@ middle. The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading, paint composition and seam joins. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds; colored facets use round joins. Shading is automatic, with no additional dialog option. +## Fills and strokes + +Extrude & Bevel and Inflate build a solid from the path for the object's top fill, and one from the outline of +each visible stroke (with its caps, joins, dashes, alignment and arrowheads), each in its own paint. An admin's +reply on the reference app's bug forum says that strokes "will be treated as outlined when applying 3D" +([August 2024](https://illustrator.uservoice.com/forums/601447-illustrator-desktop-bugs/suggestions/48755201-expanding-a-3d-extruded-object-with-a-stroke-as-a)). +The rest is our reading: + +- When the fill and every stroke are opaque with Normal blending, the faces of all the solids are drawn in one + back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. Otherwise + each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not + white, each solid is also drawn on its own when one of them is a puffed, shaded Inflate surface in a solid color + (drawn as one composite) or is painted with a gradient that has transparent stops (drawn with an opacity mask). +- The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers + those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's + grid stays flat. +- A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. + ## Rotate The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a @@ -121,19 +139,19 @@ pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off out, perspective). No Adobe software, assets, presets or bevel shapes were used. - **Materials** (base, Adobe Substance and graphics), light presets, **shadows** - and **ray-traced rendering** are not built: shading uses directional lights, painted with the - object's top fill (else its top stroke) for Extrude & Bevel and Inflate. + and **ray-traced rendering** are not built: shading uses directional lights, in the paint of the fill or + stroke the solid is built for ([Fills and strokes](#fills-and-strokes)). - The bevel shapes and their names are our own; custom bevel paths are not supported. -- Visibility is a painter's algorithm: faces of a closed solid turned away are left out, the rest drawn back to - front. Concave solids seen at steep angles can overlap wrongly; open (hollow) solids show both sides. - Twisted walls and bevels are drawn in pieces about as long as a wall slice is wide (fewer near the limit of - 32,768 facets), and a cap or flat back turned toward the viewer is drawn over them. The piece drawn on top can - still lie a few points behind another nearby piece. +- Visibility is a painter's algorithm: faces of a closed solid turned away are left out, and the rest are drawn + back to front, with a flat cap or flat back turned toward the viewer drawn after all of them. Concave solids + seen at steep angles can overlap wrongly; open (hollow) solids show both sides. Twisted walls and bevels are + drawn in pieces about as long as a wall slice is wide (fewer near the limit of 32,768 facets). The piece drawn + on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. - Paths and compound paths only: convert type, images and groups to outlines first. One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points - and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points, Rotate (which only turns - the outline) 65,536. + and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is + counted on its own), Rotate (which only turns the outline) 65,536. - **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate, unbuilt items.