Files
solidcraft-upstream/crates/ui-egui/src/browser.rs
T

1663 lines
67 KiB
Rust

//! 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<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = 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<Rect>, Option<bool>)> {
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<String>,
/// Folders opened by the user (folders that start closed).
pub expanded: BTreeSet<String>,
/// Scroll to this body's row and open its folders (Find in Browser).
pub reveal: Option<String>,
/// The Move/Copy panel of an occurrence.
pub occurrence_move: Option<OccMove>,
/// The Redefine Sketch Plane panel (sketch id).
pub redefine: Option<u64>,
/// Components picked in the tree (they have no viewport selection).
pub picked_components: Vec<u64>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// The Edit Canvas / Calibrate panel.
pub canvas_panel: Option<CanvasPanel>,
/// What waits for the Capture Position question: a command id, or [`SAVE`].
pub capture_prompt: Option<String>,
/// The last row clicked (for Shift ranges).
anchor: Option<String>,
/// Row keys in drawing order this frame (for Shift ranges).
order: Vec<String>,
}
/// 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<solvecraft_engine::geom::Vec3>,
/// 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<String> {
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<String> {
find_group(app, id).map(|g| g.name.clone())
}
/// The menu of a group row.
pub fn group_items(app: &SolveApp, id: u64) -> Vec<Item> {
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<Item> {
let sel: Vec<String> = selected_keys(app, component, folder);
let all = entries(app, component, folder);
let mut v = Vec::new();
if folder == "bodies" {
let bodies: Vec<String> = 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<Item> {
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<Value> {
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<bool>,
/// Some(visible) for rows with an eye.
eye: Option<bool>,
icon: &'a str,
label: &'a str,
selected: bool,
dim: bool,
color: Option<Color32>,
/// Component rows: is it the active one?
radio: Option<bool>,
/// Trailing badges: "lock", "pin".
badges: &'a [&'a str],
}
/// What happened on a row.
#[derive(Default)]
struct RowResp {
rect: Option<Rect>,
clicked: bool,
double: bool,
secondary: Option<Pos2>,
fold: bool,
eye: bool,
radio: bool,
hovered: bool,
/// The row's response (drag and drop).
resp: Option<egui::Response>,
/// 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<Sel>) {
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<Sel> = 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<Sel> = 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<Sel> {
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<String>,
drop: Option<DropAction>,
}
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::<Vec<_>>());
} 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<u64> = 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: &params, ..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<String> = 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::<solvecraft_engine::render::Camera>(j) {
app.animate_to(cam);
}
}
/// Save the current camera as a named view.
pub fn save_view(app: &mut SolveApp, name: Option<&str>) -> Option<String> {
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<Item> {
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<Item> {
let visible = app.session.doc.canvases.iter().find(|c| c.id == id).is_some_and(|c| c.visible);
vec![
Item::action("ui.editCanvas", "Edit Canvas", "canvas").with(json!({ "canvas": id })),
Item::action("ui.calibrateCanvas", "Calibrate", "measure").with(json!({ "canvas": id })),
Item::sep(),
Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })),
Item::action("ui.rename", "Rename", "").with(json!({ "canvas": id })),
Item::sep(),
Item::action("ui.canvasVisible", "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<Value> = 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<Color32>,
locked: bool,
sel: Option<Sel>,
target: Option<Target>,
/// Bodies lit when the row is hovered.
bodies: Vec<String>,
}
/// What is being dragged in the tree: items of one component folder.
#[derive(Clone, Debug)]
struct Drag {
comp: u64,
folder: &'static str,
keys: Vec<String>,
}
/// The items of a component folder, in model order.
fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
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<Entry>) -> (Vec<(solvecraft_engine::doc::BrowserGroup, Vec<Entry>)>, Vec<Entry>) {
let doc = &app.session.doc;
let groups: Vec<solvecraft_engine::doc::BrowserGroup> =
doc.browser_groups.iter().filter(|g| g.component == comp && g.folder == folder).cloned().collect();
let mut rest: Vec<Option<Entry>> = 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<Entry> = 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<String> = 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<u64>,
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<String> = 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<String> = 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<String> {
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<std::sync::Arc<Drag>> {
let t = Tokens::get();
let hovering = resp.dnd_hover_payload::<Drag>().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::<Drag>(ui.ctx()) {
return None;
}
resp.dnd_release_payload::<Drag>().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<String>, group: Option<u64>, before: Option<String> },
/// Out of groups, in this order of the folder's loose items.
Order { comp: u64, folder: &'static str, keys: Vec<String>, order: Vec<String> },
/// Into another component (bodies and sketches).
Component { to: u64, folder: &'static str, keys: Vec<String> },
}
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<solvecraft_engine::geom::Vec3> {
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<String> = 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<Value> = 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;
}
}