diff --git a/Cargo.lock b/Cargo.lock index a4eac55700..b42037f540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -986,12 +986,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7 [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1620,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3869,7 +3869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4391,7 +4391,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4769,7 +4769,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4828,7 +4828,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5687,7 +5687,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.6", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7517,7 +7517,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec55a218f0..5bed166e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/vectorcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6", features = ["serde"] } craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 3e20c69a98..6ab80c8ba0 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -187,6 +187,9 @@ pub struct HpglOutput { pub pens: Vec<[u8; 3]>, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: sorting the paths or drawing the inner paths first was applied only + /// in part. + pub setting_notes: Vec, } /// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are @@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } + let mut setting_notes = vec![]; if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), }; - out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes }); } Ok(out) } @@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { /// for a closed path any of its points (where it then starts); without, the first in paint order. /// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. /// Once `work` is spent, the paths left keep paint order, each after the paths inside it with -/// `inside_first`, with a warning. Returns where the pen stops. +/// `inside_first`, with a note in `notes`. Returns where the pen stops. fn reorder( paths: &mut Vec>, mut at: (i64, i64), work: &mut u64, nearest: bool, inside_first: bool, - warnings: &mut Vec, + notes: &mut Vec, ) -> (i64, i64) { let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); // The paths each path lies inside, and how many paths inside each are still to draw. let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); if inside_first && outer.is_none() { - warn(warnings, UNNESTED); + warn(notes, UNNESTED); } let mut waiting = vec![0usize; paths.len()]; for o in outer.iter().flatten().flatten() { @@ -430,11 +434,11 @@ fn reorder( if *work == 0 || best.0 >= left.len() { match &outer { Some(outer) => { - warn(warnings, UNSORTED_INSIDE); + warn(notes, UNSORTED_INSIDE); inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); } None => { - warn(warnings, UNSORTED); + warn(notes, UNSORTED); paths.extend(left.into_iter().map(|(_, p, _)| p)); } } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c29ef67532..9cb4f7d727 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() { fn sorting_stops_at_its_budget_and_keeps_every_path() { let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); - assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [UNSORTED]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); assert_eq!(text(&out).matches("PD").count(), 600); } @@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() { } /// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in -/// paint order, with a warning. +/// paint order, with a note on the option. #[test] fn inside_first_past_its_budget_draws_in_paint_order() { let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED]); + assert_eq!(out.setting_notes, [UNNESTED]); let l = lefts(&out); assert_eq!(l.len(), 100); assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); - // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + // With `sort` too, the budget is spent before sorting starts: paint order, with both notes. let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]); assert_eq!(lefts(&out), l); } @@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { v }); let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNSORTED_INSIDE]); + assert_eq!(out.setting_notes, [UNSORTED_INSIDE]); let l = lefts(&out); assert_eq!(l.len(), 602); assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 0ef28a4aaa..1443829806 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -410,6 +410,9 @@ pub struct DxfOutput { pub images: Vec, /// What was approximated or left out. pub warnings: Vec, + /// What was left out because an option turns it off (Include Placed Images or Include Fill + /// Hatches off). + pub setting_notes: Vec, } /// The smallest and largest `scale` (drawing units per unit). diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..0379fc4ea5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> { /// Image keys → index into `images` (`None`: left out). by_key: HashMap>, warnings: Vec, + /// What was left out because an option asked for it ([`DxfOutput::setting_notes`]). + setting_notes: Vec, brushes: Option>, extents: Option<[f64; 4]>, /// The opacity of the containers around the current object. @@ -554,6 +556,7 @@ impl<'a> Scene<'a> { files: vec![], by_key: HashMap::new(), warnings: vec![], + setting_notes: vec![], brushes: None, extents: None, opacity: 1.0, @@ -588,15 +591,24 @@ impl<'a> Scene<'a> { metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), next: self.next, }; - DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes } } + /// Note `w`, once, about something of the document that the drawing leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about something left out because an option turns it off. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn handle(&mut self) -> Handle { let h = Handle(self.next); self.next = self.next.saturating_add(1); @@ -930,7 +942,7 @@ impl<'a> Scene<'a> { // The outlines close as the fill does, so an open path's fill keeps its closing edge. self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { - self.warn(HATCHES_OFF); + self.note_setting(HATCHES_OFF); return; } let h = self.handle(); @@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> { return; } if !self.o.include_images { - self.warn(IMAGES_OFF); + self.note_setting(IMAGES_OFF); return; } let Some(i) = self.image_def(im) else { return }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9d2a519e1d..96c037a4a7 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); - assert_eq!(out.warnings, [HATCHES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [HATCHES_OFF]); let e = entities(&out); assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); // Hatches stay by default, with no note. let on = export(&d, &opts(&d)).unwrap(); - assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty()); // The outlines are the ones written beside hatches, also for overlapping subpaths, which are // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. let compound = doc_with(|d| { @@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); // Nothing filled: no note. let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap(); + assert!(none.warnings.is_empty() && none.setting_notes.is_empty()); // R12 has no hatches anyway: the same outlines, and its own note alone. let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); let (off, on) = (r12(false), r12(true)); assert_eq!(off.bytes, on.bytes); assert_eq!(off.warnings, on.warnings); - assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(off.setting_notes.is_empty()); } #[test] @@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() { let p = pairs(&out.bytes); assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); - assert_eq!(out.warnings, [IMAGES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [IMAGES_OFF]); // An image that can't be decoded is not read when images are left out. let mut broken = d.clone(); broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); - assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap(); + assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings); + assert_eq!(left_out.setting_notes, [IMAGES_OFF]); assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); // R12 has no images anyway: its own note alone. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); - assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty()); } #[test] @@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b let cnc = run(DxfPreset::Cnc.options()); assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); assert!(cnc.images.is_empty()); - assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes); let custom = DxfPreset::Custom.options(); assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); } diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index e449151d89..8f9933c40c 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -4,7 +4,8 @@ use kurbo::{Point, Rect, Vec2}; -use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; +use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop}; +use crate::Color; /// Most runs of the stops [`Gradient::padded_over`] writes. pub const MAX_SPREAD_RUNS: usize = 256; @@ -33,8 +34,8 @@ pub struct Padded { pub first: f64, /// How many runs are written, evenly spaced from [`Self::first`] to 1. pub runs: usize, - /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows), - /// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed, + /// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. pub capped: bool, } @@ -50,7 +51,9 @@ impl Gradient { self.padded_within(geom, area, MAX_SPREAD_STOPS) } - /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs + /// written need more stops than that, each run leaves out the stops that change its colors + /// least ([`fit_run`]), so every run is still written. pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option { let kind = self.kind; if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { @@ -68,8 +71,8 @@ impl Gradient { // smallest 0 when the area holds the rings' center, else on its edge. let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); - let one = whole_run(&self.stops); - let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let most = MAX_SPREAD_RUNS.min(budget / 2).max(1); let capped = hi - lo > most as f64; // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) // those around the vector's start where the area reaches it, else the area's runs nearest @@ -79,6 +82,7 @@ impl Gradient { _ => lo, }; let n = (hi - lo).min(most as f64).max(1.0); + let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method); // A radial or diamond writes its runs among the levels from 0, which its stops' offsets // (single-precision fractions of the outermost level) tell apart only so far out, the // nearer the closer its stops lie: past that, the runs end there and the gradient pads. @@ -112,8 +116,9 @@ impl Gradient { self.unrolled_within(MAX_SPREAD_STOPS) } - /// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs - /// that fit, the last one pads. + /// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops + /// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with + /// fewer than two stops for each run do the runs past those that fit pad with the last one. pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { use std::borrow::Cow; if !self.is_spread() { @@ -123,13 +128,29 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } - let one = whole_run(&self.stops); let n = self.repeat_count() as usize; - let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let kept = n.min(budget / 2).max(1); + let one = fit_run(whole_run(&self.stops), budget / kept, self.method); let stops = self.runs(&one, false, 0.0, kept, n as f64); Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) } + /// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two): + /// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that + /// pads with more stops than that without the stops that change its colors least + /// ([`fit_run`]). Others as they are. + pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if self.is_spread() { + return self.unrolled_within(max_stops); + } + if self.stops.len() <= max_stops.max(2) { + return Cow::Borrowed(self); + } + Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() }) + } + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and @@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec { out } +/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two): +/// as it is when it fits, else without the stops that change its colors least, left out one at a +/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still +/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or +/// process plates lie from the line between theirs (a whole unit when its swatch link or color +/// model differs from either of theirs, and a whole unit more when its midpoint or the one before +/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose +/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm. +fn fit_run(one: Vec, budget: usize, method: GradientMethod) -> Vec { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let budget = budget.max(2); + let n = one.len(); + if n <= budget { + return one; + } + let mix = |x: &Color, y: &Color, v: f32| match method { + GradientMethod::Classic => x.lerp_keep_model(y, v), + m => m.mix(x, y, v), + }; + let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 { + let span = b.offset - a.offset; + let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 }; + // Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`. + let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb()); + let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max); + let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs(); + // Process inks, which colors that look alike can print differently: off their line, or a + // change of color model. + let process = match (&a.color, &s.color, &b.color) { + (Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => { + [(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max) + } + _ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0, + _ => 1.0, + }; + let color = color.max(process); + // Separations: a change of swatch link, or of tint or process plates off their line. + let ink = if s.swatch != a.swatch || s.swatch != b.swatch { + 1.0 + } else { + let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs(); + let plates = match (a.plates, s.plates, b.plates) { + (None, None, None) => 0.0, + (Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max), + _ => 1.0, + }; + tint.max(plates) + }; + // Leaving it out merges two spans, and an off-center midpoint shapes one of them. + let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 }; + // Non-negative, so the bits order as the numbers do (NaN counts as 0). + (color.max(opacity).max(ink) + shaped).max(0.0).to_bits() + }; + // The stops still kept on either side of each stop, and each stop's latest cost entry. + let mut prev: Vec = (0..n).map(|i| i.saturating_sub(1)).collect(); + let mut next: Vec = (1..=n).collect(); + let mut gone = vec![false; n]; + let mut version = vec![0u32; n]; + let mut heap: BinaryHeap> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect(); + let mut left = n; + while left > budget { + let Some(Reverse((_, i, v))) = heap.pop() else { break }; + if gone[i] || v != version[i] { + continue; + } + gone[i] = true; + left -= 1; + let (a, b) = (prev[i], next[i]); + next[a] = b; + prev[b] = a; + for j in [a, b] { + if j != 0 && j != n - 1 { + version[j] += 1; + heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j]))); + } + } + } + one.into_iter() + .enumerate() + .filter(|(i, _)| !gone[*i]) + .map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } }) + .collect() +} + /// A run mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(run: &[GradientStop]) -> Vec { let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index 2465039ca5..a88319ccaa 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { } } -/// A run of the stops far too short for the area, or a gradient of many stops, is written with at -/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without -/// stops have nothing to pad. +/// A run of the stops far too short for the area is written with at most so many runs, and pads +/// past them; a gradient of many stops is written with every run the area needs, each run +/// without the stops that change its colors least, within so many stops; a conic, a padding +/// gradient and one without stops have nothing to pad. #[test] -fn far_repeats_and_many_stops_are_capped() { +fn far_repeats_are_capped_and_many_stops_are_sampled() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); @@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() { let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); - assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len()); + for x in [0.7, 33.3, 66.1, 99.5] { + let q = Point::new(x, 0.5); + let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); @@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() { assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); } +/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run +/// when unrolled, each run without the stops that change its colors least: its last run ends on +/// its last color instead of the rest padding with it. +#[test] +fn unrolled_many_stops_keep_every_run() { + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) }; + let u = g.unrolled(); + assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len()); + for t in [0.005, 0.5051, 0.9149, 0.9951] { + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = u.sample(t as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}"); + } + // A padding gradient fitted to fewer stops keeps its ends. + let pad = Gradient::new(GradientKind::Linear, g.stops.clone()); + let fitted = pad.fitted(16); + assert_eq!(fitted.stops.len(), 16); + for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] { + assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color); + } + assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_))); +} + +/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1) +/// is written within the limit, every run the area needs included. +#[test] +fn a_run_over_the_stop_limit_is_written_within_it() { + let stops: Vec = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap(); + assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len()); + assert!(g.unrolled_within(256).stops.len() <= 256); +} + +/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a +/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose +/// them. +#[test] +fn fitting_keeps_sharp_steps_and_swatch_links() { + let mut stops: Vec = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect(); + let red = Color::rgb(1.0, 0.0, 0.0); + // A band of red from 0.5003 to 0.5013, with a sharp step at each side. + for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] { + let gray = Color::gray(offset); + stops.push(GradientStop::new(offset, color.unwrap_or(gray))); + } + let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) }; + stops.push(spot.clone()); + // Three stops of one ink that look alike but print at different tints. + for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] { + stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) }); + } + // A span shaped by an off-center midpoint. + if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) { + s.midpoint = 0.13; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let g = Gradient::new(GradientKind::Linear, stops); + let fitted = g.fitted(32); + assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len()); + let red_at = |t: f32| fitted.sample(t).0.to_rgb(); + assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays"); + assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow"); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays"); + assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays"); + // Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the + // first stays. (The stop before an ink starts stays as well: leaving it out would blend the + // ink in sooner.) + let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) }; + let few = vec![ + GradientStop::new(0.0, Color::gray(0.0)), + GradientStop::new(0.2, Color::gray(0.17)), + GradientStop::new(0.3, Color::gray(0.3)), + ink(0.4, 0.2), + ink(0.5, 0.9), + ink(0.6, 0.2), + GradientStop::new(1.0, Color::gray(1.0)), + ]; + let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]); + // A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line + // gives. + let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5)); + let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5); + let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)]; + let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) }; + let fitted = perceptual.fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + // A black of other inks between blacks that look the same prints differently: it stays. + let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 }); + let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 }); + let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)]; + let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() }; + let u = spread.unrolled_within(128); + assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run"); +} + /// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and /// PDF write as images) repeat with the stops, for each kind. #[test] diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 50a4033e4c..8115d00954 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,6 +1,7 @@ //! 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. Butt caps end at the path's end points. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points, +//! and a bevel ends at the corners of the two segments it joins. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; @@ -39,10 +40,20 @@ fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator Option<[Point; 3]> { - let cos = t_in.dot(t_out); // cos(half the turn) is sin(half the angle between the segments): the miter is side / it. - let half = ((1.0 + cos) / 2.0).max(0.0).sqrt(); - if cos >= CORNER_COS || half * limit < 1.0 { + let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt(); + if half * limit < 1.0 { + return None; + } + let ([a, b], out) = bevel(at, t_in, t_out, side)?; + Some([a, at + out * (side / half), b]) +} + +/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path: +/// the outer corners of the two segments' strokes and the unit direction out of the turn, or +/// `None` when the path doesn't turn there. +fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> { + if t_in.dot(t_out) >= CORNER_COS { return None; } let out = unit(t_in - t_out); @@ -51,7 +62,7 @@ fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[P let n = Vec2::new(-t.y, t.x); if n.dot(out) >= 0.0 { n } else { -n } }; - Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side]) + Some(([at + outer(t_in) * side, at + outer(t_out) * side], out)) } /// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as @@ -187,26 +198,44 @@ 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, - /// 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. + /// Does bounding this stroke need its path's shape: miter spikes, bevels, projecting caps, + /// arrowheads, 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.join != LineJoin::Round || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// 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 { + /// Does this stroke stop at the corners of its segments along a subpath that is `closed` or + /// open, where its caps are butt caps on an open subpath or its joins are bevels or miters? + /// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop. + fn stops_at_corners(&self, closed: bool) -> bool { + let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed); + (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) + } + + /// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the + /// direction of its segments at their corners: where it stops at them + /// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting + /// caps also paint? + fn reaches_by_corners(&self, closed: bool) -> bool { + self.stops_at_corners(closed) || self.join == LineJoin::Miter + } + + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short + /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an + /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one + /// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is + /// empty. Butt dashes stop inside the same box. The caps other than butt ones, 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, which + /// starts as `within` and the first and last segments' boxes. + fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option { let (first, last) = (segs.first()?, segs.last()?); - let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within); 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)) } }); @@ -217,24 +246,28 @@ impl StrokeLayer { } /// 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. + /// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its + /// caps, 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. fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); 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); + // A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at + // the two segments' corners, so such a subpath is measured along its segments. Every + // subpath reaches evenly unless one is measured that way; only then is each subpath's own + // box needed. + let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1)); + let mut even = even_only.then_some(bounds); for (r, closed) in subs { let side = self.side_reach(closed); let butt = self.cap == LineCap::Butt && !closed; - if !butt { + let along = self.stops_at_corners(closed); + if !along { side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); } if side <= 0.0 { @@ -242,10 +275,10 @@ impl StrokeLayer { } let square = self.cap == LineCap::Square && !closed; let miters = measure_tips && self.join == LineJoin::Miter; - if !(butt || square || miters || !even_only) { + if !(along || square || miters || !even_only) { continue; } - let segs = drawn(&els[r]); + let segs = drawn(&els[r.clone()]); 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) { @@ -253,13 +286,24 @@ impl StrokeLayer { } } } - if butt { - if let Some(body) = self.butt_body(&segs, side) { - out = out.union(body); + if along { + // The path's bounds are in the box already, so only the segments that can reach + // past them are measured. + match self.body_along(&segs, side, out) { + Some(body) => out = out.union(body), + // A subpath with no length paints at most its caps' dot. + None if !butt => { + let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)); + out = dot.map_or(out, |d| out.union(d.inflate(side, side))); + } + None => {} } - continue; - } - if !even_only { + if self.cap == LineCap::Round && !closed { + for (at, _) in ends(&segs).into_iter().flatten() { + out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + } + } else if !even_only { even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); } if square { @@ -279,7 +323,8 @@ impl StrokeLayer { /// 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`]). + /// with butt caps does not reach past its ends, and a bevel does not reach round its corner + /// ([`Self::body_along`]). pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { self.reach_along(bp, bounds, None, true) } @@ -329,6 +374,9 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); + // Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile + // keeps the round reach at the ends. + let squared = self.profile.is_none() && self.stops_at_corners(closed); // Every segment near enough to reach `p`: with a width profile the wider side of a // farther segment can paint past the nearest one, so the nearest alone is not enough. let near = segs.iter().enumerate().filter_map(|(i, s)| { @@ -365,7 +413,8 @@ impl StrokeLayer { } }) }; - if d <= half * factor.max(0.0) + tol && on_side(region, d) { + if d <= half * factor.max(0.0) + tol && on_side(region, d) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol)) + { return true; } } @@ -393,6 +442,35 @@ impl StrokeLayer { false } + /// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]), + /// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs` + /// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of + /// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join + /// paints there, when the path does not turn at that end, or when `p` is within `tol` of the + /// bevel's triangle. Projecting caps and + /// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`]) + /// counts as painting round its ends. + fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool { + let (n, at_start) = (segs.len(), t <= 1e-9); + let Some(seg) = segs.get(i) else { return true }; + if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) { + return true; + } + let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) }; + if (p - at).dot(out) <= tol { + return true; + } + // The segment on the other side of this end, `None` at the end of an open subpath. + let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) }; + let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round }; + if self.join == LineJoin::Round { + return true; + } + let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) }; + // A joint where the path does not turn has no bevel: the stroke runs on through it. + bevel(at, t_in, t_out, side).is_none_or(|([a, b], _)| in_convex(&[at, a, b], p, tol)) + } + /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With @@ -428,7 +506,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, bevels, 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() { @@ -439,17 +517,21 @@ 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 this appearance have a visible stroke that stops at the corners of its segments on an + /// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins + /// ([`StrokeLayer::reaches_by_corners`])? + pub fn has_cornered_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.reaches_by_corners(false)) } - /// 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() { + /// Does a visible stroke of this appearance that stops at the corners of its segments (butt + /// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with + /// round or projecting caps included, run along a subpath of `bp` where it does so, 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 those corners, and at miter tips, depends on their direction, which a + /// scale out of proportion changes. + pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_cornered_stroke() { return false; } let slanted = |v: Vec2| { @@ -457,9 +539,10 @@ impl Appearance { (d.x * d.y).abs() > 1e-9 }; let els = bp.elements(); - subpaths(bp) - .into_iter() - .filter(|(_, closed)| !closed) + let subs = subpaths(bp); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed)); + subs.into_iter() + .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) } @@ -470,28 +553,28 @@ impl Appearance { } } -/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] 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)) + /// Does a visible stroke of this object, of an object in it or of an object in a symbol + /// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant + /// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers + /// yes. + pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_corners(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 { + /// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into. + fn slanted_corners(&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); + return symbols.measure_art(symbol, |art| art.slanted_corners(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))) + (self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left))) } } @@ -568,6 +651,79 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + #[test] + fn a_bevel_stops_at_the_corners_of_the_segments_it_joins() { + // A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its + // 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt. + let mut tri = BezPath::new(); + tri.move_to((0.0, 100.0)); + tri.line_to((20.0, 0.0)); + tri.line_to((40.0, 100.0)); + tri.close_path(); + let tb = tri.bounding_box(); + let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt()); + let limited = stroke(20.0, |s| s.miter_limit = 2.0); + let bevel = stroke(20.0, |s| { + s.join = LineJoin::Bevel; + s.cap = LineCap::Round; + }); + let b = limited.bounds_on(&tri, tb); + assert!((b.y0 - top).abs() < 1e-9, "{b:?}"); + // The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right. + assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}"); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0); + *ap.stroke_mut().unwrap() = bevel.clone(); + let b = ap.stroked_bounds(tb, || tri.clone()); + for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // Open, the same corner is beveled and the round caps reach the half weight round the ends. + let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + let b = bevel.bounds_on(&chevron, chevron.bounding_box()); + for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // A round join, and the round caps of dashes, which can end at the tip, reach round it. + let round = stroke(20.0, |s| s.join = LineJoin::Round); + assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9); + let mut dashed = bevel.clone(); + dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false }); + assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9); + } + + #[test] + fn a_click_past_a_bevel_or_a_butt_end_misses() { + // 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which + // reaches about 1.96 pt above it. A round join paints there. + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5)); + for join in [LineJoin::Bevel, LineJoin::Miter] { + let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0)); + assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}"); + assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}"); + } + assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5)); + // 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round + // cap paints past it. + let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + let butt = stroke(4.0, |s| s.cap = LineCap::Butt); + assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25)); + assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + } + + #[test] + fn a_joint_too_shallow_to_bevel_paints_past_both_segments_ends() { + // The path turns by about 0.57° at (100, -0.5), too little to bevel, so the stroke runs on + // through the joint: 3.5 pt above it is past the end of both segments and still painted. + let kink = polyline(&[(0.0, 0.0), (100.0, -0.5), (200.0, 0.0)]); + let above = Point::new(100.0, -4.0); + for join in [LineJoin::Bevel, LineJoin::Miter, LineJoin::Round] { + assert!(stroke(10.0, |s| s.join = join).hits(&kink, FillRule::NonZero, above, 0.0), "{join:?}"); + } + } + /// An open path through `points`. fn polyline(points: &[(f64, f64)]) -> BezPath { let mut bp = BezPath::new(); @@ -693,15 +849,25 @@ mod tests { 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)); + assert!(!ap.has_slanted_stroke_corners(&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. + assert!(ap.has_slanted_stroke_corners(&apex, id)); + // A rotation slants a vertical line. let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3))); + // With round caps, the miter join still stops at the corners of the slanted segments, on a + // closed path too; round caps and round joins never do. ap.stroke_mut().unwrap().cap = LineCap::Round; - assert!(!ap.has_slanted_butt_stroke(&apex, id)); + assert!(ap.has_slanted_stroke_corners(&apex, id)); + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + ap.stroke_mut().unwrap().join = LineJoin::Bevel; + assert!(ap.has_slanted_stroke_corners(&tri, id)); + assert!(!ap.has_slanted_stroke_corners(&square(), id)); + ap.stroke_mut().unwrap().join = LineJoin::Round; + assert!(!ap.has_slanted_stroke_corners(&apex, id)); + assert!(!ap.has_slanted_stroke_corners(&tri, id)); } #[test] @@ -725,11 +891,11 @@ mod tests { } 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!(top.has_slanted_stroke_corners(&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)); + assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); } #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 80cfef7b96..080098ad24 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -89,8 +89,8 @@ pub use revolve::{ pub use solids::{ CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, - three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, - validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, + validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, 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 20a66e9a83..d82e4e8818 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -509,6 +509,60 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol +/// instances, blends and clipping groups already resolved), in document coordinates: with the +/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of +/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for +/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in +/// after a fill's or stroke's Revolve are looked up in `doc`. +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) + }; + if is_container(n) { + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && is_revolve(&e.id)) + .ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + 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, fx_options(fx))?; + let mut out = vec![]; + for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { + out.extend(faces(&s.path, b, options)?); + } + return Ok(out); + } + let (path, _) = object_profile(n, "Revolve")?; + let ctx = GeomContext::of(n); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + if surface_item(&solid_paints(n)).is_none() { + return Err("the revolved object has no fill or stroke".into()); + } + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + return faces(&path, b, fx_options(fx)); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let surface = faces(&p, b, fx_options(fx))?; + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); + } + } + 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 diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index a49829f50c..9e7578d6d7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for}; pub(crate) mod shadow; +pub(crate) mod wireframe; +pub use wireframe::three_d_wireframe_in; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs new file mode 100644 index 0000000000..f1dc324679 --- /dev/null +++ b/crates/effects/src/solids/wireframe.rs @@ -0,0 +1,276 @@ +//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that +//! can be edited on their own. +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; +use vectorcraft_three_d::Facet; + +use super::{ + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, + region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, +}; +use crate::{GeomContext, is_geometry}; + +/// The most distinct edges one object's wireframe may have. +pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; + +/// The grid points are rounded to before edges are compared, in points: two faces that share an +/// edge give it once. +const GRID: f64 = 1.0 / 1024.0; + +const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; + +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D +/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as +/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; +/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry +/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a +/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D +/// effect, when its solids can't be built, when the canvas draws the source art in their place, when +/// they draw no face, or past [`MAX_WIRE_EDGES`] edges. +pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err(NO_EFFECT.into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + // The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries + // it first). When neither draws, past their limits, the canvas shows the source art and there + // is no surface to expand. + let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some(); + if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) { + // The builders' own error says why, when there is one. Revolve's art is cut for Revolve, + // as the canvas cuts it. + if crate::has_revolve(n) { + crate::revolve::revolve_face_paths(doc, &*resolve_for(doc, n, label, false)?)?; + } else { + solid_face_paths(doc, &*resolve(doc, n, label)?)?; + } + return Err(format!("{label} draws the source art for this object instead of its 3D surface")); + } + // Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas + // cuts it ([`crate::revolve_art_in`]). + let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? }; + let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; + let faces = after_geometry(doc, n, faces, revolve); + let wire = edges(&faces)?; + if wire.is_empty() { + return Err(format!("{label} draws no faces for this object")); + } + Ok(wire) +} + +/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them +/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude & +/// Bevel, Inflate or Rotate. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, revolve: bool) -> Vec { + let fx = &n.appearance.effects; + let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; + let Some(index) = drawn else { return faces }; + reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) +} + +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as +/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect +/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep +/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, +/// and the document. +pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { + let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + if after.is_empty() { + return faces; + } + let id = NodeId(ctx.seed); + let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect +/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, +/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect +/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + if is_container(n) { + let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let mut shapes = members(n, label_of(&fx.id))?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + return facet_paths(fx, &shapes); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + let (path, rule) = object_profile(n, label)?; + let ctx = GeomContext::of(n); + let paints = solid_paints(n); + if let Some((index, fx)) = solid(&n.appearance.effects) { + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); + } + } + Ok(out) +} + +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and +/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, +/// as it is left out of the drawn art. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { + let label = label_of(&fx.id); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let mut out = vec![]; + if is_plane(&fx.id) { + for s in shapes { + for item in &s.items { + let (area, rule) = region(&s.path, s.rule, item); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path)); + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + let built: Vec, String>> = if fx.id == INFLATE { + let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) + } else { + parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() + }; + for ((item, ..), f) in parts.iter().zip(built) { + match f { + Ok(f) => out.extend(f.iter().map(Facet::path)), + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// A point on the [`GRID`]. +type Snap = (i64, i64); + +fn snap(p: Point) -> Snap { + ((p.x / GRID).round() as i64, (p.y / GRID).round() as i64) +} + +fn reversed(c: CubicBez) -> CubicBez { + CubicBez::new(c.p3, c.p2, c.p1, c.p0) +} + +/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]). +pub(crate) fn edges(faces: &[PathData]) -> Result, String> { + let mut seen: HashSet<[Snap; 4]> = HashSet::new(); + let mut segments: Vec = vec![]; + for sp in faces.iter().flat_map(|f| &f.subpaths) { + for i in 0..sp.segment_count() { + let mut c = sp.segment(i); + if sp.segment_is_line(i) { + c = CubicBez::new(c.p0, c.p0, c.p3, c.p3); + } + if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + continue; + } + let key = [c.p0, c.p1, c.p2, c.p3].map(snap); + // A segment that starts and ends on one grid point with its handles there too draws nothing. + if key.iter().all(|k| *k == key[0]) { + continue; + } + let back = [key[3], key[2], key[1], key[0]]; + if seen.contains(&back) || !seen.insert(key) { + continue; + } + if segments.len() >= MAX_WIRE_EDGES { + return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges")); + } + segments.push(c); + } + } + Ok(chain(&segments)) +} + +/// `segments` joined end to end: each path starts at the first segment not yet used and takes in +/// the unused segments that meet its ends until none is left at either end, or it closes. +fn chain(segments: &[CubicBez]) -> Vec { + let mut at: HashMap> = HashMap::new(); + for (i, c) in segments.iter().enumerate() { + at.entry(snap(c.p0)).or_default().push(i); + if snap(c.p3) != snap(c.p0) { + at.entry(snap(c.p3)).or_default().push(i); + } + } + let mut used = vec![false; segments.len()]; + // The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it. + let mut take = |used: &mut Vec, point: Snap, leave: bool| -> Option { + let list = at.get_mut(&point)?; + while let Some(&i) = list.last() { + if used.get(i).copied().unwrap_or(true) { + list.pop(); + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let c = *segments.get(i)?; + let starts = snap(c.p0) == point; + return Some(if starts == leave { c } else { reversed(c) }); + } + None + }; + let mut out = vec![]; + for (i, first) in segments.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let mut run: VecDeque = VecDeque::from([*first]); + // A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment + // that comes back to its start stays open, with its two ends on one point. + let closes = |run: &VecDeque| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3)); + while !closes(&run) { + let Some(end) = run.back().map(|c| snap(c.p3)) else { break }; + let Some(c) = take(&mut used, end, true) else { break }; + run.push_back(c); + } + while !closes(&run) { + let Some(start) = run.front().map(|c| snap(c.p0)) else { break }; + let Some(c) = take(&mut used, start, false) else { break }; + run.push_front(c); + } + out.push(PathData::single(subpath(&run, closes(&run)))); + } + out +} + +/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor. +fn subpath(run: &VecDeque, closed: bool) -> SubPath { + let mut anchors: Vec = vec![]; + if let Some(first) = run.front() { + let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 }; + anchors.push(Anchor::with_handles(first.p0, h_in, first.p1)); + } + for (k, c) in run.iter().enumerate() { + let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1); + anchors.push(Anchor::with_handles(c.p3, c.p2, h_out)); + } + // A closed run's last anchor is its first, whose in handle already comes from the last segment. + if closed { + anchors.pop(); + } + SubPath::new(anchors, closed) +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs new file mode 100644 index 0000000000..5ea4423ab3 --- /dev/null +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -0,0 +1,235 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; + +use super::*; + +fn square(x: f64, y: f64, size: f64) -> PathData { + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + size, y)); + b.line_to((x + size, y + size)); + b.line_to((x, y + size)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// A filled 100 pt square without a stroke, with effect `id` and `params`. +fn solid_square(id: &str, params: Value) -> Node { + let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +/// Every segment of `wire`, each as its two ends on the grid. +fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> { + wire.iter() + .flat_map(|p| &p.subpaths) + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))) + .map(|c| (snap(c.p0), snap(c.p3))) + .collect() +} + +fn bounds(paths: &[PathData]) -> Rect { + paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap() +} + +/// The paths of the faces `art` draws (every leaf of the art). +fn face_paths(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +#[test] +fn two_faces_that_share_an_edge_give_it_once() { + let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap(); + let segs = segments(&wire); + assert_eq!(segs.len(), 7, "{segs:?}"); + for (i, a) in segs.iter().enumerate() { + for b in segs.iter().skip(i + 1) { + assert!(a != b && (a.1, a.0) != *b, "{a:?} twice"); + } + } +} + +#[test] +fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() { + let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap(); + assert_eq!(wire.len(), 1); + assert!(wire[0].subpaths[0].closed); + assert_eq!(wire[0].subpaths[0].anchors.len(), 4); + + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + b.close_path(); + let wire = edges(&[PathData::from_bezpath(&b)]).unwrap(); + let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i))); + assert!(curved, "{wire:?}"); +} + +/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on +/// one point, since a path of one anchor has no segment. +#[test] +fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() { + let p = Point::new(0.0, 0.0); + let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)]; + let face = PathData::single(SubPath::new(anchors, false)); + assert_eq!(face.subpaths[0].segment_count(), 1); + let wire = edges(&[face]).unwrap(); + assert_eq!(wire.len(), 1); + let sp = &wire[0].subpaths[0]; + assert_eq!(sp.segment_count(), 1, "{sp:?}"); + assert!(!sp.segment_is_line(0)); +} + +#[test] +fn edges_without_length_or_with_non_finite_points_are_left_out() { + let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false)); + let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false)); + assert!(edges(&[point, bad]).unwrap().is_empty()); + assert!(edges(&[]).unwrap().is_empty()); +} + +#[test] +fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert_eq!(segments(&wire).len(), 9, "{wire:?}"); + // The wireframe covers what the effect draws, in the same place. + let art = super::super::solid_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } +} + +#[test] +fn the_classic_wireframe_surface_expands_to_all_twelve_edges() { + let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})); + assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12); +} + +#[test] +fn revolve_inflate_and_rotate_expand_too() { + for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] { + let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(!wire.is_empty(), "{id}"); + assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}"); + } + // Rotate is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same +/// place. +#[test] +fn revolve_classic_and_rotate_classic_expand_too() { + let n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert!(!wire.is_empty()); + let art = crate::revolve_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } + // On a fill, with a geometry effect after it. + let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap()); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}"); + // Rotate (Classic) is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// The geometry effects after a 3D effect reshape each face on its own, as they reshape each face +/// of the drawn surface: two faces that touch stay two outlines. +#[test] +fn later_geometry_effects_reshape_each_face_on_its_own() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; + let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2, "{out:?}"); + let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); + assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); +} + +/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. +#[test] +fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let mut n = solid_square(id, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{id}: {plain:?} {moved:?}"); + } +} + +/// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only +/// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again. +#[test] +fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() { + let mut n = solid_square(crate::REVOLVE, json!({})); + n.appearance.effects.insert(0, crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + let alone = bounds(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.insert(0, crate::new_effect(super::super::EXTRUDE, &json!({})).unwrap()); + let both = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}"); +} + +/// A clipping group with Revolve and Rotate draws Revolve, which cuts the art's fills as one piece +/// in the top fill's place. Its wireframe is cut the same way, so art with more fills than a 3D +/// effect takes members still expands, to the edges of Revolve alone. +#[test] +fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { + let mut art = Node::path(NodeId(3), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = art.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).cloned().unwrap(); + art.appearance.items = vec![fill; super::super::MAX_MEMBERS + 4]; + let mut clip = Node::path(NodeId(2), square(100.0, 100.0, 50.0), Appearance::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut n = Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &json!({})).unwrap()); + let alone = segments(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + assert!(crate::revolve_art_in(None, &n).is_some(), "the canvas draws Revolve"); + let both = segments(&three_d_wireframe_in(None, &n).unwrap()); + assert_eq!(both, alone); +} + +#[test] +fn an_object_without_a_visible_3d_effect_is_an_error() { + let mut n = solid_square(super::super::EXTRUDE, json!({})); + n.appearance.effects[0].visible = false; + assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect")); + n.appearance.effects.clear(); + assert!(three_d_wireframe_in(None, &n).is_err()); +} diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index ec54be20cd..ffd128b6dd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -32,6 +32,12 @@ const SHARP: f64 = 1.25; const HAIRPIN: f64 = 4.0; /// Rows of a mesh across the stroke (more where the gradient has stops). const MESH_ROWS: usize = 16; +/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at +/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out). +pub(crate) const MAX_MESH_STOPS: usize = 1024; +/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more: +/// a subpath with more ribs than leave room for a row at each stop becomes several meshes. +pub(crate) const MAX_MESH_POINTS: usize = 1 << 20; /// The most of a subpath one column of a mesh along the stroke spans. const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. @@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec { /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the -/// stroke's outline), coloured as [`gradient_slices`] paint it. +/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to +/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is +/// split into meshes of fewer ribs ([`rib_pieces`]). pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { - let g = &*g.unrolled(); + let fitted = g.fitted(MAX_MESH_STOPS); let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); - let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec = if across { vec![] } else { at_stops.clone() }; + let marks: Vec = if across { vec![] } else { mesh_marks(&fitted) }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let us = &us; + let g = &*fitted; + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + // A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than + // that leaves room for is split into meshes of fewer ribs. + let per_mesh = MAX_MESH_POINTS / (us.len() + 2); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) + .flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec())) .map(|ribs| { let (rows, cols) = (us.len() + 1, ribs.len() - 1); // Each level across: a point on each rib and the gradient position there. @@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }) .collect() } + +/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip: +/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before, +/// so the meshes meet without a gap. +pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec> { + let per_mesh = per_mesh.max(2); + (0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect() +} diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 3745e4a6f6..784c84d1db 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { } } +/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100 +/// times: each mesh holds at most so many points, and every run still starts on its first color. +#[test] +fn a_mesh_across_many_ribs_and_stops_stays_bounded() { + use vectorcraft_color::GradientSpread; + let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect(); + let bp = poly(&pts, false); + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) }; + let mut st = stroke(4.0, StrokeGradientMode::Across); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g); + assert!(!meshes.is_empty()); + for m in &meshes { + assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len()); + // A row at the start of each of the 100 runs, on every rib, is white. + let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count(); + assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len()); + } + // A strip of more ribs than one mesh has room for is split into meshes that share their end + // ribs, every rib in one of them. + use super::gradient::rib_pieces; + assert_eq!(rib_pieces(5, 3), [0..3, 2..5]); + assert_eq!(rib_pieces(4, 3), [0..3, 2..4]); + let one = rib_pieces(2, 1000); + assert_eq!((one.len(), one.first()), (1, Some(&(0..2)))); + assert_eq!(rib_pieces(3, 0), [0..2, 1..3]); +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 6e141fd5b5..5c66c9fe9d 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -78,6 +78,35 @@ fn a_butt_capped_open_path_is_measured_where_it_paints() { } } +#[test] +fn beveled_corners_are_measured_where_they_paint() { + let mut tip = BezPath::new(); + tip.move_to((0.0, 100.0)); + tip.line_to((20.0, 0.0)); + tip.line_to((40.0, 100.0)); + tip.close_path(); + let mut chevron = BezPath::new(); + chevron.move_to((0.0, 100.0)); + chevron.line_to((20.0, 0.0)); + chevron.line_to((40.0, 100.0)); + // A rectangle with no width turns back at its top and bottom. + let flat = vectorcraft_geom::shapes::rectangle(Rect::new(3.0, 100.0, 3.0, 150.0)).to_bezpath(); + let ps = paths(); + for (name, bp) in [("tip", &tip), ("chevron", &chevron), ("zigzag", &ps[1]), ("triangle", &ps[2]), ("flat", &flat)] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for (join, limit) in [(LineJoin::Bevel, 10.0), (LineJoin::Miter, 1.0), (LineJoin::Miter, 2.0), (LineJoin::Miter, 10.0)] { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 20.0); + (st.cap, st.join, st.miter_limit) = (cap, join, limit); + let painted = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, &st).to_bezpath().bounding_box(); + let bounds = st.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} {cap:?} {join:?} {limit}: 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.rs b/crates/engine/src/cmd.rs index 1aadd228a1..509279a3ae 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -106,6 +106,7 @@ mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; +mod wireframe; mod xform; use serde::Serialize; @@ -310,6 +311,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); + v.extend(wireframe::specs()); v.extend(pathops::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index dd6af8e6eb..7cb2ff138d 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; +use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file}; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec { @@ -118,20 +118,22 @@ fn export(s: &mut Session, p: &Value) -> Result { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // Encode the pictures of rasterized art, and add each of their warnings once. + // Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't + // describe is a loss. let mut images = Vec::new(); - let mut warnings = sheet.warnings.clone(); + let mut notes: Vec = sheet.warnings.iter().cloned().map(Note::loss).collect(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + let (png, given) = encode_with_notes(&d, "png", &json!({}))?; images.push((name, png)); - for w in notes { - if !warnings.contains(&w) { - warnings.push(w); + for n in given { + if !notes.iter().any(|x| x.text == n.text) { + notes.push(n); } } } + let warnings = note_texts(¬es); let n = sheet.rules.len(); match path { Some(path) => { diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 4ed63e7ec1..ac79d0acc7 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -131,14 +131,16 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient. +/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the +/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a +/// pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], + brushes: &[(String, String)], repeats: &[(String, String)], spot_objects: &[(String, String)], pattern_objects: &[(String, String)], @@ -184,7 +186,7 @@ fn sections( rows } "graphicStyles" => names("graphicStyleNames"), - "brushes" => names("brushNames"), + "brushes" => brushes.to_vec(), "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), @@ -326,12 +328,18 @@ fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option< if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } + Some((t, drawn_runs(d, t))) +} + +/// The runs of type `t` that its layout draws: those with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar, laid out as the renderer lays it out. +fn drawn_runs(d: &Document, t: &vectorcraft_doc::TextObject) -> BTreeSet { let db = vectorcraft_text::FontDb::global(); let resolved = d.inline_resolved(t); let lay = vectorcraft_text::layout(db, &resolved); let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); - Some((t, painted)) + painted } /// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its @@ -340,7 +348,8 @@ type PaintNames<'a> = dyn Fn(&Paint) -> Vec + 'a; /// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or /// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A -/// hidden fill or stroke and a stroke with no width paint nothing, and neither does any fill or +/// hidden fill or stroke, a fill or stroke at an opacity of 0 and a stroke with no width paint +/// nothing, and neither does any fill or /// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the /// compound path's own fills and strokes paint it) or of type whose layout draws nothing /// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape. @@ -364,7 +373,7 @@ fn own_paints( .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect()); let mut out: Vec = vec![]; for item in &n.appearance.items { - if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { + if !item.visible() || item.opacity() <= 0.0 || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { continue; } for name in label(item.paint()) { @@ -381,6 +390,41 @@ fn one_label(label: fn(&Paint) -> Option) -> impl Fn(&Paint) -> Vec