Files
cadcraft/crates/render/src/raster.rs
T

356 lines
15 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.
//! CPU rasteriser: draws a display list with tiny-skia (PNG export, plot preview, tests).
use cadcraft_color::Rgb;
use cadcraft_geom::{Bounds2, Vec2};
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
use crate::{DImage, DisplayList, Kind};
/// A 2D view: world point at the image centre and pixels per drawing unit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
pub center: Vec2,
pub scale: f64,
pub width: u32,
pub height: u32,
}
impl View {
pub fn to_screen(&self, p: Vec2) -> (f32, f32) {
let x = (p.x - self.center.x) * self.scale + f64::from(self.width) / 2.0;
let y = f64::from(self.height) / 2.0 - (p.y - self.center.y) * self.scale;
(x as f32, y as f32)
}
/// Fit `b` with a margin fraction.
pub fn fit(b: &Bounds2, width: u32, height: u32, margin: f64) -> View {
if b.is_empty() {
return View { center: Vec2::ZERO, scale: 1.0, width, height };
}
let w = b.width().max(1e-9);
let h = b.height().max(1e-9);
let s = (f64::from(width) / w).min(f64::from(height) / h) * (1.0 - margin);
View { center: b.center(), scale: if s.is_finite() && s > 0.0 { s } else { 1.0 }, width, height }
}
/// The view of `window` (drawing units), filling the image: `width` × `height` pixels when both
/// are given; otherwise the side given (default 1600 wide) with the other matching the window's
/// shape, each side 16..=8192.
pub fn of_window(window: &Bounds2, width: Option<u32>, height: Option<u32>) -> View {
let aspect = if window.width() > 1e-12 && window.height() > 1e-12 { window.width() / window.height() } else { 1.0 };
let side = |v: f64| v.round().clamp(16.0, 8192.0) as u32;
let (w, h) = match (width, height) {
(Some(w), Some(h)) => (side(f64::from(w)), side(f64::from(h))),
(None, Some(h)) => (side(f64::from(h) * aspect), side(f64::from(h))),
(w, None) => {
let w = f64::from(w.unwrap_or(1600));
(side(w), side(w / aspect))
}
};
View::fit(window, w, h, 0.0)
}
pub fn world_bounds(&self) -> Bounds2 {
let hw = f64::from(self.width) / 2.0 / self.scale;
let hh = f64::from(self.height) / 2.0 / self.scale;
Bounds2::new(self.center - Vec2::new(hw, hh), self.center + Vec2::new(hw, hh))
}
}
#[derive(Clone, Copy, Debug)]
pub struct RasterOptions {
pub background: Rgb,
/// Line width in pixels for zero-lineweight lines.
pub hairline: f32,
/// Pixels per mm for lineweights (0 = ignore lineweights).
pub px_per_mm: f32,
pub antialias: bool,
}
impl Default for RasterOptions {
fn default() -> Self {
RasterOptions { background: Rgb(33, 40, 48), hairline: 1.0, px_per_mm: 0.0, antialias: true }
}
}
/// Render to a pixmap. Returns `None` for zero or absurd sizes.
pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixmap> {
if view.width == 0 || view.height == 0 || view.width > 16384 || view.height > 16384 {
return None;
}
let mut pm = Pixmap::new(view.width, view.height)?;
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
let vis = view.world_bounds();
let mut hairlines = Hairlines::default();
let mut screen: Vec<(f32, f32)> = Vec::new();
for p in &list.prims {
let c = p.display_rgb(o.background);
let mut paint = Paint::default();
// A transparent object blends with what is drawn under it.
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
paint.anti_alias = o.antialias;
let pts = list.points(p);
if p.kind != Kind::Polyline {
hairlines.flush(&mut pm, o.antialias);
}
match p.kind {
Kind::Polyline => {
let bb = Bounds2::from_points(pts.iter().copied());
if !bb.intersects(&vis) {
continue;
}
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
screen.clear();
screen.extend(pts.iter().map(|q| view.to_screen(*q)));
if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) {
hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias);
continue;
}
hairlines.flush(&mut pm, o.antialias);
let mut pb = PathBuilder::new();
for (i, (x, y)) in screen.iter().enumerate() {
if i == 0 {
pb.move_to(*x, *y);
} else {
pb.line_to(*x, *y);
}
}
if let Some(path) = pb.finish() {
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
Kind::Tris | Kind::Mask => {
if p.kind == Kind::Mask {
paint.set_color_rgba8(o.background.0, o.background.1, o.background.2, 255);
}
let mut pb = PathBuilder::new();
for t in pts.chunks(3) {
if let [a, b, c] = t {
let (ax, ay) = view.to_screen(*a);
let (bx, by) = view.to_screen(*b);
let (cx, cy) = view.to_screen(*c);
pb.move_to(ax, ay);
pb.line_to(bx, by);
pb.line_to(cx, cy);
pb.close();
}
}
if let Some(path) = pb.finish() {
pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
}
}
Kind::Point => {
if let Some(q) = pts.first() {
let (x, y) = view.to_screen(*q);
// Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug
// assertion on rects under two pixels wide whose sides both fall mid-pixel.
if let Some(r) = tiny_skia::Rect::from_xywh(x - 0.5, y - 0.5, 1.5, 1.5) {
pm.fill_path(&PathBuilder::from_rect(r), &paint, FillRule::Winding, Transform::identity(), None);
}
}
}
Kind::Image { index } => {
if let Some(img) = list.images.get(index as usize)
&& Bounds2::from_points(pts.iter().copied()).intersects(&vis)
{
image(&mut pm, img, view, p.alpha(), o.antialias);
}
}
Kind::Infinite { ray } => {
let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) else { continue };
let far = (vis.width() + vis.height()) * 2.0 + base.dist(vis.center());
let a = if ray { *base } else { *base - *dir * far };
let b = *base + *dir * far;
let mut pb = PathBuilder::new();
let (ax, ay) = view.to_screen(a);
let (bx, by) = view.to_screen(b);
pb.move_to(ax, ay);
pb.line_to(bx, by);
if let Some(path) = pb.finish() {
pm.stroke_path(&path, &paint, &Stroke { width: o.hairline, ..Default::default() }, Transform::identity(), None);
}
}
}
}
hairlines.flush(&mut pm, o.antialias);
Some(pm)
}
/// Whether tiny-skia strokes a line `width` pixels wide (untransformed) as a hairline: zero
/// width, or at most one pixel when anti-aliased.
fn is_hairline(width: f32, antialias: bool) -> bool {
width == 0.0 || (antialias && width <= 1.0)
}
/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a
/// path's integer bounds could overflow, so such a polyline is stroked on its own as before.
const MAX_BATCH_COORD: f32 = 1.0e8;
fn batchable(v: f32) -> bool {
v.is_finite() && v.abs() <= MAX_BATCH_COORD
}
/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path.
///
/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with
/// its own caps, a polyline's first and last segment extended for round caps), so one path of
/// several polylines paints exactly the pixels, in the same order, as stroking them one by one;
/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled
/// outlines, where overlaps would cover once instead of twice, and are never batched.
#[derive(Default)]
struct Hairlines {
key: Option<([u8; 4], f32)>,
pb: PathBuilder,
}
impl Hairlines {
fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) {
if self.key.is_some_and(|k| k != (rgba, width)) {
self.flush(pm, antialias);
}
self.key = Some((rgba, width));
for (i, (x, y)) in pts.iter().enumerate() {
if i == 0 {
self.pb.move_to(*x, *y);
} else {
self.pb.line_to(*x, *y);
}
}
}
fn flush(&mut self, pm: &mut Pixmap, antialias: bool) {
let Some(([r, g, b, a], width)) = self.key.take() else { return };
let pb = std::mem::take(&mut self.pb);
let Some(path) = pb.finish() else { return };
let mut paint = Paint::default();
paint.set_color_rgba8(r, g, b, a);
paint.anti_alias = antialias;
let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
self.pb = path.clear();
}
}
/// Blank (white) everything outside `printable`, a rectangle in paper units with the origin at the
/// sheet's bottom-left, on a pixmap showing the whole sheet at `px_per_unit` pixels per paper unit.
/// A placed plot is clipped to the printable area this way, in the PNG plot and in the preview.
pub fn blank_outside_printable(pm: &mut Pixmap, printable: &Bounds2, px_per_unit: f64) {
let ph = f64::from(pm.height());
blank_outside_pixels(
pm,
(printable.min.x * px_per_unit, ph - printable.max.y * px_per_unit),
(printable.max.x * px_per_unit, ph - printable.min.y * px_per_unit),
);
}
/// Blank (white) everything outside the pixel rectangle from `top_left` to `bottom_right` (image
/// coordinates, y down; rounded and clamped to the image). For a pixmap whose view does not map
/// the sheet onto the whole image, map the printable corners through `View::to_screen`.
pub fn blank_outside_pixels(pm: &mut Pixmap, top_left: (f64, f64), bottom_right: (f64, f64)) {
let (pw, ph) = (pm.width(), pm.height());
let col = |x: f64| x.round().clamp(0.0, f64::from(pw)) as usize;
let row = |y: f64| y.round().clamp(0.0, f64::from(ph)) as usize;
let (x0, x1) = (col(top_left.0), col(bottom_right.0));
let (y0, y1) = (row(top_left.1), row(bottom_right.1));
let stride = pw as usize * 4;
for (r, line) in pm.data_mut().chunks_exact_mut(stride.max(4)).enumerate() {
for (c, px) in line.as_chunks_mut::<4>().0.iter_mut().enumerate() {
if r < y0 || r >= y1 || c < x0 || c >= x1 {
px.copy_from_slice(&[255, 255, 255, 255]);
}
}
}
}
/// Draw `img` with the object opacity `alpha`, cut to its clip outlines.
fn image(pm: &mut Pixmap, img: &DImage, view: &View, alpha: u8, antialias: bool) {
let r = &img.raster;
let Some(to_screen) = grid_to_screen(img, view) else { return };
let Ok(mask) = clip_mask(pm, img, view, antialias) else { return };
// tiny-skia wants premultiplied pixels; opaque ones already are.
let owned;
let pixels = if r.opaque() {
tiny_skia::PixmapRef::from_bytes(r.rgba(), r.width(), r.height())
} else {
owned = premultiplied(r);
owned.as_ref().map(Pixmap::as_ref)
};
let Some(pixels) = pixels else { return };
let paint = Paint {
shader: tiny_skia::Pattern::new(
pixels,
tiny_skia::SpreadMode::Pad,
tiny_skia::FilterQuality::Bilinear,
img.opacity(alpha),
Transform::identity(),
),
anti_alias: antialias,
..Paint::default()
};
// The picture's own rectangle in its pixel grid, carried to the screen by `to_screen`.
// Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug assertion on narrow
// rects (see the points above), which a one-pixel image drawn at its own size would be.
if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, r.width() as f32, r.height() as f32) {
pm.fill_path(&PathBuilder::from_rect(rect), &paint, FillRule::Winding, to_screen, mask.as_ref());
}
}
/// The transform from `img`'s pixel grid (x right, y down) to screen pixels; `None` when the
/// image has no area on screen or lies out of number range.
fn grid_to_screen(img: &DImage, view: &View) -> Option<Transform> {
let s = view.scale;
let (x, y) = view.to_screen(img.origin);
// Screen y runs down, world y up.
let ts = Transform::from_row((img.right.x * s) as f32, (-img.right.y * s) as f32, (img.down.x * s) as f32, (-img.down.y * s) as f32, x, y);
(ts.is_finite() && ts.invert().is_some()).then_some(ts)
}
/// The image has a clip outline without area: nothing of it shows.
struct Hidden;
/// The part of the picture `img`'s clip outlines leave visible: `None` for all of it.
fn clip_mask(pm: &Pixmap, img: &DImage, view: &View, antialias: bool) -> Result<Option<tiny_skia::Mask>, Hidden> {
let mut mask: Option<tiny_skia::Mask> = None;
for outline in &img.clips {
let path = outline_path(outline, view).ok_or(Hidden)?;
match &mut mask {
// The first outline starts the mask; each further one narrows it.
None => {
let mut m = tiny_skia::Mask::new(pm.width(), pm.height()).ok_or(Hidden)?;
m.fill_path(&path, FillRule::EvenOdd, antialias, Transform::identity());
mask = Some(m);
}
Some(m) => m.intersect_path(&path, FillRule::EvenOdd, antialias, Transform::identity()),
}
}
Ok(mask)
}
/// A closed world-space outline as a screen path.
fn outline_path(outline: &[Vec2], view: &View) -> Option<tiny_skia::Path> {
let mut pb = PathBuilder::new();
for (i, q) in outline.iter().enumerate() {
let (x, y) = view.to_screen(*q);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.close();
pb.finish()
}
/// The pixels of `r` with each colour multiplied by its alpha.
fn premultiplied(r: &cadcraft_doc::Raster) -> Option<Pixmap> {
let mut data = Vec::with_capacity(r.rgba().len());
for [red, green, blue, a] in r.rgba().as_chunks::<4>().0 {
let m = |c: &u8| ((u16::from(*c) * u16::from(*a) + 127) / 255) as u8;
data.extend_from_slice(&[m(red), m(green), m(blue), *a]);
}
Pixmap::from_vec(data, tiny_skia::IntSize::from_wh(r.width(), r.height())?)
}
/// Render and encode as PNG.
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
render(list, view, o)?.encode_png().ok()
}