//! The browser (left): the design as a tree of components, each with its bodies, sketches and //! construction planes, plus document settings and the origin. //! //! Rows are drawn in code (icons from [`icons`]) and act through commands. A click selects //! (Ctrl adds, Shift extends over the visible rows), hovering a body row lights it up in the //! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu //! ([`crate::context_menu`]). use std::collections::BTreeSet; use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; use solvecraft_engine::Sel; use solvecraft_engine::doc::FeatureKind; use crate::context_menu::{Item, Target}; use crate::theme::Tokens; use crate::{SolveApp, icons}; thread_local! { /// The rows drawn this frame: (label, row, fold arrow area, open). For automation /// (`ui.at {browser | chevron}`, `ui.browser`). static DRAWN: std::cell::RefCell, Option)>> = const { std::cell::RefCell::new(Vec::new()) }; } /// The rows on screen: (label, row rect, fold arrow area, open). pub fn drawn_rows() -> Vec<(String, Rect, Option, Option)> { DRAWN.with(|d| d.borrow().clone()) } /// Browser state kept between frames. #[derive(Default)] pub struct TreeState { /// Folders closed by the user (keys like `c0/bodies`). pub collapsed: BTreeSet, /// Folders opened by the user (folders that start closed). pub expanded: BTreeSet, /// Scroll to this body's row and open its folders (Find in Browser). pub reveal: Option, /// The Move/Copy panel of an occurrence. pub occurrence_move: Option, /// The Redefine Sketch Plane panel (sketch id). pub redefine: Option, /// Components picked in the tree (they have no viewport selection). pub picked_components: Vec, /// Canvases picked in the tree. pub picked_canvases: Vec, /// The Edit Canvas / Calibrate panel. pub canvas_panel: Option, /// What waits for the Capture Position question: a command id, or [`SAVE`]. pub capture_prompt: Option, /// The last row clicked (for Shift ranges). anchor: Option, /// Row keys in drawing order this frame (for Shift ranges). order: Vec, } /// Moving an occurrence: the offsets typed so far (pending until Capture Position). #[derive(Clone, Debug)] pub struct OccMove { pub occurrence: u64, /// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was. pub translate: [f64; 3], pub angles: [f64; 3], /// The occurrence's origin when the move began: what it turns about. pub pivot: Option, /// The values when the current triad drag began. drag0: Option<([f64; 3], [f64; 3])>, } impl OccMove { pub fn new(occurrence: u64) -> Self { OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None } } } // --------------------------------------------------------------------------------------------- // Queries used by the menus // --------------------------------------------------------------------------------------------- /// Is a sketch drawn in the viewport? pub fn sketch_visible(app: &SolveApp, id: u64) -> bool { if app.session.visibility.hidden_sketches.contains(&id) { return false; } app.session.visibility.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id)) } pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) { app.session.visibility.hidden_sketches.retain(|x| *x != id); app.session.visibility.shown_sketches.retain(|x| *x != id); if on { app.session.visibility.shown_sketches.push(id); } else { app.session.visibility.hidden_sketches.push(id); } } pub fn redefine_sketch(app: &mut SolveApp, id: u64) { app.tree.redefine = Some(id); } fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> { app.session.doc.browser_groups.iter().find(|g| g.id == id) } /// The bodies in a group (none for sketch and plane groups). pub fn group_bodies(app: &SolveApp, id: u64) -> Vec { let st = app.session.world_state(); find_group(app, id) .filter(|g| g.folder == "bodies") .map(|g| g.items.iter().filter(|b| st.body(b).is_some()).cloned().collect()) .unwrap_or_default() } pub fn group_name(app: &SolveApp, id: u64) -> Option { find_group(app, id).map(|g| g.name.clone()) } /// The menu of a group row. pub fn group_items(app: &SolveApp, id: u64) -> Vec { let Some(g) = find_group(app, id) else { return Vec::new() }; let mut v = vec![ Item::action("ui.rename", "Rename", "").with(json!({ "group": id })), Item::action("ui.ungroup", "Ungroup", "folder").with(json!({ "group": id })), ]; if g.folder == "bodies" { let bodies = group_bodies(app, id); let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b)); v.push(Item::sep()); v.push(Item::action(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": bodies }))); v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies }))); } v } /// The menu of a folder row (Bodies, Sketches, Construction). pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec { let sel: Vec = selected_keys(app, component, folder); let all = entries(app, component, folder); let mut v = Vec::new(); if folder == "bodies" { let bodies: Vec = all.iter().map(|e| e.key.clone()).collect(); v.push(Item::action("component.from_bodies", "Create Components from Bodies", "component").with(json!({ "bodies": bodies }))); } v.push( Item::action("ui.newGroup", if folder == "sketches" { "New Sketch Group" } else { "New Group" }, "folder") .with(json!({ "component": component, "folder": folder })), ); if sel.len() > 1 { v.push(Item::action("ui.groupSelected", "Group Selected", "folder").with(json!({ "component": component, "folder": folder, "items": sel }))); } v.push(Item::sep()); v.push(Item::action("ui.folderVisible", "Show/Hide", "eye").key("V").with(json!({ "component": component, "folder": folder }))); v.push(Item::action("ui.showAll", "Show All", "eye")); v } /// The menu of the Origin folder. pub fn origin_items(app: &SolveApp) -> Vec { let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p)); let axes = ["X", "Y", "Z"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p)); vec![ Item::action("ui.origin", "Show/Hide", "eye").key("V"), Item::action("ui.originAll", "Show All", "eye"), Item::action("ui.originPart", if planes { "Hide Planes" } else { "Show Planes" }, "plane") .with(json!({ "keys": ["XY", "XZ", "YZ"], "show": !planes })), Item::action("ui.originPart", if axes { "Hide Axes" } else { "Show Axes" }, "axis").with(json!({ "keys": ["X", "Y", "Z"], "show": !axes })), ] } /// Show or hide everything in a component folder. pub fn toggle_folder(app: &mut SolveApp, component: u64, folder: &str) { if folder == "sketches" { app.ui.show_sketches = !app.ui.show_sketches; return; } let all = entries(app, component, folder); let any = all.iter().any(|e| e.visible); let refs: Vec<&Entry> = all.iter().collect(); set_visible(app, folder, &refs, !any); } /// Selected browser items of the folder of a body or sketch, when more than one (for Group /// Selected in their menus). pub fn selection_group(app: &SolveApp, target: &Target) -> Option { let (comp, folder) = match target { Target::Body { name } => { let st = app.session.world_state(); let b = st.body(name)?; (app.session.doc.body_component(name, b.feature), "bodies") } Target::Sketch { id } => (app.session.doc.feature(*id)?.component, "sketches"), _ => return None, }; let sel = selected_keys(app, comp, folder); (sel.len() > 1).then(|| json!({ "component": comp, "folder": folder, "items": sel })) } /// Group actions from the menus. pub fn group_action(app: &mut SolveApp, action: &str, p: &Value) { match action { "ui.newGroup" | "ui.groupSelected" => { let folder = p.get("folder").and_then(Value::as_str).unwrap_or("bodies"); let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0); let items = p.get("items").cloned().unwrap_or(json!([])); if let Ok(r) = app.run("browser.group", json!({ "component": comp, "folder": folder, "items": items })) && let Some(g) = r.get("group").and_then(Value::as_u64) { app.tree.collapsed.remove(&format!("c{comp}/{folder}")); app.tree.collapsed.remove(&format!("g{g}")); } } "ui.ungroup" => { if let Some(g) = p.get("group") { let _ = app.run("browser.ungroup", json!({ "group": g })); } } "ui.renameGroup" => { let _ = app.run("browser.rename_group", p.clone()); } _ => {} } } // --------------------------------------------------------------------------------------------- // Drawing // --------------------------------------------------------------------------------------------- const ROW_H: f32 = 22.0; const INDENT: f32 = 14.0; /// One row's look. #[derive(Default)] struct Row<'a> { depth: usize, /// Some(open) for rows that fold. fold: Option, /// Some(visible) for rows with an eye. eye: Option, icon: &'a str, label: &'a str, selected: bool, dim: bool, color: Option, /// Component rows: is it the active one? radio: Option, /// Trailing badges: "lock", "pin". badges: &'a [&'a str], } /// What happened on a row. #[derive(Default)] struct RowResp { rect: Option, clicked: bool, double: bool, secondary: Option, fold: bool, eye: bool, radio: bool, hovered: bool, /// The row's response (drag and drop). resp: Option, /// The press began on the fold arrow, eye or radio (not a drag of the row). on_control: bool, } fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let t = Tokens::get(); // Rows scrolled out of view only take their space: no widgets, no painting. let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H)); if !ui.is_rect_visible(r) { ui.allocate_space(r.size()); return RowResp { rect: Some(r), ..Default::default() }; } // A stable id per row (not the layout position), so a press and its release stay on the // same row while rows above open or close. let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover()); let resp = ui.interact(r, id, Sense::click_and_drag()); // While something is dragged (an appearance swatch, drawn under the pointer) `hovered` is // off; the row under the pointer still counts, so a drop lands on it. let dragged_over = egui::DragAndDrop::has_any_payload(ui.ctx()) && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); let mut out = RowResp { rect: Some(r), hovered: resp.hovered() || dragged_over, ..Default::default() }; if row.selected { ui.painter().rect_filled(r, 3.0, t.accent_soft); } else if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } let mut x = r.left() + 2.0 + row.depth as f32 * INDENT; let cy = r.center().y; let ink = if row.dim { t.text_dim } else { t.icon }; // The row is one widget: where a click lands decides what it does (the fold arrow with // padding, the eye, the radio, or the row itself), so a click on the arrow never also // selects the row or starts a drag. let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom())); let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height())); let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(15.0, r.height())); let at = |p: Pos2| -> u8 { if row.fold.is_some() && fold_r.contains(p) { 1 } else if row.eye.is_some() && eye_r.contains(p) { 2 } else if row.radio.is_some() && radio_r.contains(p) { 3 } else { 0 } }; DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, row.fold.map(|_| fold_r), row.fold))); let press_on = ui.input(|i| i.pointer.press_origin()).map_or(0, at); let click_on = resp.interact_pointer_pos().map_or(0, at); let hover_on = resp.hover_pos().map_or(0, at); // Fold arrow. if let Some(open) = row.fold { let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0)); let pts = if open { vec![pos2(ar.left() + 2.0, ar.top() + 4.0), pos2(ar.right() - 2.0, ar.top() + 4.0), pos2(ar.center().x, ar.bottom() - 3.0)] } else { vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)] }; let ink = if hover_on == 1 { t.text } else { t.text_dim }; ui.painter().add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE)); out.fold = resp.clicked() && click_on == 1; crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center()); } x += 14.0; // Eye. if let Some(vis) = row.eye { let er = Rect::from_center_size(pos2(x + 8.0, cy), vec2(15.0, 15.0)); icons::paint(ui.painter(), er, "eye", if vis { ink } else { t.border }, t.icon_fill, t.accent); if !vis { ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim)); } out.eye = resp.clicked() && click_on == 2; } x += 18.0; // Activation radio (components). if let Some(active) = row.radio { let rc = pos2(x + 6.0, cy); ui.painter().circle_stroke(rc, 5.0, Stroke::new(1.2, if active { t.accent } else { t.text_dim })); if active { ui.painter().circle_filled(rc, 2.8, t.accent); } out.radio = resp.clicked() && click_on == 3; x += 15.0; } if !row.icon.is_empty() { let ir = Rect::from_min_size(pos2(x, cy - 8.0), vec2(16.0, 16.0)); icons::paint(ui.painter(), ir, row.icon, ink, if row.dim { t.panel_header } else { t.icon_fill }, t.accent); x += 21.0; } let col = row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }); let galley = ui.painter().layout_no_wrap(row.label.to_string(), FontId::proportional(12.5), col); let lw = galley.size().x; ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, col); let mut bx = (x + lw + 6.0).min(r.right() - 16.0); for b in row.badges { let br = Rect::from_center_size(pos2(bx + 6.0, cy), vec2(12.0, 12.0)); badge(ui.painter(), br, b, t.text_dim); bx += 15.0; } crate::scenario::publish_handle(&format!("row:{}", row.label), r.center()); let tip = match hover_on { 1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }), 2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }), 3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }), _ => None, }; let resp = match tip { Some(t) => resp.on_hover_text(t), None => resp, }; out.on_control = press_on != 0; out.clicked = resp.clicked() && click_on == 0; out.double = resp.double_clicked() && click_on == 0; out.resp = Some(resp.clone()); if resp.secondary_clicked() { out.secondary = resp.interact_pointer_pos().or(Some(r.left_bottom())); } out } /// Small glyphs after a row's label: a padlock, a pin (grounded). fn badge(p: &egui::Painter, r: Rect, name: &str, c: Color32) { let s = Stroke::new(1.3, c); match name { "lock" => { let body = Rect::from_min_max(pos2(r.left() + 1.5, r.center().y - 0.5), pos2(r.right() - 1.5, r.bottom() - 0.5)); p.rect_filled(body, 1.5, c); let top = pos2(r.center().x, r.top() + 4.0); p.add(egui::Shape::line( (0..=8) .map(|k| { let a = std::f32::consts::PI * (k as f32 / 8.0); pos2(top.x - 3.2 * a.cos(), top.y - 3.2 * a.sin() + 1.0) }) .chain([pos2(top.x + 3.2, body.top())]) .collect(), s, )); p.line_segment([pos2(top.x - 3.2, top.y + 1.0), pos2(top.x - 3.2, body.top())], s); } "pin" => { let head = pos2(r.center().x + 2.0, r.top() + 4.0); p.circle_filled(head, 3.2, c); p.line_segment([head, pos2(r.left() + 1.5, r.bottom() - 1.0)], Stroke::new(1.6, c)); } _ => {} } } /// A Ctrl/Shift-aware click on a selectable row. fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option) { let mods = ui.input(|i| i.modifiers); if mods.shift && let Some(a) = app.tree.anchor.clone() { let order = app.tree.order.clone(); if let (Some(i), Some(j)) = (order.iter().position(|k| *k == a), order.iter().position(|k| *k == key)) { let (lo, hi) = (i.min(j), i.max(j)); let items: Vec = order[lo..=hi].iter().filter_map(|k| sel_of_key(k)).collect(); let _ = app.run("select.set", json!({ "items": items, "add": mods.command || mods.ctrl })); return; } } app.tree.anchor = Some(key); let Some(sel) = sel else { return }; if mods.command || mods.ctrl { if app.session.selection.contains(&sel) { let rest: Vec = app.session.selection.iter().filter(|x| **x != sel).cloned().collect(); let _ = app.run("select.set", json!({ "items": rest })); } else { let _ = app.run("select.set", json!({ "items": [sel], "add": true })); } } else { app.tree.picked_components.clear(); let _ = app.run("select.set", json!({ "items": [sel] })); } } fn sel_of_key(k: &str) -> Option { if let Some(b) = k.strip_prefix("b:") { return Some(Sel::Body { name: b.to_string() }); } if let Some(s) = k.strip_prefix("s:") { return s.parse().ok().map(|id| Sel::Feature { id }); } None } fn is_open(app: &SolveApp, key: &str, default: bool) -> bool { if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) } } fn toggle(app: &mut SolveApp, key: &str, default: bool) { if default { if !app.tree.collapsed.remove(key) { app.tree.collapsed.insert(key.to_string()); } } else if !app.tree.expanded.remove(key) { app.tree.expanded.insert(key.to_string()); } } /// Everything a frame's rows want done, applied after drawing. #[derive(Default)] struct Actions { menu: Option<(Pos2, Target)>, hover_bodies: Vec, drop: Option, } pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); app.tree.order.clear(); DRAWN.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); egui::Panel::left("sc_browser") .exact_size(250.0) .resizable(false) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6))) .show(ui, |ui| { ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); ui.add_space(4.0); egui::ScrollArea::vertical() .auto_shrink([false, false]) .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; component_rows(app, ui, 0, 0, &mut acts); }); }); app.viewport.hover_bodies = acts.hover_bodies; if let Some(d) = acts.drop { apply_drop(app, d); } if let Some((at, target)) = acts.menu { crate::context_menu::open_for(app, at, target); } occurrence_panel(app, ui); capture_prompt(app, ui.ctx()); canvas_panel(app, ui.ctx()); redefine_panel(app, ui.ctx()); } fn component_name(app: &SolveApp, id: u64) -> String { if id == 0 { return app.session.doc.name.clone(); } let doc = &app.session.doc; let name = doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); let n = doc.occurrences.iter().filter(|o| o.component == id).count(); if n > 1 { format!("{name} ({n} instances)") } else { format!("{name}:1") } } fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions) { let t = Tokens::get(); let key = format!("c{id}"); let open = is_open(app, &key, true); let active = app.session.active_component == id; let bodies = crate::context_menu::component_bodies(app, id); let visible = bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b)); let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded); let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active; let name = component_name(app, id); let badges: &[&str] = if grounded { &["pin"] } else { &[] }; let row = Row { depth, fold: Some(open), eye: (id != 0).then_some(visible), icon: "component", label: &name, selected: app.tree.picked_components.contains(&id), dim: faded, radio: Some(active), badges, ..Default::default() }; let r = draw_row(ui, ui.id().with(("comp", id)), &row); if r.fold { toggle(app, &key, true); } if r.radio { let _ = app.run("component.activate", json!({ "component": id })); } if r.eye { if visible { app.session .visibility .hidden_bodies .extend(bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(b)).cloned().collect::>()); } else { app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)); } } if r.clicked { let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); if !ctrl { app.tree.picked_components.clear(); let _ = app.run("select.clear", json!({})); } if app.tree.picked_components.contains(&id) { app.tree.picked_components.retain(|c| *c != id); } else { app.tree.picked_components.push(id); } } if r.double { let _ = app.run("component.activate", json!({ "component": id })); } if r.hovered { acts.hover_bodies = bodies.clone(); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::Component { id })); } // Bodies and sketches dropped on a component move into it. if let Some(resp) = &r.resp && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != id && d.folder != "construction", DropMark::Box) { acts.drop = Some(DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() }); } if !open { return; } let d = depth + 1; if id == 0 { doc_settings(app, ui, d); named_view_rows(app, ui, d, acts); origin_rows(app, ui, d); canvas_rows(app, ui, d, acts); } let _ = t; for folder in ["bodies", "sketches", "construction"] { folder_rows(app, ui, id, folder, d, acts); } if id == 0 { crate::dialogs_assembly::browser_joints(app, ui, d); } let children: Vec = app.session.doc.components.iter().filter(|c| c.parent == id).map(|c| c.id).collect(); for c in children { component_rows(app, ui, c, d, acts); } } fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { let key = "docsettings"; let open = is_open(app, key, false); let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "settings", label: "Document Settings", ..Default::default() }); if r.fold || r.clicked { toggle(app, key, false); } if !open { return; } let units = format!("Units: {}", app.session.doc.units); let r = draw_row(ui, ui.id().with("units"), &Row { depth: depth + 1, icon: "settings", label: &units, ..Default::default() }); if let Some(p) = r.secondary.or(r.clicked.then(|| r.rect.map(|x| x.left_bottom())).flatten()) { crate::context_menu::open_for(app, p, Target::Units); } let prec = format!( "Precision: {} {}, {}°", format_args!("{:.*}", usize::from(app.preferences.length_decimals), 0.0), crate::prefs::unit_scale(&app.session.doc.units).1, format_args!("{:.*}", usize::from(app.preferences.angle_decimals), 0.0) ); if draw_row(ui, ui.id().with("precision"), &Row { depth: depth + 1, icon: "settings", label: &prec, ..Default::default() }).clicked { app.prefs_window.open = true; } let params = format!("Parameters ({})", app.session.doc.params.len()); if draw_row(ui, ui.id().with("params"), &Row { depth: depth + 1, icon: "params", label: ¶ms, ..Default::default() }).clicked { app.start("parameters.change"); } } fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { let key = "origin"; let open = is_open(app, key, false); let r = draw_row( ui, ui.id().with(key), &Row { depth, fold: Some(open), eye: Some(app.ui.show_origin), icon: "origin", label: "Origin", ..Default::default() }, ); if r.fold || r.clicked { toggle(app, key, false); } if r.eye { app.ui.show_origin = !app.ui.show_origin; } if let Some(p) = r.secondary { crate::context_menu::open_for(app, p, Target::Origin); } if !open { return; } let items = [ ("O", "point", "O"), ("X", "axis", "X"), ("Y", "axis", "Y"), ("Z", "axis", "Z"), ("XY", "plane", "XY"), ("XZ", "plane", "XZ"), ("YZ", "plane", "YZ"), ]; for (k, icon, label) in items { let visible = !app.session.visibility.hidden_origin.iter().any(|h| h == k); let sel = match icon { "plane" => Some(Sel::Plane { name: k.into() }), "axis" => Some(Sel::Axis { name: k.into() }), _ => None, }; let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x)); let r = draw_row( ui, ui.id().with(("o", k)), &Row { depth: depth + 1, eye: Some(visible), icon, label, selected, dim: !app.ui.show_origin, ..Default::default() }, ); if r.eye { if visible { app.session.visibility.hidden_origin.push(k.into()); } else { app.session.visibility.hidden_origin.retain(|h| h != k); } } if r.clicked && let Some(x) = sel { pick_from_browser(app, x); } } } /// The standard views listed under Named Views: (label, view). pub const STANDARD_VIEWS: [(&str, &str); 4] = [("Top", "top"), ("Front", "front"), ("Right", "right"), ("Home", "home")]; /// Named Views: the standard views, then the design's saved cameras. A double-click restores /// one (animated). fn named_view_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) { let key = "namedviews"; let open = is_open(app, key, false); let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Named Views", ..Default::default() }); if r.fold || r.clicked { toggle(app, key, false); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::NamedViews)); } if !open { return; } for (label, view) in STANDARD_VIEWS { let r = draw_row(ui, ui.id().with(("stdview", view)), &Row { depth: depth + 1, icon: "home", label, ..Default::default() }); if r.double { app.animate_view(view); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::NamedView { name: label.to_string() })); } } let names: Vec = app.session.doc.named_views.iter().map(|v| v.name.clone()).collect(); for name in names { let r = draw_row(ui, ui.id().with(("view", &name)), &Row { depth: depth + 1, icon: "perspective", label: &name, ..Default::default() }); if r.double { restore_view(app, &name); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::NamedView { name })); } } } /// Turn the camera to a named view (a saved one or a standard one), animated. pub fn restore_view(app: &mut SolveApp, name: &str) { if let Some((_, v)) = STANDARD_VIEWS.iter().find(|(l, _)| *l == name) { app.animate_view(v); return; } let Some(v) = app.session.doc.named_views.iter().find(|v| v.name == name) else { return }; let j = json!({ "target": v.target, "yaw": v.yaw, "pitch": v.pitch, "distance": v.distance, "fov": v.fov }); if let Ok(cam) = serde_json::from_value::(j) { app.animate_to(cam); } } /// Save the current camera as a named view. pub fn save_view(app: &mut SolveApp, name: Option<&str>) -> Option { let cam = serde_json::to_value(app.cam).ok()?; let r = app.run("view.save", json!({ "name": name, "camera": cam })).ok()?; app.tree.collapsed.remove("namedviews"); app.tree.expanded.insert("namedviews".into()); r.get("view").and_then(Value::as_str).map(str::to_string) } /// The menu of a named view. pub fn view_items(app: &SolveApp, name: &str) -> Vec { let saved = app.session.doc.named_views.iter().any(|v| v.name == name); vec![ Item::action("ui.restoreView", "Restore", "home").with(json!({ "view": name })), Item::action("ui.updateView", "Update to Current View", "perspective").with(json!({ "view": name })).on_if(saved), Item::sep(), Item::action("ui.rename", "Rename", "").with(json!({ "view": name })).on_if(saved), Item::action("view.delete", "Delete", "delete").with(json!({ "view": name })).on_if(saved), ] } /// 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", "Show/Hide", "eye").key("V").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) }; crate::frame::window(ctx, title, crate::frame::Width::Normal) .id(egui::Id::new("sc_canvas_panel")) .pivot(egui::Align2::LEFT_TOP) .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) { let hit = match &x { Sel::Plane { name } => crate::viewport::Hit::Plane { name: name.clone(), point: solvecraft_engine::geom::Vec3::ZERO }, Sel::Axis { name } => crate::viewport::Hit::Axis { name: name.clone() }, _ => return, }; if let Some(mut d) = app.dialog.take() { if let Some(sel) = d.candidate(&app.session, &hit) { d.pick(&app.session, sel); } app.dialog = Some(d); return; } let _ = app.run("select.set", json!({ "items": [x] })); } /// One item of a component folder. struct Entry { /// The browser key (body name, or the feature id of a sketch or plane). key: String, /// The key in [`TreeState`] row order (`b:`, `s:`, `p:`). row_key: String, label: String, icon: &'static str, visible: bool, selected: bool, color: Option, locked: bool, sel: Option, target: Option, /// Bodies lit when the row is hovered. bodies: Vec, } /// What is being dragged in the tree: items of one component folder. #[derive(Clone, Debug)] struct Drag { comp: u64, folder: &'static str, keys: Vec, } /// The items of a component folder, in model order. fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { let t = Tokens::get(); let doc = &app.session.doc; let sel = &app.session.selection; match folder { "bodies" => app .session .model .state() .bodies .iter() .filter(|b| doc.body_component(&b.name, b.feature) == comp) .map(|b| Entry { key: b.name.clone(), row_key: format!("b:{}", b.name), label: b.name.clone(), icon: "body", visible: !app.session.visibility.hidden_bodies.contains(&b.name), selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)), color: None, locked: app.ui.locked_bodies.contains(&b.name), sel: Some(Sel::Body { name: b.name.clone() }), target: Some(Target::Body { name: b.name.clone() }), bodies: vec![b.name.clone()], }) .collect(), "sketches" => doc .features .iter() .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) .map(|f| { let active = app.session.active_sketch == Some(f.id); Entry { key: f.id.to_string(), row_key: format!("s:{}", f.id), label: if active { format!("{} (editing)", f.name) } else { f.name.clone() }, icon: "sketch", visible: sketch_visible(app, f.id), selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)), color: active.then_some(t.sketch_accent), locked: false, sel: Some(Sel::Feature { id: f.id }), target: Some(Target::Sketch { id: f.id }), bodies: Vec::new(), } }) .collect(), _ => doc .features .iter() .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. })) .map(|f| Entry { key: f.id.to_string(), row_key: format!("p:{}", f.id), label: f.name.clone(), icon: "plane", visible: !app.session.visibility.hidden_origin.contains(&f.name), selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)), color: None, locked: false, sel: Some(Sel::Plane { name: f.name.clone() }), target: None, bodies: Vec::new(), }) .collect(), } } /// A folder's groups (with their items) and the items in no group, in browser order. fn arrange(app: &SolveApp, comp: u64, folder: &str, all: Vec) -> (Vec<(solvecraft_engine::doc::BrowserGroup, Vec)>, Vec) { let doc = &app.session.doc; let groups: Vec = doc.browser_groups.iter().filter(|g| g.component == comp && g.folder == folder).cloned().collect(); let mut rest: Vec> = all.into_iter().map(Some).collect(); let mut take = |key: &str| rest.iter_mut().find(|e| e.as_ref().is_some_and(|e| e.key == key)).and_then(Option::take); let grouped: Vec<_> = groups .into_iter() .map(|g| { let items = g.items.iter().filter_map(|k| take(k)).collect(); (g, items) }) .collect(); let mut loose: Vec = rest.into_iter().flatten().collect(); if let Some(order) = doc.browser_order.get(&format!("{comp}/{folder}")) { let pos = |k: &str| order.iter().position(|x| x == k).unwrap_or(usize::MAX); loose.sort_by_key(|e| pos(&e.key)); } (grouped, loose) } /// Show or hide items of a folder. fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) { for e in items { match folder { "bodies" => { app.session.visibility.hidden_bodies.retain(|b| *b != e.key); if !on { app.session.visibility.hidden_bodies.push(e.key.clone()); } } "sketches" => { if let Ok(id) = e.key.parse() { set_sketch_visible(app, id, on); } } _ => { app.session.visibility.hidden_origin.retain(|p| *p != e.label); if !on { app.session.visibility.hidden_origin.push(e.label.clone()); } } } } } fn folder_label(folder: &str) -> &'static str { match folder { "bodies" => "Bodies", "sketches" => "Sketches", _ => "Construction", } } fn folder_static(folder: &str) -> &'static str { match folder { "bodies" => "bodies", "sketches" => "sketches", _ => "construction", } } /// A component's folder: its row, its groups and its items. Rows are drag sources; items, /// groups and the folder row are drop targets (reorder, into a group, out of groups). fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) { let t = Tokens::get(); let all = entries(app, comp, folder); if all.is_empty() { return; } let fstatic = folder_static(folder); let key = format!("c{comp}/{folder}"); if let Some(r) = app.tree.reveal.clone() && folder == "bodies" && all.iter().any(|e| e.key == r) { app.tree.collapsed.remove(&key); app.tree.collapsed.remove(&format!("c{comp}")); for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&r)) { app.tree.collapsed.remove(&format!("g{}", g.id)); } } let open = is_open(app, &key, true); let any_visible = if folder == "sketches" { app.ui.show_sketches } else { all.iter().any(|e| e.visible) }; let r = draw_row( ui, ui.id().with(&key), &Row { depth, fold: Some(open), eye: Some(any_visible), icon: "folder", label: folder_label(folder), ..Default::default() }, ); if r.fold || r.clicked { toggle(app, &key, true); } if r.eye { if folder == "sketches" { app.ui.show_sketches = !app.ui.show_sketches; } else { let refs: Vec<&Entry> = all.iter().collect(); set_visible(app, folder, &refs, !any_visible); } } if r.hovered { acts.hover_bodies = all.iter().flat_map(|e| e.bodies.clone()).collect(); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::Folder { component: comp, folder: fstatic.to_string() })); } // Dropping on the folder row takes items out of their groups, to the end. if let Some(resp) = &r.resp && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box) { acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: None, before: None }); } if !open { return; } let (groups, loose) = arrange(app, comp, folder, all); let loose_keys: Vec = loose.iter().map(|e| e.key.clone()).collect(); for (g, items) in &groups { let gkey = format!("g{}", g.id); let gopen = is_open(app, &gkey, true); let gvis = items.is_empty() || items.iter().any(|e| e.visible); let label = format!("{} ({})", g.name, items.len()); let r = draw_row( ui, ui.id().with(&gkey), &Row { depth: depth + 1, fold: Some(gopen), eye: Some(gvis), icon: "folder", label: &label, dim: !gvis, ..Default::default() }, ); if r.fold || r.clicked { toggle(app, &gkey, true); } if r.eye { let refs: Vec<&Entry> = items.iter().collect(); set_visible(app, folder, &refs, !gvis); } if r.hovered { acts.hover_bodies = items.iter().flat_map(|e| e.bodies.clone()).collect(); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::Group { id: g.id })); } if let Some(resp) = &r.resp && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box) { acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(g.id), before: None }); } if gopen { for e in items { entry_row(app, ui, comp, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts); } } } for e in &loose { entry_row(app, ui, comp, fstatic, e, depth + 1, None, &loose_keys, acts); } let _ = t; } /// One item row of a folder. #[allow(clippy::too_many_arguments)] fn entry_row( app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &'static str, e: &Entry, depth: usize, group: Option, loose: &[String], acts: &mut Actions, ) { app.tree.order.push(e.row_key.clone()); let badges: &[&str] = if e.locked { &["lock"] } else { &[] }; let r = draw_row( ui, ui.id().with(("item", &e.row_key)), &Row { depth, eye: Some(e.visible), icon: e.icon, label: &e.label, selected: e.selected, dim: !e.visible, color: e.color, badges, ..Default::default() }, ); if folder == "bodies" && app.tree.reveal.as_deref() == Some(e.key.as_str()) && let Some(rect) = r.rect { ui.scroll_to_rect(rect, Some(egui::Align::Center)); app.tree.reveal = None; } if r.eye { set_visible(app, folder, &[e], !e.visible); } if r.double && folder == "sketches" { if let Ok(id) = e.key.parse() { app.edit_sketch(id); } } else if r.clicked { if folder == "construction" { if let Some(s) = e.sel.clone() { pick_from_browser(app, s); } } else { click_select(app, ui, e.row_key.clone(), e.sel.clone()); } } if r.hovered && !e.bodies.is_empty() { acts.hover_bodies = e.bodies.clone(); } if let Some(p) = r.secondary && let Some(t) = e.target.clone() { if !e.selected && let Some(s) = &e.sel { let _ = app.run("select.set", json!({ "items": [s] })); } acts.menu = Some((p, t)); } let Some(resp) = &r.resp else { return }; // Drag: the selected items of this folder when this one is selected, else just this one. if resp.drag_started() && !r.on_control { let keys: Vec = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] }; resp.dnd_set_drag_payload(Drag { comp, folder, keys }); } if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key), DropMark::Above) { acts.drop = Some(match group { Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), group: Some(g), before: Some(e.key.clone()) }, None => { let mut order: Vec = loose.iter().filter(|k| !d.keys.contains(k)).cloned().collect(); let at = order.iter().position(|k| *k == e.key).unwrap_or(order.len()); for (i, k) in d.keys.iter().enumerate() { order.insert(at + i, k.clone()); } DropAction::Order { comp, folder, keys: d.keys.clone(), order } } }); } } /// Keys of the selected items of a component folder. fn selected_keys(app: &SolveApp, comp: u64, folder: &str) -> Vec { entries(app, comp, folder).into_iter().filter(|e| e.selected).map(|e| e.key).collect() } #[derive(Clone, Copy, PartialEq)] enum DropMark { /// A line above the row: the items go before it. Above, /// A box around the row: the items go into it. Box, } /// While a matching payload hovers the row, mark where it would land; on release, the payload. fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> bool, mark: DropMark) -> Option> { let t = Tokens::get(); let hovering = resp.dnd_hover_payload::().filter(|d| accept(d)); if hovering.is_some() { let r = resp.rect; match mark { DropMark::Above => ui.painter().hline(r.x_range(), r.top(), Stroke::new(2.0, t.accent)), DropMark::Box => ui.painter().rect_stroke(r.shrink(0.5), 3.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside), }; } // Taking a payload takes it whatever its type: leave other drags (appearance swatches) be. if !egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { return None; } resp.dnd_release_payload::().filter(|d| accept(d)) } /// A drop, applied after the tree is drawn. enum DropAction { /// Into a group (before an item of it), or out of groups. Move { comp: u64, folder: &'static str, keys: Vec, group: Option, before: Option }, /// Out of groups, in this order of the folder's loose items. Order { comp: u64, folder: &'static str, keys: Vec, order: Vec }, /// Into another component (bodies and sketches). Component { to: u64, folder: &'static str, keys: Vec }, } fn apply_drop(app: &mut SolveApp, d: DropAction) { match d { DropAction::Move { comp, folder, keys, group, before } => { let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": keys, "group": group, "before": before })); } DropAction::Order { comp, folder, keys, order } => { let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": keys })); let _ = app.run("browser.order", json!({ "component": comp, "folder": folder, "order": order })); } DropAction::Component { to, folder, keys } => { let _ = match folder { "bodies" => app.run("component.move_bodies", json!({ "bodies": keys, "component": to })), "sketches" => app.run("component.move_sketches", json!({ "sketches": keys, "component": to })), _ => Ok(serde_json::Value::Null), }; } } } // --------------------------------------------------------------------------------------------- // Panels opened from the menus // --------------------------------------------------------------------------------------------- /// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position. fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { let Some(mut m) = app.tree.occurrence_move.clone() else { return }; let ctx = ui.ctx().clone(); if m.pivot.is_none() { m.pivot = occurrence_origin(app, m.occurrence); } let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default(); let mut open = true; let mut action: Option<&str> = None; let before = (m.translate, m.angles); crate::frame::window(&ctx, format!("Move: {name}"), crate::frame::Width::Normal) .id(egui::Id::new("sc_occ_move")) .pivot(egui::Align2::LEFT_TOP) .default_pos(panel_pos(app)) .open(&mut open) .resizable(false) .collapsible(false) .show(&ctx, |ui| { egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.translate.get_mut(k) { ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); } ui.end_row(); } for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.angles.get_mut(k) { ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg")); } ui.end_row(); } }); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Capture Position").clicked() { action = Some("capture"); } if ui.button("Revert").clicked() { action = Some("revert"); } }); }); triad(app, ui, &mut m); if (m.translate, m.angles) != before { apply_occurrence_move(app, &m); } match action { Some("capture") => { let _ = app.run("component.capture_position", json!({})); app.tree.occurrence_move = None; } Some(_) => { let _ = app.run("component.revert_position", json!({})); app.tree.occurrence_move = None; } None if !open => { let _ = app.run("component.revert_position", json!({})); app.tree.occurrence_move = None; } None => app.tree.occurrence_move = Some(m), } } /// The pending move: turned about X, then Y, then Z through the starting origin, then moved. pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) { use crate::gizmo; use solvecraft_engine::geom::Vec3; let a = m.angles.map(f64::to_radians); let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0])); let (axis, angle) = gizmo::axis_angle(&r); let o = m.pivot.unwrap_or(Vec3::ZERO); let _ = app.session.execute("component.revert_position", &json!({})); let _ = app.run( "occurrence.move", json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }), ); } /// The shared move triad at the occurrence's origin: arrows and squares move it, rings turn it. fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) { use crate::gizmo::{self, Change}; let (Some(rect), Some(pivot)) = (app.viewport.rect, m.pivot) else { return }; let proj = crate::viewport::projection(app, rect); let center = pivot + solvecraft_engine::geom::Vec3::new(m.translate[0], m.translate[1], m.translate[2]); let layer = egui::LayerId::new(egui::Order::Middle, egui::Id::new("sc_occ_triad_layer")); let mut gui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(rect)); let painter = gui.painter().with_clip_rect(rect); let t = gizmo::Triad { center, translate: true, rotate: true }; let step = crate::canvas::snap_step(app.cam.half_height()); let Some(g) = gizmo::show(&mut gui, &painter, &proj, egui::Id::new("sc_occ_triad"), &t, step) else { return }; if g.started || m.drag0.is_none() { m.drag0 = Some((m.translate, m.angles)); } let (t0, a0) = m.drag0.unwrap_or((m.translate, m.angles)); match g.change { Change::Translate(v) => m.translate = [t0[0] + v.x, t0[1] + v.y, t0[2] + v.z], Change::Rotate { axis, angle } => { if let Some(slot) = m.angles.get_mut(axis) { *slot = a0.get(axis).copied().unwrap_or(0.0) + angle.to_degrees(); } } } if g.done { m.drag0 = None; } } /// Dimensions of finished sketches whose dimensions are shown (Show Dimensions), drawn over the /// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable. pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { use solvecraft_engine::geom::Vec2; use solvecraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout}; let Some(rect) = app.viewport.rect else { return }; if app.ui.shown_dims.is_empty() { return; } let tk = Tokens::get(); let proj = crate::viewport::projection(app, rect); let painter = ctx.layer_painter(egui::LayerId::background()).with_clip_rect(rect); let st = app.session.world_state(); for id in app.ui.shown_dims.iter().filter(|id| app.session.active_sketch != Some(**id) && sketch_visible(app, **id)) { let Some(ss) = st.sketch(*id) else { continue }; let sk = &ss.sketch; let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); // Unplaced dimensions sit away from the middle of the sketch. let centres = chain_centres(sk); let n = centres.len().max(1) as f64; let middle = centres.iter().fold(Vec2::ZERO, |a, p| a + *p) * (1.0 / n); for c in &sk.constraints { let Some(frame) = dim_frame(sk, &c.kind) else { continue }; let anchor = match frame { DimFrame::Linear { p0, p1, .. } => (p0 + p1) * 0.5, DimFrame::Radial { center, .. } | DimFrame::ArcLength { center, .. } => center, DimFrame::Angular { vertex, .. } => vertex, }; let (Some(a), Some(b)) = (to(anchor), to(anchor + Vec2::X)) else { continue }; let px = 1.0 / f64::from(a.distance(b)).max(1e-9); if !px.is_finite() { continue; } let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str()); let col = if c.driven { tk.dimension_driven } else { tk.dimension }; let galley = painter.layout_no_wrap(crate::dim_view::label(&c.kind, expr, c.driven), FontId::proportional(11.5), col); let size = galley.size() + vec2(6.0, 2.0); let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px); let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half); for line in &lay.lines { painter.add(egui::Shape::line(line.iter().filter_map(|q| to(*q)).collect(), Stroke::new(1.0, col))); } for (tip, dir) in &lay.arrows { if let (Some(t), Some(back)) = (to(*tip), to(*tip - *dir * px)) { let d = (t - back).normalized(); let n = vec2(-d.y, d.x); painter.add(egui::Shape::convex_polygon(vec![t, t - d * 9.0 + n * 3.0, t - d * 9.0 - n * 3.0], col, Stroke::NONE)); } } let Some(tc) = to(lay.text) else { continue }; let r = Rect::from_center_size(tc, size); painter.rect_filled(r, 2.0, tk.viewport_top.gamma_multiply(0.85)); painter.galley(r.min + vec2(3.0, 1.0), galley, col); } } } /// Where the browser's panels open: the viewport's top left corner. fn panel_pos(app: &SolveApp) -> Pos2 { app.viewport.rect.map(|r| r.left_top() + vec2(16.0, 16.0)).unwrap_or(pos2(270.0, 140.0)) } /// The Capture Position question stands for saving. pub const SAVE: &str = "__save"; /// Does starting `what` (a command id or [`SAVE`]) need the Capture Position question first? /// Yes while components were moved without capturing their positions, except for the commands /// that capture, revert or keep moving. pub fn needs_capture(app: &SolveApp, what: &str) -> bool { !app.session.pending_moves.is_empty() && !matches!(what, "component.capture_position" | "component.revert_position" | "occurrence.move" | "edit.undo" | "edit.redo") && app.tree.capture_prompt.is_none() } /// Answer the Capture Position question: `capture`, `revert` or `cancel`. The waiting command /// (or save) then goes ahead, unless cancelled. pub fn answer_capture(app: &mut SolveApp, action: &str) -> Result<(), String> { let Some(what) = app.tree.capture_prompt.clone() else { return Err("no Capture Position question is open".into()) }; match action { "capture" => drop(app.run("component.capture_position", json!({}))), "revert" => drop(app.run("component.revert_position", json!({}))), "cancel" => { app.tree.capture_prompt = None; return Ok(()); } other => return Err(format!("unknown answer `{other}` (capture, revert, cancel)")), } app.tree.capture_prompt = None; app.tree.occurrence_move = None; if what == SAVE { crate::toolbar::save(app); } else { app.start(&what); } Ok(()) } fn capture_prompt(app: &mut SolveApp, ctx: &egui::Context) { if app.tree.capture_prompt.is_none() { return; } let n = app.session.pending_moves.len(); let mut answer: Option<&str> = None; crate::frame::window(ctx, "Capture Position", crate::frame::Width::Normal) .id(egui::Id::new("sc_capture_prompt")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -80.0)) .show(ctx, |ui| { ui.label(if n == 1 { "A component was moved but its position isn't captured.".to_string() } else { format!("{n} components were moved but their positions aren't captured.") }); ui.label(RichText::new("Capture keeps the new position; Revert puts it back.").color(Tokens::get().text_dim)); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Capture Position").clicked() { answer = Some("capture"); } if ui.button("Revert").clicked() { answer = Some("revert"); } if ui.button("Cancel").clicked() { answer = Some("cancel"); } }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some("cancel"); } }); if let Some(a) = answer { let _ = answer_capture(app, a); } } /// Where an occurrence's origin is now (with its pending move). fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option { let o = app.session.doc.occurrences.iter().find(|o| o.id == occurrence)?; let parent = app.session.doc.component_transform(o.parent); let own = app.session.pending_moves.get(&occurrence).copied().unwrap_or(o.transform); Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO)) } /// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face. fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) { let Some(id) = app.tree.redefine else { return }; let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); let mut planes: Vec = vec!["XY".into(), "XZ".into(), "YZ".into()]; planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone())); let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None }); let mut open = true; let mut pick: Option = None; crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal) .id(egui::Id::new("sc_redefine")) .pivot(egui::Align2::LEFT_TOP) .default_pos(panel_pos(app)) .open(&mut open) .resizable(false) .collapsible(false) .show(ctx, |ui| { ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim)); ui.horizontal_wrapped(|ui| { for p in &planes { if ui.button(p).clicked() { pick = Some(json!(p)); } } }); ui.add_space(4.0); let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face")); if b.clicked() && let Some(f) = face { pick = Some(json!({ "face": [f.x, f.y, f.z] })); } b.on_disabled_hover_text("Select a planar face in the viewport first"); }); if let Some(plane) = pick { if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() { app.tree.redefine = None; } } else if !open { app.tree.redefine = None; } }