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()); + } }