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 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
bc21959777
commit
00ddaa25f5
11 files changed
+506
-2
No files matched your search
Generated
+2
@@ -1532,6 +1532,8 @@ dependencies = [
|
||||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Reference images on planes (Insert Canvas).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub canvases: Vec<crate::canvas::Canvas>,
|
||||
/// Comments of feature-input parameters (by name).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub param_comments: std::collections::BTreeMap<String, String>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod assembly;
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
mod document;
|
||||
mod eval;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
make(s, p, "FusionAddCanvasCommand", false)
|
||||
}
|
||||
|
||||
fn add_decal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HashMap<u64, (usize, Option<TextureHandle>)>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn texture(ctx: &egui::Context, id: u64, data: &str, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Option<TextureId> {
|
||||
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)));
|
||||
}
|
||||
@@ -26,6 +26,19 @@ thread_local! {
|
||||
static PREV: Cell<Option<Vec2>> = const { Cell::new(None) };
|
||||
/// Curvature comb teeth (foot, tip) shown while the comb tool is active.
|
||||
static COMBS: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
|
||||
/// The image file a Canvas or Decal tool places.
|
||||
static IMAGE: RefCell<Option<String>> = 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<Vec3> {
|
||||
@@ -175,7 +188,7 @@ enum Mode {
|
||||
fn mode(id: &str) -> Option<Mode> {
|
||||
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<String> {
|
||||
"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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user