Context menus: radial marking menu in the viewport (Repeat on top, Undo, Delete, Press Pull, Create Sketch, Extrude, Move/Copy, Hole; right press-and-hold or a right drag on the ring flicks to pick), contextual lists for faces, edges, bodies, sketch entities (construction, fix, applicable constraints) and empty space; browser menus for bodies, sketches and components; rename box, Properties window; body.rename and sketch.redefine commands; ui.menu / ui.menuPick / ui.rename control methods

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 01:38:52 +00:00
1 parent 1df3e3fe0f
commit fa8da64613
11 files changed
+2333 -217

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
//! Browser commands: renaming bodies, moving a sketch to another plane.
use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
.icon("plane")
.params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
];
/// Replace every string equal to `old` with `new`; true when something changed.
fn replace_str(v: &mut Value, old: &str, new: &str, depth: usize) -> bool {
if depth > 64 {
return false;
}
match v {
Value::String(s) if s == old => {
*s = new.to_string();
true
}
Value::Array(a) => a.iter_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc),
Value::Object(o) => o.values_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc),
_ => false,
}
}
fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "body.rename";
let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string();
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
if name == old {
return Ok(json!({"body": old, "name": name}));
}
let st = s.model.state();
let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?;
if st.body(name).is_some() {
return Err(bad(cmd, format!("another body is called `{name}`")));
}
// The body is the k-th new body of its feature; the names before it are kept as they are.
let fid = b.feature;
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
let d = s.doc_mut();
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
while f.body_names.len() <= k {
let i = f.body_names.len();
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
}
if let Some(slot) = f.body_names.get_mut(k) {
*slot = name.to_string();
}
// Later features refer to bodies by name.
let mut failed = None;
for f in d.features.iter_mut().filter(|f| f.id != fid) {
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
if replace_str(&mut v, &old, name, 0) {
match serde_json::from_value(v) {
Ok(k) => f.kind = k,
Err(e) => failed = Some(e.to_string()),
}
}
}
if let Some(e) = failed {
return Err(bad(cmd, e));
}
if let Some(m) = d.materials.remove(&old) {
d.materials.insert(name.to_string(), m);
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
}
Ok(json!({"body": old, "name": name}))
}
fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.redefine";
let key = match p.get("sketch") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
};
let id = match s.doc.find_feature(&key) {
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => f.id,
_ => return Err(bad(cmd, format!("no sketch `{key}`"))),
};
if p.get("plane").is_none() {
return Err(bad(cmd, "`plane` is required"));
}
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane(&vals, &plane, 0)?;
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
*slot = plane;
}
Ok(json!({ "sketch": id }))
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
#[test]
fn renamed_body_keeps_its_users() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "body.rename", json!({"body": b, "name": "Lid"}));
assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body");
// Duplicate names and unknown bodies are refused; undo restores the old name.
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err());
run(&mut s, "UndoCommand", json!({}));
assert_eq!(names(&s)[1], b);
}
#[test]
fn redefined_sketch_moves_its_extrude() {
let mut s = Session::default();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
assert!(z > 9.0, "the profile stands up on XZ: {z}");
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
}
}
+2
View File
@@ -2,6 +2,7 @@
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
//! as the parity metric (`cargo xtask parity`).
mod browser;
mod clipboard;
mod component;
mod edit;
@@ -125,6 +126,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
+1 -1
View File
@@ -284,7 +284,7 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
// ---------------------------------------------------------------------------------------------
// Sketch lifecycle
fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
let base = match p.get("plane") {
None => PlaneRef::Origin { name: "XY".into() },
Some(Value::String(n)) => {
+736 -121
View File
@@ -1,32 +1,481 @@
//! The browser (left): document settings, origin, bodies and sketches.
//! 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 egui::{Color32, RichText, Sense, vec2};
use serde_json::json;
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};
fn row(ui: &mut egui::Ui, icon: &str, label: &str, color: Option<Color32>, selected: bool) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click());
if selected {
ui.painter().rect_filled(r, 2.0, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let ir = egui::Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0));
icons::paint(ui.painter(), ir, icon, t.icon, t.icon_fill, t.accent);
ui.painter().text(r.min + vec2(22.0, 10.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), color.unwrap_or(t.text));
resp
/// 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>,
/// The last row clicked (for Shift ranges).
anchor: Option<String>,
/// Row keys in drawing order this frame (for Shift ranges).
order: Vec<String>,
}
fn eye(ui: &mut egui::Ui, visible: bool) -> bool {
/// 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);
}
pub fn group_bodies(_app: &SolveApp, _id: u64) -> Vec<String> {
Vec::new()
}
pub fn group_items(_app: &SolveApp, _id: u64) -> Vec<Item> {
Vec::new()
}
pub fn group_name(_app: &SolveApp, _id: u64) -> Option<String> {
None
}
pub fn group_action(_app: &mut SolveApp, _action: &str, _p: &Value) {}
// ---------------------------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------------------------
const ROW_H: f32 = 22.0;
const INDENT: f32 = 14.0;
/// One row's look.
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],
}
impl Default for Row<'_> {
fn default() -> Self {
Row { depth: 0, fold: None, eye: None, icon: "", label: "", selected: false, dim: false, color: None, radio: None, badges: &[] }
}
}
/// 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,
}
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(16.0, 16.0), Sense::click());
icons::paint(ui.painter(), r, "eye", if visible { t.icon } else { t.border }, t.icon_fill, t.accent);
resp.on_hover_text(if visible { "Hide" } else { "Show" }).clicked()
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click());
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.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<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>,
}
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]).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((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::<Vec<_>>());
} 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 }));
}
if !open {
return;
}
let d = depth + 1;
if id == 0 {
doc_settings(app, ui, d);
origin_rows(app, ui, d);
}
let _ = t;
body_rows(app, ui, id, d, acts);
sketch_rows(app, ui, id, d, acts);
plane_rows(app, ui, id, 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);
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: &params, ..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,
@@ -47,110 +496,276 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) {
let _ = app.run("select.set", json!({ "items": [x] }));
}
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
/// A folder row (Bodies, Sketches, Construction) with an eye for everything in it; true when
/// it is open.
fn folder(app: &mut SolveApp, ui: &mut egui::Ui, key: &str, label: &str, depth: usize, visible: Option<bool>) -> (bool, RowResp) {
let open = is_open(app, key, true);
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), eye: visible, icon: "folder", label, ..Default::default() });
if r.fold || r.clicked {
toggle(app, key, true);
}
(open, r)
}
fn body_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize, acts: &mut Actions) {
let st = app.session.model.state();
let doc = app.session.doc.clone();
let bodies: Vec<String> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp).map(|b| b.name.clone()).collect();
if bodies.is_empty() {
return;
}
let key = format!("c{comp}/bodies");
let reveal = app.tree.reveal.as_ref().is_some_and(|b| bodies.contains(b));
if reveal {
app.tree.collapsed.remove(&key);
app.tree.collapsed.remove(&format!("c{comp}"));
}
let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b));
let (open, r) = folder(app, ui, &key, "Bodies", depth, Some(any_visible));
if r.eye {
if any_visible {
app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::<Vec<_>>());
} else {
app.ui.hidden_bodies.retain(|b| !bodies.contains(b));
}
}
if r.hovered {
acts.hover_bodies = bodies.clone();
}
if !open {
return;
}
for b in &bodies {
let visible = !app.ui.hidden_bodies.contains(b);
let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if name == b));
let locked = app.ui.locked_bodies.contains(b);
let badges: &[&str] = if locked { &["lock"] } else { &[] };
let key = format!("b:{b}");
app.tree.order.push(key.clone());
let r = draw_row(
ui,
ui.id().with(("body", b)),
&Row { depth: depth + 1, eye: Some(visible), icon: "body", label: b, selected, dim: !visible, badges, ..Default::default() },
);
if app.tree.reveal.as_deref() == Some(b.as_str())
&& let Some(rect) = r.rect
{
ui.scroll_to_rect(rect, Some(egui::Align::Center));
app.tree.reveal = None;
}
if r.eye {
if visible {
app.ui.hidden_bodies.push(b.clone());
} else {
app.ui.hidden_bodies.retain(|n| n != b);
}
}
if r.clicked {
click_select(app, ui, key, Some(Sel::Body { name: b.clone() }));
}
if r.hovered {
acts.hover_bodies = vec![b.clone()];
}
if let Some(p) = r.secondary {
if !selected {
let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b}]}));
}
acts.menu = Some((p, Target::Body { name: b.clone() }));
}
}
}
fn sketch_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize, acts: &mut Actions) {
let t = Tokens::get();
egui::Panel::left("sc_browser")
.exact_size(230.0)
let sketches: Vec<(u64, String)> = app
.session
.doc
.features
.iter()
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. }))
.map(|f| (f.id, f.name.clone()))
.collect();
if sketches.is_empty() {
return;
}
let key = format!("c{comp}/sketches");
let (open, r) = folder(app, ui, &key, "Sketches", depth, Some(app.ui.show_sketches));
if r.eye {
app.ui.show_sketches = !app.ui.show_sketches;
}
if !open {
return;
}
for (id, name) in sketches {
let active = app.session.active_sketch == Some(id);
let visible = sketch_visible(app, id);
let selected = active || app.session.selection.iter().any(|x| matches!(x, Sel::Feature { id: f } if *f == id));
let label = if active { format!("{name} (editing)") } else { name.clone() };
let key = format!("s:{id}");
app.tree.order.push(key.clone());
let r = draw_row(
ui,
ui.id().with(("sketch", id)),
&Row {
depth: depth + 1,
eye: Some(visible),
icon: "sketch",
label: &label,
selected,
dim: !visible,
color: active.then_some(t.sketch_accent),
..Default::default()
},
);
if r.eye {
set_sketch_visible(app, id, !visible);
}
if r.double {
app.edit_sketch(id);
} else if r.clicked {
click_select(app, ui, key, Some(Sel::Feature { id }));
}
if let Some(p) = r.secondary {
acts.menu = Some((p, Target::Sketch { id }));
}
}
}
fn plane_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize) {
let planes: Vec<String> = app
.session
.doc
.features
.iter()
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
.map(|f| f.name.clone())
.collect();
if planes.is_empty() {
return;
}
let key = format!("c{comp}/construction");
let (open, _) = folder(app, ui, &key, "Construction", depth, None);
if !open {
return;
}
for p in planes {
let visible = !app.ui.hidden_origin.contains(&p);
let sel = Sel::Plane { name: p.clone() };
let selected = app.session.selection.contains(&sel);
let r = draw_row(
ui,
ui.id().with(("plane", &p)),
&Row { depth: depth + 1, eye: Some(visible), icon: "plane", label: &p, selected, dim: !visible, ..Default::default() },
);
if r.eye {
if visible {
app.ui.hidden_origin.push(p.clone());
} else {
app.ui.hidden_origin.retain(|h| *h != p);
}
}
if r.clicked {
pick_from_browser(app, sel);
}
}
}
// ---------------------------------------------------------------------------------------------
// 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")).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");
}
});
});
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),
}
}
/// 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;
egui::Window::new(format!("Redefine Sketch Plane: {name}"))
.id(egui::Id::new("sc_redefine"))
.open(&mut open)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)))
.show(ui, |ui| {
ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim));
.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 st = app.session.model.state();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint(ui.painter(), r, "box", t.icon, t.icon_fill, t.accent);
ui.label(RichText::new(&app.session.doc.name).strong());
});
ui.indent("doc", |ui| {
egui::CollapsingHeader::new("Document Settings").default_open(false).show(ui, |ui| {
row(ui, "settings", &format!("Units: {}", app.session.doc.units), None, false);
let n = app.session.doc.params.len();
if row(ui, "params", &format!("Parameters ({n})"), None, false).clicked() {
app.start("ChangeParameterCommand");
}
});
ui.horizontal(|ui| {
if eye(ui, app.ui.show_origin) {
app.ui.show_origin = !app.ui.show_origin;
}
egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| {
let items = [("O", "point", "Origin point"), ("X", "axis", "X axis"), ("Y", "axis", "Y axis"), ("Z", "axis", "Z axis")]
.into_iter()
.chain([("XY", "plane", "XY"), ("XZ", "plane", "XZ"), ("YZ", "plane", "YZ")]);
for (key, icon, label) in items {
let visible = !app.ui.hidden_origin.iter().any(|h| h == key);
let sel = match icon {
"plane" => Some(Sel::Plane { name: key.into() }),
"axis" => Some(Sel::Axis { name: key.into() }),
_ => None,
};
let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x));
ui.horizontal(|ui| {
if eye(ui, visible) {
if visible {
app.ui.hidden_origin.push(key.into());
} else {
app.ui.hidden_origin.retain(|h| h != key);
}
}
if row(ui, icon, label, None, selected).clicked()
&& let Some(x) = &sel
{
pick_from_browser(app, x.clone());
}
});
}
});
});
egui::CollapsingHeader::new(format!("Bodies ({})", st.bodies.len())).default_open(true).show(ui, |ui| {
for b in &st.bodies {
let visible = !app.ui.hidden_bodies.contains(&b.name);
let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name));
ui.horizontal(|ui| {
if eye(ui, visible) {
if visible {
app.ui.hidden_bodies.push(b.name.clone());
} else {
app.ui.hidden_bodies.retain(|n| n != &b.name);
}
}
if row(ui, "body", &b.name, None, selected).clicked() {
let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b.name}]}));
}
});
}
});
let sketches: Vec<(u64, String)> = app
.session
.doc
.features
.iter()
.filter(|f| matches!(f.kind, FeatureKind::Sketch { .. }))
.map(|f| (f.id, f.name.clone()))
.collect();
egui::CollapsingHeader::new(format!("Sketches ({})", sketches.len())).default_open(true).show(ui, |ui| {
ui.horizontal(|ui| {
if eye(ui, app.ui.show_sketches) {
app.ui.show_sketches = !app.ui.show_sketches;
}
ui.label(RichText::new("show unused sketches").size(11.0).color(t.text_dim));
});
for (id, name) in sketches {
let active = app.session.active_sketch == Some(id);
let r = if active {
row(ui, "sketch", &format!("{name} (editing)"), Some(t.sketch_accent), true)
} else {
row(ui, "sketch", &name, None, false)
};
if r.double_clicked() {
app.edit_sketch(id);
}
r.on_hover_text("Double-click to edit");
}
});
});
});
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;
}
}
File diff suppressed because it is too large. Load diff
+39 -1
View File
@@ -6,7 +6,8 @@
//! - `engine.commands`: every command with tab, panel, params and enablement
//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches)
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?}` (box selection, navigation);
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation;
//! `hold` keeps the button down that many frames before moving);
//! `ui.selection` (selection, dialog inputs, hover); `ui.editFeature {feature}` (edit dialog)
//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate)
//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs)
@@ -14,6 +15,10 @@
//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}`
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless
//! CPU render of the model with the current camera (no window needed)
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
//! `ui.rename {text?, commit?}` finishes the rename box
//! - `ui.resize {width, height}`, `app.quit`
use std::sync::mpsc::Sender;
@@ -170,6 +175,10 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
// `hold`: frames to keep the button down before moving (press-and-hold).
for _ in 0..f("hold").unwrap_or(0.0).clamp(0.0, 600.0) as usize {
app.synthetic.push(egui::Event::PointerMoved(a));
}
let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize;
for k in 1..=steps {
app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32)));
@@ -214,6 +223,35 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ok(Value::Null)
}
"ui.menu" => {
if b("close") {
crate::context_menu::close(app);
return ok(json!({"open": false}));
}
if let (Some(x), Some(y)) = (f("x"), f("y")) {
let target = match p.get("target") {
Some(v) => match serde_json::from_value::<crate::context_menu::Target>(v.clone()) {
Ok(t) => t,
Err(e) => return err(e),
},
None => crate::context_menu::Target::Viewport,
};
crate::context_menu::request_open(app, egui::pos2(x as f32, y as f32), target);
return ok(json!({"pending": true}));
}
ok(crate::context_menu::describe(app))
}
"ui.menuPick" => {
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
wrap(crate::context_menu::pick(app, key))
}
"ui.rename" => {
if crate::context_menu::finish_rename(app, s("text"), p.get("commit").and_then(Value::as_bool).unwrap_or(true)) {
ok(json!({"done": true}))
} else {
err("no rename in progress")
}
}
"ui.resize" => {
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
+5
View File
@@ -424,6 +424,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.circle(12.0, 12.0, 2.6, ink, ink);
}
"body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0),
// Component: a box with a smaller one stacked on it.
"component" => {
pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
}
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
"origin" => {
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
+19
View File
@@ -15,6 +15,8 @@ pub mod control;
pub mod dialogs;
pub mod gpu;
pub mod icons;
#[cfg(test)]
mod menu_tests;
pub mod palette;
pub mod preview;
pub mod selection;
@@ -59,6 +61,13 @@ pub struct UiState {
pub pick_off: Vec<String>,
/// A click on a face picks its whole body.
pub pick_bodies: bool,
/// Sketches hidden one by one, and finished sketches shown although a feature uses them.
pub hidden_sketches: Vec<u64>,
pub shown_sketches: Vec<u64>,
/// Sketches whose closed profiles are not shaded.
pub hidden_profiles: Vec<u64>,
/// Bodies locked in the browser (no move or delete).
pub locked_bodies: Vec<String>,
}
impl Default for UiState {
@@ -77,6 +86,10 @@ impl Default for UiState {
dark: true,
pick_off: Vec::new(),
pick_bodies: false,
hidden_sketches: Vec::new(),
shown_sketches: Vec::new(),
hidden_profiles: Vec::new(),
locked_bodies: Vec::new(),
}
}
}
@@ -106,6 +119,10 @@ pub struct SolveApp {
pub tool: Option<tools::Tool>,
pub dialog: Option<dialogs::Dialog>,
pub palette: palette::Palette,
/// Context menus, rename box and Properties window.
pub menu: context_menu::MenuState,
/// Browser tree state (reveal, groups being edited, occurrence moves).
pub tree: browser::TreeState,
pub preview: preview::PreviewState,
/// The last command started interactively (id, label), for Repeat.
pub last_command: Option<(String, String)>,
@@ -136,6 +153,8 @@ impl SolveApp {
tool: None,
dialog: None,
palette: palette::Palette::default(),
menu: context_menu::MenuState::default(),
tree: browser::TreeState::default(),
preview: preview::PreviewState::default(),
last_command: None,
status: None,
+164
View File
@@ -0,0 +1,164 @@
//! Context menu tests: what the menus offer for each target, and that their items act.
use egui::pos2;
use serde_json::json;
use solvecraft_engine::Session;
use crate::context_menu::{self, Item, Target};
use crate::{Services, SolveApp};
fn sample_app() -> SolveApp {
let mut s = Session::default();
s.run_script(&solvecraft_engine::sample::script()).unwrap();
SolveApp::new(s, Services::default())
}
fn find(items: &[Item], label: &str) -> Item {
items
.iter()
.find(|i| i.label == label)
.cloned()
.unwrap_or_else(|| panic!("no `{label}` in {:?}", items.iter().map(|i| &i.label).collect::<Vec<_>>()))
}
/// Run the item labelled `label` of the target's menu.
fn act(app: &mut SolveApp, t: &Target, label: &str) {
let item = find(&context_menu::items(app, t), label);
context_menu::run_item(app, &item, pos2(0.0, 0.0));
}
fn body(app: &SolveApp) -> String {
app.session.model.state().bodies[0].name.clone()
}
#[test]
fn empty_space_offers_view_items_and_greys_out_undo() {
let mut app = sample_app();
app.session.undo.clear();
let items = context_menu::items(&app, &Target::Viewport);
assert!(!find(&items, "Undo").enabled, "nothing to undo");
assert!(find(&items, "Fit").enabled);
assert_eq!(context_menu::radial_items(&app).len(), 8);
assert!(!context_menu::radial_items(&app)[0].enabled, "no command to repeat yet");
// Hide Origin toggles.
context_menu::run_item(&mut app, &find(&items, "Hide Origin"), pos2(0.0, 0.0));
assert!(!app.ui.show_origin);
}
#[test]
fn face_menu_hides_and_finds_the_body() {
let mut app = sample_app();
let b = body(&app);
app.run("select.set", json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap();
let items = context_menu::items(&app, &Target::Viewport);
for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] {
assert!(find(&items, l).enabled, "{l}");
}
assert!(!find(&items, "Appearance…").enabled);
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
context_menu::run_item(&mut app, &find(&items, "Hide Body"), pos2(0.0, 0.0));
assert_eq!(app.ui.hidden_bodies, vec![b]);
}
#[test]
fn body_menu_isolates_renames_locks_and_deletes() {
let mut app = sample_app();
app.run("PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [200, 0, 0]})).unwrap();
let b = body(&app);
let t = Target::Body { name: b.clone() };
let items = context_menu::items(&app, &t);
context_menu::run_item(&mut app, &find(&items, "Isolate"), pos2(0.0, 0.0));
assert_eq!(app.ui.hidden_bodies.len(), 1, "the other body is hidden");
assert!(!app.ui.hidden_bodies.contains(&b));
// Rename through the rename box.
context_menu::run_item(&mut app, &find(&items, "Rename"), pos2(10.0, 10.0));
assert!(context_menu::finish_rename(&mut app, Some("Base"), true));
assert!(app.session.model.state().body("Base").is_some());
// Locked bodies can't be moved or deleted.
let t = Target::Body { name: "Base".into() };
act(&mut app, &t, "Lock");
let items = context_menu::items(&app, &t);
assert!(!find(&items, "Delete").enabled && !find(&items, "Move/Copy").enabled);
context_menu::run_item(&mut app, &find(&items, "Unlock"), pos2(0.0, 0.0));
let n = app.session.model.state().bodies.len();
act(&mut app, &t, "Delete");
assert_eq!(app.session.model.state().bodies.len(), n - 1);
}
#[test]
fn properties_measure_the_body() {
let mut app = sample_app();
let b = body(&app);
let items = context_menu::items(&app, &Target::Body { name: b });
context_menu::run_item(&mut app, &find(&items, "Properties"), pos2(0.0, 0.0));
let (_, v) = app.menu.props.clone().unwrap();
assert!(v["total"]["volume_mm3"].as_f64().unwrap() > 1000.0);
assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0);
}
#[test]
fn sketch_menu_hides_and_moves_to_another_plane() {
let mut app = sample_app();
let id = app.session.doc.features.iter().find(|f| f.name == "Holes").unwrap().id;
let t = Target::Sketch { id };
let items = context_menu::items(&app, &t);
let vis = crate::browser::sketch_visible(&app, id);
context_menu::run_item(&mut app, &find(&items, if vis { "Hide" } else { "Show" }), pos2(0.0, 0.0));
assert_eq!(crate::browser::sketch_visible(&app, id), !vis);
context_menu::run_item(&mut app, &find(&items, "Edit Sketch"), pos2(0.0, 0.0));
assert_eq!(app.session.active_sketch, Some(id));
app.finish_sketch();
context_menu::run_item(&mut app, &find(&items, "Redefine Sketch Plane"), pos2(0.0, 0.0));
assert_eq!(app.tree.redefine, Some(id));
}
#[test]
fn sketch_entities_offer_the_constraints_that_fit() {
let mut app = sample_app();
app.run("SketchCreate", json!({"plane": "XY"})).unwrap();
app.run("DrawPolyline", json!({"points": [[0, 0], [10, 1]]})).unwrap();
app.run("DrawPolyline", json!({"points": [[0, 5], [10, 7]]})).unwrap();
let ids: Vec<String> = {
let st = app.session.model.state();
let ss = st.sketch(app.session.active_sketch.unwrap()).unwrap();
ss.sketch.curves.iter().map(|c| c.id.clone()).collect()
};
app.run("select.set", json!({"items": ids.iter().map(|i| json!({"type": "sketch_curve", "id": i})).collect::<Vec<_>>()})).unwrap();
let items = context_menu::items(&app, &Target::Viewport);
let par = find(&items, "Parallel");
assert!(find(&items, "Finish Sketch").enabled);
context_menu::run_item(&mut app, &par, pos2(0.0, 0.0));
let st = app.session.model.state();
let ss = st.sketch(app.session.active_sketch.unwrap()).unwrap();
assert!(ss.sketch.constraints.iter().any(|c| format!("{:?}", c.kind).contains("Parallel")), "{:?}", ss.sketch.constraints);
}
#[test]
fn component_menu_activates_grounds_and_pastes() {
let mut app = sample_app();
let b = body(&app);
app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})).unwrap();
let c = app.session.doc.components[0].id;
let t = Target::Component { id: c };
act(&mut app, &t, "Activate Component");
assert_eq!(app.session.active_component, c);
act(&mut app, &t, "Ground");
assert!(app.session.doc.occurrence_of(c).unwrap().grounded);
assert!(!find(&context_menu::items(&app, &t), "Move/Copy").enabled, "grounded");
act(&mut app, &t, "Paste New");
assert_eq!(app.session.doc.components.len(), 2);
}
#[test]
fn pick_runs_an_item_of_the_open_menu() {
let mut app = sample_app();
assert!(context_menu::pick(&mut app, "Fit").is_err(), "no menu open");
context_menu::open_for(&mut app, pos2(500.0, 400.0), Target::Viewport);
assert!(context_menu::pick(&mut app, "Nope").is_err());
app.session.undo.clear();
assert!(context_menu::pick(&mut app, "Undo").is_err(), "disabled");
assert!(context_menu::pick(&mut app, "ui.origin").is_ok());
assert!(app.viewport.context_menu.is_none(), "a pick closes the menu");
assert!(!app.ui.show_origin);
}
+36 -7
View File
@@ -39,6 +39,8 @@ pub struct ViewportState {
pub boxsel: Option<BoxSel>,
/// Timeline item under the cursor: its bodies are highlighted.
pub hover_feature: Option<u64>,
/// Bodies whose browser row is under the cursor: highlighted the same way.
pub hover_bodies: Vec<String>,
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
@@ -140,7 +142,11 @@ fn visible_sketches(app: &SolveApp) -> Vec<u64> {
let st = app.session.model.state();
st.sketches
.iter()
.filter(|s| app.session.active_sketch == Some(s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))
.filter(|s| {
app.session.active_sketch == Some(s.feature)
|| (!app.ui.hidden_sketches.contains(&s.feature)
&& (app.ui.shown_sketches.contains(&s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature))))
})
.map(|s| s.feature)
.collect()
}
@@ -151,6 +157,10 @@ fn scene_key(app: &SolveApp) -> u64 {
app.ui.show_grid.hash(&mut h);
app.ui.show_sketches.hash(&mut h);
app.ui.hidden_bodies.hash(&mut h);
app.ui.hidden_sketches.hash(&mut h);
app.ui.shown_sketches.hash(&mut h);
app.ui.hidden_profiles.hash(&mut h);
app.session.active_component.hash(&mut h);
app.preview.replaced.hash(&mut h);
crate::theme::is_dark().hash(&mut h);
app.session.active_sketch.hash(&mut h);
@@ -195,13 +205,20 @@ fn build_scene(app: &SolveApp) -> GpuScene {
if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
continue;
}
let col = c4(s.doc.materials.get(&b.name).and_then(|m| crate::theme::material_color(m)).unwrap_or(tk.body));
// With a component active, bodies outside it are drawn faded.
let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component);
let base = s.doc.materials.get(&b.name).and_then(|m| crate::theme::material_color(m)).unwrap_or(tk.body);
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
let m = b.mesh();
for t in &m.triangles {
for k in t {
let i = *k as usize;
if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) {
sc.tri(p.to_f32(), n.to_f32(), col);
if faded {
sc.trans_tri(p.to_f32(), n.to_f32(), col);
} else {
sc.tri(p.to_f32(), n.to_f32(), col);
}
}
}
}
@@ -219,7 +236,8 @@ fn build_scene(app: &SolveApp) -> GpuScene {
let active = s.active_sketch == Some(sid);
// Closed profiles are shaded (lifted off a face they may lie on).
let lift = ss.plane.normal() * 0.01;
for p in &ss.profiles {
let profiles = if app.ui.hidden_profiles.contains(&sid) { &[][..] } else { &ss.profiles[..] };
for p in profiles {
for tri in p.region.triangulate(0.05) {
for q in tri {
sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill));
@@ -563,6 +581,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.hash(&mut h);
app.ui.pick_bodies.hash(&mut h);
crate::theme::is_dark().hash(&mut h);
app.preview.replaced.hash(&mut h);
@@ -660,10 +679,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
_ => {}
}
}
// Bodies made by the timeline item under the cursor.
if let Some(fid) = app.viewport.hover_feature {
// Bodies made by the timeline item under the cursor, or whose browser row is.
if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() {
let k = t.hover_face_lift;
for b in st.bodies.iter().filter(|b| b.feature == fid && !app.ui.hidden_bodies.contains(&b.name)) {
let lit =
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name);
for b in st.bodies.iter().filter(|b| lit(b) && !app.ui.hidden_bodies.contains(&b.name)) {
let c = body_rgb(app, &b.name);
let m = b.mesh();
for f in 0..b.body.face_count() {
@@ -783,13 +804,19 @@ pub fn delete_selection(app: &mut SolveApp) {
}
let mut sketch_ids: Vec<String> = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = Vec::new();
for x in &sel {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()),
Sel::Feature { id } => features.push(*id),
Sel::Body { name } if !app.ui.locked_bodies.contains(name) => bodies.push(name.clone()),
_ => {}
}
}
// Deleting a body removes it from here on in the timeline (a Remove feature).
if !bodies.is_empty() && app.session.active_sketch.is_none() {
let _ = app.run("SoftDeleteCommand", json!({ "bodies": bodies }));
}
if !sketch_ids.is_empty() && app.session.active_sketch.is_some() {
let _ = app.run("sketch.delete", json!({"entities": sketch_ids}));
}
@@ -815,6 +842,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
i.pointer.delta(),
)
});
// The right button belongs to the marking menu while it is open.
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
// The view cube handles its own clicks; the model underneath must not see them.
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p));
+5 -2
View File
@@ -25,9 +25,12 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, `dark`, …) | `dark: false` switches to the light theme |
| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | |
| `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command |
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?` | real pointer input |
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input |
| `ui.move` | `x`, `y` | |
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing) |
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it) |
| `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) |
| `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does |
| `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened |
| `ui.selection` | | the selection, the open dialog's inputs and the hovered item |
| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) |
| `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor |