Files
cadcraft/crates/render/src/raster.rs
T
Valentin KleinandClaude Opus 5.5 7336a3f597 Wipeouts mask what is drawn before them; WIPEOUTFRAME controls the frame (fixes #291) (#308)
A wipeout was drawn only as its outline, so objects behind it stayed
visible on the canvas and in PNG, SVG and PDF output. The display list
gets a new primitive kind, Kind::Mask: the wipeout polygon as triangles
that every backend fills with its background colour (paper white for
SVG/PDF and plots, the raster background for PNG, the canvas colour or
the white sheet on screen), in draw order, so it hides what came before
it (DRAWORDER decides). The frame follows WIPEOUTFRAME: 0 none, 1 shown
and plotted (default), 2 shown but not plotted.

The GPU canvas drew all fills before all lines; it now splits the mesh
into draw-order groups at each mask (MeshData::breaks) and draws each
group's fills, lines and quads in turn. Infinite lines stay on top as
before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:11:35 -04:00

145 lines
6.0 KiB
Rust

//! 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::{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 }
}
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();
for p in &list.prims {
let c = p.display_rgb(o.background);
let mut paint = Paint::default();
paint.set_color_rgba8(c.0, c.1, c.2, 255);
paint.anti_alias = o.antialias;
let pts = list.points(p);
match p.kind {
Kind::Polyline => {
let bb = Bounds2::from_points(pts.iter().copied());
if !bb.intersects(&vis) {
continue;
}
let mut pb = PathBuilder::new();
for (i, q) in pts.iter().enumerate() {
let (x, y) = view.to_screen(*q);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
if let Some(path) = pb.finish() {
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 };
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::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);
}
}
}
}
Some(pm)
}
/// 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()
}