//! 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}; /// 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, /// 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, pub translate: [f64; 3], pub angle_deg: f64, } impl OccMove { pub fn new(occurrence: u64) -> Self { OccMove { occurrence, translate: [0.0; 3], angle_deg: 0.0 } } } // --------------------------------------------------------------------------------------------- // Queries used by the menus // --------------------------------------------------------------------------------------------- /// Is a sketch drawn in the viewport? pub fn sketch_visible(app: &SolveApp, id: u64) -> bool { if app.ui.hidden_sketches.contains(&id) { return false; } app.ui.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.ui.hidden_sketches.retain(|x| *x != id); app.ui.shown_sketches.retain(|x| *x != id); if on { app.ui.shown_sketches.push(id); } else { app.ui.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.model.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.ui.hidden_bodies.contains(b)); v.push(Item::sep()); v.push( Item::action(if hidden { "ui.show" } else { "ui.hide" }, if hidden { "Show" } else { "Hide" }, "eye").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 mut v = vec![Item::action("ui.newGroup", "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 }))); } if folder == "bodies" { v.push(Item::sep()); v.push(Item::action("ui.showAll", "Show All Bodies", "eye")); } v } /// 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.model.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, } fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let t = Tokens::get(); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); let mut out = RowResp { rect: Some(r), hovered: resp.hovered(), ..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 }; // 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)] }; ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked(); } 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)); } let e = ui.interact(er.expand(2.0), id.with("eye"), Sense::click()); out.eye = e.clicked(); e.on_hover_text(if vis { "Hide" } else { "Show" }); } 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); } let rr = ui.interact(Rect::from_center_size(rc, vec2(14.0, 14.0)), id.with("radio"), Sense::click()); out.radio = rr.clicked(); rr.on_hover_text(if active { "Active component" } else { "Activate" }); 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; } out.resp = Some(resp.clone()); out.clicked = resp.clicked(); out.double = resp.double_clicked(); 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(); 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.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.ui.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.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::>()); } else { app.ui.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); origin_rows(app, ui, d); } let _ = t; for folder in ["bodies", "sketches", "construction"] { folder_rows(app, ui, id, folder, d, acts); } 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); draw_row(ui, ui.id().with("units"), &Row { depth: depth + 1, icon: "settings", label: &units, ..Default::default() }); 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("ChangeParameterCommand"); } } 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 !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.ui.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.ui.hidden_origin.push(k.into()); } else { app.ui.hidden_origin.retain(|h| h != k); } } if r.clicked && let Some(x) = sel { pick_from_browser(app, x); } } } /// 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.ui.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.ui.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.ui.hidden_bodies.retain(|b| *b != e.key); if !on { app.ui.hidden_bodies.push(e.key.clone()); } } "sketches" => { if let Ok(id) = e.key.parse() { set_sketch_visible(app, id, on); } } _ => { app.ui.hidden_origin.retain(|p| *p != e.label); if !on { app.ui.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() { 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), }; } 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, ctx: &egui::Context) { let Some(mut m) = app.tree.occurrence_move.clone() else { return }; 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.angle_deg); egui::Window::new(format!("Move: {name}")) .id(egui::Id::new("sc_occ_move")) .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(); } ui.label("Z angle"); ui.add(egui::DragValue::new(&mut m.angle_deg).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"); } }); }); gizmo(app, ctx, &mut m); if (m.translate, m.angle_deg) != before { // The pending move is the typed offset from where the occurrence is now. let _ = app.session.execute("AsBuiltPositionsCmd", &json!({})); let _ = app.run( "occurrence.move", json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [0, 0, 1], "angle": m.angle_deg.to_radians(), "capture": false }), ); } match action { Some("capture") => { let _ = app.run("SnapshotCmd", json!({})); app.tree.occurrence_move = None; } Some(_) => { let _ = app.run("AsBuiltPositionsCmd", json!({})); app.tree.occurrence_move = None; } None if !open => { let _ = app.run("AsBuiltPositionsCmd", json!({})); app.tree.occurrence_move = None; } None => app.tree.occurrence_move = Some(m), } } /// 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)) } /// 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)) } /// Screen length of the gizmo's arrows. const GIZMO_PX: f32 = 80.0; /// Arrows along X, Y and Z at the occurrence's origin: dragging one moves it along that axis. fn gizmo(app: &mut SolveApp, ctx: &egui::Context, m: &mut OccMove) { use solvecraft_engine::geom::Vec3; let t = Tokens::get(); let (Some(rect), Some(o)) = (app.viewport.rect, occurrence_origin(app, m.occurrence)) else { return }; let proj = crate::viewport::projection(app, rect); let Some(p0) = proj.to_screen(o) else { return }; if !rect.contains(p0) { return; } let axes = [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)]; // Per axis: the screen direction and pixels per millimetre. let mut arms = Vec::new(); for (k, (a, col)) in axes.iter().enumerate() { let Some(p1) = proj.to_screen(o + *a) else { continue }; let d = p1 - p0; let px_per_mm = d.length(); if px_per_mm < 1e-6 || !px_per_mm.is_finite() { continue; } arms.push((k, d / px_per_mm, px_per_mm, *col)); } let bbox = Rect::from_center_size(p0, vec2(2.0 * GIZMO_PX + 30.0, 2.0 * GIZMO_PX + 30.0)).intersect(rect); egui::Area::new(egui::Id::new("sc_occ_gizmo")).fixed_pos(bbox.min).order(egui::Order::Middle).show(ctx, |ui| { let (_, _) = ui.allocate_exact_size(bbox.size(), Sense::hover()); let painter = ui.painter_at(bbox); for (k, dir, px_per_mm, col) in &arms { // An axis seen end-on has no arrow to drag. let len = GIZMO_PX * dir.length().min(1.0); let tip = p0 + *dir * len; let handle = Rect::from_center_size(p0 + *dir * (len * 0.6), vec2(len * 0.8, len * 0.8)).intersect(Rect::from_two_pos(p0, tip).expand(9.0)); let resp = ui.interact(handle, ui.id().with(("arm", *k)), Sense::drag()); let lit = resp.hovered() || resp.dragged(); let w = if lit { 4.0 } else { 2.5 }; painter.line_segment([p0 + *dir * 10.0, tip], Stroke::new(w, *col)); let n = vec2(-dir.y, dir.x); painter.add(egui::Shape::convex_polygon(vec![tip + *dir * 10.0, tip + n * 5.5, tip - n * 5.5], *col, Stroke::NONE)); if resp.dragged() { let mm = resp.drag_delta().dot(*dir) / px_per_mm; if let Some(v) = m.translate.get_mut(*k) && mm.is_finite() { *v += f64::from(mm); } } if resp.drag_stopped() && let Some(v) = m.translate.get_mut(*k) { *v = (*v * 100.0).round() / 100.0; } } painter.circle(p0, 6.0, t.panel, Stroke::new(1.5, t.accent)); }); } /// 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; egui::Window::new(format!("Redefine Sketch Plane: {name}")) .id(egui::Id::new("sc_redefine")) .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; } }