diff --git a/ASSETS.md b/ASSETS.md index c502e331a..3fd8493fd 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -32,3 +32,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | +| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | diff --git a/Cargo.lock b/Cargo.lock index 53f3c3918..c8c97afa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,9 +843,14 @@ dependencies = [ name = "designcraft-cli" version = "0.1.0" dependencies = [ + "designcraft-color", + "designcraft-compose", "designcraft-doc", "designcraft-engine", + "designcraft-format", + "designcraft-geom", "designcraft-mcp", + "designcraft-pdf", "designcraft-render", "serde_json", ] diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index e354a1b80..7aaa454cb 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -16,5 +16,10 @@ path = "src/main.rs" designcraft-engine = { workspace = true } designcraft-render = { workspace = true } designcraft-doc = { workspace = true } +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-compose = { workspace = true } +designcraft-format = { workspace = true } +designcraft-pdf = { workspace = true } designcraft-mcp = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index d70d1a467..fef9d7335 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -4,9 +4,13 @@ //! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR] //! designcraft-cli commands # list every command (JSON) //! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) +//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document +//! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! ``` use std::process::ExitCode; +mod perf; + use designcraft_engine::Session; use serde_json::{Value, json}; @@ -20,9 +24,11 @@ fn main() -> ExitCode { ExitCode::SUCCESS } Some("mcp") => report(mcp(&args[1..])), + Some("perf") => report(perf::perf(&args[1..])), + Some("bench") => report(perf::bench(&args[1..])), _ => { eprintln!( - "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]" + "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]" ); ExitCode::FAILURE } diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs new file mode 100644 index 000000000..7a3939344 --- /dev/null +++ b/apps/designcraft-cli/src/perf.rs @@ -0,0 +1,557 @@ +//! `designcraft-cli perf` / `bench`: the performance budgets (plan/architecture.md §9) measured on +//! a synthetic stress document, or on a given file. +//! +//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load +//! average above ~¾ of the core count) they are noise, and the report says so. +//! +//! Zoom levels follow the canvas: 100% on a 2880×1800 HiDPI canvas is 2 device pixels per point. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, StoryId}; +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +/// Device pixels per point at 100% (HiDPI canvas). +const HIDPI: f64 = 2.0; +const W: u32 = 2880; +const H: u32 = 1800; + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } + fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { + v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)] + } +} + +const WORDS: &[&str] = &[ + "the", + "grid", + "page", + "margin", + "column", + "rhythm", + "baseline", + "reader", + "story", + "frame", + "quiet", + "layout", + "type", + "paragraph", + "composer", + "weighs", + "every", + "line", + "break", + "texture", + "even", + "hyphen", + "image", + "spread", + "pulse", + "caption", + "colour", + "swatch", + "ink", + "tint", + "gradient", + "style", + "voice", + "printer", + "proof", + "bleed", + "a", + "of", + "and", + "to", + "in", + "with", + "for", + "is", + "on", + "that", + "by", + "it", + "as", + "careful", + "measure", + "leading", + "kerning", + "tracking", + "glyph", + "serif", + "italic", + "heading", + "running", + "folio", +]; + +/// `chars` characters of original filler text in paragraphs of ~400 characters. +fn filler(r: &mut Rng, chars: usize) -> String { + let mut s = String::with_capacity(chars + 16); + let mut para = 0; + while s.len() < chars { + let w = r.pick(WORDS); + if para == 0 { + let mut c = w.chars(); + if let Some(f) = c.next() { + s.extend(f.to_uppercase()); + s.push_str(c.as_str()); + } + } else { + s.push_str(w); + } + para += w.len() + 1; + if para > 400 { + s.push_str(".\n"); + para = 0; + } else { + s.push(' '); + } + } + s +} + +/// Procedural RGB image, PNG-encoded. +fn art_png(w: u32, h: u32, seed: f32) -> Vec { + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]); + } + } + designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png() +} + +/// Parameters of the synthetic stress document. +pub struct Spec { + pub pages: usize, + pub frames_per_page: usize, + pub frames_per_story: usize, + pub chars: usize, + pub images: usize, + pub assets: usize, +} + +impl Default for Spec { + fn default() -> Self { + Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 } + } +} + +/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text +/// frames, and a strip of placed images (one with a drop shadow per spread). +pub fn synthetic(spec: &Spec) -> Document { + let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() }); + let lid = d.default_layer(); + d.swatches.push(Swatch { + name: "Perf Gradient".into(), + value: SwatchValue::Gradient { + gradient: Gradient { + kind: GradientKind::Linear, + stops: vec![ + GradientStop { offset: 0.0, color: Color::cmyk(0.6, 0.9, 0.3, 0.2), opacity: 1.0, midpoint: 0.5 }, + GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.6, 0.8, 0.0), opacity: 1.0, midpoint: 0.5 }, + ], + }, + }, + locked: false, + named: true, + }); + let mut rng = Rng(0x5eed); + let (aw, ah) = (800u32, 600u32); + let assets: Vec = (0..spec.assets.max(1)) + .map(|k| { + let id = AssetId(d.alloc()); + let data = art_png(aw, ah, k as f32 * 0.37); + d.assets.insert( + id, + Arc::new(Asset { + id, + name: format!("art-{k}.png"), + mime: "image/png".into(), + link: None, + data: Arc::new(data), + pixels: Some((aw, ah)), + }), + ); + id + }) + .collect(); + let total_frames = spec.pages * spec.frames_per_page; + let stories = total_frames.div_ceil(spec.frames_per_story.max(1)); + let per_story = spec.chars / stories.max(1); + let rows = spec.frames_per_page.div_ceil(2); + let (top, strip) = (48.0, 130.0); + let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64; + let mut prev: Option = None; + let mut n_frame = 0usize; + let mut placed_images = 0usize; + for abs in 0..spec.pages { + let Some((si, pi)) = d.page_loc(abs) else { break }; + let sr = SpreadRef::Doc(si); + let x0 = d.spreads[si].pages[pi].x; + // Gradient band. + let id = ItemId(d.alloc()); + let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0))); + band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() }; + band.fill.gradient_angle = Some(rng.next() * 90.0); + d.insert_item(sr, band, None).expect("spread"); + // Text frames. + for k in 0..spec.frames_per_page { + let (c, r) = (k % 2, k / 2); + let fx = x0 + 36.0 + c as f64 * 280.0; + let fy = top + r as f64 * row_h; + let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0); + let first = n_frame.is_multiple_of(spec.frames_per_story.max(1)); + let text = if first { filler(&mut rng, per_story) } else { String::new() }; + let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).expect("frame"); + if let (false, Some(p)) = (first, prev) { + d.thread(p, fid).expect("thread"); + } + prev = Some(fid); + n_frame += 1; + } + // Images: spread the requested count evenly over the pages. + let want = (spec.images * (abs + 1)) / spec.pages - placed_images; + for k in 0..want { + let w = 540.0 / want as f64; + let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0); + let asset = assets[(placed_images + k) % assets.len()]; + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + let (nw, nh) = (aw as f64, ah as f64); + let s = (r.width() / nw).max(r.height() / nh); + it.content = Content::Graphic(Graphic { + asset, + size: (nw, nh), + xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), + auto_fit: designcraft_doc::Fitting::FillProportionally, + }); + if k == 0 && pi == 0 { + it.effects.drop_shadow.on = true; + } + d.insert_item(sr, it, None).expect("spread"); + } + placed_images += want; + } + d +} + +fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { + let mut v: Vec = (0..runs.max(1)) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +/// 1-minute load average (macOS / Linux), if available. +fn load_average() -> Option { + let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success()); + let text = match out { + Some(o) => String::from_utf8_lossy(&o.stdout).to_string(), + None => std::fs::read_to_string("/proc/loadavg").ok()?, + }; + text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok()) +} + +/// Compose every story into a fresh cache on `threads` threads. +fn compose_all(doc: &Document, threads: usize) -> Arc { + let cache = Arc::new(Cache::new()); + let ids: Vec = doc.stories.keys().copied().collect(); + let next = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..threads.max(1) { + s.spawn(|| { + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(id) = ids.get(i) else { break }; + drop(cache.get(doc, *id, None)); + } + }); + } + }); + cache +} + +/// All spreads stacked vertically, centred on x = 0 (like the canvas). +fn layout(doc: &Document) -> Vec<(Placed, Rect)> { + let mut y = 0.0; + doc.spreads + .iter() + .enumerate() + .map(|(i, sp)| { + let b = sp.bounds(); + let offset = Vec2::new(-b.center().x, y - b.y0); + y += b.height() + 36.0; + (Placed { spread: SpreadRef::Doc(i), offset }, b + offset) + }) + .collect() +} + +/// View transform showing canvas point `c` at the centre of the image at `scale` px/pt. +fn view_at(c: designcraft_geom::Point, scale: f64) -> Affine { + Affine::translate((W as f64 / 2.0, H as f64 / 2.0)) * Affine::scale(scale) * Affine::translate(-c.to_vec2()) +} + +struct Row { + name: String, + ms: f64, + budget: Option, +} + +fn row(rows: &mut Vec, name: impl Into, ms: f64, budget: Option) { + let name = name.into(); + eprintln!(" … {name}: {ms:.2} ms"); + rows.push(Row { name, ms, budget }); +} + +/// Measure `doc`. `runs` = repetitions per row (median). +fn measure(doc: &Document, runs: usize) -> Vec { + let mut rows = vec![]; + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let chars: usize = doc.stories.values().map(|s| s.text.len()).sum(); + // Budget: 250 ms per 300k characters (MT), scaled to this document. + let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0); + let mut cache = Arc::new(Cache::new()); + row( + &mut rows, + format!("compose all stories cold, MT ({} stories, {}k chars)", doc.stories.len(), chars / 1000), + median_ms(runs.min(3), || cache = compose_all(doc, cores)), + Some(compose_budget), + ); + row(&mut rows, "compose all stories cold, 1 thread", median_ms(1, || drop(compose_all(doc, 1))), None); + + let placed = layout(doc); + let all: Vec = placed.iter().map(|p| p.0).collect(); + let mid = placed.len() / 2; + let centre = placed[mid].1.center(); + let greek = |scale: f64| RenderOptions { + background: Some([230, 230, 230, 255]), + page_shadow: true, + greek_below_px: 3.0 * HIDPI.min(scale.max(1.0)), + ..Default::default() + }; + let mut r = Renderer::new(); + // Warm-up: decode images, glyph outlines, compose (already cached). + let fit = { + let b = placed[mid].1; + let s = ((W as f64 - 80.0) / b.width()).min((H as f64 - 80.0) / b.height()); + (s, view_at(centre, s)) + }; + let dump = std::env::var("DESIGNCRAFT_PERF_DUMP").ok(); + let save = |name: &str, img: designcraft_render::Rendered| { + if let Some(d) = &dump { + let _ = std::fs::create_dir_all(d); + let _ = std::fs::write(format!("{d}/{name}.png"), img.to_png()); + } + }; + save("fit", r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0))); + row( + &mut rows, + format!("render spread, fit ({:.0}%), {W}×{H}", fit.0 / HIDPI * 100.0), + median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)))), + Some(16.0), + ); + for zoom in [0.25, 1.0, 4.0] { + let s = zoom * HIDPI; + // At 100% look at the text block of the left page. + let c = if zoom >= 1.0 { centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0) } else { centre }; + let v = view_at(c, s); + save(&format!("zoom-{:.0}", zoom * 100.0), r.render(doc, &cache, &all, W, H, v, &greek(s))); + let budget = if zoom == 1.0 { Some(16.0) } else { None }; + row( + &mut rows, + format!("render at {:.0}%, {W}×{H}", zoom * 100.0), + median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, v, &greek(s)))), + budget, + ); + } + // Panning at 100%: a new view every frame (no pixel cache), same glyph caches. + let mut dy = 0.0; + let pan = median_ms(runs, || { + dy += 37.0; + let v = view_at(centre + Vec2::new(-200.0, dy - 300.0), HIDPI); + drop(r.render(doc, &cache, &all, W, H, v, &greek(HIDPI))); + }); + row(&mut rows, "render at 100% while panning", pan, Some(16.0)); + // Cold renderer (no glyph-path / image caches): first frame after open. + let cold = median_ms(runs.min(3), || { + let mut r = Renderer::new(); + drop(r.render(doc, &cache, &all, W, H, view_at(centre, HIDPI), &greek(HIDPI))); + }); + row(&mut rows, "render at 100%, cold renderer", cold, None); + + // Keystroke: insert a character in the story of the spread's first frame, recompose, repaint. + if let Some(sid) = doc.spreads[mid].items.iter().find_map(|i| i.text_frame().map(|t| t.story)) { + let mut d = doc.clone(); + let mut k = 0usize; + let v = view_at(centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0), HIDPI); + let ms = median_ms(runs, || { + if let Some(st) = d.stories.get_mut(&sid) { + let st = Arc::make_mut(st); + let at = (st.text.len() / 2 + k).min(st.text.len()); + let at = (0..=at).rev().find(|i| st.text.is_char_boundary(*i)).unwrap_or(0); + st.text.insert(at, 'x'); + if let Some(run) = st.chars.iter_mut().find(|r| r.len > 0) { + run.len += 1; + } + st.rev += 1; + k += 1; + } + drop(r.render(&d, &cache, &all, W, H, v, &greek(HIDPI))); + }); + row(&mut rows, "keystroke: recompose story + repaint at 100%", ms, Some(8.0)); + } + + let opts = RenderOptions { printing_only: true, ..Default::default() }; + let mut png = 0usize; + let ms = median_ms(runs.min(5), || { + let img = r.render_page(doc, &cache, doc.first_page_of_spread(mid), 150.0 / 72.0, true, &opts).expect("page"); + png = img.to_png().len(); + }); + row(&mut rows, format!("export page PNG at 150 dpi ({} kB)", png / 1024), ms, None); + + let mut bytes = vec![]; + row( + &mut rows, + format!("save .designcraft ({} pages)", doc.page_count()), + median_ms(runs.min(3), || bytes = designcraft_format::save(doc).expect("save")), + Some(300.0), + ); + row(&mut rows, "open .designcraft", median_ms(runs.min(3), || drop(designcraft_format::load(&bytes).expect("load"))), Some(300.0)); + let n = doc.page_count().min(100); + let pdf = designcraft_pdf::PdfOptions { pages: Some((0..n).collect()), ..Default::default() }; + row( + &mut rows, + format!("PDF export {n} pages"), + median_ms(1, || drop(designcraft_pdf::export_pdf(doc, &cache, &pdf).expect("pdf"))), + Some(2000.0 * n as f64 / 100.0), + ); + rows +} + +fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> { + println!("{title}"); + let mut over = 0; + println!(" {:<60} {:>11} {:>10}", "", "measured", "budget"); + for r in rows { + let (b, flag) = match r.budget { + Some(b) if r.ms > b => { + over += 1; + (format!("{b:>7.0} ms"), "OVER") + } + Some(b) => (format!("{b:>7.0} ms"), "ok"), + None => (" — ".into(), ""), + }; + println!(" {:<60} {:>8.2} ms {b} {flag}", r.name, r.ms); + } + match (over, noisy) { + (0, _) => Ok(()), + (_, true) => { + println!("{over} over budget, but the machine is busy: re-run when idle."); + Ok(()) + } + _ => { + println!("{over} budget(s) exceeded"); + Ok(()) + } + } +} + +fn header() -> (usize, bool) { + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let load = load_average(); + let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); + println!( + "{cores} cores, load average {}, render threads {}", + load.map_or("?".into(), |l| format!("{l:.1}")), + designcraft_render::default_threads() + ); + if noisy { + println!("WARNING: the machine is busy (load average above ¾ of the cores); wall-clock timings below are not trustworthy."); + } + (cores, noisy) +} + +/// `designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict]`. +pub fn perf(args: &[String]) -> Result<(), String> { + let mut spec = Spec::default(); + let mut runs = 7usize; + let mut strict = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + let mut num = || it.next().and_then(|v| v.parse::().ok()).ok_or(format!("{a} needs a number")); + match a.as_str() { + "--pages" => spec.pages = num()?.max(1), + "--frames" => spec.frames_per_page = num()?.max(1), + "--chars" => spec.chars = num()?, + "--images" => spec.images = num()?, + "--runs" => runs = num()?.max(1), + "--strict" => strict = true, + other => return Err(format!("unknown perf option `{other}`")), + } + } + let (_, noisy) = header(); + let t = Instant::now(); + let doc = synthetic(&spec); + let frames = spec.pages * spec.frames_per_page; + println!( + "DesignCraft performance budgets — synthetic: {} pages, {frames} text frames in {} threaded stories, {}k chars, {} images ({} assets), {} gradients (built in {:.0} ms)", + spec.pages, + doc.stories.len(), + doc.stories.values().map(|s| s.text.len()).sum::() / 1000, + spec.images, + spec.assets, + spec.pages, + t.elapsed().as_secs_f64() * 1000.0 + ); + let rows = measure(&doc, runs); + print("", &rows, noisy)?; + let over = rows.iter().filter(|r| r.budget.is_some_and(|b| r.ms > b)).count(); + if strict && over > 0 && !noisy { Err(format!("{over} budget(s) exceeded")) } else { Ok(()) } +} + +/// `designcraft-cli bench FILE [--runs N]`: the same measurements on one document. +pub fn bench(args: &[String]) -> Result<(), String> { + let mut file = None; + let mut runs = 5usize; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--runs" => runs = it.next().and_then(|v| v.parse().ok()).ok_or("--runs needs a number")?, + f if file.is_none() && !f.starts_with("--") => file = Some(f.to_string()), + other => return Err(format!("unknown bench option `{other}`")), + } + } + let file = file.ok_or("usage: designcraft-cli bench FILE [--runs N]")?; + let (_, noisy) = header(); + let mut s = designcraft_engine::Session::new(); + let t = Instant::now(); + s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?; + let open = t.elapsed().as_secs_f64() * 1000.0; + let doc = s.doc().map_err(|e| e.to_string())?.doc.clone(); + println!("{file}: {} pages, {} stories, {} assets (opened in {open:.0} ms)", doc.page_count(), doc.stories.len(), doc.assets.len()); + let rows = measure(&doc, runs); + print("", &rows, noisy) +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 749cc8e37..0971fe8bc 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -375,12 +375,67 @@ impl Default for DropShadow { } } +/// Inner shadow: a blurred, offset shadow inside the object's edge. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + pub distance: f64, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. + pub choke: f64, +} + +impl Default for InnerShadow { + fn default() -> Self { + InnerShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 135.0, distance: 7.0, size: 7.0, choke: 0.0 } + } +} + +/// Outer glow: a blurred silhouette around the object, without offset. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OuterGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. + pub spread: f64, +} + +impl Default for OuterGlow { + fn default() -> Self { + OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + } +} + +fn is_default(v: &T) -> bool { + *v == T::default() +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Effects { + /// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows + /// the silhouette instead of blurring it. pub drop_shadow: DropShadow, /// Basic feather width (0 = off). pub feather: f64, + #[serde(skip_serializing_if = "is_default")] + pub inner_shadow: InnerShadow, + #[serde(skip_serializing_if = "is_default")] + pub outer_glow: OuterGlow, +} + +impl Effects { + /// Any effect drawn with soft (raster) filters? + pub fn any(&self) -> bool { + self.drop_shadow.on || self.feather > 0.0 || self.inner_shadow.on || self.outer_glow.on + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs new file mode 100644 index 000000000..2967cafed --- /dev/null +++ b/crates/render/src/fx.rs @@ -0,0 +1,204 @@ +//! Soft object effects (Effects panel): drop shadow, outer glow, inner shadow and basic feather, +//! drawn with vello filter layers. +//! +//! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded +//! pipeline each effect is rendered into an offscreen single-threaded context cropped to its +//! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts +//! draw directly. +//! +//! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text, +//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2), +//! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's +//! opacity rides on the filter colour's alpha. +//! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top. +//! - Feather: the object is masked by its shape inset by half the width and blurred, so the +//! edge fades from ~0 at the path to full strength `width` inside. + +use designcraft_doc::Item; +use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; +use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; +use vello_cpu::{RenderContext, kurbo}; + +use crate::{Frame, Renderer, color_of}; + +/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance. +pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 { + let a = angle.to_radians(); + Vec2::new(-a.cos() * distance, a.sin() * distance) +} + +/// How far (points) the effects of `it` reach outside its geometry. +pub(crate) fn outset(it: &Item) -> f64 { + let e = &it.effects; + let mut o: f64 = 0.0; + if e.drop_shadow.on { + o = o.max(e.drop_shadow.distance.abs() + e.drop_shadow.size.abs() * 1.6 + 1.0); + } + if e.outer_glow.on { + o = o.max(e.outer_glow.size.abs() * 1.6 + 1.0); + } + o +} + +/// (blur σ, growth) in points for an effect `size` with `pct` percent hardened. +fn split(size: f64, pct: f64) -> (f64, f64) { + let size = size.max(0.0); + let p = (pct / 100.0).clamp(0.0, 1.0); + (size * (1.0 - p) / 2.0, size * p) +} + +fn blur(sigma: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None }) +} + +fn shadow(sigma: f64, color: peniko::Color) -> Filter { + Filter::from_primitive(FilterPrimitive::DropShadowOnly { + dx: 0.0, + dy: 0.0, + std_deviation: sigma.max(0.0) as f32, + color, + edge_mode: EdgeMode::None, + }) +} + +impl Renderer { + /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. + pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + let doc = f.doc; + let e = &it.effects; + let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.weight }; + // Spread-space reach of what the object paints. + let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); + // The silhouette is the shape when the object paints its area (fill / image); text frames + // without a fill cast the shadow of their text only, which can't be grown. + let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)); + let mut below = vec![]; + if e.outer_glow.on { + let g = &e.outer_glow; + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + } + if e.drop_shadow.on { + let d = &e.drop_shadow; + below.push((offset(d.angle, d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + } + for (off, size, pct, color, opacity) in below { + let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; + let (sigma, grow) = split(size, pct); + let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); + // The offset moves the geometry, not the filter: vello drops layer content that lies + // entirely outside the viewport before filtering. + let r = (reach + off).inflate(grow, grow); + self.with_filters(ctx, f, r, sigma, |me, c, fr| { + let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; + c.set_transform(moved.view); + c.push_layer(None, None, None, None, Some(filter)); + me.draw_body(c, &moved, it, bp, xf, page_name); + if grow > 0.0 && solid { + c.set_transform(moved.view * xf); + c.set_paint(peniko::Color::BLACK); + c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + } + c.pop_layer(); + }); + } + // The object, feathered or not. + if e.feather > 0.0 && it.path.is_closed() { + let w = e.feather; + self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { + c.set_transform(fr.view * xf); + c.push_clip_layer(bp); + me.draw_body(c, fr, it, bp, xf, page_name); + // Keep the body where the blurred inset silhouette is. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(w / 4.0))); + c.set_transform(fr.view * xf); + c.set_paint(peniko::Color::BLACK); + c.fill_path(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + c.set_transform(fr.view * xf); + c.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + }); + } else { + self.draw_body(ctx, f, it, bp, xf, page_name); + } + // Inner shadow, clipped to the shape. + if e.inner_shadow.on + && it.path.is_closed() + && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) + { + let s = &e.inner_shadow; + let (sigma, choke) = split(s.size, s.choke); + let off = offset(s.angle, s.distance); + let paint = color_of(&c, s.opacity.clamp(0.0, 1.0)); + self.with_filters(ctx, f, reach, sigma, |_, c, fr| { + c.set_transform(fr.view * xf); + c.push_clip_layer(bp); + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + // Everything outside the offset shape, so the shadow bleeds in from the edges. + let m = Affine::translate(off) * xf; + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); + let mut shape = bp.clone(); + shape.apply_affine(m); + inv.extend(shape.iter()); + c.set_paint(paint); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + c.set_fill_rule(peniko::Fill::NonZero); + if choke > 0.0 { + c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(&shape); + } + c.pop_layer(); + c.pop_layer(); + }); + } + } + + /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a + /// multithreaded context the layer is drawn into an offscreen single-threaded context + /// covering `reach` (spread space) plus the blur's spread, then composited back. + pub(crate) fn with_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + sigma: f64, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), + ) { + if !f.mt { + return draw(self, ctx, f); + } + // 3σ of the Gaussian in pixels, plus a pixel of antialiasing. + let spread = sigma.max(0.0) * 3.0 / f.px + 2.0; + let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread); + let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64)); + if w < 1.0 || h < 1.0 { + return; + } + let (w, h) = (w as u16, h as u16); + let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + draw(self, &mut off, &shifted); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + ctx.set_transform(Affine::translate((x0, y0))); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + ctx.set_transform(Affine::IDENTITY); + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs new file mode 100644 index 000000000..897607108 --- /dev/null +++ b/crates/render/src/images.rs @@ -0,0 +1,134 @@ +//! Decoded placed images, shared by every [`crate::Renderer`] in the process (canvas worker, +//! thumbnails, exports), so each asset is decoded once and each mip level built once. +//! +//! Entries are keyed by the address of the asset's encoded bytes (`Arc>`) and hold a +//! clone of that `Arc`, so the address can't be reused while cached. Least recently used levels +//! are evicted beyond a memory budget. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use vello_cpu::Pixmap; + +use crate::{decode_pixmap, halve}; + +/// Decoded pixels kept (bytes). +const BUDGET: usize = 768 << 20; + +struct Entry { + _bytes: Arc>, + pm: Arc, + stamp: u64, +} + +#[derive(Default)] +struct Cache { + map: HashMap<(usize, u8), Entry>, + bytes: usize, + clock: u64, + /// Assets that failed to decode (don't retry every frame). + bad: HashMap>>, +} + +static CACHE: Mutex> = Mutex::new(None); + +fn size_of(pm: &Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + +fn with(f: impl FnOnce(&mut Cache) -> R) -> R { + let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + f(g.get_or_insert_with(Cache::default)) +} + +fn lookup(key: (usize, u8)) -> Option> { + with(|c| { + c.clock += 1; + let clock = c.clock; + c.map.get_mut(&key).map(|e| { + e.stamp = clock; + e.pm.clone() + }) + }) +} + +fn store(bytes: &Arc>, level: u8, pm: Arc) { + with(|c| { + c.clock += 1; + let key = (Arc::as_ptr(bytes) as usize, level); + let n = size_of(&pm); + if let Some(old) = c.map.insert(key, Entry { _bytes: bytes.clone(), pm, stamp: c.clock }) { + c.bytes -= size_of(&old.pm); + } + c.bytes += n; + if c.bytes > BUDGET { + let mut v: Vec<(u64, (usize, u8))> = c.map.iter().map(|(k, e)| (e.stamp, *k)).collect(); + v.sort_unstable(); + for (_, k) in v { + if c.bytes <= BUDGET * 3 / 4 { + break; + } + if k == key { + continue; + } + if let Some(e) = c.map.remove(&k) { + c.bytes -= size_of(&e.pm); + } + } + } + }); +} + +/// Full-resolution decode of `bytes` (cached). +pub fn decoded(bytes: &Arc>) -> Option> { + let id = Arc::as_ptr(bytes) as usize; + if let Some(pm) = lookup((id, 0)) { + return Some(pm); + } + if with(|c| c.bad.contains_key(&id)) { + return None; + } + // Decode outside the lock: other threads keep drawing cached images meanwhile. + match decode_pixmap(bytes) { + Some(pm) => { + let pm = Arc::new(pm); + store(bytes, 0, pm.clone()); + Some(pm) + } + None => { + with(|c| c.bad.insert(id, bytes.clone())); + None + } + } +} + +/// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic +/// `width_pt` points wide. +pub fn mip(bytes: &Arc>, width_pt: f64, on_screen: f64) -> Option> { + let full = decoded(bytes)?; + let ppt = full.width() as f64 / width_pt.max(1e-6); + let id = Arc::as_ptr(bytes) as usize; + let mut pm = full; + let mut level = 0u8; + while ppt / (1u64 << (level + 1)) as f64 >= on_screen * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 { + level += 1; + pm = match lookup((id, level)) { + Some(p) => p, + None => { + let half = Arc::new(halve(&pm)); + store(bytes, level, half.clone()); + half + } + }; + } + Some(pm) +} + +/// Drop every decoded image. +pub fn clear() { + with(|c| { + c.map.clear(); + c.bad.clear(); + c.bytes = 0; + }); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7dacf57b5..96df7323a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,9 +12,8 @@ use std::collections::HashMap; use std::sync::Arc; use designcraft_color::BlendMode as DcBlend; -use designcraft_compose::{Cache, ComposedStory, FrameText}; -use designcraft_doc::{AssetId, Content, Document, Item, PageSide, SpreadRef, StrokeAlign, StrokeType}; -use designcraft_fonts::FontDb; +use designcraft_compose::{Cache, ComposedStory}; +use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef, StoryId, StrokeAlign, StrokeType}; use designcraft_geom::corners; use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use vello_cpu::kurbo; @@ -23,6 +22,10 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; pub use vello_cpu; +mod fx; +pub mod images; +mod text; + /// A rendered image (premultiplied RGBA8, row-major). #[derive(Clone)] pub struct Rendered { @@ -115,11 +118,14 @@ pub struct FrameStats { pub micros: u64, } -/// Reusable renderer (keeps the render context and decoded images). +/// Reusable renderer: keeps the render context, the frame-space glyph paths of composed text +/// and (process-wide) decoded images between renders. pub struct Renderer { ctx: Option, resources: Resources, - images: HashMap<(AssetId, usize), Arc>, + glyphs: text::GlyphCache, + /// Composed stories looked up during the current render. + stories: HashMap<(StoryId, Option), Arc>, pub threads: u16, pub stats: FrameStats, } @@ -130,13 +136,18 @@ impl Default for Renderer { } } -struct Frame<'a> { +#[derive(Clone, Copy)] +pub(crate) struct Frame<'a> { doc: &'a Document, cache: &'a Cache, view: Affine, + /// Visible area in spread space. visible: Rect, + /// Size of a device pixel in points. px: f64, opts: &'a RenderOptions, + /// Drawing into a multithreaded context (no filter layers). + mt: bool, } pub fn default_threads() -> u16 { @@ -155,7 +166,25 @@ pub fn default_threads() -> u16 { impl Renderer { pub fn new() -> Self { - Self { ctx: None, resources: Resources::new(), images: HashMap::new(), threads: default_threads(), stats: FrameStats::default() } + Self { + ctx: None, + resources: Resources::new(), + glyphs: text::GlyphCache::default(), + stories: HashMap::new(), + threads: default_threads(), + stats: FrameStats::default(), + } + } + + /// Drop cached glyph paths (they are rebuilt on demand). + pub fn clear_caches(&mut self) { + self.glyphs.clear(); + self.stories.clear(); + } + + /// Number of text frames whose glyph paths are cached. + pub fn cached_frames(&self) -> usize { + self.glyphs.len() } /// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels). @@ -173,13 +202,16 @@ impl Renderer { let w = width.clamp(1, u16::MAX as u32) as u16; let h = height.clamp(1, u16::MAX as u32) as u16; let mut ctx = match self.ctx.take() { - Some(mut c) if c.width() == w && c.height() == h => { + Some(mut c) if c.width() == w && c.height() == h && c.render_settings().num_threads == self.threads => { c.reset(); c } _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }), }; + let mt = ctx.render_settings().num_threads > 0; self.stats = FrameStats::default(); + self.stories.clear(); + self.glyphs.tick(); if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); @@ -196,15 +228,17 @@ impl Renderer { if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) { continue; } - let f = Frame { doc, cache, view: sview, visible, px, opts }; + let f = Frame { doc, cache, view: sview, visible, px, opts, mt }; self.draw_spread(&mut ctx, &f, pl.spread); } ctx.flush(); - let mut pm = Pixmap::new(w, h); - ctx.render(&mut pm, &mut self.resources); + // Render straight into the returned buffer (no extra copy of the frame). + let mut pixels = vec![0u8; w as usize * h as usize * 4]; + ctx.render(vello_cpu::PixmapMut::new(w, h, &mut pixels).expect("buffer size"), &mut self.resources); self.ctx = Some(ctx); + self.stories.clear(); self.stats.micros = now().saturating_sub(start); - Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() } + Rendered { width: w as u32, height: h as u32, pixels } } /// Render one document page (absolute index) at `scale` px/pt, optionally including bleed. @@ -308,33 +342,53 @@ impl Renderer { } return; } - if !rect_overlaps(vb, f.visible) && !matches!(it.content, Content::Text(_)) { + // Composed text stays inside its frame apart from glyph overhang (italics, swashes, big + // initials), so text frames get a margin rather than being exempt from culling. + let margin = match it.content { + Content::Text(_) => 36.0 + 0.25 * vb.width().max(vb.height()), + _ => 0.0, + }; + let fx_outset = if it.effects.any() { fx::outset(it) } else { 0.0 }; + if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) { return; } self.stats.items += 1; - let doc = f.doc; let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; if layered { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } - // Drop shadow (simple offset silhouette). - if it.effects.drop_shadow.on { - let ds = &it.effects.drop_shadow; - let a = ds.angle.to_radians(); - let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance); - if let Some(c) = doc.resolve_color(&ds.color, 1.0) { - ctx.set_transform(f.view * Affine::translate(off) * xf); - ctx.set_paint(color_of(&c, ds.opacity)); - ctx.fill_path(&bp); - } + if it.effects.any() { + self.draw_item_fx(ctx, f, it, &bp, xf, page_name); + } else { + self.draw_body(ctx, f, it, &bp, xf, page_name); } + if layered { + ctx.pop_layer(); + } + } + + /// The composed story for a frame (memoised for the current render: the cache lookup + /// validates the whole thread each time). + fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc { + let key = (sid, page_name.map(str::to_string)); + if let Some(cs) = self.stories.get(&key) { + return cs.clone(); + } + let cs = f.cache.get(f.doc, sid, page_name); + self.stories.insert(key, cs.clone()); + cs + } + + /// Fill, content and stroke of a leaf item (no opacity, blend or effects). + pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + let doc = f.doc; // Fill. if !it.fill.is_none() { ctx.set_transform(f.view * xf); if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() { - ctx.fill_path(&bp); + ctx.fill_path(bp); } ctx.reset_paint_transform(); } @@ -342,12 +396,12 @@ impl Renderer { match &it.content { Content::Graphic(g) => { ctx.set_transform(f.view * xf); - ctx.push_clip_layer(&bp); + ctx.push_clip_layer(bp); self.draw_graphic(ctx, f, g, xf); ctx.pop_layer(); } Content::Text(tf) => { - let cs = f.cache.get(doc, tf.story, page_name); + let cs = self.story(f, tf.story, page_name); if let Some(ft) = cs.frame(it.id) { self.draw_text(ctx, f, &cs, ft, xf); } @@ -356,10 +410,7 @@ impl Renderer { } // Stroke. if !it.stroke.is_none() { - self.draw_stroke(ctx, f, it, &bp, xf); - } - if layered { - ctx.pop_layer(); + self.draw_stroke(ctx, f, it, bp, xf); } } @@ -417,30 +468,7 @@ impl Renderer { let Some(asset) = f.doc.assets.get(&g.asset) else { return }; // Pick a mip level close to the on-screen size. let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); - let full = match self.images.get(&(g.asset, 0)) { - Some(p) => p.clone(), - None => { - let Some(pm) = decode_pixmap(&asset.data) else { return }; - let pm = Arc::new(pm); - self.images.insert((g.asset, 0), pm.clone()); - pm - } - }; - let ppt = full.width() as f64 / g.size.0.max(1e-6); // pixels per point - let need = on_screen * 1.0; // pixels per point on screen - let mut level = 0usize; - let mut pm = full.clone(); - while ppt / (1 << (level + 1)) as f64 >= need * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 { - level += 1; - pm = match self.images.get(&(g.asset, level)) { - Some(p) => p.clone(), - None => { - let half = Arc::new(halve(&pm)); - self.images.insert((g.asset, level), half.clone()); - half - } - }; - } + let Some(pm) = images::mip(&asset.data, g.size.0, on_screen) else { return }; let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); ctx.set_transform(f.view * xf * g.xf); let sx = g.size.0 / pm.width().max(1) as f64; @@ -453,97 +481,6 @@ impl Renderer { ctx.fill_rect(&rect); ctx.reset_paint_transform(); } - - fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &ComposedStory, ft: &FrameText, xf: Affine) { - let db = FontDb::global(); - let doc = f.doc; - // Decorations under text (shading) and rules. - for d in &ft.decos { - if let Some(c) = doc.resolve_color(&d.color, d.tint) { - ctx.set_transform(f.view * xf); - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_rect(&d.rect); - } - } - let scale = (f.view * xf).determinant().abs().sqrt(); - // Batch glyph outlines per run style into one path (in frame inner space). - let mut batches: HashMap = HashMap::new(); - let mut lines_deco: Vec<(u32, kurbo::Rect)> = Vec::new(); - for l in &ft.lines { - let greek = f.opts.greek_below_px > 0.0 && l.ascent * scale < f.opts.greek_below_px; - if greek { - if let (Some(a), Some(_)) = (l.glyphs.first(), l.glyphs.last()) { - let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline); - lines_deco.push((u32::MAX, r)); - } - continue; - } - for g in &l.glyphs { - if !g.visible { - continue; - } - let style = &cs.styles[g.style as usize]; - if style.underline || style.strikethrough { - let w = style.size / 14.0; - if style.underline { - lines_deco - .push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w))); - } - if style.strikethrough { - lines_deco - .push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w))); - } - } - let outline = db.outline(&g.face, g.gid); - if outline.elements().is_empty() { - continue; - } - let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let b = batches.entry(g.style).or_default(); - for el in outline.elements() { - b.push(transform_el(a, *el)); - } - self.stats.glyphs += 1; - } - } - ctx.set_transform(f.view * xf); - for (si, bp) in &batches { - let st = &cs.styles[*si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_path(bp); - } - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(bp); - } - } - for (si, r) in lines_deco { - let c = if si == u32::MAX { - designcraft_color::Color::gray(0.35) - } else { - let st = &cs.styles[si as usize]; - doc.resolve_color(&st.fill, st.fill_tint).unwrap_or(designcraft_color::Color::BLACK) - }; - ctx.set_paint(color_of(&c, if si == u32::MAX { 0.5 } else { 1.0 })); - ctx.fill_rect(&r); - } - } -} - -fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl { - use kurbo::PathEl::*; - match el { - MoveTo(p) => MoveTo(a * p), - LineTo(p) => LineTo(a * p), - QuadTo(p1, p2) => QuadTo(a * p1, a * p2), - CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3), - ClosePath => ClosePath, - } } fn rect_overlaps(a: Rect, b: Rect) -> bool { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs new file mode 100644 index 000000000..5eccc8cf4 --- /dev/null +++ b/crates/render/src/text.rs @@ -0,0 +1,264 @@ +//! Composed text → pixels. +//! +//! Glyph outlines of a frame are transformed into frame space and batched per line and run style +//! once, then cached per (composed story allocation, frame id): a composed story is immutable, so +//! the cache entry stays valid for as long as the composition cache hands out the same `Arc`. +//! Each render only fills the lines that intersect the visible rectangle, and lines smaller than +//! the greeking threshold become grey bars without touching any outline. + +use std::collections::HashMap; +use std::sync::Arc; + +use designcraft_compose::{ComposedStory, FrameText, Line}; +use designcraft_doc::ItemId; +use designcraft_fonts::FontDb; +use designcraft_geom::{Affine, BezPath, Rect, Shape}; +use vello_cpu::RenderContext; +use vello_cpu::kurbo; +use vello_cpu::peniko; + +use crate::{Frame, Renderer, color_of, rect_overlaps}; + +/// Cached outlines of one composed line. +pub(crate) struct LineGlyphs { + /// Frame-space bounds of everything drawn for the line (empty lines: `None`). + pub bounds: Option, + /// Glyph outlines batched per run style. + pub runs: Vec<(u32, BezPath)>, + /// Underline / strikethrough bars. + pub decos: Vec<(u32, Rect)>, + pub glyphs: usize, +} + +pub(crate) struct FrameGlyphs { + pub lines: Vec, + /// Path elements held (cache budget). + pub elements: usize, +} + +struct Entry { + /// Keeps the composed story alive so its address can't be reused while cached. + _story: Arc, + glyphs: Arc, + stamp: u64, +} + +/// Path cache keyed by (composed story address, frame id). +#[derive(Default)] +pub(crate) struct GlyphCache { + map: HashMap<(usize, ItemId), Entry>, + elements: usize, + clock: u64, +} + +/// Roughly 56 bytes per element: ~110 MB of cached outlines. +const MAX_ELEMENTS: usize = 2_000_000; + +impl GlyphCache { + pub fn tick(&mut self) { + self.clock += 1; + } + + fn get(&mut self, cs: &Arc, ft: &FrameText) -> Arc { + let key = (Arc::as_ptr(cs) as usize, ft.frame); + let stamp = self.clock; + if let Some(e) = self.map.get_mut(&key) { + e.stamp = stamp; + return e.glyphs.clone(); + } + let g = Arc::new(build(cs, ft)); + self.elements += g.elements; + self.map.insert(key, Entry { _story: cs.clone(), glyphs: g.clone(), stamp }); + if self.elements > MAX_ELEMENTS { + self.evict(); + } + g + } + + /// Drop least recently used entries (never the ones used by the current render) until the + /// cache is at half its budget. + fn evict(&mut self) { + let mut stamps: Vec<(u64, usize, (usize, ItemId))> = self.map.iter().map(|(k, e)| (e.stamp, e.glyphs.elements, *k)).collect(); + stamps.sort_unstable_by_key(|s| s.0); + for (stamp, n, k) in stamps { + if self.elements <= MAX_ELEMENTS / 2 || stamp >= self.clock { + break; + } + self.map.remove(&k); + self.elements -= n; + } + } + + pub fn clear(&mut self) { + self.map.clear(); + self.elements = 0; + } + + pub fn len(&self) -> usize { + self.map.len() + } +} + +fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl { + use kurbo::PathEl::*; + match el { + MoveTo(p) => MoveTo(a * p), + LineTo(p) => LineTo(a * p), + QuadTo(p1, p2) => QuadTo(a * p1, a * p2), + CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3), + ClosePath => ClosePath, + } +} + +fn union(r: Option, b: Rect) -> Option { + Some(r.map_or(b, |r| r.union(b))) +} + +fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line) -> LineGlyphs { + let mut runs: Vec<(u32, BezPath)> = Vec::new(); + let mut decos = Vec::new(); + let mut glyphs = 0; + for g in &l.glyphs { + if !g.visible { + continue; + } + let style = &cs.styles[g.style as usize]; + if style.underline || style.strikethrough { + let w = style.size / 14.0; + if style.underline { + decos.push((g.style, Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w))); + } + if style.strikethrough { + decos.push((g.style, Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w))); + } + } + let outline = db.outline(&g.face, g.gid); + if outline.elements().is_empty() { + continue; + } + let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); + let bp = match runs.iter_mut().find(|r| r.0 == g.style) { + Some(r) => &mut r.1, + None => { + runs.push((g.style, BezPath::new())); + &mut runs.last_mut().expect("pushed").1 + } + }; + for el in outline.elements() { + bp.push(transform_el(a, *el)); + } + glyphs += 1; + } + let mut bounds = None; + for (si, bp) in &runs { + let st = &cs.styles[*si as usize]; + let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 }; + bounds = union(bounds, bp.bounding_box().inflate(sw, sw)); + } + for (_, r) in &decos { + bounds = union(bounds, *r); + } + LineGlyphs { bounds, runs, decos, glyphs } +} + +fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs { + let db = FontDb::global(); + let lines: Vec = ft.lines.iter().map(|l| build_line(db, cs, l)).collect(); + let elements = lines.iter().flat_map(|l| &l.runs).map(|(_, bp)| bp.elements().len()).sum(); + FrameGlyphs { lines, elements } +} + +/// Conservative frame-space box of a line from its metrics (no outlines needed). +fn line_box(l: &Line) -> Option { + let first = l.glyphs.first()?; + let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min); + let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max); + let pad = l.ascent.max(1.0); + Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad)) +} + +impl Renderer { + pub(crate) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &Arc, ft: &FrameText, xf: Affine) { + let doc = f.doc; + let m = f.view * xf; + // Visible area in frame space. + if xf.determinant().abs() < 1e-12 { + return; + } + let vis = xf.inverse().transform_rect_bbox(f.visible); + // Decorations under text (shading) and rules. + for d in &ft.decos { + if !rect_overlaps(d.rect, vis) { + continue; + } + if let Some(c) = doc.resolve_color(&d.color, d.tint) { + ctx.set_transform(m); + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(&d.rect); + } + } + let scale = m.determinant().abs().sqrt(); + let greek_px = f.opts.greek_below_px; + let mut greek = BezPath::new(); + let mut shown: Vec = Vec::new(); + for (i, l) in ft.lines.iter().enumerate() { + let Some(b) = line_box(l) else { continue }; + if !rect_overlaps(b, vis) { + continue; + } + if greek_px > 0.0 && l.ascent * scale < greek_px { + if let Some(a) = l.glyphs.first() { + let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline); + if r.width() > 0.0 { + greek.extend(r.path_elements(0.1)); + } + } + continue; + } + shown.push(i); + } + ctx.set_transform(m); + if !shown.is_empty() { + let fg = self.glyphs.get(cs, ft); + // Resolve each style's paints once per frame. + let mut fills: Vec>> = vec![None; cs.styles.len()]; + let mut fill_of = |si: u32| -> Option { + *fills[si as usize].get_or_insert_with(|| { + let st = &cs.styles[si as usize]; + doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) + }) + }; + for i in shown { + let lg = &fg.lines[i]; + let Some(b) = lg.bounds else { continue }; + if !rect_overlaps(b, vis) { + continue; + } + self.stats.glyphs += lg.glyphs; + for (si, bp) in &lg.runs { + if let Some(c) = fill_of(*si) { + ctx.set_paint(c); + ctx.fill_path(bp); + } + let st = &cs.styles[*si as usize]; + if st.stroke != designcraft_color::swatch::NONE + && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + { + ctx.set_paint(color_of(&c, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); + ctx.stroke_path(bp); + } + } + for (si, r) in &lg.decos { + ctx.set_paint(fill_of(*si).unwrap_or(peniko::Color::BLACK)); + ctx.fill_rect(r); + } + } + } + if !greek.elements().is_empty() { + ctx.set_paint(color_of(&designcraft_color::Color::gray(0.35), 0.5)); + ctx.fill_path(&greek); + } + } +} diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs new file mode 100644 index 000000000..f72e2f770 --- /dev/null +++ b/crates/render/tests/effects.rs @@ -0,0 +1,224 @@ +//! Golden images for the soft object effects (drop shadow, outer glow, inner shadow, feather), +//! plus property checks and a multithreaded == single-threaded check. +//! +//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur +//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended +//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`. + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, ParaFormat, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +const SIZE: u32 = 160; + +fn doc() -> Document { + Document::new(&NewDocument { width: 160.0, height: 160.0, facing_pages: false, ..Default::default() }) +} + +fn rect_item(d: &mut Document, r: Rect, swatch: &str) -> ItemId { + let id = ItemId(d.alloc()); + let lid = d.default_layer(); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + it.fill = Fill::swatch(swatch); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id +} + +fn render_with(d: &Document, threads: u16) -> Rendered { + let mut r = Renderer::new(); + r.threads = threads; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), offset: Vec2::ZERO }], SIZE, SIZE, Affine::IDENTITY, &opts) +} + +fn render(d: &Document) -> Rendered { + render_with(d, 0) +} + +fn lum(p: [u8; 4]) -> u32 { + p[0] as u32 + p[1] as u32 + p[2] as u32 +} + +const WHITE: u32 = 765; + +/// Tolerant comparison with `tests/golden/{name}.png` (written when missing or when blessing). +fn golden(name: &str, img: &Rendered) { + let path = format!("{}/tests/golden/{name}.png", env!("CARGO_MANIFEST_DIR")); + let bless = std::env::var_os("DESIGNCRAFT_BLESS").is_some(); + let Ok(bytes) = std::fs::read(&path).map_err(|_| ()).and_then(|b| if bless { Err(()) } else { Ok(b) }) else { + std::fs::create_dir_all(format!("{}/tests/golden", env!("CARGO_MANIFEST_DIR"))).unwrap(); + std::fs::write(&path, img.to_png()).unwrap(); + eprintln!("wrote golden {path}"); + return; + }; + let want = image::load_from_memory(&bytes).unwrap().to_rgba8(); + assert_eq!((want.width(), want.height()), (img.width, img.height), "{name}: size"); + let got = img.to_straight(); + let (mut bad, mut sum, mut worst) = (0usize, 0u64, 0u8); + for (a, b) in got.chunks_exact(4).zip(want.as_raw().chunks_exact(4)) { + let d = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); + sum += d as u64; + worst = worst.max(d); + if d > 8 { + bad += 1; + } + } + let n = (img.width * img.height) as usize; + let mean = sum as f64 / n as f64; + if bad * 200 > n || mean > 1.0 { + let out = std::env::temp_dir().join(format!("designcraft-golden-{name}.png")); + let _ = std::fs::write(&out, img.to_png()); + panic!("{name}: {bad} pixels differ by > 8 (worst {worst}, mean {mean:.2}); got {}", out.display()); + } +} + +fn shadow_doc(spread: f64) -> Document { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0"); + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.distance = 12.0; + ds.angle = 135.0; + ds.size = 10.0; + ds.spread = spread; + ds.opacity = 0.8; + d +} + +#[test] +fn drop_shadow_is_soft_offset_and_under_the_object() { + let img = render(&shadow_doc(0.0)); + golden("drop-shadow", &img); + // The object is unchanged on top. + let c = img.pixel(60, 60); + assert!(c[0] < 60 && c[2] > 200, "object on top: {c:?}"); + // Below-right is shadowed, above-left is not. + assert!(lum(img.pixel(104, 70)) < 500, "shadow below-right: {:?}", img.pixel(104, 70)); + assert_eq!(lum(img.pixel(22, 22)), WHITE); + // Soft: the shadow fades out gradually past its hard edge (100 + 8.5 offset). + let row: Vec = (104..130).map(|x| lum(img.pixel(x, 80))).collect(); + let mid = row.iter().filter(|&&v| v > 60 && v < 700).count(); + assert!(mid >= 6, "gradual falloff: {row:?}"); + assert!(row.windows(2).all(|w| w[1] + 2 >= w[0]), "monotonic: {row:?}"); +} + +#[test] +fn drop_shadow_spread_hardens_and_grows() { + let soft = render(&shadow_doc(0.0)); + let hard = render(&shadow_doc(100.0)); + golden("drop-shadow-spread", &hard); + // Full spread: no blur, the silhouette grows by `size` → solid well past the offset edge. + let x = 100 + 8 + 6; + assert!(lum(hard.pixel(x, 80)) < lum(soft.pixel(x, 80)), "spread grows the shadow"); + assert_eq!(lum(hard.pixel(126, 80)), WHITE, "hard edge"); +} + +#[test] +fn drop_shadow_of_text_follows_the_glyphs() { + let mut d = doc(); + let lid = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 150.0, 150.0), lid, "Shadow", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..6, |f| f.over.size = Some(40.0)); + let ds = &mut d.item_mut(fid).unwrap().effects.drop_shadow; + ds.on = true; + ds.distance = 4.0; + ds.size = 3.0; + let img = render(&d); + golden("drop-shadow-text", &img); + // No frame-shaped shadow: the bottom-right of the (unfilled) frame is clear. + assert_eq!(lum(img.pixel(140, 140)), WHITE); +} + +#[test] +fn outer_glow_surrounds_the_object() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=100 Y=0 K=0".into(); + g.opacity = 1.0; + let img = render(&d); + golden("outer-glow", &img); + for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { + let p = img.pixel(x, y); + assert!(p[1] < 200 && p[0] > p[1] + 40, "magenta glow at {x},{y}: {p:?}"); + } + assert_eq!(lum(img.pixel(10, 10)), WHITE); +} + +#[test] +fn inner_shadow_darkens_inside_the_lit_edge() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=0 M=0 Y=100 K=0"); + let s = &mut d.item_mut(id).unwrap().effects.inner_shadow; + s.on = true; + s.distance = 8.0; + s.size = 8.0; + s.opacity = 1.0; + let img = render(&d); + golden("inner-shadow", &img); + // Light from the top-left: the shadow sits inside the top-left edges. + assert!(lum(img.pixel(33, 80)) + 100 < lum(img.pixel(126, 80)), "{:?} vs {:?}", img.pixel(33, 80), img.pixel(126, 80)); + assert!(lum(img.pixel(80, 33)) + 100 < lum(img.pixel(80, 126))); + // Nothing outside the shape. + assert_eq!(lum(img.pixel(26, 80)), WHITE); +} + +#[test] +fn feather_fades_the_edge_inward() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "[Black]"); + d.item_mut(id).unwrap().effects.feather = 16.0; + let img = render(&d); + golden("feather", &img); + d.item_mut(id).unwrap().effects.feather = 0.0; + let solid = lum(render(&d).pixel(80, 80)); + let edge = lum(img.pixel(30, 80)); + let mid = lum(img.pixel(38, 80)); + let inside = lum(img.pixel(80, 80)); + assert!(edge > 600, "nearly transparent at the path: {edge}"); + assert!(mid > solid + 60 && mid < 650, "half way: {mid}"); + assert!(inside <= solid + 6, "solid inside: {inside} vs {solid}"); + assert_eq!(lum(img.pixel(27, 80)), WHITE, "nothing outside"); +} + +#[test] +fn shadow_of_an_object_outside_the_view_still_reaches_it() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(-60.0, 40.0, -2.0, 120.0), "[Paper]"); + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.angle = 180.0; + ds.distance = 20.0; + ds.size = 2.0; + ds.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(8, 80)) < 200, "{:?}", img.pixel(8, 80)); +} + +/// Multithreaded contexts draw effects offscreen; the result must match single-threaded. +#[test] +fn multithreaded_effects_match_single_threaded() { + let mut docs = vec![shadow_doc(0.0), shadow_doc(40.0)]; + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=0 M=30 Y=60 K=0"); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=100 M=0 Y=0 K=0"); + { + let it = d.item_mut(id).unwrap(); + it.effects.feather = 10.0; + it.effects.inner_shadow.on = true; + it.effects.outer_glow.on = true; + it.effects.drop_shadow.on = true; + it.opacity = 0.8; + } + docs.push(d); + for (i, d) in docs.iter().enumerate() { + let a = render_with(d, 0); + let b = render_with(d, 3); + let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); + assert!(worst <= 4, "case {i}: max channel difference {worst}"); + } +} diff --git a/crates/render/tests/golden/drop-shadow-spread.png b/crates/render/tests/golden/drop-shadow-spread.png new file mode 100644 index 000000000..48c774386 Binary files /dev/null and b/crates/render/tests/golden/drop-shadow-spread.png differ diff --git a/crates/render/tests/golden/drop-shadow-text.png b/crates/render/tests/golden/drop-shadow-text.png new file mode 100644 index 000000000..2c9e62575 Binary files /dev/null and b/crates/render/tests/golden/drop-shadow-text.png differ diff --git a/crates/render/tests/golden/drop-shadow.png b/crates/render/tests/golden/drop-shadow.png new file mode 100644 index 000000000..711f91be8 Binary files /dev/null and b/crates/render/tests/golden/drop-shadow.png differ diff --git a/crates/render/tests/golden/feather.png b/crates/render/tests/golden/feather.png new file mode 100644 index 000000000..bcab2bf1b Binary files /dev/null and b/crates/render/tests/golden/feather.png differ diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png new file mode 100644 index 000000000..87bc812b5 Binary files /dev/null and b/crates/render/tests/golden/inner-shadow.png differ diff --git a/crates/render/tests/golden/outer-glow.png b/crates/render/tests/golden/outer-glow.png new file mode 100644 index 000000000..461bfb694 Binary files /dev/null and b/crates/render/tests/golden/outer-glow.png differ diff --git a/crates/render/tests/perf.rs b/crates/render/tests/perf.rs new file mode 100644 index 000000000..a8b63de3b --- /dev/null +++ b/crates/render/tests/perf.rs @@ -0,0 +1,122 @@ +//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock +//! timings are only meaningful on an idle machine — run with +//! `cargo test --release -p designcraft-render --test perf -- --ignored`). + +use std::time::Instant; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, ParaFormat, SpreadRef}; +use designcraft_geom::{Affine, Rect, Vec2}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +const WORDS: &[&str] = &["grid", "margin", "column", "rhythm", "baseline", "reader", "story", "frame", "quiet", "layout", "type", "texture"]; + +/// `pages` pages, each with 6 frames of one threaded story. +fn text_doc(pages: usize) -> Document { + let mut d = Document::new(&NewDocument { pages, ..Default::default() }); + let lid = d.default_layer(); + let mut text = String::new(); + for i in 0..pages * 3000 { + text.push_str(WORDS[(i * 7 + i / 5) % WORDS.len()]); + text.push(if i % 70 == 69 { '\n' } else { ' ' }); + } + let mut prev = None; + for abs in 0..pages { + let (si, pi) = d.page_loc(abs).unwrap(); + let x0 = d.spreads[si].pages[pi].x; + for k in 0..6 { + let (c, r) = ((k % 2) as f64, (k / 2) as f64); + let rect = Rect::new(x0 + 36.0 + c * 280.0, 36.0 + r * 240.0, x0 + 296.0 + c * 280.0, 266.0 + r * 240.0); + let t = if prev.is_none() { text.as_str() } else { "" }; + let (id, _) = d.add_text_frame(SpreadRef::Doc(si), rect, lid, t, ParaFormat::default()).unwrap(); + if let Some(p) = prev { + d.thread(p, id).unwrap(); + } + prev = Some(id); + } + } + d +} + +fn placed(d: &Document) -> Vec { + let mut y = 0.0; + (0..d.spreads.len()) + .map(|i| { + let b = d.spreads[i].bounds(); + let p = Placed { spread: SpreadRef::Doc(i), offset: Vec2::new(-b.x0, y) }; + y += b.height() + 36.0; + p + }) + .collect() +} + +#[test] +fn glyph_paths_are_cached_and_offscreen_text_is_skipped() { + let d = text_doc(4); + let cache = Cache::new(); + let all = placed(&d); + let mut r = Renderer::new(); + r.threads = 0; + // 200% view of the top-left of the first spread: only a few frames are visible. + let view = Affine::scale(2.0); + let opts = RenderOptions::default(); + let a = r.render(&d, &cache, &all, 800, 600, view, &opts); + let drawn = r.stats.glyphs; + assert!(drawn > 100, "glyphs drawn: {drawn}"); + assert!(r.cached_frames() <= 4, "only visible frames get glyph paths: {}", r.cached_frames()); + // Same pixels from the cache. + let b = r.render(&d, &cache, &all, 800, 600, view, &opts); + assert!(a.pixels == b.pixels); + // Culling: far fewer glyphs than the visible frames hold. + let whole = r.render(&d, &cache, &all, 2400, 1600, Affine::scale(1.0), &opts); + assert!(r.stats.glyphs > drawn * 2, "{} vs {drawn}", r.stats.glyphs); + drop(whole); + // A new composition (edited story) gets fresh paths. + let mut d2 = d.clone(); + let sid = *d2.stories.keys().next().unwrap(); + d2.set_story_text(sid, "Edited").unwrap(); + let c = r.render(&d2, &cache, &all, 800, 600, view, &opts); + assert!(r.stats.glyphs < 10); + assert!(c.pixels != a.pixels); +} + +#[test] +fn greeked_text_builds_no_glyph_paths() { + let d = text_doc(2); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { greek_below_px: 6.0, ..Default::default() }; + let img = r.render(&d, &cache, &placed(&d), 400, 300, Affine::scale(0.3), &opts); + assert_eq!(r.stats.glyphs, 0); + assert_eq!(r.cached_frames(), 0); + // The grey bars are drawn. + let grey = (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 240).count(); + assert!(grey > 200, "{grey}"); +} + +#[test] +#[ignore = "wall-clock budget; run on an idle machine"] +fn render_text_heavy_spread_at_100_percent_within_budget() { + let d = text_doc(8); + let cache = Cache::new(); + let all = placed(&d); + let mut r = Renderer::new(); + // 100% on a HiDPI 2880×1800 canvas = 2 px/pt, centred on the second spread. + let view = Affine::scale(2.0) * Affine::translate((0.0, -828.0)); + let opts = RenderOptions { greek_below_px: 6.0, ..Default::default() }; + r.render(&d, &cache, &all, 2880, 1800, view, &opts); + let mut v: Vec = (0..9) + .map(|_| { + let t = Instant::now(); + drop(r.render(&d, &cache, &all, 2880, 1800, view, &opts)); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + v.sort_by(f64::total_cmp); + let median = v[v.len() / 2]; + eprintln!("render text-heavy spread at 100%, 2880×1800: {median:.2} ms (glyphs {})", r.stats.glyphs); + // plan/architecture.md §9: < 16 ms. + assert!(median < 16.0, "{median:.2} ms"); +}