Files
designcraft/crates/render/src/stroke.rs
T
NetterSilverandClaude Opus 5.5 001053e0dd Fix dotted strokes dropping most of their dots
Fixes #1950

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 03:07:22 +01:00

212 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::color::{HIDDEN_LAYERS, PLATE, color_of, overprints, plate_pixmap, screen_color, set_fill_paint};
use super::pdf_raster::{is_pdf, shown_box};
use super::types::{DisplayQuality, Frame};
use super::{
Affine, BezPath, BlendMode, Compose, Item, Mix, Rect, RenderContext, Renderer, Shape, StrokeAlign, StrokeType, colorize, images, kurbo,
pdf_layers, peniko, vello_cpu,
};
/// `stroke` as round dots `weight` wide, one every `2 × weight`. Kurbo's stroker drops
/// zero-length dashes, so each dot is a dash a hair long, centred where the zero-length dash
/// would be (so the path's ends get dots too).
pub(crate) fn dotted(stroke: kurbo::Stroke, weight: f64) -> kurbo::Stroke {
let dot = weight * 1e-3;
stroke.with_dashes(dot / 2.0, [dot, weight * 2.0 - dot]).with_caps(kurbo::Cap::Round)
}
impl Renderer {
pub(crate) fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
self.draw_stroke_painted(ctx, f, it, bp, xf);
ctx.reset_paint_transform();
}
/// The stroke, in its colour or gradient (a gradient runs across the path's box, as a fill's).
fn draw_stroke_painted(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
let st = &it.stroke;
let (paint, bounds) = (st.as_fill(), bp.bounding_box());
if f.doc.resolve_color(&st.swatch, st.tint).is_none() && designcraft_color::swatch::resolve_gradient(&f.doc.swatches, &st.swatch).is_none() {
return;
}
let mut stroke = kurbo::Stroke::new(st.weight)
.with_caps(match st.cap {
designcraft_doc::Cap::Butt => kurbo::Cap::Butt,
designcraft_doc::Cap::Round => kurbo::Cap::Round,
designcraft_doc::Cap::Projecting => kurbo::Cap::Square,
})
.with_join(match st.join {
designcraft_doc::Join::Miter => kurbo::Join::Miter,
designcraft_doc::Join::Round => kurbo::Join::Round,
designcraft_doc::Join::Bevel => kurbo::Join::Bevel,
})
.with_miter_limit(st.miter_limit);
let kind = f.doc.stroke_kind(&st.kind);
let length = designcraft_doc::path_length_bound(bp);
match &kind {
StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {
stroke = stroke.with_dashes(0.0, pattern.iter().copied())
}
StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => stroke = dotted(stroke, st.weight),
_ => {}
}
ctx.set_transform(f.view * xf);
if !set_fill_paint(ctx, f.doc, &paint, bounds) {
return;
}
let closed = it.path.is_closed();
// Arrowheads: the path is shortened under them; they're drawn after it.
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
let bp = arrows.as_ref().map_or(bp, |a| &a.0);
// Gap colour under dashes and dots: the whole path, undashed.
let gap = if matches!(kind, StrokeType::Solid) { None } else { f.doc.resolve_color(&st.gap_swatch, st.gap_tint) };
if let Some(g) = gap
&& !matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside if closed)
{
let op = overprints(f, &st.gap_swatch, st.gap_tint, st.gap_overprint);
if op {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None);
ctx.set_transform(f.view * xf);
}
ctx.set_paint(color_of(&g, 1.0));
ctx.set_stroke(kurbo::Stroke {
dash_pattern: Default::default(),
start_cap: kurbo::Cap::Butt,
end_cap: kurbo::Cap::Butt,
..stroke.clone()
});
ctx.stroke_path(bp);
if op {
ctx.pop_layer();
}
set_fill_paint(ctx, f.doc, &paint, bounds);
}
// Stripes, wavy and hash strokes are fills (along the path's centre line).
if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) {
ctx.fill_path(&o);
for h in arrows.iter().flat_map(|a| &a.1) {
match h.outline {
Some(w) => {
ctx.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Miter));
ctx.stroke_path(&h.path);
}
None => ctx.fill_path(&h.path),
}
}
return;
}
match st.align {
StrokeAlign::Inside if closed => {
// Clip a double-width stroke to the path.
ctx.push_clip_layer(bp);
ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
ctx.stroke_path(bp);
ctx.pop_layer();
}
StrokeAlign::Outside if closed => {
// Subtract the filled path from a double-width stroke. Combining a stroked
// outline (already hollow) with the path using even-odd would paint its centre.
// Isolate the stroke so the subtraction leaves the item's fill/content and
// backdrop intact; the same nonzero fill rule also preserves compound holes.
ctx.push_layer(None, None, None, None, None);
ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
ctx.stroke_path(bp);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.fill_path(bp);
ctx.pop_layer();
ctx.pop_layer();
}
_ => {
ctx.set_stroke(stroke);
ctx.stroke_path(bp);
}
}
for h in arrows.iter().flat_map(|a| &a.1) {
match h.outline {
Some(w) => {
ctx.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Miter));
ctx.stroke_path(&h.path);
}
None => ctx.fill_path(&h.path),
}
}
}
/// Draw a placed graphic, inside its clipping path when it has one.
pub(crate) fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) {
let Some((region, even_odd)) = g.clip_region() else {
self.draw_graphic_unclipped(ctx, f, g, xf);
return;
};
ctx.set_transform(f.view * xf);
if even_odd {
ctx.set_fill_rule(peniko::Fill::EvenOdd);
}
ctx.push_clip_layer(&region);
ctx.set_fill_rule(peniko::Fill::NonZero);
self.draw_graphic_unclipped(ctx, f, g, xf);
ctx.pop_layer();
}
fn draw_graphic_unclipped(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) {
let Some(asset) = f.doc.assets.get(&g.asset) else { return };
if designcraft_doc::media_kind(&asset.mime).is_some() {
// Video / sound: a dark frame with a play mark (the poster, when set, is drawn by the item).
ctx.set_transform(f.view * xf * g.xf);
ctx.set_paint(screen_color([48, 48, 52], 255));
ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1));
let (cx, cy, r) = (g.size.0 / 2.0, g.size.1 / 2.0, g.size.0.min(g.size.1) * 0.18);
let mut tri = BezPath::new();
tri.move_to((cx - r * 0.6, cy - r));
tri.line_to((cx + r, cy));
tri.line_to((cx - r * 0.6, cy + r));
tri.close_path();
ctx.set_paint(screen_color([235, 235, 235], 255));
ctx.fill_path(&tri);
return;
}
if f.opts.quality == DisplayQuality::Fast {
ctx.set_transform(f.view * xf * g.xf);
ctx.set_paint(screen_color([178, 178, 178], 255));
ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1));
return;
}
// Pick a mip level close to the on-screen size (Typical: a 72 ppi proxy).
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen };
let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone());
let data = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) {
// An explicit choice (including all-on) that cannot be rewritten fails closed:
// stale persisted/relinked choices must not reveal hidden source content.
Some(hidden) => {
let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return };
variant
}
None => asset.data.clone(),
};
// A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space.
let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h);
let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return };
// A coloured image is already in the plate's inks (its colours come from `color_of`).
let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) {
(Some(coloured), _) => coloured,
(None, Some(plate)) => plate_pixmap(&pm, plate),
(None, None) => pm,
};
// Only where the page is: a box reaching past it (a media box larger than the crop box)
// shows nothing there.
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page);
ctx.set_transform(f.view * xf * g.xf);
let sx = page_w / pm.width().max(1) as f64;
let sy = page_h / pm.height().max(1) as f64;
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::translate((page.x0, page.y0)) * Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}