mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-06 18:39:31 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbaa06bfe2 | ||
|
|
f1cd1b2499 |
@@ -15,10 +15,6 @@ use crate::xml::{A, El, W};
|
||||
|
||||
const DEG: f64 = 60_000.0;
|
||||
|
||||
/// Distinct path coordinate spaces whose guides are evaluated for one custom geometry; paths in
|
||||
/// further spaces are left out.
|
||||
const MAX_SPACES: usize = 16;
|
||||
|
||||
/// Guide variables for one shape.
|
||||
pub struct Guides {
|
||||
vars: HashMap<String, f64>,
|
||||
@@ -155,26 +151,14 @@ fn pt(g: &Guides, e: &El) -> (f64, f64) {
|
||||
pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
let mut out = vec![];
|
||||
let Some(pl) = cg.child("pathLst") else { return out };
|
||||
let mut spaces: HashMap<(u64, u64), Guides> = HashMap::new();
|
||||
// The cap on cached spaces saves guide evaluation: a geometry without guides has none to save.
|
||||
let guided = ["avLst", "gdLst"].iter().any(|n| cg.child(n).is_some_and(|l| l.elements().next().is_some()));
|
||||
for p in pl.children_named("path").take(1000) {
|
||||
let pw = p.f64("w").unwrap_or(0.0);
|
||||
let ph = p.f64("h").unwrap_or(0.0);
|
||||
// Guides are evaluated in the path's own space when it has one.
|
||||
let (gw, gh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu, h_emu) };
|
||||
// The guide lists are evaluated once per coordinate space, not once per path.
|
||||
let key = (gw.to_bits(), gh.to_bits());
|
||||
if !spaces.contains_key(&key) {
|
||||
if guided && spaces.len() >= MAX_SPACES {
|
||||
continue;
|
||||
}
|
||||
let mut g = Guides::new(gw, gh);
|
||||
g.eval_list(cg.child("avLst"));
|
||||
g.eval_list(cg.child("gdLst"));
|
||||
spaces.insert(key, g);
|
||||
}
|
||||
let Some(g) = spaces.get(&key) else { continue };
|
||||
let mut g = Guides::new(gw, gh);
|
||||
g.eval_list(cg.child("avLst"));
|
||||
g.eval_list(cg.child("gdLst"));
|
||||
let mut d = String::new();
|
||||
let mut cur = (0.0, 0.0);
|
||||
let mut start = (0.0, 0.0);
|
||||
@@ -182,7 +166,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
match c.local() {
|
||||
"moveTo" => {
|
||||
if let Some(q) = c.child("pt") {
|
||||
cur = pt(g, q);
|
||||
cur = pt(&g, q);
|
||||
start = cur;
|
||||
d.push_str("M ");
|
||||
num(&mut d, cur.0);
|
||||
@@ -193,7 +177,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
"lnTo" => {
|
||||
if let Some(q) = c.child("pt") {
|
||||
cur = pt(g, q);
|
||||
cur = pt(&g, q);
|
||||
d.push_str("L ");
|
||||
num(&mut d, cur.0);
|
||||
d.push(' ');
|
||||
@@ -202,7 +186,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
}
|
||||
"cubicBezTo" => {
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(g, q)).collect();
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(&g, q)).collect();
|
||||
if let [a, b, e] = pts.as_slice() {
|
||||
d.push_str("C ");
|
||||
for (x, y) in [a, b, e] {
|
||||
@@ -215,7 +199,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
}
|
||||
"quadBezTo" => {
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(g, q)).collect();
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(&g, q)).collect();
|
||||
if let [a, e] = pts.as_slice() {
|
||||
d.push_str("Q ");
|
||||
for (x, y) in [a, e] {
|
||||
@@ -486,29 +470,6 @@ mod tests {
|
||||
assert_eq!(paths[1].d, "M 1 1 Q 2 2 3 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_share_guides_and_distinct_spaces_are_capped() {
|
||||
let path = |w: usize| format!(r#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="mid" y="1"/></a:moveTo></a:path>"#);
|
||||
let paths: String = (1..=40).map(|i| path(10 * i)).collect();
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="mid" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{paths}{}</a:pathLst></a:custGeom>"#,
|
||||
path(10)
|
||||
);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
let out = read_cust_geom(&d.root, 1.0, 1.0);
|
||||
assert_eq!(out.len(), MAX_SPACES + 1, "16 spaces plus the repeat of the first");
|
||||
assert!(out[0].d.starts_with("M 5 1"), "{}", out[0].d);
|
||||
assert!(out[MAX_SPACES].d.starts_with("M 5 1"), "{}", out[MAX_SPACES].d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_free_geometries_are_not_capped_by_space() {
|
||||
let paths: String = (1..=40).map(|i| format!(r#"<a:path w="{}" h="10"><a:moveTo><a:pt x="1" y="1"/></a:moveTo></a:path>"#, 10 * i)).collect();
|
||||
let x = format!(r#"<a:custGeom xmlns:a="x"><a:avLst/><a:gdLst/><a:pathLst>{paths}</a:pathLst></a:custGeom>"#);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
assert_eq!(read_cust_geom(&d.root, 1.0, 1.0).len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_path() {
|
||||
let p = CustomPath { w: 100.0, h: 50.0, d: "M 0 0 L 100 0 C 100 10 90 50 50 50 Q 0 50 0 0 Z".into(), fill: FillMode::Norm, stroke: true };
|
||||
|
||||
@@ -125,7 +125,9 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &B
|
||||
let longest = bounds.width().max(bounds.height()) * k;
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
|
||||
let polys = crate::pathgrad::polygons(path);
|
||||
// Each pixel tests every edge, so a dense outline is simplified to the raster's edge budget.
|
||||
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
|
||||
let polys = crate::pathgrad::simplify(crate::pathgrad::polygons(path), pixel, crate::pathgrad::edge_budget(pw, ph));
|
||||
if polys.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Fills and gradient stops one colour glyph may paint; the rest of a hostile glyph is skipped.
|
||||
const MAX_WORK: usize = 100_000;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
@@ -26,7 +28,7 @@ pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, sca
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new(), work: 0 };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
@@ -50,6 +52,8 @@ struct Painter<'a> {
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
/// Fills plus gradient stops painted so far, against [`MAX_WORK`].
|
||||
work: usize,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
@@ -174,6 +178,16 @@ impl ColorPainter for Painter<'_> {
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
let stops = match &brush {
|
||||
Brush::Solid { .. } => 0,
|
||||
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
|
||||
color_stops.len()
|
||||
}
|
||||
};
|
||||
self.work = self.work.saturating_add(1 + stops);
|
||||
if self.work > MAX_WORK {
|
||||
return;
|
||||
}
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
|
||||
@@ -25,6 +25,16 @@ const MAX_EDGE_WORK: u64 = 64_000_000;
|
||||
/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape.
|
||||
const MIN_POLY_EDGES: usize = 8;
|
||||
|
||||
/// Most points `polygons` keeps from one path; a path flattening past this (a huge finite curve) is
|
||||
/// not drawn rather than held in memory first.
|
||||
const MAX_POINTS: usize = 2_000_000;
|
||||
|
||||
/// The edge budget for a `pw` x `ph` raster where each pixel tests every edge.
|
||||
pub(crate) fn edge_budget(pw: u32, ph: u32) -> usize {
|
||||
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
|
||||
usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES)
|
||||
}
|
||||
|
||||
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
|
||||
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
|
||||
/// shape's flattened polygon(s) for `shape`.
|
||||
@@ -114,21 +124,31 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||||
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = Vec::new();
|
||||
let mut cur: Vec<Point> = Vec::new();
|
||||
kurbo::flatten(path.iter(), 0.25, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![p];
|
||||
let mut total = 0usize;
|
||||
kurbo::flatten(path.iter(), 0.25, |el| {
|
||||
total += 1;
|
||||
if total > MAX_POINTS {
|
||||
return;
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![p];
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if total > MAX_POINTS {
|
||||
return Vec::new();
|
||||
}
|
||||
if cur.len() > 1 {
|
||||
out.push(cur);
|
||||
}
|
||||
@@ -146,7 +166,7 @@ pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points
|
||||
/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too
|
||||
/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits.
|
||||
fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
|
||||
pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
|
||||
let edges = |o: &[Vec<Point>]| o.iter().map(|p| p.len().saturating_sub(1)).sum::<usize>();
|
||||
if edges(&outline) <= max_edges {
|
||||
return outline;
|
||||
@@ -233,13 +253,17 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64,
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
|
||||
let outline = if kind == "shape" {
|
||||
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
|
||||
let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES);
|
||||
let max_edges = edge_budget(pw, ph);
|
||||
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
|
||||
simplify(polygons(path), pixel, max_edges)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// An outline `polygons` refused (too many points) has no contours: skip the shape rather than
|
||||
// paint it with the rectangle fallback's different gradient.
|
||||
if kind == "shape" && outline.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lut = ramp(stops);
|
||||
let a = alpha.clamp(0.0, 1.0);
|
||||
let mut data = Vec::with_capacity((pw * ph) as usize);
|
||||
@@ -372,6 +396,20 @@ mod tests {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_gradient_with_no_usable_outline_paints_nothing() {
|
||||
// One polygon over the per-polygon cap is dropped by `polygons`.
|
||||
let path = many_segment_square(26_000);
|
||||
assert!(polygons(&path).is_empty());
|
||||
let mut ctx = RenderContext::new(64, 64);
|
||||
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))];
|
||||
fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 64.0, 64.0), &path, Affine::IDENTITY, 1.0);
|
||||
ctx.flush();
|
||||
let mut px = vec![0u8; 64 * 64 * 4];
|
||||
ctx.render(vello_cpu::PixmapMut::new(64, 64, &mut px).unwrap(), &mut vello_cpu::Resources::new());
|
||||
assert!(px.chunks(4).all(|p| p[3] == 0), "nothing was painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ramp_runs_through_the_stops() {
|
||||
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
|
||||
|
||||
Reference in New Issue
Block a user