M5: raster effects render on the multithreaded pipeline (each filter layer offscreen, composited with its blend/opacity; tested equal to single-threaded); fix inner glow blend isolated by its clip layer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
abe2ed7080
commit
f855da4fb8
|
|
@ -58,7 +58,7 @@ _Last updated: 2026-10-01._
|
|||
| M2 | Drawing tools + smart guides | ✅ mostly done (Shaper, Pen modifier nuances) | 5–10 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard SVG/PNG interop, EPS/DXF pending | 8–12 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `drawcraft-cli bench`; raster-effect docs fall back to single-threaded (neon-drive demo: 214 ms/frame) — MT filters, dirty-region rendering, GPU backend spike pending | 10–20 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `drawcraft-cli bench`; raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! Live effects in the renderer.
|
||||
//!
|
||||
//! Geometry effects (object-level, then per fill/stroke) rewrite the path before painting via
|
||||
//! `drawcraft-effects`. Raster effects use vello_cpu filter layers (single-threaded contexts only,
|
||||
//! which is what the renderer creates):
|
||||
//! `drawcraft-effects`. Raster effects use vello_cpu filter layers. Those only work in
|
||||
//! single-threaded contexts, so on the multithreaded pipeline each filter layer is rendered
|
||||
//! offscreen on the calling thread (cropped to its reach) and composited back with the same blend
|
||||
//! mode and opacity ([`Renderer::with_filters`]):
|
||||
//!
|
||||
//! - Drop Shadow / Outer Glow: the object's silhouette in a `DropShadowOnly` filter layer, painted
|
||||
//! below the object with the effect's blend mode and opacity;
|
||||
|
|
@ -140,67 +142,146 @@ impl Renderer {
|
|||
}
|
||||
let gctx = geom_ctx(n);
|
||||
let rfx = effects::raster_effects(&n.appearance.effects);
|
||||
// Document-space reach of the painted geometry (strokes, arrowheads…).
|
||||
let outset = n.appearance.outset();
|
||||
let reach = g.bounding_box().inflate(outset, outset);
|
||||
// Below the object: shadows and outer glows.
|
||||
for fx in rfx.iter().filter(|x| x.is_below()) {
|
||||
// The offset is applied to the geometry rather than in the filter: vello_cpu drops
|
||||
// layer content that lies entirely outside the viewport before filtering.
|
||||
let (mode, opacity, dx, dy, filter) = match fx {
|
||||
let (mode, opacity, dx, dy, blur, filter) = match fx {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
|
||||
(*mode, *opacity, *dx, *dy, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
|
||||
(*mode, *opacity, *dx, *dy, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
|
||||
}
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => {
|
||||
(*mode, *opacity, 0.0, 0.0, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
|
||||
}
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, shadow_filter(0.0, 0.0, *blur, pcolor(color))),
|
||||
_ => continue,
|
||||
};
|
||||
let mut gs = g.clone();
|
||||
gs.apply_affine(Affine::translate((dx, dy)));
|
||||
ctx.set_transform(f.view);
|
||||
ctx.push_layer(None, Some(blend_mode(mode)), Some(opacity), None, Some(filter));
|
||||
self.paint_items(ctx, f, n, &gs, rule, &gctx);
|
||||
ctx.pop_layer();
|
||||
let reach = reach + drawcraft_geom::Vec2::new(dx, dy);
|
||||
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
|
||||
r.paint_items(c, fr, n, &gs, rule, &gctx);
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
// The object itself (blurred / feathered).
|
||||
let mut layers = 0;
|
||||
for fx in &rfx {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } if *radius > 0.0 => {
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.push_clip_layer(&g);
|
||||
ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
|
||||
layers += 2;
|
||||
let blur: f64 = rfx
|
||||
.iter()
|
||||
.map(|fx| match fx {
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => radius.max(0.0),
|
||||
_ => 0.0,
|
||||
})
|
||||
.sum();
|
||||
if blur > 0.0 {
|
||||
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
|
||||
let mut layers = 0;
|
||||
for fx in &rfx {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } if *radius > 0.0 => {
|
||||
c.set_transform(fr.view);
|
||||
c.set_fill_rule(fill_rule(rule));
|
||||
c.push_clip_layer(&g);
|
||||
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
|
||||
layers += 2;
|
||||
}
|
||||
RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
|
||||
layers += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
|
||||
ctx.set_transform(f.view);
|
||||
ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
|
||||
layers += 1;
|
||||
r.paint_items(c, fr, n, &g, rule, &gctx);
|
||||
for _ in 0..layers {
|
||||
c.pop_layer();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.paint_items(ctx, f, n, &g, rule, &gctx);
|
||||
for _ in 0..layers {
|
||||
ctx.pop_layer();
|
||||
});
|
||||
} else {
|
||||
self.paint_items(ctx, f, n, &g, rule, &gctx);
|
||||
}
|
||||
// Above the object: inner glows, clipped to the shape.
|
||||
for fx in &rfx {
|
||||
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.push_clip_layer(&g);
|
||||
ctx.push_layer(None, Some(blend_mode(*mode)), Some(*opacity), None, Some(shadow_filter(0.0, 0.0, *blur, pcolor(color))));
|
||||
ctx.set_paint(peniko::Color::BLACK);
|
||||
if *center {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.fill_path(&g);
|
||||
} else {
|
||||
// Everything outside the shape, so the glow bleeds in from the edges.
|
||||
let pad = blur * 2.0 + 4.0 * f.px;
|
||||
let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1);
|
||||
inv.extend(g.iter());
|
||||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
ctx.fill_path(&inv);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color));
|
||||
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| {
|
||||
// The blend layer goes outside the clip: a clip layer is isolated, so a blend
|
||||
// inside it would mix with nothing instead of the object below.
|
||||
if let Some((m, o)) = comp {
|
||||
c.set_transform(Affine::IDENTITY);
|
||||
c.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
|
||||
}
|
||||
c.set_transform(fr.view);
|
||||
c.set_fill_rule(fill_rule(rule));
|
||||
c.push_clip_layer(&g);
|
||||
c.push_layer(None, None, None, None, Some(filter));
|
||||
c.set_paint(peniko::Color::BLACK);
|
||||
if *center {
|
||||
c.set_fill_rule(fill_rule(rule));
|
||||
c.fill_path(&g);
|
||||
} else {
|
||||
// Everything outside the shape, so the glow bleeds in from the edges.
|
||||
let pad = blur * 2.0 + 4.0 * fr.px;
|
||||
let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1);
|
||||
inv.extend(g.iter());
|
||||
c.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
c.fill_path(&inv);
|
||||
}
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
if comp.is_some() {
|
||||
c.pop_layer();
|
||||
}
|
||||
});
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw`
|
||||
/// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context
|
||||
/// the layer is drawn into an offscreen single-threaded context covering `reach` (document
|
||||
/// space) plus the blur's spread, then composited back with `composite`.
|
||||
fn with_filters(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
reach: Rect,
|
||||
blur: f64,
|
||||
composite: Option<(drawcraft_doc::color::BlendMode, f32)>,
|
||||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(drawcraft_doc::color::BlendMode, f32)>),
|
||||
) {
|
||||
if !f.mt {
|
||||
return draw(self, ctx, f, composite);
|
||||
}
|
||||
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
||||
let spread = blur.max(0.0) * 1.5 / 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 = crate::single_threaded_context(w, h);
|
||||
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
draw(self, &mut off, &shifted, None);
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let layered = composite.is_some_and(|(m, o)| m != drawcraft_doc::color::BlendMode::Normal || o < 1.0);
|
||||
if let Some((m, o)) = composite.filter(|_| layered) {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
|
||||
}
|
||||
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));
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
|
|
@ -396,4 +477,51 @@ mod tests {
|
|||
let b = super::visual_bounds(n).unwrap();
|
||||
assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9);
|
||||
}
|
||||
|
||||
/// Multithreaded rendering filters each effect offscreen; it must match the single-threaded
|
||||
/// reference (blend modes against the real backdrop, objects partly off-screen, all effects).
|
||||
#[test]
|
||||
fn multithreaded_filters_match_single_threaded() {
|
||||
let cases: Vec<(Rect, Vec<(&str, serde_json::Value)>)> = vec![
|
||||
(Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.dropShadow", json!({"x": 6, "y": 4, "blur": 5, "opacity": 75}))]),
|
||||
(Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.outerGlow", json!({"color": "#ff00aa", "blur": 8, "opacity": 90}))]),
|
||||
(
|
||||
Rect::new(20.0, 20.0, 60.0, 60.0),
|
||||
vec![
|
||||
("stylize.innerGlow", json!({"blur": 6})),
|
||||
("stylize.innerGlow", json!({"blur": 4, "source": "center", "mode": "multiply", "color": "#0044ff"})),
|
||||
],
|
||||
),
|
||||
(Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.feather", json!({"radius": 6}))]),
|
||||
(Rect::new(20.0, 20.0, 60.0, 60.0), vec![("blur.gaussian", json!({"radius": 4})), ("stylize.dropShadow", json!({"blur": 3}))]),
|
||||
// Partly outside the frame: the blur still spreads in from off-screen content.
|
||||
(
|
||||
Rect::new(-30.0, 70.0, 30.0, 130.0),
|
||||
vec![("stylize.outerGlow", json!({"blur": 12, "mode": "normal"})), ("blur.gaussian", json!({"radius": 3}))],
|
||||
),
|
||||
];
|
||||
for (r, fx) in cases {
|
||||
let name = format!("{fx:?}");
|
||||
let mut d = doc_with(r, Color::rgb(0.2, 0.6, 0.3), fx);
|
||||
// A coloured backdrop so blend modes have something to blend with.
|
||||
let l = d.layers[0].id;
|
||||
let bid = d.alloc_id();
|
||||
let bg = Node::path(
|
||||
bid,
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.5, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
d.insert(Some(l), 0, bg).unwrap();
|
||||
let opts = RenderOptions::default();
|
||||
let mut st = Renderer::new();
|
||||
st.threads = 0;
|
||||
let mut mt = Renderer::new();
|
||||
mt.threads = 3;
|
||||
let a = st.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
let b = mt.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap();
|
||||
// The offscreen pass adds one 8-bit premultiplied round trip: a few levels of rounding.
|
||||
assert!(worst <= 4, "{name}: max channel difference {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ struct GeomEntry {
|
|||
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
|
||||
pub struct Renderer {
|
||||
texts: HashMap<usize, (Arc<Node>, Arc<TextGeom>)>,
|
||||
/// Single-threaded context used when the document has raster filters (vello's filters require it).
|
||||
/// Context reused when rendering single-threaded (`threads == 0`).
|
||||
ctx_st: Option<RenderContext>,
|
||||
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
|
||||
pub threads: u16,
|
||||
|
|
@ -163,7 +163,10 @@ impl Default for Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Frame<'a> {
|
||||
/// Drawing into a multithreaded context: vello filter layers must be rendered offscreen.
|
||||
mt: bool,
|
||||
doc: &'a Document,
|
||||
view: Affine,
|
||||
/// Visible region in document coordinates (for culling).
|
||||
|
|
@ -198,14 +201,9 @@ impl Renderer {
|
|||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
|
||||
let mut has_filters = false;
|
||||
doc.walk(|n| {
|
||||
if !has_filters && node_has_raster_fx(n) {
|
||||
has_filters = true;
|
||||
}
|
||||
});
|
||||
let threads = if has_filters { 0 } else { self.threads };
|
||||
// Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the
|
||||
// main context is multithreaded (see `fx::with_filters`).
|
||||
let threads = self.threads;
|
||||
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
|
||||
let mut ctx = match slot {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
|
|
@ -218,7 +216,7 @@ impl Renderer {
|
|||
let inv = view.inverse();
|
||||
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let frame = Frame { doc, view, visible, px, opts };
|
||||
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts };
|
||||
|
||||
if let Some(bg) = opts.background {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
|
|
@ -283,7 +281,7 @@ impl Renderer {
|
|||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
|
||||
}
|
||||
let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
|
||||
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
|
||||
self.draw_node(&mut ctx, &frame, n, true);
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, w);
|
||||
|
|
@ -368,7 +366,7 @@ impl Renderer {
|
|||
/// Render an opacity mask's art offscreen and turn its luminance into a coverage mask.
|
||||
fn opacity_mask(&mut self, f: &Frame, m: &drawcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask {
|
||||
let mut mctx = single_threaded_context(w, h);
|
||||
self.draw_node(&mut mctx, f, &m.art, true);
|
||||
self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true);
|
||||
mctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
mctx.render(&mut pm, &mut self.resources);
|
||||
|
|
@ -729,9 +727,6 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Number of visible painted fill/stroke items (opacity folding is exact only for one).
|
||||
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
|
||||
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
|
||||
/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask
|
||||
/// art black (clip) or white (no clip), optionally inverted.
|
||||
fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
|
||||
|
|
@ -745,20 +740,13 @@ fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
|
|||
(l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
|
||||
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
|
||||
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
|
||||
RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Whether drawing `n` itself may push a raster filter (node-level or per fill/stroke effects).
|
||||
fn node_has_raster_fx(n: &Node) -> bool {
|
||||
let raster = |e: &[_]| !e.is_empty() && !drawcraft_effects::raster_effects(e).is_empty();
|
||||
raster(&n.appearance.effects)
|
||||
|| n.appearance.items.iter().any(|i| match i {
|
||||
AppearanceItem::Fill(f) => raster(&f.effects),
|
||||
AppearanceItem::Stroke(s) => raster(&s.effects),
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of visible painted fill/stroke items (opacity folding is exact only for one).
|
||||
fn painted_items(n: &Node) -> usize {
|
||||
n.appearance
|
||||
.items
|
||||
|
|
|
|||
|
|
@ -69,8 +69,14 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
|
|||
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
for o in def.offsets_covering(region) {
|
||||
let v = view * def.instance_xf(o);
|
||||
let frame =
|
||||
Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts };
|
||||
let frame = Frame {
|
||||
mt: false,
|
||||
doc,
|
||||
view: v,
|
||||
visible: v.inverse().transform_rect_bbox(px_rect),
|
||||
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
|
||||
opts: &opts,
|
||||
};
|
||||
for a in &def.art {
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
}
|
||||
|
|
@ -182,7 +188,7 @@ impl Renderer {
|
|||
for o in def.preview_offsets() {
|
||||
let view = f.view * Affine::translate(o);
|
||||
let visible = f.visible - o;
|
||||
let frame = Frame { doc: f.doc, view, visible, px: f.px, opts: f.opts };
|
||||
let frame = Frame { view, visible, ..*f };
|
||||
for c in &children {
|
||||
self.draw_arc(ctx, &frame, c);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue