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:
1 parent
1df3e3fe0f
commit
fa8da64613
11 files changed
+2333
-217
No files matched your search
@@ -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,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());
|
||||
|
||||
@@ -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
@@ -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: ¶ms, ..Default::default() }).clicked {
|
||||
app.start("ChangeParameterCommand");
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
let key = "origin";
|
||||
let open = is_open(app, key, false);
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(key),
|
||||
&Row { depth, fold: Some(open), eye: Some(app.ui.show_origin), icon: "origin", label: "Origin", ..Default::default() },
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
toggle(app, key, false);
|
||||
}
|
||||
if r.eye {
|
||||
app.ui.show_origin = !app.ui.show_origin;
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
let items = [
|
||||
("O", "point", "O"),
|
||||
("X", "axis", "X"),
|
||||
("Y", "axis", "Y"),
|
||||
("Z", "axis", "Z"),
|
||||
("XY", "plane", "XY"),
|
||||
("XZ", "plane", "XZ"),
|
||||
("YZ", "plane", "YZ"),
|
||||
];
|
||||
for (k, icon, label) in items {
|
||||
let visible = !app.ui.hidden_origin.iter().any(|h| h == k);
|
||||
let sel = match icon {
|
||||
"plane" => Some(Sel::Plane { name: k.into() }),
|
||||
"axis" => Some(Sel::Axis { name: k.into() }),
|
||||
_ => None,
|
||||
};
|
||||
let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x));
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(("o", k)),
|
||||
&Row { depth: depth + 1, eye: Some(visible), icon, label, selected, dim: !app.ui.show_origin, ..Default::default() },
|
||||
);
|
||||
if r.eye {
|
||||
if visible {
|
||||
app.ui.hidden_origin.push(k.into());
|
||||
} else {
|
||||
app.ui.hidden_origin.retain(|h| h != k);
|
||||
}
|
||||
}
|
||||
if r.clicked
|
||||
&& let Some(x) = sel
|
||||
{
|
||||
pick_from_browser(app, x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A browser click on a plane or axis: it goes to the open dialog's input if that takes it,
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1170
-85
File diff suppressed because it is too large.
Load diff
@@ -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)));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user