From 7336a3f597e52008a46330c8f4bbc42942c10fd8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:35 +0200 Subject: [PATCH] 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 --- crates/io/src/pdf.rs | 4 +- crates/io/src/svg.rs | 4 +- crates/render/src/lib.rs | 38 +++++++++--- crates/render/src/raster.rs | 5 +- crates/render/tests/wipeout_mask.rs | 76 +++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/gpu.rs | 95 ++++++++++++++++++++++++----- 7 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 crates/render/tests/wipeout_mask.rs diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 8d3b281ba..1705c37cd 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -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); diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04f6a390c..d3640cdac 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -48,7 +48,9 @@ pub fn export_window(d: &Drawing, space: &Space, window: Option) -> 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#" &[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(); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index b6ccccb93..8b45dbdef 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -89,7 +89,10 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { + 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 { diff --git a/crates/render/tests/wipeout_mask.rs b/crates/render/tests/wipeout_mask.rs new file mode 100644 index 000000000..508b052b6 --- /dev/null +++ b/crates/render/tests/wipeout_mask.rs @@ -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"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9a041670e..4358e9a37 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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)); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index a6b6bac7c..b91f46e0b 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -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, pub tris: Vec, pub quads: Vec, + /// 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, tris: Vec, quads: Vec, + 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::(); + 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, range: std::ops::Range) -> Vec<(usize, std::ops::Range)> { + 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()); + } }