diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index c9fcff81..15f47f31 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -34,6 +34,8 @@ pub struct TreeState { pub picked_components: Vec, /// Canvases picked in the tree. pub picked_canvases: Vec, + /// The Edit Canvas / Calibrate panel. + pub canvas_panel: Option, /// The last row clicked (for Shift ranges). anchor: Option, /// Row keys in drawing order this frame (for Shift ranges). @@ -403,6 +405,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { crate::context_menu::open_for(app, at, target); } occurrence_panel(app, ui.ctx()); + canvas_panel(app, ui.ctx()); redefine_panel(app, ui.ctx()); } @@ -487,6 +490,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, if id == 0 { doc_settings(app, ui, d); origin_rows(app, ui, d); + canvas_rows(app, ui, d, acts); } let _ = t; for folder in ["bodies", "sketches", "construction"] { @@ -570,6 +574,183 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { } } +/// The Canvases folder (reference images) with an eye per canvas. +fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) { + let canvases: Vec<(u64, String, bool)> = app.session.doc.canvases.iter().map(|c| (c.id, c.name.clone(), c.visible)).collect(); + if canvases.is_empty() { + return; + } + let key = "canvases"; + let open = is_open(app, key, true); + let any = canvases.iter().any(|c| c.2); + let r = + draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), eye: Some(any), icon: "folder", label: "Canvases", ..Default::default() }); + if r.fold || r.clicked { + toggle(app, key, true); + } + if r.eye { + for (id, _, _) in &canvases { + let _ = app.run("canvas.edit", json!({ "canvas": id, "visible": !any })); + } + } + if !open { + return; + } + for (id, name, visible) in canvases { + let selected = app.tree.picked_canvases.contains(&id); + let r = draw_row( + ui, + ui.id().with(("canvas", id)), + &Row { depth: depth + 1, eye: Some(visible), icon: "canvas", label: &name, selected, dim: !visible, ..Default::default() }, + ); + if r.eye { + let _ = app.run("canvas.edit", json!({ "canvas": id, "visible": !visible })); + } + if r.clicked { + if !ui.input(|i| i.modifiers.command || i.modifiers.ctrl) { + app.tree.picked_canvases.clear(); + app.tree.picked_components.clear(); + let _ = app.run("select.clear", json!({})); + } + if selected { + app.tree.picked_canvases.retain(|c| *c != id); + } else { + app.tree.picked_canvases.push(id); + } + } + if r.double { + app.tree.canvas_panel = Some(CanvasPanel::new(app, id, false)); + } + if let Some(p) = r.secondary { + if !selected { + app.tree.picked_canvases = vec![id]; + } + acts.menu = Some((p, Target::Canvas { id })); + } + } +} + +/// The menu of a canvas. +pub fn canvas_items(app: &SolveApp, id: u64) -> Vec { + let visible = app.session.doc.canvases.iter().find(|c| c.id == id).is_some_and(|c| c.visible); + vec![ + Item::action("ui.editCanvas", "Edit Canvas", "canvas").with(json!({ "canvas": id })), + Item::action("ui.calibrateCanvas", "Calibrate", "measure").with(json!({ "canvas": id })), + Item::sep(), + Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })), + Item::action("ui.rename", "Rename", "").with(json!({ "canvas": id })), + Item::sep(), + Item::action("ui.canvasVisible", if visible { "Hide" } else { "Show" }, "eye").with(json!({ "canvas": id, "visible": !visible })), + ] +} + +/// Editing a canvas (position, size, angle, opacity, flip) or calibrating it (two points on +/// the image and their true distance). Live edits of one opening are one undo step. +#[derive(Clone, Debug)] +pub struct CanvasPanel { + pub canvas: u64, + pub calibrate: bool, + /// Undo depth when the panel opened. + depth: usize, + pub a: [f64; 2], + pub b: [f64; 2], + pub distance: f64, +} + +impl CanvasPanel { + pub fn new(app: &SolveApp, canvas: u64, calibrate: bool) -> Self { + // Calibration starts from the image's left and right edge midpoints. + let (a, b, d) = app + .session + .doc + .canvases + .iter() + .find(|c| c.id == canvas) + .map(|c| ([c.center.x - c.width / 2.0, c.center.y], [c.center.x + c.width / 2.0, c.center.y], c.width)) + .unwrap_or(([0.0, 0.0], [100.0, 0.0], 100.0)); + CanvasPanel { canvas, calibrate, depth: app.session.undo.len(), a, b, distance: d } + } +} + +fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut m) = app.tree.canvas_panel.clone() else { return }; + let Some(c) = app.session.doc.canvases.iter().find(|c| c.id == m.canvas).cloned() else { + app.tree.canvas_panel = None; + return; + }; + let mut open = true; + let mut edit: Option = None; + let mut done = false; + let title = if m.calibrate { format!("Calibrate: {}", c.name) } else { format!("Edit Canvas: {}", c.name) }; + egui::Window::new(title) + .id(egui::Id::new("sc_canvas_panel")) + .default_pos(panel_pos(app)) + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + egui::Grid::new("sc_canvas_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + if m.calibrate { + for (label, p) in [("Point A", &mut m.a), ("Point B", &mut m.b)] { + ui.label(label); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut p[0]).speed(0.5).suffix(" mm")); + ui.add(egui::DragValue::new(&mut p[1]).speed(0.5).suffix(" mm")); + }); + ui.end_row(); + } + ui.label("Distance"); + ui.add(egui::DragValue::new(&mut m.distance).speed(0.5).range(0.001..=1e6).suffix(" mm")); + ui.end_row(); + } else { + let (mut x, mut y, mut w, mut ang, mut op, mut flip) = (c.center.x, c.center.y, c.width, c.angle.to_degrees(), c.opacity, c.flip); + ui.label("Center X"); + let r1 = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(" mm")); + ui.end_row(); + ui.label("Center Y"); + let r2 = ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix(" mm")); + ui.end_row(); + ui.label("Width"); + let r3 = ui.add(egui::DragValue::new(&mut w).speed(0.5).range(0.01..=1e6).suffix(" mm")); + ui.end_row(); + ui.label("Angle"); + let r4 = ui.add(egui::DragValue::new(&mut ang).speed(1.0).suffix(" deg")); + ui.end_row(); + ui.label("Opacity"); + let r5 = ui.add(egui::Slider::new(&mut op, 0.0..=1.0)); + ui.end_row(); + ui.label("Flip"); + let r6 = ui.checkbox(&mut flip, ""); + ui.end_row(); + if [r1, r2, r3, r4, r5, r6].iter().any(|r| r.changed()) { + edit = Some(json!({ "canvas": c.id, "center": [x, y], "width": w, "angle": ang, "opacity": op, "flip": flip })); + } + } + }); + ui.add_space(6.0); + if ui.button(if m.calibrate { "Calibrate" } else { "OK" }).clicked() { + done = true; + } + }); + if let Some(p) = edit + && app.run("canvas.edit", p).is_ok() + { + // One undo step for the whole edit. + if app.session.undo.len() > m.depth + 1 { + let keep = app.session.undo.get(m.depth).cloned(); + app.session.undo.truncate(m.depth); + app.session.undo.extend(keep); + } + } + if done && m.calibrate { + if app.run("canvas.calibrate", json!({ "canvas": c.id, "a": m.a, "b": m.b, "distance": m.distance })).is_ok() { + app.tree.canvas_panel = None; + } + return; + } + app.tree.canvas_panel = if done || !open { None } else { Some(m) }; +} + /// A browser click on a plane or axis: it goes to the open dialog's input if that takes it, /// otherwise it becomes the selection. fn pick_from_browser(app: &mut SolveApp, x: Sel) { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 5193fc81..034c9f1b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -38,6 +38,10 @@ pub enum Target { Group { id: u64, }, + /// A canvas (reference image). + Canvas { + id: u64, + }, /// A component's Bodies, Sketches or Construction folder. Folder { component: u64, @@ -138,6 +142,7 @@ pub enum RenameWhat { Feature(u64), Component(u64), Group(u64), + Canvas(u64), } #[derive(Clone, Debug)] @@ -197,7 +202,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), Target::Group { id } => crate::browser::group_bodies(app, *id), - Target::Sketch { .. } | Target::Folder { .. } => Vec::new(), + Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } => Vec::new(), } } @@ -254,6 +259,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Component { id } => component_items(app, *id), Target::Group { id } => crate::browser::group_items(app, *id), Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder), + Target::Canvas { id } => crate::browser::canvas_items(app, *id), }; // Several items of the folder selected: they can be grouped. if let Some(p) = crate::browser::selection_group(app, target) { @@ -566,6 +572,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { RenameWhat::Component(c) } else if let Some(g) = id_of(&p, "group") { RenameWhat::Group(g) + } else if let Some(c) = id_of(&p, "canvas") { + RenameWhat::Canvas(c) } else { return; }; @@ -646,6 +654,12 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { app.tree.occurrence_move = Some(crate::browser::OccMove::new(o)); } } + "ui.canvasVisible" => drop(app.run("canvas.edit", p)), + "ui.editCanvas" | "ui.calibrateCanvas" => { + if let Some(c) = id_of(&p, "canvas") { + app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, item.id == "ui.calibrateCanvas")); + } + } "ui.newGroup" | "ui.ungroup" | "ui.groupSelected" => crate::browser::group_action(app, &item.id, &p), id if item.params.is_some() => { let _ = app.run(id, p); @@ -689,6 +703,7 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) { RenameWhat::Component(0) => doc.name.clone(), RenameWhat::Component(c) => doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), RenameWhat::Group(g) => crate::browser::group_name(app, *g).unwrap_or_default(), + RenameWhat::Canvas(c) => doc.canvases.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), }; app.menu.rename = Some(Rename { what, text, at, focused: false }); } @@ -711,6 +726,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) { RenameWhat::Feature(f) => drop(app.run("FusionRenameTimelineEntryCommand", json!({ "feature": f, "name": name }))), RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))), RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })), + RenameWhat::Canvas(c) => drop(app.run("canvas.edit", json!({ "canvas": c, "name": name }))), } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 51ee36b5..05bc6c74 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -438,6 +438,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0); pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0); } + // Canvas: a framed picture (sky and a hill). + "canvas" => { + pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(170, 205, 240), ink); + pen.poly(&[(4.0, 18.0), (10.0, 11.0), (14.0, 15.0), (16.0, 13.0), (20.0, 18.0)], Color32::from_rgb(110, 170, 100), ink); + pen.circle(16.5, 9.0, 1.6, Color32::from_rgb(250, 210, 80), Color32::from_rgb(250, 210, 80)); + } "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink), "origin" => { pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60)); diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index f2cf5b2f..ce2fe5fd 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -252,3 +252,31 @@ fn menu_delete_items_use_the_same_path() { act(&mut app, &Target::Sketch { id }, "Delete"); assert!(app.menu.confirm.is_some()); } + +#[test] +fn canvas_menu_hides_renames_calibrates_and_deletes() { + let mut app = sample_app(); + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend(400u32.to_be_bytes()); + png.extend(200u32.to_be_bytes()); + png.extend([8, 6, 0, 0, 0, 0, 0, 0, 0]); + let data = solvecraft_engine::doc::canvas::base64_encode(&png); + let id = app.run("FusionAddCanvasCommand", json!({"data": data, "plane": "XY", "width": 80})).unwrap()["canvas"].as_u64().unwrap(); + let t = Target::Canvas { id }; + act(&mut app, &t, "Hide"); + assert!(!app.session.doc.canvases[0].visible); + act(&mut app, &t, "Rename"); + assert!(context_menu::finish_rename(&mut app, Some("Photo"), true)); + assert_eq!(app.session.doc.canvases[0].name, "Photo"); + act(&mut app, &t, "Calibrate"); + let p = app.tree.canvas_panel.clone().unwrap(); + assert!(p.calibrate && (p.distance - 80.0).abs() < 1e-9, "starts from the image's width"); + act(&mut app, &t, "Delete"); + assert!(app.session.doc.canvases.is_empty()); + app.run("UndoCommand", json!({})).unwrap(); + assert_eq!(app.session.doc.canvases.len(), 1); + // A canvas picked in the browser goes with the Delete key. + app.tree.picked_canvases = vec![id]; + crate::delete::delete_selection(&mut app); + assert!(app.session.doc.canvases.is_empty()); +}