Browser: Canvases folder (show/hide, Edit Canvas, Calibrate, Rename, Delete; a picked canvas goes with the Delete key)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
f6b42f8750
commit
0f4a0e7053
4 files changed
+232
-1
No files matched your search
@@ -34,6 +34,8 @@ pub struct TreeState {
|
||||
pub picked_components: Vec<u64>,
|
||||
/// Canvases picked in the tree.
|
||||
pub picked_canvases: Vec<u64>,
|
||||
/// The Edit Canvas / Calibrate panel.
|
||||
pub canvas_panel: Option<CanvasPanel>,
|
||||
/// The last row clicked (for Shift ranges).
|
||||
anchor: Option<String>,
|
||||
/// 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<Item> {
|
||||
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<Value> = 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) {
|
||||
|
||||
@@ -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<String> {
|
||||
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<Item> {
|
||||
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 }))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user