From 655ea463561b19e17ba5ae5ae3aeb9b8d187e9cd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:09:54 +0200 Subject: [PATCH] Render: a group's child-coordinate scale no longer scales line widths (#233) Outlines, dashes, arrowheads and ink strokes were drawn under the full transform, including the mapping of the group's child coordinate space onto its box. A group whose children use small legacy child coordinates (scale ~1600) therefore stroked 0.75 pt lines about 1200 pt wide and covered the slide. Inside a group the outline is now carried through that mapping and stroked under the slide-level view, so the line width, dash lengths and arrowhead sizes stay in points, also for groups scaled unevenly. --- crates/render/src/lib.rs | 61 +++++++++++++++++++++++++++++++----- crates/render/src/tests.rs | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8f88024..637f0ae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -238,11 +238,14 @@ pub struct Renderer { /// What the frame being drawn has painted so far, in output pixels: the editor learns from it /// how far a slide's drawing (text spilling out of its box too) reaches off the slide. drawn: Option, + /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw + /// under it combined with the group's child-coordinate mapping, which must not scale lines. + view: Affine, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None } + Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY } } } @@ -660,6 +663,25 @@ impl Renderer { } fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + if depth == 0 { + let outer = std::mem::replace(&mut self.view, parent); + self.shape_at(ctx, f, rctx, s, parent, depth); + self.view = outer; + } else { + self.shape_at(ctx, f, rctx, s, parent, depth); + } + } + + /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the + /// one that takes the shape's outline into it. Inside a group they are the slide-level view and + /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but + /// never the line width; outside a group, `m` itself and no change to the outline. + fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) { + let view = self.view; + if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } + } + + fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { return; } @@ -718,20 +740,34 @@ impl Renderer { bp.line_to((*px, *py)); } } - ctx.set_transform(parent); + let (base, rel) = self.line_space(parent, depth); + bp.apply_affine(rel); + ctx.set_transform(base); ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&bp); } } - _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st), + _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), } if layered { ctx.pop_layer(); } } - fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) { + #[allow(clippy::too_many_arguments)] + fn geometry_shape( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + rctx: &Ctx, + s: &Shape, + m: Affine, + w: f64, + h: f64, + st: &ShapeState, + depth: usize, + ) { let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { Some(pm) => morph_path::geometry(pm, s, w, h), None => (shape_geometry(s, w, h), Vec::new()), @@ -816,11 +852,19 @@ impl Renderer { // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); + let (base, rel) = self.line_space(m, depth); for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { - stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke); + let path = if rel == Affine::IDENTITY { + std::borrow::Cow::Borrowed(&sp.path) + } else { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + std::borrow::Cow::Owned(moved) + }; + stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke); } if s.is_line() || geo.is_open() { - arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw); + arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); } } if let Some(r) = soft { @@ -1069,9 +1113,12 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { Some(((first, second), (last, prev))) } -fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, m: Affine, lw: f64) { +#[allow(clippy::too_many_arguments)] +fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, m: Affine, lw: f64) { let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return }; let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return }; + // The outline's ends in the space the arrowheads are sized and drawn in. + let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2); let col = match &line.fill { Some(Fill::Solid { color: c }) => rctx.color(c, ph), _ => Rgba::BLACK, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ee71391..f2a241b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1443,3 +1443,66 @@ fn metafile_pattern_brush_tiles_and_averages_when_tiny() { let p = img.get_pixel(16, 16); assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}"); } + +/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an +/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point). +fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image { + use deckcraft_model::style::{Dash, LineEnd}; + let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) }; + let place = |x0: f64, y0: f64, x1: f64, y1: f64| { + let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1)); + Xfrm::new(a, b, c - a, d - b) + }; + let rect = Shape { + id: ShapeId(11), + xfrm: Some(place(140.0, 120.0, 300.0, 200.0)), + geom: Geom::preset("rect"), + line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }), + ..Default::default() + }; + let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() }; + let line = Shape { + id: ShapeId(12), + xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }), + geom: Geom::preset("line"), + line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }), + ..Default::default() + }; + let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect(); + let ink = Shape { + id: ShapeId(13), + xfrm: Some(place(100.0, 100.0, 500.0, 300.0)), + kind: ShapeKind::Ink { + strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }], + }, + ..Default::default() + }; + let group = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), + kind: ShapeKind::Group { children: vec![rect, line, ink], child }, + ..Default::default() + }; + render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +fn inked(img: &Image) -> usize { + img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count() +} + +#[test] +fn group_child_scale_does_not_scale_line_widths() { + // Children laid out 1:1 with the slide are the reference. + let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y)); + let drawn = inked(&reference); + assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}"); + // The same drawing in a small legacy child space (scale 100), and one scaled unevenly + // (100 across, 50 down): same outlines, same line widths, dashes and arrowhead. + let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0)); + let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0)); + for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] { + let (n, d) = (inked(img), differing(&reference, img)); + assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}"); + assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels"); + } +}