From 00ddaa25f56cd59273309571ea22ff8b6bf4fa94 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:31:04 +0000 Subject: [PATCH] Canvas and Decal (FusionAddCanvasCommand, FusionAddEditDecalCommand): PNG/JPEG reference images stored in the design (base64), placed on an origin/construction plane, a sketch plane or a planar face; canvas.edit (position, width, angle, opacity, flip, visibility), canvas.calibrate (scale by a known distance), canvas.delete; drawn textured in the viewport; toolbar tools pick the file, then the plane or face Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- Cargo.lock | 2 + crates/doc/src/canvas.rs | 161 ++++++++++++++++++++ crates/doc/src/document.rs | 4 + crates/doc/src/lib.rs | 1 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch_canvas.rs | 199 +++++++++++++++++++++++++ crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/ref_images.rs | 84 +++++++++++ crates/ui-egui/src/sketch_tools.rs | 48 +++++- crates/ui-egui/src/viewport.rs | 1 + 11 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 crates/doc/src/canvas.rs create mode 100644 crates/engine/src/cmd/sketch_canvas.rs create mode 100644 crates/ui-egui/src/ref_images.rs diff --git a/Cargo.lock b/Cargo.lock index e311fe56..19b46405 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1532,6 +1532,8 @@ dependencies = [ "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] diff --git a/crates/doc/src/canvas.rs b/crates/doc/src/canvas.rs new file mode 100644 index 00000000..a9c07f93 --- /dev/null +++ b/crates/doc/src/canvas.rs @@ -0,0 +1,161 @@ +//! Canvases: reference images placed on a plane (Fusion's Insert Canvas), kept in the design. +//! The image bytes travel inside the design file (base64), so it reopens without the original. + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Plane, Vec2, Vec3}; + +use crate::PlaneRef; + +/// Largest image a canvas may hold (bytes). +pub const MAX_CANVAS_BYTES: usize = 16 << 20; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Canvas { + pub id: u64, + pub name: String, + /// `png` or `jpeg`. + pub format: String, + /// The image file, base64. + pub data: String, + /// Pixel size of the image. + pub pixels: [u32; 2], + pub plane: PlaneRef, + /// Centre of the image on the plane (plane coordinates, mm). + pub center: Vec2, + /// Width on the plane (mm); the height follows the image's aspect. + pub width: f64, + /// Rotation on the plane (radians). + #[serde(default)] + pub angle: f64, + #[serde(default = "half")] + pub opacity: f64, + /// Mirrored left to right. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub flip: bool, + #[serde(default = "yes")] + pub visible: bool, +} + +fn half() -> f64 { + 0.5 +} +fn yes() -> bool { + true +} + +impl Canvas { + pub fn height(&self) -> f64 { + let [w, h] = self.pixels; + if w == 0 { self.width } else { self.width * f64::from(h) / f64::from(w) } + } + /// The four corners on the plane (plane coordinates): bottom-left, bottom-right, top-right, + /// top-left of the image. + pub fn corners(&self) -> [Vec2; 4] { + let (hw, hh) = (self.width / 2.0, self.height() / 2.0); + let (s, c) = self.angle.sin_cos(); + let rot = |x: f64, y: f64| self.center + Vec2::new(x * c - y * s, x * s + y * c); + let f = if self.flip { -1.0 } else { 1.0 }; + [rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)] + } + /// Corners in world coordinates. + pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] { + self.corners().map(|p| plane.to_world(p)) + } + pub fn bytes(&self) -> Option> { + base64_decode(&self.data) + } +} + +/// Image kind and pixel size from the file header (PNG or JPEG). +pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> { + if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") { + let w = u32::from_be_bytes([*b.get(16)?, *b.get(17)?, *b.get(18)?, *b.get(19)?]); + let h = u32::from_be_bytes([*b.get(20)?, *b.get(21)?, *b.get(22)?, *b.get(23)?]); + return (w > 0 && h > 0).then_some(("png", [w, h])); + } + if b.starts_with(&[0xFF, 0xD8]) { + // Walk the markers to a start-of-frame. + let mut i = 2; + while i + 9 < b.len() { + if *b.get(i)? != 0xFF { + i += 1; + continue; + } + let m = *b.get(i + 1)?; + if m == 0xD8 || m == 0x01 || (0xD0..=0xD7).contains(&m) || m == 0xFF { + i += if m == 0xFF { 1 } else { 2 }; + continue; + } + let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?])); + if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) { + let h = u32::from(u16::from_be_bytes([*b.get(i + 5)?, *b.get(i + 6)?])); + let w = u32::from(u16::from_be_bytes([*b.get(i + 7)?, *b.get(i + 8)?])); + return (w > 0 && h > 0).then_some(("jpeg", [w, h])); + } + i += 2 + len; + } + } + None +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(b: &[u8]) -> String { + let mut out = String::with_capacity(b.len().div_ceil(3) * 4); + for c in b.chunks(3) { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + for k in 0..4 { + if k <= c.len() { + out.push(char::from(B64[((n >> (18 - 6 * k)) & 63) as usize])); + } else { + out.push('='); + } + } + } + out +} + +pub fn base64_decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for ch in s.bytes() { + let v = match ch { + b'A'..=b'Z' => ch - b'A', + b'a'..=b'z' => ch - b'a' + 26, + b'0'..=b'9' => ch - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' | b'\n' | b'\r' | b' ' => continue, + _ => return None, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xFF) as u8); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_round_trip_and_headers() { + for n in 0..10 { + let b: Vec = (0..n).map(|i| (i * 37) as u8).collect(); + assert_eq!(base64_decode(&base64_encode(&b)).unwrap(), b); + } + assert_eq!(base64_encode(b"Man"), "TWFu"); + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend(640u32.to_be_bytes()); + png.extend(480u32.to_be_bytes()); + png.extend([8, 6, 0, 0, 0]); + assert_eq!(image_info(&png), Some(("png", [640, 480]))); + let jpg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 4, 0, 0, 0xFF, 0xC0, 0, 11, 8, 0, 100, 0, 200, 3, 0, 0, 0]; + assert_eq!(image_info(&jpg), Some(("jpeg", [200, 100]))); + assert_eq!(image_info(b"nope"), None); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 3d2db965..c3bc460a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -990,6 +990,9 @@ pub struct Document { /// Favourite parameters (by name). #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] pub favorites: std::collections::BTreeSet, + /// Reference images on planes (Insert Canvas). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub canvases: Vec, /// Comments of feature-input parameters (by name). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub param_comments: std::collections::BTreeMap, @@ -1050,6 +1053,7 @@ impl Document { body_components: Default::default(), materials: Default::default(), favorites: Default::default(), + canvases: Vec::new(), param_comments: Default::default(), browser_groups: Vec::new(), browser_order: Default::default(), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fb3d60e5..a0838870 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] mod assembly; +pub mod canvas; mod clipboard; mod document; mod eval; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 04cd3302..c00ca4f4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -20,6 +20,7 @@ mod parameters; mod plastic; mod sheet; mod sketch; +mod sketch_canvas; mod sketch_constraints; mod sketch_create; mod sketch_freeform; @@ -127,6 +128,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_constraints::COMMANDS.iter()); v.extend(sketch_import::COMMANDS.iter()); v.extend(sketch_inspect::COMMANDS.iter()); + v.extend(sketch_canvas::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs new file mode 100644 index 00000000..830181a8 --- /dev/null +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -0,0 +1,199 @@ +//! Canvases (reference images on planes) and decals (images on planar faces). + +use serde_json::{Value, json}; +use solvecraft_doc::canvas::{Canvas, MAX_CANVAS_BYTES, base64_decode, base64_encode, image_info}; +use solvecraft_geom::Vec2; + +use super::CommandSpec; +use crate::params::{bad, bool_, num, str_, vec2}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FusionAddCanvasCommand", "Canvas", add_canvas) + .at("SKETCH", "INSERT") + .icon("canvas") + .params("path | data (base64 PNG or JPEG); plane?: XY|XZ|YZ|{face: [x,y,z]}|… (default XY); center?: [x,y] on the plane, or at?: [x,y,z]; width?: mm (default 100); angle?: deg; opacity?: 0…1 (default 0.5); flip?; name?"), + CommandSpec::new("FusionAddEditDecalCommand", "Decal", add_decal) + .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.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"), +]; + +fn image_bytes(p: &Value, cmd: &str) -> Result> { + let b = if let Some(d) = str_(p, "data") { + if d.len() > MAX_CANVAS_BYTES * 4 / 3 + 16 { + return Err(bad(cmd, "the image is too large")); + } + base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))? + } else { + let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?; + let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta.len() as usize > MAX_CANVAS_BYTES { + return Err(bad(cmd, "the image is too large")); + } + std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? + }; + if b.len() > MAX_CANVAS_BYTES { + return Err(bad(cmd, "the image is too large")); + } + Ok(b) +} + +fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result { + let bytes = image_bytes(p, cmd)?; + let (format, pixels) = image_info(&bytes).ok_or_else(|| bad(cmd, "only PNG and JPEG images can be inserted"))?; + let plane = super::sketch::plane_ref(s, p, cmd)?; + let (vals, _) = s.doc.param_values(); + let pl = s.doc.resolve_plane(&vals, &plane, 0)?; + // A decal sits where the face was picked. + let at = p.get("at").or_else(|| p.get("plane").and_then(|v| v.get("face"))).and_then(crate::params::vec3).map(|q| pl.to_local(q)); + let center = p.get("center").and_then(vec2).or(at).unwrap_or(Vec2::ZERO); + let width = num(p, "width").unwrap_or(100.0); + if !(width > 1e-6 && width < 1e7) { + return Err(bad(cmd, "`width` must be positive")); + } + let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0); + let doc = s.doc_mut(); + let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1; + let base = if decal { "Decal" } else { "Canvas" }; + let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("{base}{id}")); + doc.canvases.push(Canvas { + id, + name: name.clone(), + format: format.into(), + data: base64_encode(&bytes), + pixels, + plane, + center, + width, + angle: num(p, "angle").unwrap_or(0.0).to_radians(), + opacity, + flip: bool_(p, "flip").unwrap_or(false), + visible: true, + }); + Ok(json!({"canvas": id, "name": name, "pixels": pixels})) +} + +fn add_canvas(s: &mut Session, p: &Value) -> Result { + make(s, p, "FusionAddCanvasCommand", false) +} + +fn add_decal(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionAddEditDecalCommand"; + let face = p.get("face").cloned().ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on a planar face"))?; + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.insert("plane".into(), json!({"face": face})); + } + make(s, &q, cmd, true) +} + +fn find(s: &Session, p: &Value, cmd: &str) -> Result { + let key = match p.get("canvas") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`canvas` must be an id or a name")), + }; + s.doc.canvases.iter().position(|c| c.id.to_string() == key || c.name == key).ok_or_else(|| bad(cmd, format!("no canvas `{key}`"))) +} + +fn edit_canvas(s: &mut Session, p: &Value) -> Result { + let cmd = "canvas.edit"; + let i = find(s, p, cmd)?; + let width = num(p, "width"); + if width.is_some_and(|w| !(w > 1e-6 && w < 1e7)) { + return Err(bad(cmd, "`width` must be positive")); + } + let c = s.doc_mut().canvases.get_mut(i).ok_or_else(|| bad(cmd, "canvas"))?; + if let Some(v) = p.get("center").and_then(vec2) { + c.center = v; + } + if let Some(w) = width { + c.width = w; + } + if let Some(a) = num(p, "angle") { + c.angle = a.to_radians(); + } + if let Some(o) = num(p, "opacity") { + c.opacity = o.clamp(0.0, 1.0); + } + if let Some(f) = bool_(p, "flip") { + c.flip = f; + } + if let Some(v) = bool_(p, "visible") { + c.visible = v; + } + if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) { + c.name = n.to_string(); + } + Ok(json!({"canvas": c.id})) +} + +fn calibrate(s: &mut Session, p: &Value) -> Result { + let cmd = "canvas.calibrate"; + let i = find(s, p, cmd)?; + let (a, b) = (p.get("a").and_then(vec2), p.get("b").and_then(vec2)); + let (Some(a), Some(b)) = (a, b) else { return Err(bad(cmd, "`a` and `b` must be points [x,y]")) }; + let d = num(p, "distance").filter(|d| *d > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be positive"))?; + let now = a.dist(b); + if now < 1e-9 { + return Err(bad(cmd, "the points must differ")); + } + let k = d / now; + let c = s.doc_mut().canvases.get_mut(i).ok_or_else(|| bad(cmd, "canvas"))?; + c.width *= k; + c.center = a + (c.center - a) * k; + Ok(json!({"canvas": c.id, "width": c.width})) +} + +fn delete_canvas(s: &mut Session, p: &Value) -> Result { + let i = find(s, p, "canvas.delete")?; + let c = s.doc_mut().canvases.remove(i); + Ok(json!({"deleted": c.id})) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use solvecraft_doc::canvas::base64_encode; + + use crate::Session; + + fn png(w: u32, h: u32) -> String { + let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + b.extend(w.to_be_bytes()); + b.extend(h.to_be_bytes()); + b.extend([8, 6, 0, 0, 0, 0, 0, 0, 0]); + base64_encode(&b) + } + + #[test] + fn canvas_insert_edit_calibrate_save() { + let mut s = Session::default(); + let r = s.execute("FusionAddCanvasCommand", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap(); + let id = r["canvas"].as_u64().unwrap(); + let c = s.doc.canvases[0].clone(); + assert_eq!(c.pixels, [400, 200]); + assert!((c.height() - 40.0).abs() < 1e-12); + // Two points 20 apart on the image are really 50 apart. + s.execute("canvas.calibrate", &json!({"canvas": id, "a": [0, 0], "b": [20, 0], "distance": 50})).unwrap(); + assert!((s.doc.canvases[0].width - 200.0).abs() < 1e-9); + s.execute("canvas.edit", &json!({"canvas": "Canvas1", "opacity": 0.8, "angle": 90})).unwrap(); + // Undo-able and saved with the design. + let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(back.canvases, s.doc.canvases); + s.execute("canvas.delete", &json!({"canvas": id})).unwrap(); + assert!(s.doc.canvases.is_empty()); + s.execute("UndoCommand", &json!({})).unwrap(); + assert_eq!(s.doc.canvases.len(), 1); + assert!(s.execute("FusionAddCanvasCommand", &json!({"data": "bm9wZQ=="})).is_err()); + // A decal on the top face of a box. + s.execute("PrimitiveBox", &json!({"length": 30, "width": 30, "height": 10})).unwrap(); + let r = s.execute("FusionAddEditDecalCommand", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap(); + assert!(r["canvas"].is_u64()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index df204f1e..ec0542cd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -16,4 +16,4 @@ serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } web-time = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["jpeg"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a2ff8eab..6ae89b13 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ mod menu_tests; pub mod palette; pub mod params_dialog; pub mod preview; +pub mod ref_images; pub mod selection; pub mod sketch_dims; #[cfg(test)] @@ -282,6 +283,9 @@ impl SolveApp { } return; } + if !sketch_tools::pick_image(self, id) { + return; + } if id == "ChangeParameterCommand" { params_dialog::open(self); return; diff --git a/crates/ui-egui/src/ref_images.rs b/crates/ui-egui/src/ref_images.rs new file mode 100644 index 00000000..495fbba4 --- /dev/null +++ b/crates/ui-egui/src/ref_images.rs @@ -0,0 +1,84 @@ +//! Canvases and decals: the design's reference images, drawn on their planes under the sketch +//! overlays. + +use std::cell::RefCell; +use std::collections::HashMap; + +use egui::{Color32, Mesh, Shape, TextureHandle, TextureId, pos2}; +use solvecraft_engine::geom::Vec2; + +use crate::SolveApp; +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. +const CELLS: usize = 8; + +thread_local! { + /// Textures by canvas id, with the length of the data they were made from. + static TEX: RefCell)>> = RefCell::new(HashMap::new()); +} + +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() + { + return h.as_ref().map(|h| h.id()); + } + let img = 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 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 + }) +} + +pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { + 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 [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); + let mut mesh = Mesh::with_texture(tex); + let mut ok = true; + for j in 0..=CELLS { + for i in 0..=CELLS { + let (s, t) = (i as f64 / CELLS as f64, j as f64 / CELLS as f64); + let p: Vec2 = bl + u * s + v * t; + let Some(q) = proj.to_screen(plane.to_world(p)) else { + ok = false; + continue; + }; + mesh.vertices.push(egui::epaint::Vertex { pos: q, uv: pos2(s as f32, 1.0 - t as f32), color: tint }); + } + } + // Behind the camera: skip rather than draw a torn image. + if !ok { + continue; + } + let w = (CELLS + 1) as u32; + for j in 0..CELLS as u32 { + for i in 0..CELLS as u32 { + let a = j * w + i; + mesh.add_triangle(a, a + 1, a + w + 1); + mesh.add_triangle(a, a + w + 1, a + w); + } + } + 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 391d3ece..82b662d7 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -26,6 +26,19 @@ thread_local! { static PREV: Cell> = const { Cell::new(None) }; /// Curvature comb teeth (foot, tip) shown while the comb tool is active. static COMBS: RefCell>> = const { RefCell::new(Vec::new()) }; + /// The image file a Canvas or Decal tool places. + static IMAGE: RefCell> = const { RefCell::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 { + if !matches!(id, "FusionAddCanvasCommand" | "FusionAddEditDecalCommand") { + return true; + } + let path = app.services.pick_open.as_ref().and_then(|f| f()); + let ok = path.is_some(); + IMAGE.with(|i| *i.borrow_mut() = path); + ok } fn v3(v: &Value) -> Option { @@ -175,7 +188,7 @@ enum Mode { fn mode(id: &str) -> Option { Some(match id { "ProjectNewCmd" | "IntersectCmd" | "Include3DGeometry" | "FitCurvesToSectionCommand" | "SketchIsoparametricCurve" => Mode::ModelRef, - "FusionCurvatureCombAnalysisCommand" => Mode::ModelRef, + "FusionCurvatureCombAnalysisCommand" | "FusionAddCanvasCommand" | "FusionAddEditDecalCommand" => Mode::ModelRef, "TrimSketchCmd" | "ExtendSketchCmd" | "BreakSketchCmd" => Mode::CurveAt, "SketchMidpointLine" => Mode::Points(2), "ArcTangent" => Mode::Points(2), @@ -221,6 +234,8 @@ pub fn hint(id: &str) -> Option { "FitCurvesToSectionCommand" => "Fit Curves to Mesh Section: click a body", "SketchIsoparametricCurve" => "Isoparametric Curve: click a point on a face (Shift: along)", "FusionCurvatureCombAnalysisCommand" => "Curvature comb: click sketch curves or model edges", + "FusionAddCanvasCommand" => "Canvas: click a plane or a planar face where the image's centre goes", + "FusionAddEditDecalCommand" => "Decal: click a planar face where the image's centre goes", "TrimSketchCmd" => "Trim: click the piece of a curve to remove", "ExtendSketchCmd" => "Extend: click a curve near the end to extend", "BreakSketchCmd" => "Break: click a curve where it should split", @@ -359,6 +374,37 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } } + Mode::ModelRef if matches!(cmd, "FusionAddCanvasCommand" | "FusionAddEditDecalCommand") => { + let Some(path) = IMAGE.with(|i| i.borrow().clone()) else { return }; + let decal = cmd == "FusionAddEditDecalCommand"; + let target = hits.iter().find_map(|h| match h { + Hit::Face { point, .. } => Some((json!({"face": [point.x, point.y, point.z]}), *point)), + Hit::Plane { name, point } if !decal => Some((json!(name), *point)), + _ => None, + }); + // Empty space while sketching: the sketch plane. + let target = target.or_else(|| { + let st = app.session.model.state(); + let ss = st.sketch(app.session.active_sketch?)?; + let (o, d) = proj.ray(pos); + let w = ss.plane.intersect_ray(o, d)?; + let pl = &ss.plane; + Some(( + json!({"origin": [pl.origin.x, pl.origin.y, pl.origin.z], "x_dir": [pl.x.x, pl.x.y, pl.x.z], "y_dir": [pl.y.x, pl.y.y, pl.y.z]}), + w, + )) + }); + let Some((plane, at)) = target else { return }; + let p = if decal { + json!({"path": path, "face": plane.get("face").cloned().unwrap_or(Value::Null)}) + } else { + json!({"path": path, "plane": plane, "at": [at.x, at.y, at.z]}) + }; + if app.run(cmd, p).is_ok() { + IMAGE.with(|i| *i.borrow_mut() = None); + app.tool = None; + } + } Mode::ModelRef => { let r = hits.iter().find_map(|h| model_ref(app, h)); let Some(r) = r else { return }; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 72ebaac1..91300559 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1070,6 +1070,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } else { app.viewport.boxsel = None; } + crate::ref_images::show(app, ui, &painter, &proj); overlays(app, &painter, &proj); crate::dim_view::show(app, ui, &painter, &proj); hover_highlight(app, &painter, &proj);