M9: render perf (glyph path cache, culling) + soft drop shadow/feather/glow; perf harness

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 12:31:01 -04:00
1 parent 6086ceb497
commit b62a795602
18 files changed
+1666 -147

No files matched your search

+6
View File
@@ -32,3 +32,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `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 |
| `docs/images/ui-typing.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 |
Generated
+5
View File
@@ -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",
]
+5
View File
@@ -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 }
+7 -1
View File
@@ -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] [--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
}
+557
View File
@@ -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<u8> {
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<AssetId> = (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<ItemId> = 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<f64> = (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<f64> {
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<Cache> {
let cache = Arc::new(Cache::new());
let ids: Vec<StoryId> = 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<f64>,
}
fn row(rows: &mut Vec<Row>, name: impl Into<String>, ms: f64, budget: Option<f64>) {
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<Row> {
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> = 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::<usize>().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::<usize>() / 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)
}
+55
View File
@@ -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<T: Default + PartialEq>(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)]
+204
View File
@@ -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);
}
}
+134
View File
@@ -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<Vec<u8>>`) 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<Vec<u8>>,
pm: Arc<Pixmap>,
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<usize, Arc<Vec<u8>>>,
}
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
fn size_of(pm: &Pixmap) -> usize {
pm.width() as usize * pm.height() as usize * 4
}
fn with<R>(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<Arc<Pixmap>> {
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<Vec<u8>>, level: u8, pm: Arc<Pixmap>) {
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<Vec<u8>>) -> Option<Arc<Pixmap>> {
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<Vec<u8>>, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
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;
});
}
+83 -146
View File
@@ -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<RenderContext>,
resources: Resources,
images: HashMap<(AssetId, usize), Arc<Pixmap>>,
glyphs: text::GlyphCache,
/// Composed stories looked up during the current render.
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
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<ComposedStory> {
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<u32, BezPath> = 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 {
+264
View File
@@ -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<Rect>,
/// 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<LineGlyphs>,
/// 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<ComposedStory>,
glyphs: Arc<FrameGlyphs>,
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<ComposedStory>, ft: &FrameText) -> Arc<FrameGlyphs> {
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<Rect>, b: Rect) -> Option<Rect> {
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<LineGlyphs> = 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<Rect> {
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<ComposedStory>, 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<usize> = 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<Option<Option<peniko::Color>>> = vec![None; cs.styles.len()];
let mut fill_of = |si: u32| -> Option<peniko::Color> {
*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);
}
}
}
+224
View File
@@ -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<u32> = (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}");
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+122
View File
@@ -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<Placed> {
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<f64> = (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");
}