From 1e1f68bf409686bf9ab7adb74d32db32a2a922c4 Mon Sep 17 00:00:00 2001 From: aldozar Date: Sun, 11 Oct 2026 18:10:51 -0600 Subject: [PATCH] Fix fatal wgpu buffer overflow; draw MOVE/COPY/ROTATE/SCALE previews and the selection highlight on the GPU (#642) Squash of PR #642 by @aldozar. Co-Authored-By: Claude Opus 5.5 --- apps/cadcraft/src/main.rs | 11 ++ crates/engine/src/cmd/array.rs | 3 + crates/engine/src/cmd/modify.rs | 21 ++++ crates/engine/src/guard.rs | 9 ++ crates/engine/src/lib.rs | 12 ++- crates/engine/src/prompt.rs | 17 +++- crates/engine/src/tests.rs | 25 +++++ crates/ui-egui/src/canvas.rs | 137 ++++++++++++++++++++++++- crates/ui-egui/src/gpu.rs | 171 +++++++++++++++++++++++++------- 9 files changed, 363 insertions(+), 43 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 2354f17..af57744 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -252,6 +252,17 @@ fn main() -> eframe::Result { // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); + // Command previews (COPY, MOVE...) of large selections are tessellated by egui, whose index + // buffer outgrew wgpu's default 256 MiB cap; the validation error is fatal. Ask for what the + // adapter actually supports. + if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + let base = std::sync::Arc::clone(&create.device_descriptor); + create.device_descriptor = std::sync::Arc::new(move |adapter| { + let mut d = base(adapter); + d.required_limits.max_buffer_size = d.required_limits.max_buffer_size.max(adapter.limits().max_buffer_size); + d + }); + } // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file // manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1 // keeps the native Wayland backend. diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 1bcd62e..29c6de6 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -447,6 +447,9 @@ impl Interactive for ArrayM { fn preview(&self, s: &Session, cursor: Vec2) -> Vec { self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default() } + fn drag(&self, s: &Session, cursor: Vec2) -> Option { + self.inner.as_ref().and_then(|m| m.drag(s, cursor)) + } } // ---------------- ARRAYRECT ---------------- diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 1997813..122365e 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1927,6 +1927,21 @@ impl Interactive for SelectThen { out.push(line(base, c)); out } + fn drag(&self, _s: &Session, c: Vec2) -> Option { + // Mirror stays on `preview`: MIRRTEXT keeps text readable, which a plain matrix can't. + if (self.op == Op::Rotate && self.reference) || !self.sel.done { + return None; + } + let base = self.pts.first().copied()?; + let matrix = match self.op { + Op::Move | Op::Copy => Mat3::translate(c - base), + Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), + Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?), + _ => return None, + }; + Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra: vec![line(base, c)] }) + } } /// An OFFSET option prompt opened from "Specify offset distance". @@ -2730,6 +2745,12 @@ impl Interactive for DisplaceM { } self.inner.preview(s, c) } + fn drag(&self, s: &Session, c: Vec2) -> Option { + if self.displacement { + return None; + } + self.inner.drag(s, c) + } } pub(crate) fn _unused(_: Circle) {} diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 3e1f9e7..18c7c57 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -75,6 +75,15 @@ impl Interactive for Guarded { Vec::new() }) } + fn drag(&self, s: &Session, cursor: Vec2) -> Option { + if self.broken.get().is_some() { + return None; + } + catch_unwind(AssertUnwindSafe(|| self.inner.drag(s, cursor))).unwrap_or_else(|_| { + self.broken.set(Some("panic in preview")); + None + }) + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3e5951..395a027 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,7 +38,7 @@ pub use cadcraft_doc as doc; pub use cadcraft_geom as geom; pub use cadcraft_render as render; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; -pub use prompt::{Accept, Input, Interactive, Prompt, Step}; +pub use prompt::{Accept, Drag, Input, Interactive, Prompt, Step}; #[derive(Debug, thiserror::Error)] pub enum EngineError { @@ -1183,6 +1183,16 @@ impl Session { r.machine.preview(self, cursor).into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect() } + /// The active command's preview as a transform of existing objects (see + /// [`Interactive::drag`]), with its extra rubber-band geometry as entities. + pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec)> { + let r = self.running.as_ref()?; + let d = r.machine.drag(self, cursor)?; + let common = self.current_common(); + let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect(); + Some((d, extra)) + } + /// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a /// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the /// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored. diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 50369d7..a150fbb 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -1,7 +1,7 @@ //! Interactive commands: prompts, inputs and the command-line coordinate grammar. use cadcraft_doc::{EntityKind, Handle}; -use cadcraft_geom::Vec2; +use cadcraft_geom::{Mat3, Vec2}; use serde::Serialize; use crate::{Result, Session}; @@ -165,6 +165,21 @@ pub trait Interactive: Send { fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec { Vec::new() } + /// The preview as existing objects under one transform, when it is that (MOVE, COPY, + /// ROTATE, SCALE). The UI then draws them from geometry uploaded once instead of rebuilding + /// the transformed copies every frame; `None` falls back to [`Interactive::preview`]. + fn drag(&self, _s: &Session, _cursor: Vec2) -> Option { + None + } +} + +/// See [`Interactive::drag`]. +#[derive(Clone, Debug)] +pub struct Drag { + pub handles: Vec, + pub matrix: Mat3, + /// Rubber-band geometry drawn as well (the base-to-cursor line). + pub extra: Vec, } /// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist Session { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap(); + for t in [cmd, "all", "", "0,0"] { + s.cmdline(t).unwrap(); + } + s + } + let s = start("copy"); + let (d, extra) = s.drag(Vec2::new(3.0, 4.0)).expect("COPY drags its selection"); + assert_eq!(d.handles.len(), 1); + assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(4.0, 4.0), 1e-9)); + assert_eq!(extra.len(), 1, "the base-to-cursor line"); + + let (d, _) = start("rotate").drag(Vec2::new(0.0, 5.0)).expect("ROTATE drags its selection"); + assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(0.0, 1.0), 1e-9)); + + // MIRROR keeps the per-entity preview (MIRRTEXT). + let s = start("mirror"); + assert!(s.drag(Vec2::new(0.0, 5.0)).is_none()); + assert!(!s.preview(Vec2::new(0.0, 5.0)).is_empty()); +} + #[test] fn scale_reference_typed_and_picked() { // Issue #17: SCALE ▸ Reference scales by new length / reference length. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a13435b..e0da7c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -51,6 +51,17 @@ pub struct CanvasState { mesh_key: Option, mesh_slot: crate::gpu::MeshSlot, mesh_origin: Vec2, + /// The dragged objects of a MOVE/COPY/ROTATE/SCALE preview, uploaded once per drag (see + /// [`draw_drag_gpu`]). + drag_key: Option, + drag_slot: crate::gpu::MeshSlot, + drag_origin: Vec2, + /// The selection highlight on the GPU (see [`draw_highlight_gpu`]). + hl_key: Option, + hl_slot: crate::gpu::MeshSlot, + hl_origin: Vec2, + /// Selected infinite lines (clipped and drawn on the CPU each frame). + hl_infinite: Vec, /// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode). infinite: Vec, /// Milliseconds spent converting the display list to vertex data (last rebuild). @@ -351,6 +362,104 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: p.extend(shapes); } +/// Draw the selection highlight (dashed, `width` points) through the GPU: its vertex data is +/// rebuilt only when the display list, the selection or the vertex origin changes. Dashing every +/// selected line and tessellating it as egui shapes each frame made large selections sluggish. +#[allow(clippy::too_many_arguments)] +fn draw_highlight_gpu( + p: &egui::Painter, + xf: &Xf, + list: &DisplayList, + list_gen: u64, + prim_index: &mut PrimIndex, + sel: &[Handle], + color: Color32, + width: f32, + hl: (&mut Option, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec), +) { + let (hl_key, hl_slot, hl_origin, hl_infinite) = hl; + let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (list_gen, sel, origin.x.to_bits(), origin.y.to_bits(), color.to_array(), width.to_bits()).hash(&mut h); + h.finish() + }; + if *hl_key != Some(key) { + let prims = prim_index.prims(list_gen, list, sel); + let mesh = crate::gpu::build_highlight_mesh(list, &prims, origin, color.to_array(), width); + *hl_infinite = prims.into_iter().filter(|&i| list.prims.get(i).is_some_and(|p| matches!(p.kind, Kind::Infinite { .. }))).collect(); + if let Ok(mut slot) = hl_slot.lock() { + *slot = Some(mesh); + } + *hl_key = Some(key); + *hl_origin = origin; + } + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: hl_slot.clone(), + offset: [(hl_origin.x - xf.center.x) as f32, (hl_origin.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_HIGHLIGHT, + xform: crate::gpu::IDENTITY_XFORM, + dash: 10.0, + }, + )); +} + +/// Draw a MOVE/COPY/ROTATE/SCALE preview the way the drawing itself is drawn: the dragged +/// objects are tessellated and uploaded once (again only when the drawing, the selection or the +/// zoom band changes), and each frame only the transform in the view uniform moves. Rebuilding +/// the transformed copies as egui shapes every frame was slow with heavy selections and could +/// overflow the GPU buffer limit. +fn draw_drag_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, doc: &cadcraft_doc::Drawing, drag: &cadcraft_engine::Drag, bg: Rgb) { + // Tessellate for a power-of-two zoom band so zooming mid-drag rebuilds only every 2x. + let band = xf.scale.max(1e-300).log2().round() as i32; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (c.key, band, bg.0, bg.1, bg.2, &drag.handles).hash(&mut h); + h.finish() + }; + if c.drag_key != Some(key) { + let band_scale = 2f64.powi(band); + let list = cadcraft_render::build_entities( + doc, + drag.handles.iter().filter_map(|h| doc.entity(*h)).map(|e| e.as_ref()), + &cadcraft_render::Options { tolerance: 0.5 / band_scale, min_dash: 2.0 / band_scale, ..Default::default() }, + ); + let origin = if list.bounds.is_empty() { Vec2::ZERO } else { list.bounds.center() }; + let mesh = crate::gpu::build_mesh(&list, origin, bg, false); + if let Ok(mut slot) = c.drag_slot.lock() { + *slot = Some(mesh); + } + c.drag_key = Some(key); + c.drag_origin = origin; + } + // Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o). + let m = drag.matrix; + let o = c.drag_origin; + let t = m.apply(o) - o; + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: c.drag_slot.clone(), + offset: [(o.x - xf.center.x) as f32, (o.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_DRAG, + xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32], + dash: 0.0, + }, + )); +} + /// Draw the display list through the GPU paint callback (uploading it when it changed), plus /// the infinite lines, which are clipped to the view on the CPU every frame. fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) { @@ -382,6 +491,9 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32], scale: xf.scale as f32, center: [center.x, center.y], + layer: crate::gpu::LAYER_DRAWING, + xform: crate::gpu::IDENTITY_XFORM, + dash: 0.0, }, )); let mut shapes = Vec::new(); @@ -948,13 +1060,18 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { draw_highlight(&clipped, &xf, &list, &prims_of(&list, &sel), t.selection, 1.5, true); } } - } else if let CanvasState { list: Some(list), list_gen, prim_index, hover, .. } = &mut app.canvas { + } else if let CanvasState { list: Some(list), list_gen, prim_index, hover, gpu, hl_key, hl_slot, hl_origin, hl_infinite, .. } = &mut app.canvas { if let Some(h) = *hover && !sel.contains(&h) { draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, 2.0, false); } - draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true); + if gpu.is_some() && !sel.is_empty() { + draw_highlight_gpu(&painter, &xf, list, *list_gen, prim_index, &sel, t.selection, 1.5, (hl_key, hl_slot, hl_origin, &mut *hl_infinite)); + draw_highlight(&painter, &xf, list, hl_infinite, t.selection, 1.5, true); + } else { + draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true); + } } app.canvas.draw_ms = crate::now_ms() - t0; compare::paint(app, ui.ctx(), &painter, &xf_paper, rect); @@ -1007,7 +1124,21 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } // Rubber band preview of the active command. - if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) { + let drag = match (app.canvas.cursor, app.canvas.gpu.is_some() && sheet.is_none()) { + (Some(c), true) => app.session.drag(c), + _ => None, + }; + if let Some((drag, extra)) = drag { + if let Ok(st) = app.session.state() { + draw_drag_gpu(&mut app.canvas, &painter, &xf, &st.doc, &drag, bg); + let list = cadcraft_render::build_entities( + &st.doc, + extra.iter(), + &cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() }, + ); + draw_list(&painter, &xf, &list, bg, false); + } + } else if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) { let ents = app.session.preview(c); if !ents.is_empty() && let Ok(st) = app.session.state() diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index b91f46e..d12ddd2 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -25,8 +25,9 @@ use egui_wgpu::wgpu::util::DeviceExt; /// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8). const VERTEX_SIZE: usize = 12; -/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32). -const INSTANCE_SIZE: usize = 24; +/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32) + +/// length along its polyline before `a`, in world units (f32; dashing). +const INSTANCE_SIZE: usize = 28; /// Vertices (or instances) per GPU buffer; divisible by 2 and 3 so no primitive straddles two. const CHUNK: usize = 4_194_300; /// Point markers are 1.5 × 1.5 points (as on the CPU path). @@ -127,12 +128,13 @@ fn push_vertex(out: &mut Vec, p: [f32; 2], c: [u8; 4]) { out.extend_from_slice(&c); } -fn push_instance(out: &mut Vec, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32) { +fn push_instance(out: &mut Vec, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32, start: f32) { for v in [a[0], a[1], b[0], b[1]] { out.extend_from_slice(&v.to_le_bytes()); } out.extend_from_slice(&c); out.extend_from_slice(&width.to_le_bytes()); + out.extend_from_slice(&start.to_le_bytes()); } /// Lineweight in screen points for a primitive (as on the CPU path), or `None` for a hairline. @@ -161,7 +163,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> Some(w) => { for s in pts.windows(2) { if let [a, b] = s { - push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w); + push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w, 0.0); } } } @@ -192,7 +194,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> Kind::Point => { if let Some(q) = pts.first() { let p = rel(*q, origin); - push_instance(&mut m.quads, p, p, col, POINT_SIZE); + push_instance(&mut m.quads, p, p, col, POINT_SIZE, 0.0); } } // Drawn on the CPU each frame (clipped to the view). @@ -202,6 +204,48 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> m } +/// Vertex data drawing primitives `prims` of `list` as `width`-point lines in `color` (the +/// selection highlight; dashed by the callback's `dash`). Fills are outlined by their bounding +/// box, as on the CPU path; infinite lines are left to the CPU (they are clipped per frame). +pub fn build_highlight_mesh(list: &DisplayList, prims: &[usize], origin: Vec2, color: [u8; 4], width: f32) -> MeshData { + let mut m = MeshData::default(); + for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { + let pts = list.points(prim); + match prim.kind { + Kind::Polyline => { + let mut run = 0.0; + for s in pts.windows(2) { + if let [a, b] = s { + push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, width, run as f32); + run += a.dist(*b); + } + } + } + Kind::Point => { + if let Some(q) = pts.first() { + let p = rel(*q, origin); + push_instance(&mut m.quads, p, p, color, 3.0, 0.0); + } + } + Kind::Tris | Kind::Mask => { + let bb = Bounds2::from_points(pts.iter().copied()); + if !bb.is_empty() { + let c = [bb.min, Vec2::new(bb.max.x, bb.min.y), bb.max, Vec2::new(bb.min.x, bb.max.y), bb.min]; + let mut run = 0.0; + for s in c.windows(2) { + if let [a, b] = s { + push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, 1.0, run as f32); + run += a.dist(*b); + } + } + } + } + Kind::Infinite { .. } => {} + } + } + m +} + /// The vertex origin for a display list seen at `view_center` / `view_height`: the list's centre /// normally; at deep zoom (the view much smaller than the drawing) a point near the view, snapped /// to a coarse grid so panning re-uploads only every few dozen screens. @@ -232,20 +276,32 @@ pub struct CanvasCallback { pub scale: f32, /// Canvas rect centre in points. pub center: [f32; 2], + /// Which buffers to draw: [`LAYER_DRAWING`] or [`LAYER_DRAG`]. + pub layer: usize, + /// Affine transform applied to the origin-relative positions before `offset`: the linear + /// part (a, b, c, d as in `Mat3`) and the translation. [`IDENTITY_XFORM`] for the drawing. + pub xform: [f32; 6], + /// Dash period in points for wide lines (0 = solid): the selection highlight. + pub dash: f32, } +/// The drawing's display list. +pub const LAYER_DRAWING: usize = 0; +/// The objects a MOVE/COPY/ROTATE/SCALE preview drags, uploaded once per drag. +pub const LAYER_DRAG: usize = 1; +/// The selection highlight, rebuilt only when the selection or the drawing changes. +pub const LAYER_HIGHLIGHT: usize = 2; +pub const IDENTITY_XFORM: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + struct Batch { buffer: wgpu::Buffer, count: u32, } -struct Resources { - line_pipeline: wgpu::RenderPipeline, - tri_pipeline: wgpu::RenderPipeline, - quad_pipeline: wgpu::RenderPipeline, +/// One uploaded mesh with its own view uniform (each callback's transform differs). +struct Layer { uniform: wgpu::Buffer, bind_group: wgpu::BindGroup, - linear_out: bool, key: Option, lines: Vec, tris: Vec, @@ -253,23 +309,36 @@ struct Resources { breaks: Vec<[usize; 3]>, } -const UNIFORM_SIZE: u64 = 48; +struct Resources { + line_pipeline: wgpu::RenderPipeline, + tri_pipeline: wgpu::RenderPipeline, + quad_pipeline: wgpu::RenderPipeline, + linear_out: bool, + layers: Vec, +} + +const UNIFORM_SIZE: u64 = 80; const SHADER: &str = r#" struct View { a: vec4, // offset x, offset y (world), scale (device px per unit), 1 = linear output b: vec4, // canvas centre x, y (device px), screen w, h (device px) - c: vec4, // pixels per point + c: vec4, // pixels per point, dash period in points (0 = solid) + d: vec4, // transform linear part: a, b, c, d (x' = a x + c y, y' = b x + d y) + e: vec4, // transform translation x, y (world, origin-relative) }; @group(0) @binding(0) var view: View; struct VOut { @builtin(position) pos: vec4, @location(0) color: vec4, + // Device pixels along a wide line from its start (dashing). + @location(1) along: f32, }; fn to_px(p: vec2) -> vec2 { - let w = (p + view.a.xy) * view.a.z; + let q = vec2(view.d.x * p.x + view.d.z * p.y, view.d.y * p.x + view.d.w * p.y) + view.e.xy; + let w = (q + view.a.xy) * view.a.z; return vec2(view.b.x + w.x, view.b.y - w.y); } @@ -291,6 +360,7 @@ fn vs_line(@location(0) pos: vec2, @location(1) color: vec4) -> VOut { var o: VOut; o.pos = px_to_clip(to_px(pos)); o.color = out_color(color); + o.along = 0.0; return o; } @@ -301,6 +371,7 @@ fn vs_quad( @location(1) b: vec2, @location(2) color: vec4, @location(3) width: f32, + @location(4) start: f32, ) -> VOut { let pa = to_px(a); let pb = to_px(b); @@ -319,11 +390,18 @@ fn vs_quad( var o: VOut; o.pos = px_to_clip(along + n * hw * s); o.color = out_color(color); + o.along = start * view.a.z + mix(-hw, len + hw, t); return o; } @fragment fn fs_main(v: VOut) -> @location(0) vec4 { + if (view.c.y > 0.0) { + // 6 on, 4 off (as the CPU selection highlight's dashes). + if (fract(v.along / (view.c.y * view.c.x)) > 0.6) { + discard; + } + } return v.color; } "#; @@ -334,7 +412,7 @@ fn vertex_layout() -> wgpu::VertexBufferLayout<'static> { } fn instance_layout() -> wgpu::VertexBufferLayout<'static> { - const ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32]; + const ATTRS: [wgpu::VertexAttribute; 5] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32, 4 => Float32]; wgpu::VertexBufferLayout { array_stride: INSTANCE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &ATTRS } } @@ -344,7 +422,7 @@ impl Resources { device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("cad_canvas"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); let entry = |binding: u32| wgpu::BindGroupLayoutEntry { binding, - visibility: wgpu::ShaderStages::VERTEX, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None, }; @@ -354,17 +432,20 @@ impl Resources { bind_group_layouts: &[Some(&bgl)], immediate_size: 0, }); - let uniform = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("cad_canvas_view"), - size: UNIFORM_SIZE, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("cad_canvas"), - layout: &bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], - }); + let layer = || { + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("cad_canvas_view"), + size: UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("cad_canvas"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], + }); + Layer { uniform, bind_group, key: None, lines: Vec::new(), tris: Vec::new(), quads: Vec::new(), breaks: Vec::new() } + }; let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), @@ -396,14 +477,8 @@ impl Resources { line_pipeline: pipeline("cad_lines", "vs_line", wgpu::PrimitiveTopology::LineList, vertex_layout()), tri_pipeline: pipeline("cad_fills", "vs_line", wgpu::PrimitiveTopology::TriangleList, vertex_layout()), quad_pipeline: pipeline("cad_quads", "vs_quad", wgpu::PrimitiveTopology::TriangleList, instance_layout()), - uniform, - bind_group, linear_out: target.format.is_srgb(), - key: None, - lines: Vec::new(), - tris: Vec::new(), - quads: Vec::new(), - breaks: Vec::new(), + layers: vec![layer(), layer(), layer()], } } } @@ -439,6 +514,8 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { resources: &mut egui_wgpu::CallbackResources, ) -> Vec { let Some(res) = resources.get_mut::() else { return Vec::new() }; + let linear_out = res.linear_out; + let Some(res) = res.layers.get_mut(self.layer) else { return Vec::new() }; if res.key != Some(self.key) { let pending = self.slot.lock().ok().and_then(|mut s| s.take()); if let Some(m) = pending { @@ -452,16 +529,20 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { } let ppp = screen.pixels_per_point; let [w, h] = screen.size_in_pixels; - let a = [self.offset[0], self.offset[1], self.scale * ppp, if res.linear_out { 1.0 } else { 0.0 }]; + let a = [self.offset[0], self.offset[1], self.scale * ppp, if linear_out { 1.0 } else { 0.0 }]; let b = [self.center[0] * ppp, self.center[1] * ppp, w.max(1) as f32, h.max(1) as f32]; - let c = [ppp, 0.0, 0.0, 0.0]; - let bytes: Vec = a.iter().chain(&b).chain(&c).flat_map(|v| v.to_le_bytes()).collect(); + let c = [ppp, self.dash, 0.0, 0.0]; + let [ma, mb, mc, md, tx, ty] = self.xform; + let d = [ma, mb, mc, md]; + let e = [tx, ty, 0.0, 0.0]; + let bytes: Vec = a.iter().chain(&b).chain(&c).chain(&d).chain(&e).flat_map(|v| v.to_le_bytes()).collect(); queue.write_buffer(&res.uniform, 0, &bytes); Vec::new() } fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) { - let Some(res) = resources.get::() else { return }; + let Some(shared) = resources.get::() else { return }; + let Some(res) = shared.layers.get(self.layer) else { return }; if res.key != Some(self.key) { return; } @@ -478,7 +559,7 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { 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() + [(&shared.tri_pipeline, &res.tris), (&shared.line_pipeline, &res.lines), (&shared.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() { @@ -564,6 +645,20 @@ mod tests { l } + #[test] + fn highlight_dashes_run_on_along_each_polyline() { + let l = list(); + // The 3-point polyline (two unit segments) and the point marker. + let m = build_highlight_mesh(&l, &[0, 2], Vec2::new(1e6, 1e6), [1, 2, 3, 255], 1.5); + assert_eq!(m.quad_instances(), 3); + let start = |i: usize| { + let o = i * INSTANCE_SIZE + 24; + f32::from_le_bytes([m.quads[o], m.quads[o + 1], m.quads[o + 2], m.quads[o + 3]]) + }; + assert_eq!([start(0), start(1), start(2)], [0.0, 1.0, 0.0]); + assert!(m.lines.is_empty() && m.tris.is_empty()); + } + #[test] fn mesh_counts_and_precision() { let l = list();