diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 0c4ef497f..4b5f1d435 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,10 +1,10 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; -use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; +use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; use crate::appearance::HEAD_REACH; @@ -101,6 +101,58 @@ fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, w } +/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative +/// is a mix of its three control differences, and its second a mix of their two differences, which +/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold +/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an +/// axis. +fn gentle(seg: &PathSeg, side: f64) -> bool { + let c = seg.to_cubic(); + let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0); + let along = unit(d[0] + d[1] + d[2]); + let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min); + let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot()); + slowest > 0.0 && side * bend < slowest * slowest +} + +/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`: +/// the corners at the ends and the points across the stroke where the tangent is square to an axis. +/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending +/// that much is grown by `side` on every side. +fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect { + if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) { + if depth == 0 { + return ParamCurveExtrema::bounding_box(seg).inflate(side, side); + } + return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1)); + } + let across = |t: f64| { + let (p, tn) = (seg.eval(t), tangent(seg, t)); + let n = Vec2::new(-tn.y, tn.x) * side; + [p + n, p - n] + }; + let [a, b] = across(0.0); + seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p)) +} + +/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent +/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends +/// and the points on it square to an axis. A joint where the path turns back on itself is given +/// the whole disc's box, and one where it does not turn paints no arc (`None`). +fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option { + let (t_in, t_out) = (unit(t_in), unit(t_out)); + let turn = t_in.cross(t_out); + if turn.abs() < 1e-12 { + return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + let s = turn.signum(); + let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s; + let (a, b) = (outer(t_in), outer(t_out)); + let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)]; + let arc = Rect::from_points(at + a * side, at + b * side); + Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side))) +} + /// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it? fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { let n = pts.len(); @@ -115,39 +167,81 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { impl StrokeLayer { /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// or an alignment that depends on whether the path is closed? Else it reaches evenly round it. + /// an alignment that depends on whether the path is closed, or butt caps, which stop at the + /// ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { self.join == LineJoin::Miter - || self.cap == LineCap::Square + || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown - /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// The box this stroke paints along `segs`, the segments with length of an open subpath, when + /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each + /// joint between segments paints ([`round_join_box`]), a shallow one included. + /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips + /// are measured apart. A segment in the middle is measured only when its own box grown by + /// `side`, which holds everything it paints, is not inside the box so far. + fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option { + let (first, last) = (segs.first()?, segs.last()?); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { + if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } + }); + if self.join != LineJoin::Round { + return Some(body); + } + Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j)))) + } + + /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt + /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its + /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. - pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); - let mut out = bounds; + let subs = subpaths(bp); + let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; + // Every subpath reaches evenly unless one is open and butt-capped; only then is each + // subpath's own box needed. + let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); + let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); - for (r, closed) in subpaths(bp) { + for (r, closed) in subs { let side = self.side_reach(closed); - side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + let butt = self.cap == LineCap::Butt && !closed; + if !butt { + side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + } if side <= 0.0 { continue; } + let square = self.cap == LineCap::Square && !closed; + let miters = measure_tips && self.join == LineJoin::Miter; + if !(butt || square || miters || !even_only) { + continue; + } let segs = drawn(&els[r]); - if self.join == LineJoin::Miter { + if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { out = out.union_pt(tip); } } } - if self.cap == LineCap::Square && !closed { + if butt { + if let Some(body) = self.butt_body(&segs, side) { + out = out.union(body); + } + continue; + } + if !even_only { + even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); + } + if square { for (at, dir) in ends(&segs).into_iter().flatten() { let n = Vec2::new(-dir.y, dir.x) * side; out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); @@ -158,7 +252,15 @@ impl StrokeLayer { let r = self.head_reach(end); out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); } - out.union(bounds.inflate(side_max, side_max)) + even.map_or(out, |e| out.union(e.inflate(side_max, side_max))) + } + + /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown + /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// projecting caps and the reach of the arrowheads round their end points. An open subpath + /// with butt caps does not reach past its ends ([`Self::butt_body`]). + pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + self.reach_along(bp, bounds, None, true) } /// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a @@ -176,30 +278,7 @@ impl StrokeLayer { /// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured /// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does. pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option) -> Rect { - let els = bp.elements(); - let mut out = tips.map_or(bounds, |t| bounds.union(t)); - let mut side_max: f64 = 0.0; - // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. - let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); - for (r, closed) in subpaths(bp) { - let side = self.side_reach(closed); - side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); - if side <= 0.0 { - continue; - } - if self.cap == LineCap::Square && !closed { - let segs = drawn(&els[r]); - for (at, dir) in ends(&segs).into_iter().flatten() { - let n = Vec2::new(-dir.y, dir.x) * side; - out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); - } - } - } - for (at, _, end) in head_ends(self, bp) { - let r = self.head_reach(end); - out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); - } - out.union(bounds.inflate(side_max, side_max)) + self.reach_along(bp, bounds, tips, false) } /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and @@ -314,7 +393,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -325,6 +404,30 @@ impl Appearance { }) } + /// Does this appearance have a visible butt-capped stroke? + pub fn has_butt_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.cap == LineCap::Butt) + } + + /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with + /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at the stroke's corners there depends on their direction, which a scale + /// out of proportion changes. + pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_butt_stroke() { + return false; + } + let slanted = |v: Vec2| { + let d = unit(a * v.to_point() - a * Point::ZERO); + (d.x * d.y).abs() > 1e-9 + }; + let els = bp.elements(); + subpaths(bp) + .into_iter() + .filter(|(_, closed)| !closed) + .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) + } + /// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within /// `tol` of `p` ([`StrokeLayer::hits`])? pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { @@ -332,6 +435,31 @@ impl Appearance { } } +/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// before it answers yes without looking further. +pub const SLANT_INSTANCES: usize = 4096; + +impl crate::Node { + /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a + /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment + /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it + /// answers yes. + pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + } + + /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. + fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { + if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { + let Some(l) = left.get().checked_sub(1) else { return true }; + left.set(l); + return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + } + (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + } +} + #[cfg(test)] mod tests { use vectorcraft_color::{Color, Paint}; @@ -378,7 +506,7 @@ mod tests { let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b)); assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}"); // A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside. - assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}"); + assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == 0.0, "{big:?}"); // The start has no head. assert_eq!(at(300.0).head_reach(false), 0.0); } @@ -405,6 +533,164 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + /// An open path through `points`. + fn polyline(points: &[(f64, f64)]) -> BezPath { + let mut bp = BezPath::new(); + for (i, p) in points.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + bp + } + + #[test] + fn butt_caps_stop_at_the_ends_of_an_open_path() { + let butt = |f: fn(&mut StrokeLayer)| { + stroke(2.0, |s| { + s.cap = LineCap::Butt; + f(s); + }) + }; + // A vertical line reaches sideways only. + let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]); + assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + // A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y. + let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]); + let b = butt(|_| {}).bounds_on(&d, d.bounding_box()); + for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // The dashes of a butt stroke stop inside the line as well, and an open path ignores the + // alignment. + let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false })); + assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + // A round cap reaches half the weight past each end, a square one the corners. + let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box()); + assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0)); + let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box()); + assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0)); + // Two strokes union. + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let wide = stroke(10.0, |s| s.cap = LineCap::Round); + ap.items.push(crate::AppearanceItem::Stroke(wide)); + let vb = v.bounding_box(); + assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0)); + } + + #[test] + fn a_butt_paths_joins_and_curves_stay_inside_the_box() { + // A corner in the middle: the round join reaches a full half weight straight down, outside + // the turn, the ends do not, and a miter reaches its tip. + let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]); + let round = stroke(4.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Round; + }) + .bounds_on(&zig, zig.bounding_box()); + assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}"); + assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}"); + // A round join paints its arc on the outer side of a turn too shallow to count as a + // corner, and nothing where the path goes straight on. + let round_butt = stroke(4.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Round; + }); + let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]); + let b = round_butt.bounds_on(&shallow, shallow.bounding_box()); + assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}"); + let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]); + assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0)); + let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box()); + assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}"); + // An arc over the top whose ends leave the page straight down: the top reaches half the + // weight past the path, and the bottom stops at the ends. + let mut arc = BezPath::new(); + arc.move_to((0.0, 50.0)); + arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0)); + arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0)); + let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box()); + assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}"); + assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let id = vectorcraft_geom::Affine::IDENTITY; + assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + // Square ends, slanted segments in the middle. + let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); + assert!(ap.has_slanted_butt_stroke(&apex, id)); + // A rotation slants a vertical line; a round cap is never slanted. + let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + ap.stroke_mut().unwrap().cap = LineCap::Round; + assert!(!ap.has_slanted_butt_stroke(&apex, id)); + } + + #[test] + fn the_slant_check_looks_into_at_most_4096_symbol_instances() { + // S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before + // it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096 + // the check answers yes. + let mut d = Document::new(400.0, 400.0); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap); + d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) }); + for k in 1..=12 { + let mut instance = |dx: f64| { + let xf = vectorcraft_geom::Affine::translate((dx, 0.0)); + std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf })) + }; + let pair = vec![instance(0.0), instance(20.0)]; + let art = Node::group(d.alloc_id(), pair); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) }); + } + let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let symbols = d.symbol_boxes(); + assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); + // One instance of S1 holds three instances, all looked into. + let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + } + + #[test] + fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() { + // A closed square at the top and an open line below it: only the square reaches past its + // own edges. + let mut bp = BezPath::new(); + bp.extend(square().elements().iter().copied()); + bp.move_to((50.0, 150.0)); + bp.line_to((50.0, 200.0)); + let st = stroke(4.0, |s| s.cap = LineCap::Butt); + assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0)); + } + + #[test] + fn a_middle_segment_can_set_the_box_of_a_butt_path() { + // The flat middle segment reaches a full half weight above the path, and the ends' corners + // reach 10/51 of it below. + let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]); + let b = stroke(2.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Bevel; + }) + .bounds_on(&hat, hat.bounding_box()); + let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt(); + assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() { + let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]); + let st = stroke(2.0, |s| s.cap = LineCap::Butt); + assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.0)); + } + #[test] fn clicks_hit_outside_strokes_on_their_side_only() { let sq = square(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e5b3201a4..82db95b5b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,7 +87,7 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_r pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as, - validate_solid, validate_three_d, validate_three_d_params, view_options, + validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index fd423689f..99a6e053e 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -60,6 +61,7 @@ pub fn revolve_options(p: &Value) -> Revolve { shade: flag(p, "shade", true), smooth_shading: true, cap_ends: flag(p, "cap", true), + curve_samples: None, } } @@ -67,12 +69,93 @@ pub fn revolve_options(p: &Value) -> Revolve { /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; - let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; - if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { - return Err("Revolve requires at least two anchors".into()); + let effects = &n.appearance.effects; + let before = effects.iter().position(|e| e.visible && 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)); } - vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) + 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`. @@ -87,38 +170,31 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } -fn profile(n: &Node) -> Option { - match &n.kind { - NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), - NodeKind::Compound { children, .. } => { - Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) - } - _ => None, - } -} - 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, -) -> Option { - let b = path.bounds()?; + 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 options = revolve_options(&merged_params(REVOLVE, &fx.params)); let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - // A face's fill, its shadow and their seam strokes: up to four copies of the paint. - if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + *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. @@ -146,6 +222,7 @@ fn surface( }) .collect() }); + let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); let children = faces .into_iter() .map(|face| { @@ -202,7 +279,16 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some(group) + 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 { @@ -237,26 +323,23 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// 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> { - let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; + 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 profile with at least two anchors")?; + 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, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + 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 item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) - .ok_or("the revolved object has no fill or stroke")?; + 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); @@ -274,28 +357,49 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul 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(n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } - let path = profile(n)?; + 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 item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; - let (paint, opacity, blend) = paint_of(item); + 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(surface(n, &path, fx, paint, opacity, blend, expand)?)); + 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 @@ -312,9 +416,8 @@ fn art(n: &Node, expand: bool) -> Option { 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 (paint, opacity, blend) = paint_of(item); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + 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 { @@ -328,6 +431,86 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } +/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` +/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity +/// and blending mode, so each applies once, to the members it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option, level: usize, out: &mut Vec>) { + if let Some(scope) = scope { + let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); + group.opacity = scope.opacity; + group.blend = scope.blend; + out.push(Arc::new(group)); + } + } + let mut out = vec![]; + let mut run = vec![]; + let mut scope = None; + for (scopes, s) in surfaces { + let at = scopes.get(level).copied(); + if at.map(|a| a.key) != scope.map(|s: Scope| s.key) { + close(id, &mut run, scope, level, &mut out); + scope = at; + } + if scope.is_some() { + run.push((scopes, s)); + } else { + out.push(Arc::new(s)); + } + } + close(id, &mut run, scope, level, &mut out); + out +} + +/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, +/// each in its own paint. Opaque members' 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 opaque && plain { + 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 members' order. + faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] + } else { + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).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::*; @@ -495,6 +678,213 @@ mod tests { 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_and_others_keep_their_own_surfaces() { + 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); + let mut faded = (*member(2, 50.0)).clone(); + faded.opacity = 0.5; + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); + let art = revolve_art(&g).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); + assert_eq!(surfaces[1].opacity, 0.5); + } + + 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 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 modes = |n: &Node| match &n.kind { + NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::>(), + _ => panic!("a group"), + }; + assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let screened = &children[2]; + assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); + let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; + assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + 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' surfaces 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 NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::>(); + assert_eq!(children.iter().map(|c| c.opacity).collect::>(), [1.0, 0.5]); + assert_eq!(opacities(&children[1]), [1.0, 1.0], "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 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_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(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3390cc71f..ab119192c 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -138,7 +138,7 @@ pub fn inflate_options(p: &Value) -> Inflate { } /// The shape a solid is made from: a path's or a compound path's outline. -fn profile(n: &Node) -> Option<(PathData, FillRule)> { +pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)), NodeKind::Compound { children, rule } => { @@ -156,7 +156,7 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { /// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the /// fill and stroke of its first painted run (its characters' colours). -fn solid_paints(n: &Node) -> Vec { +pub(crate) fn solid_paints(n: &Node) -> Vec { let own: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) { return own; @@ -207,29 +207,52 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); } - let (path, rule) = profile(n).ok_or_else(|| { - format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") - })?; + let (path, rule) = object_profile(n, label)?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; facets(id, &path, rule, b, p).map(|_| ()) } +/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from +/// (empty, or only spaces) is a shape without anchors, and any other object is the wrong kind. +pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> { + profile(n).ok_or_else(|| { + if matches!(n.kind, NodeKind::Text(_)) { + format!("{label} requires a shape with at least two anchors") + } else { + format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") + } + }) +} + +/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending +/// mode is not Normal. The members of one group share its `key`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Scope { + pub(crate) key: usize, + pub(crate) opacity: f32, + pub(crate) blend: BlendMode, +} + /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint -/// it, and the opacity of the objects it is in (1 for the object with the effect itself). -struct Profile { - path: PathData, - rule: FillRule, - items: Vec, - opacity: f32, +/// it, and the opacity of the objects it is in multiplied together (1 for the object with the +/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from +/// the outermost group down to the member itself (none for the object itself). Group Revolve +/// applies each once, to the surfaces of the members it holds together. +pub(crate) struct Profile { + pub(crate) path: PathData, + pub(crate) rule: FillRule, + pub(crate) items: Vec, + pub(crate) opacity: f32, + pub(crate) scopes: Vec, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? -fn is_container(n: &Node) -> bool { +pub(crate) fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) } /// The bounds of every shape together: the centre a group's members turn about. -fn union_bounds(shapes: &[Profile]) -> Option { +pub(crate) fn union_bounds(shapes: &[Profile]) -> Option { shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c)) } @@ -245,15 +268,26 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// effects and painted with its own fills and strokes. Like the effect on a group in the /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. -fn members(n: &Node, label: &str) -> Result, String> { - fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { +pub(crate) fn members(n: &Node, label: &str) -> Result, String> { + struct Walk<'a> { + label: &'a str, + out: Vec, + keys: usize, + } + fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { + let label = w.label; for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } let opacity = opacity * c.opacity; + let mut scopes = scopes.to_vec(); + if c.blend != BlendMode::Normal || c.opacity < 1.0 { + w.keys += 1; + scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); + } if is_container(c) { - walk(c, opacity, label, out)?; + walk(c, opacity, &scopes, w)?; continue; } let Some((path, rule)) = profile(c) else { @@ -266,19 +300,19 @@ fn members(n: &Node, label: &str) -> Result, String> { }; return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); }; - if out.len() >= MAX_MEMBERS { + if w.out.len() >= MAX_MEMBERS { return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); } let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); if path.bounds().is_some() { - out.push(Profile { path, rule, items: solid_paints(c), opacity }); + w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes }); } } Ok(()) } - let mut out = vec![]; - walk(n, 1.0, label, &mut out)?; - Ok(out) + let mut w = Walk { label, out: vec![], keys: 0 }; + walk(n, 1.0, &[], &mut w)?; + Ok(w.out) } /// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked @@ -288,6 +322,19 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } } +/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of +/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself +/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the +/// item's geometry effects before it, together with the object's other items that carry a Revolve. +/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws. +pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + match at { + Some(_) if is_container(n) => Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()), + Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + _ => validate_three_d(n, id, p), + } +} + /// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. @@ -385,7 +432,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32 /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. -fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { +pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { if before.iter().any(|e| e.visible && is_geometry(&e.id)) && let Some(b) = union_bounds(shapes) { @@ -501,7 +548,7 @@ pub fn solid_art(n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -517,7 +564,7 @@ pub fn solid_art(n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); @@ -680,14 +727,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result 0, "{what}: no samples"); } +#[test] +fn a_butt_capped_open_path_is_measured_where_it_paints() { + let mut vertical = BezPath::new(); + vertical.move_to((50.0, 10.0)); + vertical.line_to((50.0, 90.0)); + let mut diagonal = BezPath::new(); + diagonal.move_to((10.0, 10.0)); + diagonal.line_to((40.0, 50.0)); + let mut arc = BezPath::new(); + arc.move_to((10.0, 50.0)); + arc.curve_to((10.0, 22.4), (32.4, 0.0), (60.0, 0.0)); + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + stroke.cap = LineCap::Butt; + for (name, bp) in [("vertical", vertical), ("diagonal", diagonal), ("arc", arc)] { + let painted = outline_region(&PathData::from_bezpath(&bp), FillRule::NonZero, &stroke).to_bezpath().bounding_box(); + let bounds = stroke.bounds_on(&bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name}: measured {bounds:?}, paints {painted:?}"); + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 6d1e7284c..92d01bb7e 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments( let n = sp.anchors.len(); let mut selected_segs = BTreeSet::new(); - let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si)); + // Segments picked anywhere on the path leave anchors out of it, as Clear does. + let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty()); if has_explicit_segs { if let Some(segments) = seg_refs { for &(s, seg) in segments { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5809835e3..80574d2d5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1063,8 +1063,12 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs); if cuts.is_empty() { subpaths.push(sp); - } else { + } else if seg_refs.is_some_and(|segments| !segments.is_empty()) { subpaths.extend(super::path::without_segments(sp, &cuts)); + } else { + // Picked anchors leave the path with their connections, as Clear removes them. + let gone: BTreeSet = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect(); + subpaths.extend(super::path::without_anchors(sp, &gone)); } } subpaths.retain(|sp| sp.anchors.len() >= 2); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 297279b64..9d396edc1 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -280,9 +280,13 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - for target in appearance_targets(s, p)? { + let targets = appearance_targets(s, p)?; + for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?; - effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?; + // A new effect goes after the item's others. + let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); + effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?; if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -589,7 +593,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { cur.extend(new); } if visible != Some(false) { - effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { // Hidden, the effect draws nothing, so its object need not fit. The values given are // checked, and after a change of 3D type so is every value it keeps. @@ -865,8 +869,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // keeps it as its only member). Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { - let m = if container && effects::has_solid(&v) { - // A group's or layer's 3D effect: the shaded faces of its members' solids. + let m = if (container || matches!(n.kind, NodeKind::Text(_))) && (effects::has_solid(&v) || effects::has_revolve(&v)) { + // A 3D effect on a group or layer, or on type: the shaded faces of its solids. effects::expand_leaf(d, &v, &mut stroke_art) } else if container { // A group's or layer's own fills and strokes become art among its members. diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 088094e55..add1f3b79 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -193,6 +193,153 @@ fn type_extrudes_as_its_glyphs_and_stays_editable() { assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text"); } +/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves, +/// exports, expands and undoes as one step. +#[test] +fn type_revolves_as_its_glyphs_and_stays_editable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "O", "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + let art = effects::revolve_art(&n).expect("a surface of the glyphs"); + let mut tinted = false; + art.walk(&mut |c| { + if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + tinted |= r > g && r > b; + } + }); + assert!(tinted, "shaded from the characters' red"); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save"); + let meshes = effects::three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1, "one mesh of every contour of the type"); + s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text"); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Text(_))); + assert!(effects::has_revolve(&node(&s, id))); + // Extrude & Bevel on type expands the same way. + s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_solid(&node(&s, id)), "expanded"); +} + +/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an +/// effect. Extrude & Bevel gives the same reason for empty type. +#[test] +fn revolve_refuses_type_and_groups_without_a_shape() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + for text in ["", " "] { + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + for effect in ["threeD.revolve", "threeD.extrude"] { + let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}"); + assert!(node(&s, id).appearance.effects.is_empty()); + } + } + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + for member in [a.0, b] { + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + } + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("group holding a shape"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + +/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well. +#[test] +fn revolve_on_a_fill_is_checked_in_that_fills_paint() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{AppearanceItem, FillLayer}; + let (mut s, id) = session_with_rect(); + // The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it. + let count = (256 << 20) / (4 * 100 * std::mem::size_of::()); + let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect(); + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("fills", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.items = + vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))]; + Ok(()) + }) + .unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); + assert!(node(&s, id).appearance.items[0].effects().is_empty()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); +} + +/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps +/// each member's paint, follows an edited member, expands and undoes as one step. +#[test] +fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() { + let (mut s, a) = session_with_rect(); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were"); + let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members"); + // The axis is the left edge of both members together (x = 100), so the right member's far + // wall sweeps out to 200 pt from it on either side: x = 300 and x = -100. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}"); + let (mut red, mut blue) = (false, false); + art.walk(&mut |c| { + if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + red |= r > b; + blue |= b > r; + } + }); + assert!(red && blue, "each member keeps its own paint"); + assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "both members exported as vector faces"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface"); + s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, g)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(effects::has_revolve(&node(&s, g))); +} + +/// A group holding what can't be swept is refused as a whole, and a clipping group is named. +#[test] +fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string(); + assert!(err.contains("path, compound path, type or group"), "{err}"); + let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. #[test] diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 9d4795235..df2c1ffd1 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -125,15 +125,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are - // measured as drawn after each scale tried instead ([`refit_drawn`]). + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and + // objects with a butt-capped stroke along a segment at a slant, whose margins change with the + // segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]). let symbols = d.symbol_boxes(); - let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n)); + let to_local = frame.to_doc().inverse(); + let drawn = || { + ids.iter() + .filter_map(|id| d.node(*id)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + }; let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { let options = scaling_options(s, p); - let to_local = frame.to_doc().inverse(); let measure = |sx: f64, sy: f64| { let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy))); let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() }; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index cb82cfe3f..5d7f93581 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1329,7 +1329,7 @@ pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, orig /// Remove anchors and their incident segments, keeping each surviving stretch as an open path. /// Only a closed path can have a surviving stretch that wraps past the last anchor. -fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { let n = sp.anchors.len(); let gone: BTreeSet = removed.iter().copied().filter(|&i| i < n).collect(); if gone.is_empty() { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 7a05a5896..c0946a465 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2698,19 +2698,15 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s), [2]); - // Cut takes the segment between them. The two surviving runs of an ordinary path - // become independent objects, and this path keeps the first run (#3380). + // Cut takes the two anchors out of the path, as Clear does (#6361): each side keeps one + // anchor, which makes no path, so nothing is left. s.execute("edit.undo", &json!({})).unwrap(); let original = path(&s, id); let picked = s.doc().unwrap().selection.clone(); s.execute("edit.cut", &json!({})).unwrap(); let left = paths(&s); - assert_eq!(left.len(), 2); - assert_eq!(left[0].0, id); - assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed)); - assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]); - assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]); - assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::>()); + assert!(left.is_empty()); + assert!(s.doc().unwrap().selection.objects.is_empty()); assert_eq!(s.clipboard.nodes.len(), 1); assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]); s.execute("edit.undo", &json!({})).unwrap(); diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index c9bad6e60..773b2b784 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -618,6 +618,77 @@ fn set_bounds_leaves_the_height_of_lines_with_no_height() { assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1])); } +/// A line with a 2 pt stroke and butt caps, selected, with Use Preview Bounds on. +fn butt_line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let id = NodeId(run(s, "shape.line", json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1}))["id"].as_u64().unwrap()); + run(s, "stroke.set", json!({"weight": 2, "cap": "butt"})); + preview(s, true); + run(s, "select.set", json!({"ids": [id.0]})); + id +} + +#[test] +fn set_bounds_gives_a_butt_line_the_whole_height() { + // A butt cap ends at the anchor, so 10 mm (28.3465 pt) is the length of the line itself. + let mut s = session(); + let id = butt_line(&mut s, (100.0, 100.0), (100.0, 140.0)); + assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 140.0)), "{:?}", s.transform_bounds(&[id])); + run(&mut s, "object.setBounds", json!({"height": 28.3465, "reference": 0, "strokes": false})); + assert!(close(geometric(&s, id), Rect::new(100.0, 100.0, 100.0, 128.3465)), "{:?}", geometric(&s, id)); + assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 128.3465)), "{:?}", s.transform_bounds(&[id])); +} + +#[test] +fn set_bounds_gives_a_diagonal_butt_line_the_size_out_of_proportion() { + // The stroke's corners follow the line's slope, which a scale out of proportion changes. + let mut s = session(); + let id = butt_line(&mut s, (100.0, 100.0), (130.0, 140.0)); + let before = s.transform_bounds(&[id]).unwrap(); + // The stroke's corners reach 0.8 pt across x and 0.6 pt across y at this 3-4-5 slope. + assert!(close(before, Rect::new(99.2, 99.4, 130.8, 140.6)), "{before:?}"); + run(&mut s, "object.setBounds", json!({"height": 100, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.height() - 100.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion() { + // Square ends, but the bevel join at the apex sets the top edge by the slanted segments' + // corners, which a scale out of proportion moves. + let mut s = session(); + let anchors = json!([{"x": 100, "y": 150}, {"x": 100, "y": 140}, {"x": 110, "y": 100}, {"x": 120, "y": 140}, {"x": 120, "y": 150}]); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": "butt", "join": "bevel"})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() { + let mut s = session(); + butt_line(&mut s, (100.0, 100.0), (130.0, 140.0)); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Line"}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 20, "height": 100, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 20.0).abs() < 1e-6 && (v.height() - 100.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn align_places_a_vertical_butt_line_by_its_end_points() { + // With Use Preview Bounds on, a butt line's ends line up with the top of a rectangle without + // a stroke. + let mut s = session(); + rect(&mut s, 20.0, 50.0, 0.0); + let id = butt_line(&mut s, (200.0, 160.0), (200.0, 190.0)); + run(&mut s, "select.all", json!({})); + run(&mut s, "object.align", json!({"vertical": "top"})); + assert!(close(geometric(&s, id), Rect::new(200.0, 100.0, 200.0, 130.0)), "{:?}", geometric(&s, id)); +} + /// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it /// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the /// selected objects. diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index 957d77829..c2713befe 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -192,7 +192,7 @@ fn pen_tool_drawing_copies_whole_path_not_single_anchor() { } #[test] -fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { +fn cut_adjacent_anchors_takes_them_out_of_the_source() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0); @@ -207,15 +207,93 @@ fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { assert_eq!(clip_path.subpaths[0].segment_count(), 1); assert_eq!(clip_path.subpaths[0].anchors.len(), 2); - // Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1. + // The source keeps anchors 3 and 0 and the one segment between them. let src_node = s.doc().unwrap().doc.node(r).unwrap(); let src_path = src_node.path_data().unwrap(); - assert_eq!(src_path.subpaths[0].segment_count(), 3); - assert_eq!(src_path.subpaths[0].anchors.len(), 4); + assert_eq!(src_path.subpaths[0].segment_count(), 1); + assert_eq!(src_path.subpaths[0].anchors.len(), 2); assert!(!src_path.subpaths[0].closed); +} - // Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments). - assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4); +/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor +/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path. +fn contours_after(command: &str, picked: Value, compound: bool) -> Vec> { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80})); + if compound { + let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [id.0, other.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + } + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + contours(&s) +} + +/// Every contour in the document, as `[x, y]` lists. +fn contours(s: &Session) -> Vec> { + let mut left = vec![]; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |n| { + if let Some(path) = n.path_data() { + left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>())); + } + }); + } + left +} + +#[test] +fn cut_end_anchors_leaves_the_source_as_clear_does() { + for compound in [false, true] { + let picked = || json!([[0, 0], [0, 1]]); + let left = contours_after("edit.cut", picked(), compound); + assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}"); + assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}"); + } +} + +#[test] +fn cut_interior_anchors_leaves_the_source_as_clear_does() { + let picked = || json!([[0, 1], [0, 2]]); + let left = contours_after("edit.cut", picked(), true); + assert_eq!(left, contours_after("edit.clear", picked(), true)); + assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}"); +} + +#[test] +fn cut_one_end_anchor_of_a_compound_member_shortens_it() { + let left = contours_after("edit.cut", json!([[0, 3]]), true); + assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]); +} + +#[test] +fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() { + let after = |command: &str| { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 400})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false}); + run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]})); + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"})); + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + s + }; + let cut = after("edit.cut"); + assert_eq!(contours(&cut), contours(&after("edit.clear"))); + assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut)); + // The clipboard holds what left the source: the picked segment alone. + assert_eq!(cut.clipboard.nodes.len(), 1); + let clip = cut.clipboard.nodes[0].path_data().unwrap(); + assert_eq!(clip.subpaths.len(), 1); + assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>(), [[60.0, 120.0], [110.0, 120.0]]); } #[test] diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 71f21e10d..b780c115c 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -145,7 +145,7 @@ fn a_file_an_importer_reads_opens_as_editable_art() { let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#; open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap(); let (x0, y0, x1, y1) = artboard(&s); - assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); + assert!(x0 == 0.0 && x1 == 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); s.execute("select.all", &json!({})).unwrap(); s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 3937045db..4f667fbe4 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -133,7 +133,7 @@ impl Space { } /// A named colour or ink's full colour: `[c m y k /DeviceCMYK (name) /kind]` or `[r g b /DeviceRGB …]`. -fn ink(a: &[V]) -> Option { +pub(super) fn ink(a: &[V]) -> Option { let at = a.iter().position(|v| matches!(v, V::Name(_)))?; let nums: Vec = a.get(..at)?.iter().map(|v| v.num().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32)).collect::>()?; match (a.get(at).and_then(V::text).as_deref(), nums.as_slice()) { diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index a25f2b82a..5f39c3b97 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -72,6 +72,37 @@ pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Pa } } +/// `n [c m y k /DeviceCMYK (name) /Separation] XC` (or `[r g b /DeviceRGB …]`) before an image: its +/// samples are one ink's tints. The ink at 100 % as a paint, with the named colour that makes its +/// spot swatch (none for Registration). `None` for a device space (`/DeviceGray XC`), the ink +/// `None`, which paints nothing, or another form. +pub(super) fn separation_ink(vals: &[V], names: &mut Names) -> Option<(Paint, Option)> { + let a = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(a) } else { None })?; + let [.., name @ V::Str(_), V::Name(kind)] = a.as_slice() else { return None }; + if kind.trim_start_matches('/') != "Separation" { + return None; + } + let color = super::mesh::ink(a)?; + let name = names.text(name)?; + if name == "None" { + return None; + } + if is_registration(&name) { + let paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + return Some((paint, None)); + } + // A process plate is its CMYK colour, not a spot ink: no swatch of its name. + if let Some(plate) = ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == name) { + let mut cmyk = [0.0; 4]; + if let Some(v) = cmyk.get_mut(plate) { + *v = 1.0; + } + return Some((Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])), None)); + } + let paint = Paint::Solid { color, swatch: Some(name.clone()), tint: 1.0 }; + Some((paint, Some(Named { name, color, tint: 1.0, spot: true }))) +} + fn is_registration(name: &str) -> bool { matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]") } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7d2565172..3d859bf74 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -472,10 +472,34 @@ impl<'a> Reader<'a> { return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); + // An image in one ink's Separation space (`XC`): its samples are the ink's tints, the most + // at 100 % (255, or a set bit). It comes in as a grayscale image of them, black at 100 %, + // that the ink colorizes, as a Separation image opens from a PDF; its alpha channel + // becomes its opacity mask. + let ink = if channels == 1 && !image_mask { self.raster.as_mut().and_then(|r| r.ink.take()) } else { None }; + let inverted; + let samples = if ink.is_some() { + inverted = samples.iter().map(|b| !b).collect::>(); + inverted.as_slice() + } else { + samples + }; + let ink = ink.map(|(paint, named)| { + // Its colour counts toward the document's colour model, as a fill's does (Registration + // says nothing of it). + let registration = matches!(&paint, Paint::Solid { swatch: Some(s), .. } if s == vectorcraft_color::swatch::REGISTRATION); + match paint.color().filter(|_| !registration) { + Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, + Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, + _ => {} + } + self.spots.extend(named); + paint + }); let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none(); if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) { - // An image mask keeps the color that colorizes it. - let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint()); + // An image mask keeps the color that colorizes it, an ink image its ink. + let colorize = if image_mask && channels == 1 && *bits == 1.0 { Some(self.mask_paint()) } else { ink }; return self.large_image(samples, opacity, shape, k, &m, colorize); } // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK @@ -487,7 +511,11 @@ impl<'a> Reader<'a> { return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None); } if image_mask && channels == 1 && *bits == 1.0 { - return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32)); + let paint = self.mask_paint(); + return self.colorized_raster(samples, (row, 1), opacity, &m, (w as u32, h as u32), paint); + } + if let Some(paint) = ink { + return self.colorized_raster(samples, (row, *bits as u8), opacity, &m, (w as u32, h as u32), paint); } let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h as usize { @@ -526,15 +554,24 @@ impl<'a> Reader<'a> { self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None) } - /// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image - /// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color, - /// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black. - fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + /// A one-channel image (`samples` of `bits` bits, `row` bytes a row) as a grayscale image + /// colorized with `paint`: a 1-bit gray image whose ImageMask is 1, with the fill color (its 0 + /// bits, which open black with ImageMask 0, take that color, and its 1 bits are transparent; a + /// fill that isn't a solid color colorizes it black), or an ink's tints, inverted, with the ink. + fn colorized_raster( + &mut self, + samples: &[u8], + (row, bits): (usize, u8), + alpha: Option<&[u8]>, + m: &[f64], + (w, h): (u32, u32), + paint: Paint, + ) -> Result<(), String> { let mut gray = Vec::with_capacity(w as usize * h as usize); for y in 0..h as usize { let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); for x in 0..w as usize { - let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255); + let v = if bits == 1 { line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255) } else { line.get(x).copied().unwrap_or(0) }; gray.push(v); } } @@ -544,7 +581,6 @@ impl<'a> Reader<'a> { self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } - let paint = self.mask_paint(); self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint)) } @@ -625,12 +661,15 @@ impl<'a> Reader<'a> { return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize); } let cmyk = s.channels == 4; - let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk); + // A colorized image's alpha channel becomes its opacity mask, as at its full size: a + // grayscale image whose pixels have alpha takes only a spot color, black or white. + let inline = !cmyk && colorize.is_none(); + let (pixels, mask) = raster::reduce(samples, opacity, s, k, inline); let blob = if cmyk { Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i)) } else { use image::ImageEncoder as _; - let colour = match (s.channels, opacity.is_some()) { + let colour = match (s.channels, inline && opacity.is_some()) { (1, false) => image::ExtendedColorType::L8, (1, true) => image::ExtendedColorType::La8, (_, false) => image::ExtendedColorType::Rgb8, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index e1fbe0d4d..f7df4f35d 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -181,9 +181,13 @@ impl<'a> Reader<'a> { } else if line.starts_with("AI5_EndRaster") { // A file `XG` named in this raster object is no later one's. self.image_link = None; - if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() { + if let Some(Raster { operands: Some(vals), mut samples, half, max, ink, .. }) = self.raster.take() { end_hex(&mut samples, half, max); - return self.image_from(vals, &samples, true); + // The image's ink (`XC`) stays for `image_from` to read. + self.raster = Some(Raster { ink, ..Raster::default() }); + let read = self.image_from(vals, &samples, true); + self.raster = None; + return read; } } else if line.starts_with("AI5_BeginPlace") { // One that is still open (no `~` ended it) isn't read. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f584f26c2..6c84eff23 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -361,6 +361,14 @@ impl<'a> Reader<'a> { r.space = vals.iter().rev().find_map(V::text); } } + // `n [… (name) /Separation] XC`: the image's samples are that ink's tints. A device + // space (`/DeviceGray XC` after a linked image's file name) changes nothing read here. + "XC" => { + let ink = paint::separation_ink(&vals, &mut self.names); + if let Some(r) = self.raster.as_mut() { + r.ink = ink; + } + } // Placed files. // `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in // newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index b07270ef8..473a07079 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -279,6 +279,9 @@ impl BrushArtColors { #[derive(Debug, Default)] pub(super) struct Raster { pub(super) space: Option, + /// The ink of an image in a Separation space (`XC`): its paint at 100 % and the named colour + /// for its swatch (`paint::separation_ink`). + pub(super) ink: Option<(Paint, Option)>, /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). pub(super) operands: Option>, @@ -370,8 +373,6 @@ pub(super) const IGNORED: &[&str] = &[ "As", "Xd", "Xr", - // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. - "XC", "Xh", "XH", "Bc", diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2d6aa9967..19cd6fa27 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2755,6 +2755,108 @@ fn image_masks_are_colorized_with_the_fill() { assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]); } +/// A gray image `w` × 1 (`bits` bits a pixel, `alpha` channels, data `data`) whose colour space is +/// set by `xc` (the operands of `XC`) instead of `/DeviceGray XN`. +fn ink_image(xc: &str, bits: u32, alpha: u32, w: u32, data: &[u8]) -> Vec { + let mut out = image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, alpha), data, data.len() + 3); + // In place, as bytes: the samples aren't text. + let mut swap = |from: &[u8], to: &[u8]| { + let at = out.windows(from.len()).position(|s| s == from).unwrap(); + out.splice(at..at + from.len(), to.iter().copied()); + }; + swap(b"/DeviceGray XN\n", format!("{xc} XC\n").as_bytes()); + swap(format!(" 8 1 {alpha} 0 1 0 ").as_bytes(), format!(" {bits} 1 {alpha} 0 1 0 ").as_bytes()); + out +} + +/// An image in one ink's Separation space (`n [… (name) /Separation] XC`): its samples are the +/// ink's tints (255 at 100 %), so it opens as a grayscale image of them, black at 100 %, colorized +/// by the ink's spot swatch; its alpha channel becomes its opacity mask. +#[test] +fn separation_images_are_colorized_with_their_ink() { + let black = "1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]"; + let r = read(&ink_image(black, 8, 0, 3, &[0, 128, 255])).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), tint }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), *tint), ("Black Ink", 1.0)); + assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0)); + assert!(r.doc.swatch("Black Ink").is_some_and(|s| s.spot), "a spot swatch for the ink"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha)); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0], "no ink is white, 100 % black"); + + // An RGB alternate gives the ink's colour; an alpha channel becomes the opacity mask. + let white = "1 [1 1 1 /DeviceRGB (White Ink) /Separation]"; + let r = read(&ink_image(white, 8, 1, 2, &[255, 255, 255, 0])).unwrap(); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), color.to_rgba8(1.0)), ("White Ink", Color::rgb(1.0, 1.0, 1.0).to_rgba8(1.0))); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + + // 1 bit a pixel: a set bit is the ink at 100 % (101). + let r = read(&ink_image(black, 1, 0, 3, b"\xa0")).unwrap(); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [0, 255, 0]); + + // A device space, and the ink None (which paints nothing), leave the image as it was. + for xc in ["/DeviceGray", "1 [0 0 0 1 /DeviceCMYK (None) /Separation]"] { + let doc = read(&ink_image(xc, 8, 0, 2, &[0, 200])).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none(), "{xc}"); + assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255], "{xc}"); + } +} + +/// A process plate's Separation colorizes in the plate's CMYK colour, with no swatch of its name; +/// Registration in the Registration colour. +#[test] +fn separation_images_of_process_plates_and_registration() { + let r = read(&ink_image("1 [0 0 0 1 /DeviceCMYK (Black) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + assert_eq!(art(&r.doc)[0].extras().colorize.as_deref(), Some(&Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)))); + assert!(r.doc.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate"); + let r = read(&ink_image("1 [1 1 1 1 /DeviceCMYK (All) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + let swatch = art(&r.doc)[0].extras().colorize.as_deref().and_then(|p| if let Paint::Solid { swatch, .. } = p { swatch.clone() } else { None }); + assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION)); +} + +/// The version 7 form, with samples in hex comment lines after `XI`, keeps the image's ink. +#[test] +fn a_separation_image_with_hex_comment_lines_keeps_its_ink() { + let s = "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n\ + 1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation] XC\n[ 1 0 0 1 0 100 ] 3 1 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 3 1 3 1 8 1 0 0 0 0 XI\n%0080FF\n%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n"; + let mut data = stream("").into_bytes(); + let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap(); + data.splice(at..at, s.bytes()); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert!( + matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), .. }) if s == "Black Ink"), + "{:?}", + a[0].extras().colorize + ); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0]); +} + +/// A Separation image reduced to fit the limits keeps its ink, and its alpha channel becomes its +/// opacity mask, as at its full size. +#[test] +fn a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask() { + let samples = [255u8; 16].iter().chain([0u8; 16].iter()).copied().collect::>(); + let data = ink_image("1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]", 8, 1, 16, &samples); + let r = super::with_image_limits(4, 1 << 31, 512 << 20, || read(&data).unwrap()); + assert!(r.warnings.iter().any(|w| w.contains("was reduced")), "{:?}", r.warnings); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha), "no alpha in the pixels"); +} + #[test] fn images_with_alpha_channels() { // RGB samples, then the alpha channel (not counted in the data's size). diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 80f338736..5c4474b22 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -900,8 +900,8 @@ fn the_brush_library_is_checked_only_after_3d_art_is_measured() { assert_eq!(r.brush_library, None); } -/// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the -/// free measure does it: as the renderer draws it, by its members. +/// An object whose 3D effect builds nothing (Revolve on a group's fill) is measured by the renderer +/// as the free measure does it: as the renderer draws it, by its members. #[test] fn a_3d_effect_that_builds_nothing_is_measured_alike() { let mut d = Document::new(100.0, 100.0); @@ -917,10 +917,12 @@ fn a_3d_effect_that_builds_nothing_is_measured_alike() { Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); let mut g = Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)]); - g.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }]; + g.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into(); + *g.appearance.items[0].effects_mut() = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }]; let l = d.layers[0].id; d.insert(Some(l), 0, g).unwrap(); let n = d.layers[0].children().unwrap()[0].clone(); + assert!(crate::effects::has_revolve(&n) && crate::effects::revolve_art(&n).is_none(), "builds nothing"); let symbols = d.symbol_boxes(); assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n)); assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n)); @@ -1761,8 +1763,8 @@ fn an_image_inner_glow_follows_its_visible_pixels() { } } -/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the -/// item as without it: the art made for the object is drawn without it, not as an object with +/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen, in a paint +/// whose copies on the faces would pass the budget) draws the item as without it: the art made for the object is drawn without it, not as an object with /// effects again, and that art again, without end. A stroked object draws exactly as without the /// 3D effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too. An object with /// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a @@ -1775,6 +1777,12 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { let rough = Effect { id: "distort.roughen".into(), params: json!({"size": 10, "relative": false, "detail": 10, "points": "smooth"}), visible: true }; let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }; + // A gradient of one color with 1,000 stops. + let ramp = |c: Color| { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = (0..1000).map(|i| GradientStop::new(i as f32 / 1000.0, c)).collect(); + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))) + }; let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; let blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 2}), visible: true }; let cases = [ @@ -1789,8 +1797,8 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { for (object, on_fill, stroked) in cases { let draw = |object: &[Effect], three: bool| { let mut d = Document::new(100.0, 100.0); - let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None }; - let ap = Appearance::basic(Paint::solid(Color::BLACK), stroke, 2.0); + let stroke = if stroked { ramp(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None }; + let ap = Appearance::basic(ramp(Color::BLACK), stroke, 2.0); let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), ap); let l = d.layers[0].id; let id = d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 6ce5f3501..479481c2a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -18,11 +18,16 @@ mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; /// Resource limits shared by desktop, CLI and browser evaluation. -const MAX_PROFILE_POINTS: usize = 1024; +const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; +/// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. +const MAX_REVOLVE_FACES: usize = 163_840; +/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. +const CURVE_SAMPLES: usize = 16; +const MIN_CURVE_SAMPLES: usize = 4; #[derive(Clone, Copy, Debug, Default, PartialEq)] struct V3(f64, f64, f64); @@ -86,6 +91,9 @@ pub struct Revolve { /// Close circular endpoint rings of open profiles. Partial sweeps get sectors; /// the angular cut sides are left open. Enabled by default. pub cap_ends: bool, + /// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of + /// one object. `None` plans them from the profile alone. + pub curve_samples: Option, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -114,6 +122,7 @@ impl Default for Revolve { shade: true, smooth_shading: true, cap_ends: true, + curve_samples: None, } } } @@ -307,6 +316,11 @@ impl Revolve { ..self } } + /// How many steps a sanitized sweep takes. + fn steps(self) -> usize { + ((self.segments as f64 * self.angle / 360.0).ceil() as usize).max(1) + } + fn rotate(self, v: V3) -> V3 { let (sx, cx) = self.rotation_x.to_radians().sin_cos(); let (sy, cy) = self.rotation_y.to_radians().sin_cos(); @@ -449,16 +463,230 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { - revolve_faces(path, bounds, options, None) + revolve_faces(path, FillRule::NonZero, bounds, options, None) } -/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. -fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { +const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; +const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; + +/// The samples each curved segment takes when `paths`, the profiles of one object's surfaces, are +/// revolved with `options`: [`CURVE_SAMPLES`] when every surface fits the limits together, else as +/// many as fit, the same number for every curve. An error when [`MIN_CURVE_SAMPLES`] do not fit. +/// The count is made before any face is built. +pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, options: Revolve) -> Result { let o = options.sanitized(); + let steps = o.steps(); + let per_edge = if o.shade && o.smooth_shading { steps.saturating_mul(2) } else { steps }; + let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); + for sp in paths.into_iter().flat_map(|p| &p.subpaths) { + anchors = anchors.saturating_add(sp.anchors.len()); + if anchors > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } + contours += 1; + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..segments { + if sp.segment_is_line(i) { + lines += 1; + } else { + curves += 1; + } + } + if o.cap_ends && !sp.closed && segments > 0 { + caps += 2; + } + } + let points = MAX_PROFILE_POINTS.saturating_sub(contours); + if lines.saturating_add(curves.saturating_mul(MIN_CURVE_SAMPLES)) > points { + return Err(TOO_COMPLEX_PROFILE.into()); + } + let edges = (MAX_REVOLVE_FACES.saturating_sub(caps.saturating_mul(steps)) / per_edge).min(points); + if curves == 0 { + return if lines > edges { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(CURVE_SAMPLES) }; + } + let samples = (edges.saturating_sub(lines) / curves).min(CURVE_SAMPLES); + if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } +} + +/// Most work the search for holes does: each winding test counts its contour's elements, and each +/// contour placed in a cell of the grid or looked at as a candidate for a point counts one. +const MAX_HOLE_WORK: usize = 16_000_000; + +/// Most cells along each side of the grid the search for holes sorts contours into. +const MAX_HOLE_GRID: usize = 256; + +/// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is +/// outside its closed contour at the segment's middle and not inside. An error past +/// [`MAX_HOLE_WORK`]. +#[cfg(test)] +fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + hole_segments_counted(path, rule).map(|(holes, _)| holes) +} + +/// [`hole_segments`] and the work it did. +#[cfg(test)] +fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { + let mut search = HoleSearch::new(path, rule)?; + let mut holes = Vec::with_capacity(path.subpaths.len()); + for (i, sp) in path.subpaths.iter().enumerate() { + let segments = if sp.closed { sp.anchors.len() } else { 0 }; + holes.push((0..segments).map(|segment| search.is_hole(i, sp.segment(segment).eval(0.5))).collect::, _>>()?); + } + Ok((holes, search.work)) +} + +/// The closed contours of a profile sorted into a grid over the bounds of them all, each listed in +/// the cells its bounds reach into, to find which parts of the profile's edges wall a hole under +/// its fill rule. A point is tested only against the contours of its cell. Every test counts +/// toward [`MAX_HOLE_WORK`]. +struct HoleSearch { + /// Each subpath's outline, its bounds, and for a closed one which way it winds. + contours: Vec<(BezPath, Rect, Option)>, + rule: FillRule, + area: Option, + side: usize, + grid: Vec>, + work: usize, +} + +impl HoleSearch { + fn new(path: &PathData, rule: FillRule) -> Result { + let contours: Vec<(BezPath, Rect, Option)> = path + .subpaths + .iter() + .map(|sp| { + let contour = PathData::new(vec![sp.clone()]).to_bezpath(); + let bounds = contour.bounding_box(); + let direction = sp.closed.then(|| if contour.area() < 0.0 { -1 } else { 1 }); + (contour, bounds, direction) + }) + .collect(); + let closed = || contours.iter().enumerate().filter(|(_, (_, _, d))| d.is_some()); + let area = closed().map(|(_, (_, b, _))| *b).reduce(|a, b| a.union(b)); + let side = ((closed().count() as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let mut search = HoleSearch { contours: vec![], rule, area, side, grid: vec![vec![]; side * side], work: 0 }; + for (j, (_, b, _)) in closed() { + let cells = search.cells(*b); + search.spend(cells.len())?; + for c in cells { + if let Some(c) = search.grid.get_mut(c) { + c.push(j); + } + } + } + search.contours = contours; + Ok(search) + } + + fn spend(&mut self, n: usize) -> Result<(), String> { + self.work = self.work.saturating_add(n); + if self.work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + } + + /// The grid cells `r` reaches into. + fn cells(&self, r: Rect) -> Vec { + let Some(a) = self.area else { return vec![] }; + let side = self.side; + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let (x0, x1) = (at(r.x0, a.x0, a.x1), at(r.x1, a.x0, a.x1)); + let (y0, y1) = (at(r.y0, a.y0, a.y1), at(r.y1, a.y0, a.y1)); + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| y * side + x)).collect() + } + + /// The closed contours other than `own` whose bounds may meet `r`, each once. + fn candidates(&mut self, own: usize, r: Rect) -> Result, String> { + let mut out = vec![]; + for c in self.cells(r) { + let listed = self.grid.get(c).map_or(&[][..], Vec::as_slice); + out.extend(listed.iter().copied().filter(|j| *j != own)); + } + self.spend(out.len())?; + out.sort_unstable(); + out.dedup(); + Ok(out) + } + + /// Whether the edge of closed subpath `own` at `p` walls a hole: the fill is outside its + /// contour there and not inside. + fn is_hole(&mut self, own: usize, p: Point) -> Result { + let Some(direction) = self.contours.get(own).and_then(|c| c.2) else { return Ok(false) }; + let mut others = 0i32; + for j in self.candidates(own, Rect::from_points(p, p))? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if !bounds.contains(p) { + continue; + } + let (elements, winding) = (contour.elements().len(), contour.winding(p)); + self.spend(elements)?; + others += winding; + } + Ok(match self.rule { + FillRule::EvenOdd => others % 2 != 0, + FillRule::NonZero => others != 0 && others + direction == 0, + }) + } + + /// The line from `a` to `b` on subpath `own`, cut where it crosses another closed contour: each + /// part's end, the last one `b`, and whether that part walls a hole at its middle. + fn parts(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + let mut out = vec![]; + let mut from = 0.0; + for t in self.crossings(own, a, b)?.into_iter().chain([1.0]) { + let hole = self.is_hole(own, a.lerp(b, (from + t) * 0.5))?; + out.push((if t < 1.0 { a.lerp(b, t) } else { b }, hole)); + from = t; + } + Ok(out) + } + + /// Where the line from `a` to `b` on closed subpath `own` crosses another closed contour, as + /// sorted fractions of the way from `a` to `b`, strictly between them. + fn crossings(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + if self.contours.get(own).and_then(|c| c.2).is_none() { + return Ok(vec![]); + } + let line = vectorcraft_geom::kurbo::Line::new(a, b); + let reach = Rect::from_points(a, b); + let mut out = vec![]; + for j in self.candidates(own, reach)? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if bounds.x0 > reach.x1 || bounds.x1 < reach.x0 || bounds.y0 > reach.y1 || bounds.y1 < reach.y0 { + continue; + } + let found: Vec = contour.segments().flat_map(|s| s.intersect_line(line)).map(|i| i.line_t).collect(); + self.spend(contour.elements().len())?; + out.extend(found.into_iter().filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9)); + } + out.sort_by(f64::total_cmp); + out.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + Ok(out) + } +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, +/// each turned out of the region `rule` fills. +fn revolve_faces( + path: &PathData, + rule: FillRule, + bounds: Rect, + options: Revolve, + mut mesh: Option<&mut Vec>, +) -> Result, String> { + let o = options.sanitized(); + let curve_samples = match o.curve_samples { + Some(samples) => samples.clamp(1, CURVE_SAMPLES), + None => revolve_curve_samples([path], o)?, + }; + // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so + // each part's normal turns toward a hole only where it walls one. + let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); - if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); @@ -470,7 +698,7 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let center_y = (bounds.y0 + bounds.y1) * 0.5; let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0)); - let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let steps = o.steps(); let sweep = o.angle.to_radians(); let smooth = o.shade && o.smooth_shading; let [lx, ly, lz] = o.light_direction(); @@ -489,32 +717,33 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti if let Some(face) = face { faces.push(face); mesh_cost += cost; - if mesh_cost > MAX_FACES { - return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + if mesh_cost > MAX_REVOLVE_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } } Ok(()) }; let mut count = 0; let mut curve_budget = 0; - for sp in &path.subpaths { + for (contour, sp) in path.subpaths.iter().enumerate() { let mut profile = Vec::new(); let mut tangents = Vec::new(); + let mut hole_edges = Vec::new(); if let Some(first) = sp.anchors.first() { profile.push(first.p); count += 1; if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } } let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; - // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + // Bound work before sampling any data. Straight edges get one sample. if segments > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + let samples = if sp.segment_is_line(i) { 1 } else { curve_samples }; for k in 1..=samples { let t0 = (k - 1) as f64 / samples as f64; let t1 = k as f64 / samples as f64; @@ -536,11 +765,19 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); if v.hypot2() > 1e-20 { v } else { b - a } }; - tangents.push([tangent(t0), tangent(t1)]); - profile.push(b); - count += 1; - if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + let a = profile.last().copied().unwrap_or(b); + let parts = match search.as_mut() { + Some(search) => search.parts(contour, a, b)?, + None => vec![(b, false)], + }; + for (end, hole) in parts { + tangents.push([tangent(t0), tangent(t1)]); + hole_edges.push(hole); + profile.push(end); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } } } } @@ -565,8 +802,9 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); if area < 0.0 { -1.0 } else { 1.0 } }; - for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { + for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { let [a, b] = pair else { continue }; + let turn = if hole { -turn } else { turn }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 2f2f69844..d1970c202 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -76,10 +76,11 @@ pub fn inflate_meshes( } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than -/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). -pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). The +/// walls of holes under `rule`, such as the counter of an O, face into the hole. +pub fn revolve_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result { let mut faces = vec![]; - crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?; mesh(&faces) } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 3251cbb3e..2011d6689 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -231,7 +231,7 @@ fn a_revolved_profile_is_a_closed_cylinder() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(closed(&m)); let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); @@ -246,7 +246,7 @@ fn a_partial_revolve_caps_its_ends_with_sectors() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); } @@ -261,7 +261,7 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); assert!(closed(&m), "{from} to {to}"); @@ -269,6 +269,62 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct } } +/// The radial direction of every triangle's normal at the wall `x` pt from the axis, each as the +/// sign of the normal's part along the way from the axis: 1 away from it, -1 toward it. +fn wall_directions(m: &Mesh, x: f64) -> Vec { + m.triangles + .iter() + .filter_map(|t| { + let [a, b, c] = corners(m, *t); + let centre = [(a[0] + b[0] + c[0]) / 3.0, 0.0, (a[2] + b[2] + c[2]) / 3.0]; + let radius = centre[0].hypot(centre[2]); + let n = m.normals[t[0] as usize]; + ((radius - x).abs() < 1.0 && n[1].abs() < 0.5).then(|| dot(n, centre).signum()) + }) + .collect() +} + +#[test] +fn the_wall_of_a_counter_faces_into_the_hole() { + // A square ring 20 pt thick, its axis at the outer square's left edge minus 10: the outer wall + // reaches 110 pt from the axis, and the hole's far wall 90 pt from it. + let ring = |hole_clockwise: bool, rule: FillRule| { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 20.0, 20.0, 60.0, hole_clockwise); + let (p, r) = shape(&b); + revolve_mesh(&p, rule, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap() + }; + for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] { + let m = ring(clockwise, rule); + let outer = wall_directions(&m, 110.0); + assert!(!outer.is_empty() && outer.iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); + let hole = wall_directions(&m, 90.0); + assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}"); + } + // Wound the same way under the nonzero rule, the inner square is not a hole. + let m = ring(false, FillRule::NonZero); + let inner = wall_directions(&m, 90.0); + assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0)); +} + +#[test] +fn an_edge_that_crosses_another_contour_faces_the_hole_only_where_it_walls_it() { + // Two squares overlapping under the even-odd rule leave their overlap empty. The first square's + // right edge (110 pt from the axis) and the second's left edge (50 pt from it) each wall the + // overlap along part of their length and the outside along the rest. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 40.0, 40.0, 100.0, false); + let (p, r) = shape(&b); + let m = revolve_mesh(&p, FillRule::EvenOdd, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap(); + for radius in [110.0, 50.0] { + let walls = wall_directions(&m, radius); + assert!(walls.iter().any(|s| *s > 0.0) && walls.iter().any(|s| *s < 0.0), "both ways at {radius}"); + } + assert!(wall_directions(&m, 150.0).iter().all(|s| *s > 0.0), "the second square's far edge faces out"); +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index e2b6985fb..1b0ffa3ff 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -4,6 +4,7 @@ mod caps; mod classic; mod lighting; mod multiple_lights; +mod profile_limits; mod smooth; #[test] diff --git a/crates/three-d/src/tests/caps.rs b/crates/three-d/src/tests/caps.rs index 32fc5ac0b..cae43f4a5 100644 --- a/crates/three-d/src/tests/caps.rs +++ b/crates/three-d/src/tests/caps.rs @@ -93,14 +93,14 @@ fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() { assert!(faces.len() <= 384); let mut excessive = BezPath::new(); excessive.move_to((40.0, 0.0)); - for i in 1..=1024 { + for i in 1..=MAX_PROFILE_POINTS { excessive.line_to((40.0, i as f64)); } - assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err()); + assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, MAX_PROFILE_POINTS as f64), options()).is_err()); assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty()); // Viewed along the axis, the side walls disappear but the caps still consume // the same face budget. Repeated rings cannot bypass it through degenerate walls. - let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]); + let repeated = PathData::new(vec![path.subpaths[0].clone(); 1025]); let o = Revolve { segments: 128, ..options() }; assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty()); assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex")); diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs new file mode 100644 index 000000000..5eeb10f2a --- /dev/null +++ b/crates/three-d/src/tests/profile_limits.rs @@ -0,0 +1,139 @@ +use crate::*; +use vectorcraft_geom::{Anchor, SubPath}; + +/// A closed star of `points` tips: twice as many straight edges. +fn star(points: usize) -> PathData { + let anchors = (0..points * 2) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + Anchor::corner(Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// An open wave of `anchors` smooth anchors: one curved segment between each two. +fn wave(anchors: usize) -> PathData { + let anchors = (0..anchors) + .map(|i| { + let p = Point::new(100.0 + if i % 2 == 0 { 0.0 } else { 20.0 }, i as f64); + Anchor::smooth(p, p + Vec2::new(6.0, 0.3)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, false)]) +} + +fn revolved(path: &PathData, options: Revolve) -> Result, String> { + revolve(path, path.bounds().unwrap(), options) +} + +#[test] +fn a_star_of_600_points_revolves_at_the_default_segments() { + let faces = revolved(&star(600), Revolve::default()).unwrap(); + // The tip on the axis gives two edges with a side of no length. + assert!((1198 * 64 * 2..=1200 * 64 * 2).contains(&faces.len()), "{}", faces.len()); + let refused = revolved(&star(600), Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); +} + +#[test] +fn a_curved_profile_past_the_old_limit_takes_fewer_samples_for_each_curve() { + let wave = wave(200); + assert!(revolved(&wave, Revolve::default()).is_ok()); + let samples = revolve_curve_samples([&wave], Revolve::default()).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); +} + +#[test] +fn a_profile_within_the_limits_keeps_every_sample() { + assert_eq!(revolve_curve_samples([&wave(50)], Revolve::default()), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&star(5)], Revolve::default()), Ok(CURVE_SAMPLES)); +} + +#[test] +fn two_thousand_curves_fit_at_low_segments_and_are_refused_before_any_face_at_high() { + let wave = wave(2001); + let samples = revolve_curve_samples([&wave], Revolve { segments: 8, ..Revolve::default() }).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); + assert!(revolved(&wave, Revolve { segments: 8, ..Revolve::default() }).is_ok()); + let refused = revolve_curve_samples([&wave], Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolved(&wave, Revolve { segments: 128, ..Revolve::default() }), Err(refused)); +} + +#[test] +fn too_many_anchors_are_refused_as_a_profile() { + let refused = revolve_curve_samples([&wave(MAX_PROFILE_POINTS + 1)], Revolve { segments: 8, ..Revolve::default() }).unwrap_err(); + assert_eq!(refused, TOO_COMPLEX_PROFILE); +} + +#[test] +fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { + let straight = |anchors: usize| { + let anchors = (0..anchors).map(|i| Anchor::corner(Point::new(100.0 + (i % 2) as f64, i as f64))).collect(); + PathData::new(vec![SubPath::new(anchors, false)]) + }; + let narrow = Revolve { angle: 1.0, cap_ends: false, segments: 8, ..Revolve::default() }; + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS)], narrow), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS + 1)], narrow), Err(TOO_COMPLEX_PROFILE.to_string())); +} + +#[test] +fn the_profiles_of_one_object_share_one_budget() { + let (one, other) = (star(600), star(600)); + let o = Revolve::default(); + assert!(revolve_curve_samples([&one], o).is_ok()); + assert!(revolve_curve_samples([&other], o).is_ok()); + let refused = revolve_curve_samples([&one, &other], o).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + // Contours of one profile count together as well. + let both = PathData::new(one.subpaths.iter().chain(&other.subpaths).cloned().collect()); + assert_eq!(revolve_curve_samples([&both], o), Err(refused)); +} + +#[test] +fn deeply_nested_contours_stop_the_search_for_holes() { + // Concentric triangles: each tests every contour outside it. + let triangle = |r: f64| { + let anchors = [(0.0, -r), (r, r), (-r, r)].iter().map(|(x, y)| Anchor::corner(Point::new(200_000.0 + x, 200_000.0 + y))).collect(); + SubPath::new(anchors, true) + }; + let nested = PathData::new((1..=4000).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&nested, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string())); + let few = PathData::new((1..=4).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); +} + +#[test] +fn a_point_is_tested_only_against_the_contours_near_it() { + // 3,000 separate squares: each segment's middle lies in the bounds of its own square only. + let squares = (0..3000) + .map(|i| { + let (x, y) = ((i % 60) as f64 * 20.0, (i / 60) as f64 * 20.0); + let corners = [(x, y), (x + 10.0, y), (x + 10.0, y + 10.0), (x, y + 10.0)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }) + .collect(); + let (holes, work) = hole_segments_counted(&PathData::new(squares), FillRule::NonZero).unwrap(); + assert!(holes.iter().flatten().all(|hole| !hole)); + assert!(work < 200_000, "{work}"); +} + +#[test] +fn overlapping_contours_are_holes_only_where_they_overlap() { + let square = |x: f64, y: f64, side: f64| { + let corners = [(x, y), (x + side, y), (x + side, y + side), (x, y + side)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }; + let overlapping = PathData::new(vec![square(0.0, 0.0, 100.0), square(40.0, 40.0, 100.0)]); + let expected = vec![vec![false, true, true, false], vec![true, false, false, true]]; + assert_eq!(hole_segments(&overlapping, FillRule::EvenOdd), Ok(expected)); +} + +#[test] +fn given_samples_are_used_for_each_curve() { + let wave = wave(4); + let at = |samples| revolved(&wave, Revolve { curve_samples: Some(samples), cap_ends: false, segments: 8, ..Revolve::default() }).unwrap().len(); + assert_eq!(at(4) * 2, at(8)); +} diff --git a/crates/three-d/src/tests/smooth.rs b/crates/three-d/src/tests/smooth.rs index c65172326..085255ecb 100644 --- a/crates/three-d/src/tests/smooth.rs +++ b/crates/three-d/src/tests/smooth.rs @@ -118,6 +118,6 @@ fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() { } assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty()); let mut p = path("M40 0"); - p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); + p.subpaths[0].anchors.extend((1..1100).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err()); } diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index c59ef6f8c..001404765 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -12,8 +12,8 @@ //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. -//! The field takes the wheel only when the pointer was moved onto it after the field last moved and -//! the wheel stream began there. +//! The field takes the wheel only when the pointer was moved onto it after the field last moved or +//! was covered by a window or overlay, and the wheel stream began there. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -685,6 +685,9 @@ fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec) -> Vec { let key = wheel_fields_id(ui.ctx().viewport_id()); let pass = ui.ctx().cumulative_pass_nr(); + // A window or overlay over the pointer covers the field even when no input arrives. + let pointer = ui.data_mut(|d| d.get_temp_mut_or_default::(key).pointer).or_else(|| ui.ctx().input(|i| i.pointer.latest_pos())); + let covered = pointer.is_some_and(|p| resp.interact_rect.contains(p) && ui.ctx().layer_id_at(p) != Some(resp.layer_id)); ui.data_mut(|d| { let mut fields: WheelFields = d.get_temp(key).unwrap_or_default(); if fields.pass != pass { @@ -695,7 +698,7 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men if let Some(armed) = &mut fields.armed && armed.id == resp.id { - if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 { + if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 && !covered { armed.pass = pass; } else { fields.armed = None; @@ -714,8 +717,8 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men /// Take the closed font fields' wheel events before egui smooths and combines them. Other /// events stay in the input, so scrolling elsewhere keeps its own delta and event order. A field /// takes a wheel stream only when the stream begins with the pointer over it and the pointer was -/// moved onto it after the field last moved. A stream that began anywhere else stays there until -/// it ends, and a stream that leaves its field does not return to it. +/// moved onto it after the field last moved or was covered. A stream that began anywhere else +/// stays there until it ends, and a stream that leaves its field does not return to it. pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { let key = wheel_fields_id(raw.viewport_id); let Some(mut fields) = ctx.data(|d| d.get_temp::(key)) else { return }; diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 75d2810e5..a05479437 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -358,6 +358,11 @@ fn activating_the_family_popup_leaves_its_wheel_input_available() { /// A family field at `top`, the input's clock at `time`. Returns the wheel events left in the /// input, the notches the field took and the point in the middle of the field. fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { + covered_stream_frame(ctx, top, false, time, events) +} + +/// [`stream_frame`], with an overlay over the whole field when `covered` is set. +fn covered_stream_frame(ctx: &egui::Context, top: Option, covered: bool, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; super::take_raw_wheel(ctx, &mut raw); let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count(); @@ -373,6 +378,12 @@ fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec egui::Id { egui::Id::unique("wheel-taken-from-raw").with(viewport) } -/// The field the pointer was last moved onto, while that field stays where it was. +/// The field the pointer was last moved onto, while that field stays where it was and uncovered. #[derive(Clone, Copy)] pub(crate) struct ArmedWheelField { pub(crate) id: egui::Id, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a04c8926e..1f911f6e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -223,6 +223,9 @@ private editing data. VectorCraft: A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it, or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black with ImageMask 0 take the color and the others are transparent. + An image whose color space `XC` sets to one ink (`n [… (name) /Separation] XC`) comes in as a grayscale image of + its tints (black at 100 %) colorized by the ink (its spot swatch, or a process plate's CMYK color), its alpha channel + as its opacity mask (#6350). - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files. @@ -324,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | | 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | diff --git a/docs/mcp.md b/docs/mcp.md index 66459255a..cbb145966 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1387,9 +1387,13 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 -(https://json-schema.org/draft/2020-12), with `type`, `properties`, `required`, `additionalProperties: false`, -`minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own -fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, +(https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the +command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and +`additionalProperties: false` for objects; `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges; +`enum` for a list of names; `items` for the values of a list; `prefixItems`, `items: false` and `minItems: 2` for a +point; `anyOf` and `minItems: 3` for a color, which is a string, a list of at least three numbers or an object; +`description` for a parameter's doc text; `default` for a default the engine fills in; and `deprecated: true` for a +deprecated parameter. VectorCraft's own fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, `x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`), `x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null as left out is an `anyOf` with `{"type": "null"}`. Null params read as `{}`, so a schema with no required parameter has @@ -2383,10 +2387,12 @@ default) white ones knock out instead; `"discard"` writes none. ## Revolve (initial 3D effect) -`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path -or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path, +compound path, type, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. +Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export cuts each sampled piece of a closed contour where it crosses another closed contour, adding a profile point at each cut, and tests the middle of each part against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` @@ -2459,7 +2465,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and -`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. +`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. On a group or +layer the 3D effect goes on the group itself: `effect.apply` and `effect.setParams` refuse one on its fill or stroke. `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` @@ -3226,7 +3233,11 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter -limit. A rectangle's right-angle miters stay within half the weight of its edges. +limit. A rectangle's right-angle miters stay within half the weight of its edges. A butt-capped stroke on an open +path does not reach past the path's ends: a straight segment's box comes from the four corners of its stroked +rectangle. Past an end, only a round or projecting cap or an arrowhead adds to the box. A curve that bends more +tightly than half the stroke weight can measure a little larger than it paints, and a butt-dashed path whose first or +last dash is a gap is measured to the path's ends. A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in `document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees, diff --git a/docs/revolve.md b/docs/revolve.md index 2ea788f90..fd0f36f16 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -2,7 +2,10 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). -Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. +On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) +describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, +the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. @@ -88,13 +91,13 @@ with the standard command. - Lighting offers Standard, Diffuse, Top Left and Right, using the [shared original rigs](three-d.md#presets). Other effective settings show Custom; changing the view does not change the recognized lighting preset. -- Paths and compound paths only. Type, images and groups must be converted to paths first. +- Paths, compound paths, type, and groups or layers of them only. Images and other objects must be converted to paths first. - Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. - Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. - Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. -- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. +- Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. - Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5d91cd61b..d6d5b7035 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,6 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. @@ -1043,12 +1044,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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)). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - 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)). + - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). - 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. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). - Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test). - Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). + - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - 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. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). diff --git a/docs/three-d.md b/docs/three-d.md index f3088cc8d..0e9901061 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -232,8 +232,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. 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, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, - clipping groups and other live objects have to be converted first. Revolve on a group is not built yet. One 3D - effect per object. + clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about + one axis ([revolve.md](revolve.md)). 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 (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, diff --git a/docs/type-parity.md b/docs/type-parity.md index ee0dcfada..853348724 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -18,7 +18,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| -| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. Order, bounds, Undo grouping, this rule and its 0.5 s are VectorCraft's choices; scores unchanged. | +| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. Order, bounds, Undo grouping, this rule and its 0.5 s are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 922c5f974..ebe96817c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -264,7 +264,7 @@ tools, never checked side by side) isn't captured row by row. | Behaviour | Status | Notes | |---|---|---| -| Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. | +| Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. |