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>
This commit is contained in:
Valentin Klein
2026-10-10 16:11:35 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 91c2471e2b
commit 7336a3f597
7 changed files with 198 additions and 30 deletions
+3 -1
View File
@@ -282,8 +282,10 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun
}
pending = true;
}
Kind::Tris => {
Kind::Tris | Kind::Mask => {
flush(&mut c, &mut pending);
// A wipeout's mask is paper white.
let rgb = if p.kind == Kind::Mask { white } else { rgb };
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
+3 -1
View File
@@ -48,7 +48,9 @@ pub fn export_window(d: &Drawing, space: &Space, window: Option<Bounds2>) -> Str
}
s.push_str(r#""/>"#);
}
Kind::Tris => {
Kind::Tris | Kind::Mask => {
// A wipeout's mask hides what lies under it with the white background.
let c = if p.kind == Kind::Mask { white.hex() } else { c };
let _ = write!(s, r#"<path fill="{c}" d=""#);
for t in pts.chunks(3) {
if let [a, b2, cc] = t {
+29 -9
View File
@@ -37,6 +37,10 @@ pub enum Kind {
Polyline,
/// `tris[start..start+len]`, three vertices per triangle.
Tris,
/// A wipeout: triangles laid out like [`Kind::Tris`], filled with the background colour
/// (paper white when plotting, the canvas colour on screen) so they hide what was drawn
/// before them.
Mask,
/// A single point marker at `verts[start]`.
Point,
/// An infinite line or ray: `verts[start]` = base, `verts[start+1]` = unit direction.
@@ -103,7 +107,7 @@ impl DisplayList {
/// The polyline points of a primitive.
pub fn points(&self, p: &DPrim) -> &[Vec2] {
match p.kind {
Kind::Tris => self.tris.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]),
Kind::Tris | Kind::Mask => self.tris.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]),
_ => self.verts.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]),
}
}
@@ -361,9 +365,9 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) {
push_raw(dst, p, Kind::Polyline, &piece);
}
}
Kind::Tris => {
Kind::Tris | Kind::Mask => {
let t = clip::clip_triangles(pts, rect);
push_raw(dst, p, Kind::Tris, &t);
push_raw(dst, p, p.kind, &t);
}
Kind::Point => {
if let Some(q) = pts.first()
@@ -386,13 +390,13 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) {
fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) {
let min = match kind {
Kind::Polyline => 2,
Kind::Tris => 3,
Kind::Tris | Kind::Mask => 3,
_ => 1,
};
if pts.len() < min {
return;
}
let tris = kind == Kind::Tris;
let tris = matches!(kind, Kind::Tris | Kind::Mask);
let start = if tris { dst.tris.len() } else { dst.verts.len() } as u32;
for q in pts {
dst.bounds.add(*q);
@@ -586,11 +590,27 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
}
}
EntityKind::Wipeout(w) => {
let mut pts = w.boundary.clone();
if let Some(f) = pts.first().copied() {
pts.push(f);
// Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME:
// 0 = none, 1 = shown and plotted, 2 = shown but not plotted.
if b.opts.fill {
let n = b.list.prims.len();
b.tris(ctx, rgb, &fill::triangulate_evenodd(std::slice::from_ref(&w.boundary)));
if let Some(p) = b.list.prims.get_mut(n) {
p.kind = Kind::Mask;
}
}
let frame = match ctx.d.header.i64("WIPEOUTFRAME", 1) {
0 => false,
2 => !b.plotting,
_ => true,
};
if frame {
let mut pts = w.boundary.clone();
if let Some(f) = pts.first().copied() {
pts.push(f);
}
b.polyline(ctx, rgb, lw, &pts);
}
b.polyline(ctx, rgb, lw, &pts);
}
EntityKind::Image(i) => {
let o = i.insert.xy();
+4 -1
View File
@@ -89,7 +89,10 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
Kind::Tris => {
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 {
+76
View File
@@ -0,0 +1,76 @@
//! Wipeouts hide what is drawn before them and show their frame per WIPEOUTFRAME (issue #291).
use cadcraft_color::Rgb;
use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space, Wipeout};
use cadcraft_geom::{Vec2, Vec3};
use cadcraft_render::raster::{RasterOptions, View, render};
use cadcraft_render::{DisplayList, Kind, Options, build, build_plot};
fn line() -> EntityKind {
EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 10.0, 0.0) })
}
fn wipeout() -> EntityKind {
let b = [(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)];
EntityKind::Wipeout(Wipeout { boundary: b.iter().map(|&(x, y)| Vec2::new(x, y)).collect() })
}
/// A diagonal line and a square wipeout over its middle, in the given draw order.
fn drawing(line_on_top: bool) -> Drawing {
let mut d = Drawing::new_imperial();
let order = if line_on_top { [wipeout(), line()] } else { [line(), wipeout()] };
for k in order {
d.add(&Space::Model, Common::default(), k).unwrap();
}
d
}
/// Dark (drawn) pixels inside and outside the wipeout, on white paper (colour 7 prints black).
fn dark_pixels(list: &DisplayList) -> (usize, usize) {
let view = View { center: Vec2::new(5.0, 5.0), scale: 10.0, width: 120, height: 120 };
let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() };
let pm = render(list, &view, &o).unwrap();
let (mut inside, mut outside) = (0, 0);
for y in 0..120u32 {
for x in 0..120u32 {
let c = pm.pixel(x, y).unwrap();
if c.red() > 128 {
continue;
}
// Screen (60, 60) is world (5, 5); 10 px per unit. Inside = clear of the frame.
let (wx, wy) = (5.0 + (f64::from(x) + 0.5 - 60.0) / 10.0, 5.0 - (f64::from(y) + 0.5 - 60.0) / 10.0);
if (2.5..7.5).contains(&wx) && (2.5..7.5).contains(&wy) {
inside += 1;
} else {
outside += 1;
}
}
}
(inside, outside)
}
#[test]
fn wipeout_hides_what_is_drawn_before_it() {
let (inside, outside) = dark_pixels(&build(&drawing(false), &Space::Model, &Options::default()));
assert_eq!(inside, 0, "the line is hidden under the wipeout");
assert!(outside > 0, "the line shows outside it");
// Brought to front (drawn after the wipeout), the line shows on top.
let (inside, _) = dark_pixels(&build(&drawing(true), &Space::Model, &Options::default()));
assert!(inside > 0);
}
#[test]
fn wipeoutframe_controls_the_frame() {
// Polylines in the list: the line, plus the frame when it is drawn.
let polylines = |d: &Drawing, plot: bool| {
let l = if plot { build_plot(d, &Space::Model, &Options::default()) } else { build(d, &Space::Model, &Options::default()) };
assert_eq!(l.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 1);
l.prims.iter().filter(|p| p.kind == Kind::Polyline).count()
};
let mut d = drawing(false);
assert_eq!((polylines(&d, false), polylines(&d, true)), (2, 2), "default 1: shown and plotted");
d.header.set_i64("WIPEOUTFRAME", 0);
assert_eq!((polylines(&d, false), polylines(&d, true)), (1, 1), "0: no frame");
d.header.set_i64("WIPEOUTFRAME", 2);
assert_eq!((polylines(&d, false), polylines(&d, true)), (2, 1), "2: shown, not plotted");
}
+4 -2
View File
@@ -278,7 +278,9 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay:
shapes.push(Shape::line(screen, Stroke::new(w, col)));
}
}
Kind::Tris => {
Kind::Tris | Kind::Mask => {
// A wipeout's mask hides what was drawn before it with the background colour.
let col = if prim.kind == Kind::Mask { color32(bg) } else { col };
let mut mesh = egui::Mesh::default();
for tri in pts.chunks(3) {
if let [a, b, c] = tri {
@@ -388,7 +390,7 @@ fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize
shapes.push(Shape::circle_stroke(xf.to_screen(*q), 3.0, Stroke::new(1.0, color)));
}
}
Kind::Tris => {
Kind::Tris | Kind::Mask => {
// Outline triangles' bounding region lightly.
let bb = Bounds2::from_points(pts.iter().copied());
let r = Rect::from_two_pos(xf.to_screen(bb.min), xf.to_screen(bb.max));
+79 -16
View File
@@ -7,8 +7,10 @@
//! follows the view (see [`choose_origin`]).
//!
//! Batches: filled triangles (TriangleList), 1-px hairlines (LineList), and instanced screen-space
//! quads for wide lineweights and point markers. Fills are drawn first, then lines on top. The
//! shader only uses vertex buffers and one uniform buffer, so it runs on WebGPU and WebGL2.
//! quads for wide lineweights and point markers. Fills are drawn first, then lines on top. A
//! wipeout starts a new draw-order group (see [`MeshData::breaks`]): its background-coloured mask
//! is drawn after everything before it and under everything after it. The shader only uses
//! vertex buffers and one uniform buffer, so it runs on WebGPU and WebGL2.
//!
//! Infinite lines (xline/ray) and all overlays stay egui shapes (see `canvas.rs`), and the
//! canvas falls back to the CPU path when no wgpu render state was handed to the app.
@@ -98,6 +100,9 @@ pub struct MeshData {
pub lines: Vec<u8>,
pub tris: Vec<u8>,
pub quads: Vec<u8>,
/// Draw-order breaks, one before each wipeout mask: the fill vertices, line vertices and
/// quad instances drawn before it. Each group is drawn fills, lines, quads in turn.
pub breaks: Vec<[usize; 3]>,
}
impl MeshData {
@@ -145,6 +150,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
lines: Vec::with_capacity(list.segment_count().saturating_mul(2 * VERTEX_SIZE)),
tris: Vec::with_capacity(list.tris.len().saturating_mul(VERTEX_SIZE)),
quads: Vec::new(),
breaks: Vec::new(),
};
for prim in &list.prims {
let c = prim.display_rgb(bg);
@@ -175,6 +181,14 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
}
}
}
Kind::Mask => {
m.breaks.push([m.tri_vertices(), m.line_vertices(), m.quad_instances()]);
for t in pts.as_chunks::<3>().0 {
for q in t {
push_vertex(&mut m.tris, rel(*q, origin), [bg.0, bg.1, bg.2, 255]);
}
}
}
Kind::Point => {
if let Some(q) = pts.first() {
let p = rel(*q, origin);
@@ -236,6 +250,7 @@ struct Resources {
lines: Vec<Batch>,
tris: Vec<Batch>,
quads: Vec<Batch>,
breaks: Vec<[usize; 3]>,
}
const UNIFORM_SIZE: u64 = 48;
@@ -388,6 +403,7 @@ impl Resources {
lines: Vec::new(),
tris: Vec::new(),
quads: Vec::new(),
breaks: Vec::new(),
}
}
}
@@ -430,6 +446,7 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
res.lines = upload(device, "cad_lines", &m.lines, VERTEX_SIZE, usage);
res.tris = upload(device, "cad_fills", &m.tris, VERTEX_SIZE, usage);
res.quads = upload(device, "cad_quads", &m.quads, INSTANCE_SIZE, usage);
res.breaks = m.breaks;
res.key = Some(self.key);
}
}
@@ -455,24 +472,47 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
// Whole-screen device pixels; egui already set the scissor to the canvas clip rect.
pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0);
pass.set_bind_group(0, &res.bind_group, &[]);
pass.set_pipeline(&res.tri_pipeline);
for b in &res.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
pass.set_pipeline(&res.line_pipeline);
for b in &res.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
pass.set_pipeline(&res.quad_pipeline);
for b in &res.quads {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
// Draw-order groups split at each wipeout; without wipeouts there is one group.
let total = |bs: &[Batch]| bs.iter().map(|b| b.count as usize).sum::<usize>();
let all = [total(&res.tris), total(&res.lines), total(&res.quads)];
let mut from = [0usize; 3];
for to in res.breaks.iter().copied().chain(std::iter::once(all)) {
for (i, (pipeline, batches)) in
[(&res.tri_pipeline, &res.tris), (&res.line_pipeline, &res.lines), (&res.quad_pipeline, &res.quads)].into_iter().enumerate()
{
let parts = batch_ranges(batches.iter().map(|b| b.count), from[i]..to[i]);
if parts.is_empty() {
continue;
}
pass.set_pipeline(pipeline);
for (j, r) in parts {
let Some(b) = batches.get(j) else { continue };
pass.set_vertex_buffer(0, b.buffer.slice(..));
// Quads are instanced: six vertices per instance.
if i == 2 { pass.draw(0..6, r) } else { pass.draw(r, 0..1) }
}
}
from = to;
}
}
}
/// For batches holding `counts` vertices (or instances) each: the batch indices and the ranges
/// within them that cover `range` of the batches laid end to end.
fn batch_ranges(counts: impl IntoIterator<Item = u32>, range: std::ops::Range<usize>) -> Vec<(usize, std::ops::Range<u32>)> {
let mut out = Vec::new();
let mut off = 0usize;
for (j, n) in counts.into_iter().enumerate() {
let n = n as usize;
let (lo, hi) = (range.start.max(off), range.end.min(off + n));
if lo < hi {
out.push((j, (lo - off) as u32..(hi - off) as u32));
}
off += n;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -580,4 +620,27 @@ mod tests {
assert_eq!(choose_origin(&Bounds2::EMPTY, Vec2::new(1.0, 1.0), 1.0), Vec2::ZERO);
assert_eq!(choose_origin(&b, Vec2::new(f64::NAN, 0.0), 1.0), b.center());
}
#[test]
fn wipeout_masks_start_a_draw_order_group() {
let mut l = list();
if let Some(p) = l.prims.get_mut(1) {
p.kind = Kind::Mask;
}
let bg = Rgb(10, 20, 30);
let m = build_mesh(&l, Vec2::ZERO, bg, false);
// The polyline's two segments come before the mask; the point marker after it.
assert_eq!(m.breaks, vec![[0, 4, 0]]);
assert_eq!(m.tri_vertices(), 3);
assert_eq!(&m.tris[8..12], &[10, 20, 30, 255], "the mask takes the background colour");
assert_eq!(m.quad_instances(), 1);
}
#[test]
fn batch_ranges_cover_a_range_across_batches() {
assert_eq!(batch_ranges([4, 4, 4], 2..9), vec![(0, 2..4), (1, 0..4), (2, 0..1)]);
assert_eq!(batch_ranges([4, 4], 4..8), vec![(1, 0..4)]);
assert!(batch_ranges([4, 4], 3..3).is_empty());
assert!(batch_ranges([], 0..5).is_empty());
}
}