diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index 830181a8..9c973aef 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "INSERT") .icon("decal") .params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"), - CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"), + CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).at("SKETCH", "INSERT").icon("canvas").params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"), CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate) .params("canvas: id or name; a, b: two points on the canvas plane [x,y]; distance: their true distance (mm): scales the image about a"), CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).params("canvas: id or name"), diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 99b43885..67da7f20 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -81,6 +81,23 @@ impl GpuScene { pub type SceneSlot = Arc>>; +/// A reference image (canvas or decal): a textured quad, depth-tested against the model. +#[derive(Clone)] +pub struct GpuImage { + pub id: u64, + /// Changes when the pixels change (the texture is re-uploaded). + pub version: u64, + pub size: [u32; 2], + /// Straight RGBA8, `size[0] * size[1] * 4` bytes. + pub rgba: Arc>, + /// Bottom-left, bottom-right, top-right, top-left of the image. + pub corners: [[f32; 3]; 4], + pub opacity: f32, +} + +/// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32). +const IMG_SIZE: usize = 24; + /// Per-frame parameters. pub struct ViewportCallback { pub key: u64, @@ -101,6 +118,8 @@ pub struct ViewportCallback { pub clip: [f32; 4], /// Colour of the inside of cut bodies (straight sRGBA, 0..1). pub cap: [f32; 4], + /// Canvases and decals. + pub images: Vec, } struct Batch { @@ -153,6 +172,13 @@ struct Resources { model: Batches, highlight: Batches, preview: Batches, + image: wgpu::RenderPipeline, + image_bgl: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + /// Textures by image id: (version, bind group). + textures: std::collections::HashMap, + /// This frame's quads: (image id, vertices). + quads: Vec<(u64, Batch)>, } const SHADER: &str = r#" @@ -278,6 +304,35 @@ fn fs_line(i: LOut) -> @location(0) vec4 { return out_color(i.c); } +struct IOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, + @location(1) a: f32, + @location(2) wp: vec3, +}; + +@group(1) @binding(0) var img_t: texture_2d; +@group(1) @binding(1) var img_s: sampler; + +@vertex +fn vs_img(@location(0) p: vec3, @location(1) uv: vec2, @location(2) a: f32) -> IOut { + var o: IOut; + o.pos = u.vp * vec4(p, 1.0); + // Pulled toward the eye like lines, so a decal on a face is not lost in it. + o.pos.z = o.pos.z - u.screen.z * o.pos.w; + o.uv = uv; + o.a = a; + o.wp = p; + return o; +} + +@fragment +fn fs_img(i: IOut) -> @location(0) vec4 { + if (clipped(i.wp)) { discard; } + let c = textureSample(img_t, img_s, i.uv); + return out_color(vec4(c.rgb, c.a * i.a)); +} + /// Lines the section never cuts (the grid, highlights drawn on top). @fragment fn fs_line_all(i: LOut) -> @location(0) vec4 { @@ -365,7 +420,70 @@ impl Resources { }) }; let alpha = Some(wgpu::BlendState::ALPHA_BLENDING); + let image_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_image"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_image"), + bind_group_layouts: &[Some(&bgl), Some(&image_bgl)], + immediate_size: 0, + }); + const IA: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32]; + let image = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_image"), + layout: Some(&image_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_img"), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: IMG_SIZE as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &IA, + })], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: depth(wgpu::CompareFunction::LessEqual, false), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_img"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("sc_image"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); Resources { + image, + image_bgl, + sampler, + textures: Default::default(), + quads: Vec::new(), tri: pipeline("sc_tris", "vs_tri", "fs_solid", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), @@ -419,6 +537,55 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.highlight.take(device, self.hl_key, &self.hl_slot); res.preview.take(device, self.pv_key, &self.pv_slot); queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); + // Images: upload new or changed textures, drop removed ones, rebuild the quads. + res.textures.retain(|id, _| self.images.iter().any(|i| i.id == *id)); + res.quads.clear(); + for img in &self.images { + let [w, h] = img.size; + if w == 0 || h == 0 || img.rgba.len() != (w as usize) * (h as usize) * 4 { + continue; + } + if res.textures.get(&img.id).is_none_or(|t| t.0 != img.version) { + let size = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }; + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("sc_image"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &img.rgba, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * w), rows_per_image: Some(h) }, + size, + ); + let view = tex.create_view(&Default::default()); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_image"), + layout: &res.image_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.sampler) }, + ], + }); + res.textures.insert(img.id, (img.version, bind)); + } + let c = img.corners; + let uv = [[0.0_f32, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]; + let mut bytes = Vec::with_capacity(6 * IMG_SIZE); + for k in [0, 1, 2, 0, 2, 3] { + for v in c[k].iter().chain(&uv[k]).chain(std::iter::once(&img.opacity)) { + bytes.extend_from_slice(&v.to_le_bytes()); + } + } + if let Some(b) = upload(device, "sc_image_quad", &bytes, IMG_SIZE) { + res.quads.push((img.id, b)); + } + } Vec::new() } @@ -452,6 +619,14 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { lines(pass, &res.line, &m.lines); lines(pass, &res.line, &pv.lines); lines(pass, &res.line, &h.lines); + for (id, q) in &res.quads { + if let Some((_, bind)) = res.textures.get(id) { + pass.set_pipeline(&res.image); + pass.set_bind_group(1, bind, &[]); + pass.set_vertex_buffer(0, q.buffer.slice(..)); + pass.draw(0..q.count, 0..1); + } + } tris(pass, &res.ghost, &pv.ghost); tris(pass, &res.trans, &m.trans); tris(pass, &res.trans, &h.trans); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index abe5cb97..21124fa3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -317,7 +317,7 @@ impl SolveApp { } return; } - if !sketch_tools::pick_image(self, id) { + if !sketch_tools::start_hook(self, id) { return; } if id == "ChangeParameterCommand" { diff --git a/crates/ui-egui/src/ref_images.rs b/crates/ui-egui/src/ref_images.rs index 495fbba4..60cad5ee 100644 --- a/crates/ui-egui/src/ref_images.rs +++ b/crates/ui-egui/src/ref_images.rs @@ -1,55 +1,112 @@ -//! Canvases and decals: the design's reference images, drawn on their planes under the sketch -//! overlays. +//! Canvases and decals: the design's reference images. With the GPU viewport they are textured +//! quads in the 3D pass (the model hides them); the CPU viewport draws them over its image. use std::cell::RefCell; use std::collections::HashMap; +use std::sync::Arc; use egui::{Color32, Mesh, Shape, TextureHandle, TextureId, pos2}; +use solvecraft_engine::doc::canvas::Canvas; use solvecraft_engine::geom::Vec2; use crate::SolveApp; +use crate::gpu::GpuImage; use crate::viewport::Proj; /// Largest texture side; bigger images are scaled down for display. const MAX_SIDE: u32 = 2048; -/// Grid cells per side, so perspective views bend the image only slightly. +/// Grid cells per side, so perspective views bend the image only slightly (CPU path). const CELLS: usize = 8; +/// Decoded pixels: (data length they came from, version, size, RGBA). +type Decoded = (usize, u64, [u32; 2], Arc>); + thread_local! { - /// Textures by canvas id, with the length of the data they were made from. - static TEX: RefCell)>> = RefCell::new(HashMap::new()); + static PIXELS: RefCell>> = RefCell::new(HashMap::new()); + /// egui textures for the CPU path, by canvas id, with the version they hold. + static TEX: RefCell> = RefCell::new(HashMap::new()); + static VERSION: std::cell::Cell = const { std::cell::Cell::new(0) }; } -fn texture(ctx: &egui::Context, id: u64, data: &str, bytes: impl FnOnce() -> Option>) -> Option { - TEX.with(|t| { - let mut t = t.borrow_mut(); - if let Some((len, h)) = t.get(&id) - && *len == data.len() +fn pixels(c: &Canvas) -> Option { + PIXELS.with(|m| { + let mut m = m.borrow_mut(); + if let Some(d) = m.get(&c.id) + && d.as_ref().is_none_or(|d| d.0 == c.data.len()) { - return h.as_ref().map(|h| h.id()); + return d.clone(); } - let img = bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| { + let d = c.bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| { let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i }; let rgba = i.to_rgba8(); - egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()) + let v = VERSION.with(|v| { + v.set(v.get() + 1); + v.get() + }); + (c.data.len(), v, [rgba.width(), rgba.height()], Arc::new(rgba.into_raw())) }); - let h = img.map(|img| ctx.load_texture(format!("sc_canvas_{id}"), img, egui::TextureOptions::LINEAR)); - let out = h.as_ref().map(|h| h.id()); - t.insert(id, (data.len(), h)); - out + m.insert(c.id, d.clone()); + d }) } +fn forget_removed(app: &SolveApp) { + let doc = &app.session.doc; + PIXELS.with(|m| m.borrow_mut().retain(|id, _| doc.canvases.iter().any(|c| c.id == *id))); + TEX.with(|t| t.borrow_mut().retain(|id, _| doc.canvases.iter().any(|c| c.id == *id))); +} + +/// The visible canvases as GPU quads. +pub fn gpu_images(app: &SolveApp) -> Vec { + forget_removed(app); + let doc = &app.session.doc; + if doc.canvases.is_empty() { + return Vec::new(); + } + let (vals, _) = doc.param_values(); + doc.canvases + .iter() + .filter(|c| c.visible && c.opacity > 0.0) + .filter_map(|c| { + let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?; + let (_, version, size, rgba) = pixels(c)?; + let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]); + Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 }) + }) + .collect() +} + +fn texture(ctx: &egui::Context, c: &Canvas) -> Option { + let (_, version, [w, h], rgba) = pixels(c)?; + TEX.with(|t| { + let mut t = t.borrow_mut(); + if let Some((v, h)) = t.get(&c.id) + && *v == version + { + return Some(h.id()); + } + let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); + let handle = ctx.load_texture(format!("sc_canvas_{}", c.id), img, egui::TextureOptions::LINEAR); + let id = handle.id(); + t.insert(c.id, (version, handle)); + Some(id) + }) +} + +/// CPU viewport only: the images over the rendered model. pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { + if app.viewport.gpu.is_some() { + return; + } + forget_removed(app); let doc = &app.session.doc; if doc.canvases.is_empty() { - TEX.with(|t| t.borrow_mut().clear()); return; } let (vals, _) = doc.param_values(); for c in doc.canvases.iter().filter(|c| c.visible) { let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue }; - let Some(tex) = texture(ui.ctx(), c.id, &c.data, || c.bytes()) else { continue }; + let Some(tex) = texture(ui.ctx(), c) else { continue }; let [bl, br, _, tl] = c.corners(); let (u, v) = (br - bl, tl - bl); let tint = Color32::from_white_alpha((c.opacity.clamp(0.0, 1.0) * 255.0) as u8); @@ -80,5 +137,4 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) } painter.add(Shape::mesh(mesh)); } - TEX.with(|t| t.borrow_mut().retain(|id, _| doc.canvases.iter().any(|c| c.id == *id))); } diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 58039fe0..52e24fcb 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -28,10 +28,20 @@ thread_local! { static COMBS: RefCell>> = const { RefCell::new(Vec::new()) }; /// The image file a Canvas or Decal tool places. static IMAGE: RefCell> = const { RefCell::new(None) }; + /// The canvas whose panel is open. + static CANVAS_PANEL: Cell> = const { Cell::new(None) }; + /// The opacity slider while it is dragged: (canvas, value). + static OPACITY: Cell> = const { Cell::new(None) }; } -/// Canvas and Decal start by asking for the image; false when none was chosen. -pub fn pick_image(app: &SolveApp, id: &str) -> bool { +/// Commands that start with a step of their own: Canvas and Decal ask for the image first, +/// Edit Canvas opens the canvas panel. False when the command should not go on. +pub fn start_hook(app: &SolveApp, id: &str) -> bool { + if id == "canvas.edit" { + let first = app.session.doc.canvases.first().map(|c| c.id); + CANVAS_PANEL.with(|c| c.set(first)); + return false; + } if !matches!(id, "FusionAddCanvasCommand" | "FusionAddEditDecalCommand") { return true; } @@ -41,6 +51,73 @@ pub fn pick_image(app: &SolveApp, id: &str) -> bool { ok } +/// The canvas panel: opacity, size, angle, flip, visibility of one canvas. +fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) { + let Some(id) = CANVAS_PANEL.with(|c| c.get()) else { return }; + let Some(c) = app.session.doc.canvases.iter().find(|c| c.id == id).cloned() else { + CANVAS_PANEL.with(|c| c.set(None)); + return; + }; + let ids: Vec<(u64, String)> = app.session.doc.canvases.iter().map(|c| (c.id, c.name.clone())).collect(); + let mut open = true; + let mut edit: Option = None; + let mut delete = false; + egui::Window::new("Canvas").open(&mut open).collapsible(false).resizable(false).default_pos(egui::pos2(320.0, 160.0)).show(ctx, |ui| { + egui::ComboBox::from_id_salt("sc_canvas_pick").selected_text(&c.name).show_ui(ui, |ui| { + for (cid, name) in &ids { + if ui.selectable_label(*cid == id, name).clicked() { + CANVAS_PANEL.with(|p| p.set(Some(*cid))); + } + } + }); + egui::Grid::new("sc_canvas_grid").num_columns(2).show(ui, |ui| { + ui.label("Opacity"); + let mut o = OPACITY.with(|v| v.get()).filter(|v| v.0 == id).map(|v| v.1).unwrap_or(c.opacity); + let r = ui.add(egui::Slider::new(&mut o, 0.0..=1.0).fixed_decimals(2)); + OPACITY.with(|v| v.set(Some((id, o)))); + if r.drag_stopped() || (r.changed() && !r.dragged()) { + edit = Some(json!({"canvas": id, "opacity": o})); + } + ui.end_row(); + ui.label("Width"); + let mut w = c.width; + if ui.add(egui::DragValue::new(&mut w).range(0.001..=1e6).suffix(" mm").speed(c.width * 0.005)).changed() { + edit = Some(json!({"canvas": id, "width": w})); + } + ui.end_row(); + ui.label("Angle"); + let mut a = c.angle.to_degrees(); + if ui.add(egui::DragValue::new(&mut a).suffix("°").speed(0.5)).changed() { + edit = Some(json!({"canvas": id, "angle": a})); + } + ui.end_row(); + let mut flip = c.flip; + if ui.checkbox(&mut flip, "Flip").changed() { + edit = Some(json!({"canvas": id, "flip": flip})); + } + let mut vis = c.visible; + if ui.checkbox(&mut vis, "Visible").changed() { + edit = Some(json!({"canvas": id, "visible": vis})); + } + ui.end_row(); + }); + if ui.button("Delete canvas").clicked() { + delete = true; + } + }); + if let Some(p) = edit { + let _ = app.run("canvas.edit", p); + OPACITY.with(|v| v.set(None)); + } + if delete { + let _ = app.run("canvas.delete", json!({"canvas": id})); + open = false; + } + if !open { + CANVAS_PANEL.with(|c| c.set(None)); + } +} + fn v3(v: &Value) -> Option { Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?)) } @@ -428,9 +505,10 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } else { json!({"path": path, "plane": plane, "at": [at.x, at.y, at.z]}) }; - if app.run(cmd, p).is_ok() { + if let Ok(v) = app.run(cmd, p) { IMAGE.with(|i| *i.borrow_mut() = None); app.tool = None; + CANVAS_PANEL.with(|c| c.set(v["canvas"].as_u64())); } } Mode::ModelRef => { @@ -849,6 +927,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P snap_hint(app, painter, proj); combs(app, painter, proj); text_entry(app, ui.ctx()); + canvas_panel(app, ui.ctx()); } /// The text-entry box for a placed text. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 76f78130..fb278313 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1105,6 +1105,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { view_proj: proj.vp.to_f32(), back: proj.cam.back().to_f32(), size_px: [rect.width() * ppp, rect.height() * ppp], + images: crate::ref_images::gpu_images(app), }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else {