feat(ui): Select Other — pick what lies under something else (#503)
Hold the left button still (long-press on touch) or right-click › Select Other… to list everything under the cursor: sketch points and curves ("Line 3", "Point (arc 2 centre)"), vertices, edges, faces, bodies, profiles, planes and axes, filtered like a click and by the open dialog's active input. Hovering a row lights it up; clicking selects it (Shift/Ctrl add) or feeds the dialog. A repeat click on the same spot with nothing changed selects the next thing there; a double-click keeps its own actions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1fd4a2f782
commit
4aaa5457b3
13 files changed
+952
-76
No files matched your search
@@ -164,6 +164,8 @@ pub struct MenuState {
|
||||
pub confirm: Option<crate::delete::Pending>,
|
||||
/// The Properties window: title and measurements.
|
||||
pub props: Option<(String, Value)>,
|
||||
/// The viewport menu opened over something: it offers Select Other.
|
||||
pub over_geometry: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -346,6 +348,11 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
|
||||
v.push(Item::sep());
|
||||
}
|
||||
// Everything under the cursor, for what lies beneath something else.
|
||||
if app.menu.over_geometry {
|
||||
v.push(act("ui.selectOther", "Select Other…", "").key("Hold click"));
|
||||
v.push(Item::sep());
|
||||
}
|
||||
if app.session.active_sketch.is_some() {
|
||||
v.extend(sketch_entity_items(app));
|
||||
v.push(Item::sep());
|
||||
@@ -726,6 +733,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
app.start(&id);
|
||||
}
|
||||
}
|
||||
"ui.selectOther" => {
|
||||
crate::select_other::open(app, at);
|
||||
}
|
||||
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
|
||||
"ui.delete" => delete(app),
|
||||
"ui.finishSketch" => app.finish_sketch(),
|
||||
@@ -1161,6 +1171,8 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) {
|
||||
/// Open the viewport menu at a screen position. A right-click on something not selected
|
||||
/// selects it first, so the menu is about what is under the cursor.
|
||||
pub fn open(app: &mut SolveApp, at: Pos2) {
|
||||
app.menu.over_geometry = app.viewport.hover.is_some();
|
||||
crate::select_other::close(app);
|
||||
if app.session.active_sketch.is_none()
|
||||
&& let Some(h) = app.viewport.hover.clone()
|
||||
&& let Some((_, sel)) = crate::viewport::candidate(app, &[h])
|
||||
|
||||
@@ -501,6 +501,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||||
),
|
||||
},
|
||||
"ui.selectOther" => wrap(crate::select_other::control(app, p)),
|
||||
"ui.menuPick" => {
|
||||
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
|
||||
wrap(crate::context_menu::pick(app, key))
|
||||
|
||||
@@ -165,6 +165,13 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
|
||||
)
|
||||
.size(11.0)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.separator();
|
||||
let q = app.keymap.filter.to_lowercase();
|
||||
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
|
||||
@@ -79,6 +79,7 @@ mod revolve_edit_tests;
|
||||
#[cfg(test)]
|
||||
mod revolve_manipulator_tests;
|
||||
pub mod scenario;
|
||||
pub mod select_other;
|
||||
pub mod selection;
|
||||
#[cfg(test)]
|
||||
mod shell_face_sketch_ui_tests;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
|
||||
//! text (with `near`: the one in that label's row)
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
||||
//! `{"hold": AT, "frames"?}` (press and hold still: Select Other),
|
||||
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
||||
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
||||
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
|
||||
@@ -378,6 +379,16 @@ impl Harness {
|
||||
let [x, y] = found.ok_or_else(|| format!("no widget `{text}` on screen"))?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}));
|
||||
self.call("ui.click", json!({"x": x, "y": y}))
|
||||
} else if let Some(at) = s.get("hold") {
|
||||
// Press and hold still (Select Other): `frames` with the button down (default 40,
|
||||
// about 0.67 s), then let go.
|
||||
let [x, y] = self.at(at)?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}));
|
||||
let frames = s.get("frames").and_then(Value::as_u64).unwrap_or(40);
|
||||
self.call(
|
||||
"ui.drag",
|
||||
json!({"x0": x, "y0": y, "x1": x, "y1": y, "steps": 1, "hold": frames, "shift": flag("shift"), "ctrl": flag("ctrl")}),
|
||||
)
|
||||
} else if let Some(at) = s.get("move") {
|
||||
let [x, y] = self.at(at)?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}))
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Select Other: everything under the cursor, for picking what lies under something else (two
|
||||
//! lines drawn on top of each other, coincident points, an edge in front of a face, overlapping
|
||||
//! bodies).
|
||||
//!
|
||||
//! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the
|
||||
//! viewport menu's "Select Other…", opens a list at the cursor of every pickable thing there,
|
||||
//! in the selection filter's and the open dialog's terms: sketch points and curves first, then
|
||||
//! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first.
|
||||
//! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add,
|
||||
//! as clicks do) or, with a dialog open, gives it to the dialog's active selection input.
|
||||
//! Esc or a click elsewhere closes the list.
|
||||
//! - Clicking again on the same spot with nothing changed selects the next thing in that list
|
||||
//! (click cycling). The second click of a double-click keeps its own actions.
|
||||
//!
|
||||
//! The control channel's `ui.selectOther` opens, describes and picks from the list.
|
||||
|
||||
use egui::{Align2, FontId, Pos2, Rect, Sense, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::sketch::CurveKind;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::{Hit, Proj};
|
||||
|
||||
/// How long the left button stays down, without moving, before the list opens.
|
||||
pub const HOLD_SECS: f64 = 0.5;
|
||||
/// How far (screen points) a repeat click may be from the last one and still cycle.
|
||||
const CYCLE_SLOP: f32 = 4.0;
|
||||
|
||||
/// One row of the list.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Row {
|
||||
pub hit: Hit,
|
||||
pub sel: Sel,
|
||||
pub label: String,
|
||||
pub icon: &'static str,
|
||||
}
|
||||
|
||||
/// The open list.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Popup {
|
||||
pub at: Pos2,
|
||||
pub rows: Vec<Row>,
|
||||
/// The row under the pointer (lit in the viewport).
|
||||
pub hovered: Option<usize>,
|
||||
/// The model and dialog it was made for: it closes when they change.
|
||||
revision: u64,
|
||||
dialog: bool,
|
||||
/// The browser's body highlight is ours to clear.
|
||||
lit_body: bool,
|
||||
}
|
||||
|
||||
/// The last plain click, for cycling.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Cycle {
|
||||
at: Pos2,
|
||||
chosen: Sel,
|
||||
selection: Vec<Sel>,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub popup: Option<Popup>,
|
||||
/// A left press that may become a hold: where and when.
|
||||
hold: Option<(Pos2, f64)>,
|
||||
/// The current press belongs to Select Other (it opened or closed the list): the viewport
|
||||
/// doesn't also click, drag or box-select with it.
|
||||
held: bool,
|
||||
cycle: Option<Cycle>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What is under the cursor
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth.
|
||||
pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Row> {
|
||||
let hits = crate::viewport::pick_all(app, proj, pos);
|
||||
let mut found = crate::viewport::candidates(app, &hits);
|
||||
let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks());
|
||||
// A body behind each face (body picking already turns faces into bodies).
|
||||
if !dialog && !app.ui.pick_bodies {
|
||||
for h in &hits {
|
||||
if let Hit::Face { body, .. } = h {
|
||||
found.push((h.clone(), Sel::Body { name: body.clone() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(Hit, Sel)> = Vec::new();
|
||||
for (h, s) in found {
|
||||
if !out.iter().any(|(_, x)| crate::selection::same_item(x, &s)) {
|
||||
out.push((h, s));
|
||||
}
|
||||
}
|
||||
let sketching = app.session.active_sketch.is_some();
|
||||
out.sort_by_key(|(_, s)| rank(s, sketching));
|
||||
out.into_iter()
|
||||
.map(|(hit, sel)| {
|
||||
let label = label(app, &sel);
|
||||
Row { icon: icon(app, &sel), hit, sel, label }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sort key: in a sketch its own points and curves come first; then by kind.
|
||||
fn rank(s: &Sel, sketching: bool) -> (u8, u8) {
|
||||
let kind = match s {
|
||||
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } => 0,
|
||||
Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. } => 1,
|
||||
Sel::SketchConstraint { .. } => 2,
|
||||
Sel::Vertex { .. } => 3,
|
||||
Sel::Edge { .. } => 4,
|
||||
Sel::Face { .. } | Sel::Feature { .. } => 5,
|
||||
Sel::Body { .. } | Sel::Occurrence { .. } => 6,
|
||||
Sel::Profile { .. } => 7,
|
||||
Sel::Plane { .. } => 8,
|
||||
Sel::Axis { .. } => 9,
|
||||
};
|
||||
let own = matches!(s, Sel::SketchPoint { .. } | Sel::SketchCurve { .. });
|
||||
(if sketching && !own { 1 } else { 0 }, kind)
|
||||
}
|
||||
|
||||
/// The number at the end of an id (`l3` → 3).
|
||||
fn id_number(id: &str) -> Option<u64> {
|
||||
let digits: String = id.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn curve_kind_name(k: &CurveKind) -> &'static str {
|
||||
match k {
|
||||
CurveKind::Line { .. } => "Line",
|
||||
CurveKind::Circle { .. } => "Circle",
|
||||
CurveKind::Arc { .. } => "Arc",
|
||||
CurveKind::Ellipse { .. } => "Ellipse",
|
||||
CurveKind::Spline { .. } => "Spline",
|
||||
CurveKind::Conic { .. } => "Conic",
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch_name(app: &SolveApp, sketch: u64) -> String {
|
||||
app.session.doc.feature(sketch).map(|f| f.name.clone()).unwrap_or_else(|| format!("Sketch {sketch}"))
|
||||
}
|
||||
|
||||
/// "Line 3" (its kind and number), with what kind of geometry it is when that matters.
|
||||
fn curve_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
||||
if let Some((i, c)) = sk.curves.iter().enumerate().find(|(_, c)| c.id == id) {
|
||||
let kind = curve_kind_name(&c.kind);
|
||||
let n = id_number(id).unwrap_or_else(|| sk.curves.iter().take(i + 1).filter(|x| curve_kind_name(&x.kind) == kind).count() as u64);
|
||||
let mut s = format!("{kind} {n}");
|
||||
if c.link.is_some() {
|
||||
s.push_str(" (projected)");
|
||||
} else if c.construction {
|
||||
s.push_str(" (construction)");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
if sk.wires.iter().any(|w| w.id == id) {
|
||||
return format!("3D Curve {}", id_number(id).unwrap_or(0));
|
||||
}
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// "Point (arc 2 centre)": the role it plays in the first curve that uses it.
|
||||
fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
||||
let Some(pi) = sk.points.iter().position(|p| p.id == id) else { return id.to_string() };
|
||||
let role = sk.curves.iter().find_map(|c| {
|
||||
let name = format!("{} {}", curve_kind_name(&c.kind).to_lowercase(), id_number(&c.id).unwrap_or(0));
|
||||
let (start, end) = if c.reversed { ("end", "start") } else { ("start", "end") };
|
||||
let role = match &c.kind {
|
||||
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } if *a == pi => Some(start.to_string()),
|
||||
CurveKind::Line { b, .. } | CurveKind::Arc { b, .. } | CurveKind::Conic { b, .. } if *b == pi => Some(end.to_string()),
|
||||
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } if *c == pi => Some("centre".to_string()),
|
||||
CurveKind::Ellipse { m, .. } if *m == pi => Some("major axis end".to_string()),
|
||||
CurveKind::Conic { apex, .. } if *apex == pi => Some("apex".to_string()),
|
||||
CurveKind::Spline { pts, .. } => pts.iter().position(|p| *p == pi).map(|k| format!("point {}", k + 1)),
|
||||
_ => None,
|
||||
}?;
|
||||
Some(format!("{name} {role}"))
|
||||
});
|
||||
let fixed = if sk.point_locked(pi) { ", fixed" } else { "" };
|
||||
match role {
|
||||
Some(r) => format!("Point ({r}{fixed})"),
|
||||
None if fixed.is_empty() => format!("Point {}", id_number(id).unwrap_or(pi as u64 + 1)),
|
||||
None => format!("Point {} (fixed)", id_number(id).unwrap_or(pi as u64 + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row's name: "Line 3", "Point (arc 2 centre)", "Edge 4 · Body1", "XY Plane"…
|
||||
pub fn label(app: &SolveApp, sel: &Sel) -> String {
|
||||
let st = app.session.world_state();
|
||||
let in_sketch =
|
||||
|sketch: u64, f: &dyn Fn(&solidcraft_engine::sketch::Sketch) -> String| st.sketch(sketch).map(|ss| f(&ss.sketch)).unwrap_or_default();
|
||||
match sel {
|
||||
Sel::SketchPoint { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| point_label(sk, id))).unwrap_or_else(|| id.clone()),
|
||||
Sel::SketchCurve { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| curve_label(sk, id))).unwrap_or_else(|| id.clone()),
|
||||
Sel::ExternalSketchPoint { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| point_label(sk, id)), sketch_name(app, *sketch)),
|
||||
Sel::ExternalSketchCurve { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| curve_label(sk, id)), sketch_name(app, *sketch)),
|
||||
Sel::SketchConstraint { id } => format!("Constraint {id}"),
|
||||
Sel::Vertex { body, .. } => format!("Vertex · {body}"),
|
||||
Sel::Edge { body, index, .. } => format!("Edge {} · {body}", index + 1),
|
||||
Sel::Face { body, index, .. } => format!("Face {} · {body}", index + 1),
|
||||
Sel::Body { name } => format!("Body · {name}"),
|
||||
Sel::Occurrence { path } => format!("Occurrence {path:?}"),
|
||||
Sel::Feature { id } => app.session.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
|
||||
Sel::Profile { sketch, index } => format!("Profile {} · {}", index + 1, sketch_name(app, *sketch)),
|
||||
Sel::Plane { name } if matches!(name.as_str(), "XY" | "XZ" | "YZ") => format!("{name} Plane"),
|
||||
Sel::Axis { name } if matches!(name.as_str(), "X" | "Y" | "Z") => format!("{name} Axis"),
|
||||
Sel::Plane { name } | Sel::Axis { name } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(app: &SolveApp, sel: &Sel) -> &'static str {
|
||||
let st = app.session.world_state();
|
||||
let curve = |sketch: Option<u64>, id: &str| {
|
||||
let kind = sketch.and_then(|s| st.sketch(s)).and_then(|ss| ss.sketch.curves.iter().find(|c| c.id == id).map(|c| c.kind.clone()));
|
||||
match kind {
|
||||
Some(CurveKind::Circle { .. }) => "circle",
|
||||
Some(CurveKind::Arc { .. }) => "arc_tangent",
|
||||
Some(CurveKind::Ellipse { .. }) => "ellipse",
|
||||
Some(CurveKind::Spline { .. }) => "spline",
|
||||
Some(CurveKind::Conic { .. }) => "conic",
|
||||
_ => "line",
|
||||
}
|
||||
};
|
||||
match sel {
|
||||
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } | Sel::Vertex { .. } => "point",
|
||||
Sel::SketchCurve { id } => curve(app.session.active_sketch, id),
|
||||
Sel::ExternalSketchCurve { sketch, id } => curve(Some(*sketch), id),
|
||||
Sel::SketchConstraint { .. } => "dimension",
|
||||
Sel::Edge { .. } => "fillet",
|
||||
Sel::Face { .. } | Sel::Feature { .. } => "presspull",
|
||||
Sel::Body { .. } => "body",
|
||||
Sel::Occurrence { .. } => "component",
|
||||
Sel::Profile { .. } => "sketch",
|
||||
Sel::Plane { .. } => "plane",
|
||||
Sel::Axis { .. } => "axis",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Opening, picking, closing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn projection_now(app: &SolveApp) -> Option<Proj> {
|
||||
app.viewport.rect.map(|r| crate::viewport::projection(app, r))
|
||||
}
|
||||
|
||||
/// Open the list at a screen point; false (with a status message) when nothing is there.
|
||||
pub fn open(app: &mut SolveApp, at: Pos2) -> bool {
|
||||
let Some(proj) = projection_now(app) else { return false };
|
||||
open_with(app, &proj, at)
|
||||
}
|
||||
|
||||
fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
||||
let rows = rows(app, proj, at);
|
||||
if rows.is_empty() {
|
||||
app.status = Some(("Nothing to select here".into(), crate::now_ms(), false));
|
||||
return false;
|
||||
}
|
||||
close(app);
|
||||
app.viewport.select_other.popup =
|
||||
Some(Popup { at, rows, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn close(app: &mut SolveApp) {
|
||||
if let Some(p) = app.viewport.select_other.popup.take()
|
||||
&& p.lit_body
|
||||
{
|
||||
app.viewport.hover_bodies.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_open(app: &SolveApp) -> bool {
|
||||
app.viewport.select_other.popup.is_some()
|
||||
}
|
||||
|
||||
/// Pick row `i` of the open list (`add`: Shift/Ctrl, add to the selection) and close it.
|
||||
pub fn choose(app: &mut SolveApp, i: usize, add: bool) -> Result<Row, String> {
|
||||
let Some(p) = app.viewport.select_other.popup.as_ref() else { return Err("Select Other is not open".into()) };
|
||||
let at = p.at;
|
||||
let row = p.rows.get(i).cloned().ok_or_else(|| format!("no row {i} (the list has {})", p.rows.len()))?;
|
||||
close(app);
|
||||
apply(app, &row, add);
|
||||
remember(app, at, if add { None } else { Some(row.sel.clone()) });
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Select a row's entity: into the dialog's active input, or as a click does.
|
||||
fn apply(app: &mut SolveApp, row: &Row, add: bool) {
|
||||
if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
||||
crate::viewport::dialog_pick(app, &row.hit, row.sel.clone());
|
||||
} else {
|
||||
crate::viewport::select(app, Some(row.sel.clone()), add);
|
||||
}
|
||||
}
|
||||
|
||||
/// The open list as JSON (for `ui.selectOther`).
|
||||
pub fn describe(app: &SolveApp) -> Value {
|
||||
match &app.viewport.select_other.popup {
|
||||
None => json!({ "open": false }),
|
||||
Some(p) => json!({
|
||||
"open": true,
|
||||
"at": [p.at.x, p.at.y],
|
||||
"hovered": p.hovered,
|
||||
"rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The control channel's `ui.selectOther`: `{x, y}` opens the list there, `{pick: i, add?}`
|
||||
/// picks a row, `{hover: i}` lights one up, `{close: true}` closes it; it answers with the list.
|
||||
pub fn control(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
|
||||
if p.get("close").and_then(Value::as_bool) == Some(true) {
|
||||
close(app);
|
||||
return Ok(describe(app));
|
||||
}
|
||||
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return Err("x and y must be numbers".into());
|
||||
}
|
||||
if app.viewport.rect.is_none() {
|
||||
return Err("no viewport".into());
|
||||
}
|
||||
open(app, pos2(x as f32, y as f32));
|
||||
return Ok(describe(app));
|
||||
}
|
||||
if let Some(i) = p.get("hover").and_then(Value::as_u64) {
|
||||
let Some(pop) = app.viewport.select_other.popup.as_mut() else { return Err("Select Other is not open".into()) };
|
||||
pop.hovered = Some(i as usize).filter(|i| *i < pop.rows.len());
|
||||
hover_override(app);
|
||||
return Ok(describe(app));
|
||||
}
|
||||
if let Some(i) = p.get("pick").and_then(Value::as_u64) {
|
||||
let row = choose(app, i as usize, p.get("add").and_then(Value::as_bool).unwrap_or(false))?;
|
||||
return Ok(json!({ "picked": row.label, "sel": row.sel, "selection": app.session.selection }));
|
||||
}
|
||||
Ok(describe(app))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Press and hold, and click cycling (called by the viewport)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Follow a left press held still in the viewport; open the list when it has been held long
|
||||
/// enough. True while the press belongs to Select Other (the viewport must not also click,
|
||||
/// drag or box-select with it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn track_hold(
|
||||
app: &mut SolveApp,
|
||||
ctx: &egui::Context,
|
||||
proj: &Proj,
|
||||
inside: bool,
|
||||
pressed: bool,
|
||||
down: bool,
|
||||
hover: Option<Pos2>,
|
||||
navigating: bool,
|
||||
) -> bool {
|
||||
let consumed = app.viewport.select_other.held;
|
||||
let now = ctx.input(|i| i.time);
|
||||
let free = app.tool.is_none()
|
||||
&& app.viewport.context_menu.is_none()
|
||||
&& !is_open(app)
|
||||
&& app.dialog.as_ref().is_none_or(|d| d.wants_picks())
|
||||
&& app.viewport.point_drag.is_none();
|
||||
if pressed && inside && free && !navigating {
|
||||
app.viewport.select_other.hold = hover.map(|p| (p, now));
|
||||
}
|
||||
let mut opened = false;
|
||||
if let Some((p0, t0)) = app.viewport.select_other.hold {
|
||||
let slop = ctx.options(|o| o.input_options.max_click_dist);
|
||||
if !down || navigating || !free || hover.is_some_and(|p| p.distance(p0) > slop) {
|
||||
app.viewport.select_other.hold = None;
|
||||
} else if now - t0 >= HOLD_SECS {
|
||||
app.viewport.select_other.hold = None;
|
||||
opened = open_with(app, proj, p0);
|
||||
app.viewport.select_other.held = true;
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
}
|
||||
if !down {
|
||||
app.viewport.select_other.held = false;
|
||||
}
|
||||
consumed || opened
|
||||
}
|
||||
|
||||
/// While the list is open, the hovered row is what the viewport lights up.
|
||||
pub fn hover_override(app: &mut SolveApp) {
|
||||
let Some(p) = app.viewport.select_other.popup.as_mut() else { return };
|
||||
let row = p.hovered.and_then(|i| p.rows.get(i));
|
||||
let body = match row.map(|r| &r.sel) {
|
||||
Some(Sel::Body { name }) => Some(name.clone()),
|
||||
_ => None,
|
||||
};
|
||||
app.viewport.hover = if body.is_some() { None } else { row.map(|r| r.hit.clone()) };
|
||||
match body {
|
||||
Some(b) => {
|
||||
app.viewport.hover_bodies = vec![b];
|
||||
p.lit_body = true;
|
||||
}
|
||||
None if p.lit_body => {
|
||||
app.viewport.hover_bodies.clear();
|
||||
p.lit_body = false;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a repeat click must find unchanged: the model, the view, the sketch and the filter.
|
||||
fn cycle_key(app: &SolveApp) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
app.session.revision.hash(&mut h);
|
||||
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
app.ui.pick_off.hash(&mut h);
|
||||
app.ui.pick_bodies.hash(&mut h);
|
||||
app.dialog.is_some().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn remember(app: &mut SolveApp, at: Pos2, chosen: Option<Sel>) {
|
||||
app.viewport.select_other.cycle = chosen.map(|chosen| Cycle { at, chosen, selection: app.session.selection.clone(), key: cycle_key(app) });
|
||||
}
|
||||
|
||||
/// A plain click selected `sel` at `at`: a repeat click there may cycle from it.
|
||||
pub fn note_click(app: &mut SolveApp, at: Pos2, sel: Option<Sel>) {
|
||||
remember(app, at, sel);
|
||||
}
|
||||
|
||||
/// A plain click at `at`: when it repeats the last one (same spot, nothing changed since), select
|
||||
/// the next thing under the cursor and return true.
|
||||
pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
||||
let Some(c) = app.viewport.select_other.cycle.clone() else { return false };
|
||||
if c.at.distance(at) > CYCLE_SLOP || c.key != cycle_key(app) || c.selection != app.session.selection {
|
||||
return false;
|
||||
}
|
||||
let rows = rows(app, proj, at);
|
||||
if rows.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let next = rows.iter().position(|r| crate::selection::same_item(&r.sel, &c.chosen)).map_or(0, |k| (k + 1) % rows.len());
|
||||
let Some(row) = rows.get(next).cloned() else { return false };
|
||||
apply(app, &row, false);
|
||||
app.status = Some((format!("{} ({} of {})", row.label, next + 1, rows.len()), crate::now_ms(), false));
|
||||
remember(app, c.at, Some(row.sel));
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Drawing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Draw the open list; handle its hover, clicks, and a press elsewhere (which closes it).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(p) = app.viewport.select_other.popup.clone() else { return };
|
||||
if p.revision != app.session.revision || p.dialog != app.dialog.is_some() || app.tool.is_some() {
|
||||
close(app);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut hovered = None;
|
||||
let mut picked = None;
|
||||
let w = crate::context_menu::LIST_W - 10.0;
|
||||
let resp = crate::frame::area(egui::Id::new("sc_select_other"))
|
||||
.fixed_pos(p.at + vec2(12.0, 12.0))
|
||||
.constrain(true)
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 8.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::tr_label("Select Other").to_uppercase(),
|
||||
FontId::proportional(10.5),
|
||||
t.text_dim,
|
||||
);
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for (i, row) in p.rows.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
|
||||
crate::scenario::publish_handle(&format!("select_other:{i}"), r.center());
|
||||
if resp.hovered() {
|
||||
hovered = Some(i);
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover);
|
||||
}
|
||||
crate::icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(16.0, 16.0)),
|
||||
row.icon,
|
||||
t.icon,
|
||||
t.icon_fill,
|
||||
t.accent,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &row.label, FontId::proportional(13.0), t.text);
|
||||
if resp.clicked() {
|
||||
picked = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(pop) = app.viewport.select_other.popup.as_mut()
|
||||
&& pop.hovered != hovered
|
||||
{
|
||||
pop.hovered = hovered;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let (pressed, pointer, mods) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.interact_pos(), i.modifiers));
|
||||
if let Some(i) = picked {
|
||||
let _ = choose(app, i, mods.shift || mods.ctrl || mods.command);
|
||||
} else if pressed && !pointer.is_some_and(|q| resp.response.rect.contains(q)) {
|
||||
// A press elsewhere only closes the list.
|
||||
close(app);
|
||||
app.viewport.select_other.held = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "select_other_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Select Other: press and hold, the viewport menu's entry, click cycling and dialog inputs.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
/// A sketch with two lines drawn on top of each other (l1, then l2), off the origin axes.
|
||||
fn two_lines() -> (Harness, Value) {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
for _ in 0..2 {
|
||||
h.app.run("sketch.line", json!({"points":[[0,10],[20,10]],"infer":false,"connect":false})).unwrap();
|
||||
}
|
||||
h.app.cam.distance = 60.0;
|
||||
h.settle();
|
||||
let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone();
|
||||
(h, at)
|
||||
}
|
||||
|
||||
fn labels(h: &Harness) -> Vec<String> {
|
||||
crate::select_other::describe(&h.app)["rows"].as_array().into_iter().flatten().filter_map(|r| r["label"].as_str().map(String::from)).collect()
|
||||
}
|
||||
|
||||
fn curves(h: &Harness) -> Vec<String> {
|
||||
h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holding_still_lists_both_coincident_lines_and_picks_the_one_underneath() {
|
||||
let (mut h, at) = two_lines();
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
assert!(crate::select_other::is_open(&h.app), "a held press opens Select Other");
|
||||
let rows = labels(&h);
|
||||
assert!(rows.len() >= 2, "{rows:?}");
|
||||
assert_eq!(&rows[..2], ["Line 1", "Line 2"], "the sketch's own curves come first");
|
||||
// Letting go of the hold neither selected nor dragged anything.
|
||||
assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
// Hovering a row lights it up in the viewport.
|
||||
h.step(&json!({"move": {"handle": "select_other:1"}})).unwrap();
|
||||
assert!(matches!(&h.app.viewport.hover, Some(crate::viewport::Hit::SketchCurve { id, .. }) if id == "l2"), "{:?}", h.app.viewport.hover);
|
||||
h.step(&json!({"click": {"handle": "select_other:1"}})).unwrap();
|
||||
assert!(!crate::select_other::is_open(&h.app));
|
||||
assert_eq!(curves(&h), ["l2"]);
|
||||
// Shift-picking from the list adds, like a Shift-click.
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
h.step(&json!({"click": {"handle": "select_other:0"}, "shift": true})).unwrap();
|
||||
let mut got = curves(&h);
|
||||
got.sort();
|
||||
assert_eq!(got, ["l1", "l2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_and_a_click_elsewhere_close_the_list_without_selecting() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
assert!(crate::select_other::is_open(&h.app));
|
||||
h.step(&json!({"key": "Escape"})).unwrap();
|
||||
assert!(!crate::select_other::is_open(&h.app));
|
||||
assert!(h.app.session.active_sketch.is_some(), "Esc only closed the list");
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
h.step(&json!({"click": [at[0].as_f64().unwrap() + 200.0, at[1].as_f64().unwrap() + 150.0]})).unwrap();
|
||||
assert!(!crate::select_other::is_open(&h.app));
|
||||
assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_again_on_the_same_spot_selects_the_next_thing_under_it() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l1"]);
|
||||
// A deliberate second click (not part of a double-click).
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l2"]);
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l1"], "it wraps around");
|
||||
// A click somewhere else starts over.
|
||||
let x = at[0].as_f64().unwrap();
|
||||
let y = at[1].as_f64().unwrap();
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": [x + 40.0, y]})).unwrap();
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_does_not_cycle() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"click": at, "double": true})).unwrap();
|
||||
// The double-click's chain starts from the line under the cursor.
|
||||
assert!(curves(&h).contains(&"l1".to_string()), "{:?}", h.app.session.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_viewport_menu_offers_select_other() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"click": at, "button": "right"})).unwrap();
|
||||
let menu = h.call("ui.menu", json!({}))["result"].clone();
|
||||
let items: Vec<&str> = menu["items"].as_array().into_iter().flatten().filter_map(|i| i["id"].as_str()).collect();
|
||||
assert!(items.contains(&"ui.selectOther"), "{items:?}");
|
||||
let r = h.call("ui.menuPick", json!({"item": "ui.selectOther"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(labels(&h).starts_with(&["Line 1".to_string(), "Line 2".to_string()]), "{:?}", labels(&h));
|
||||
// The control channel picks rows too.
|
||||
let r = h.call("ui.selectOther", json!({"pick": 1}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(curves(&h), ["l2"]);
|
||||
// Over empty space there is nothing to offer.
|
||||
h.step(&json!({"click": [40, 400], "button": "right"})).unwrap();
|
||||
let menu = h.call("ui.menu", json!({}))["result"].clone();
|
||||
assert!(!menu["items"].as_array().into_iter().flatten().any(|i| i["id"] == "ui.selectOther"), "{menu}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_bodies_list_both_and_the_hidden_one_can_be_picked() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Inner"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[5,5,5],"body_name":"Outer"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.step(&json!({"hold": {"world": [7, 7, 15]}})).unwrap();
|
||||
let rows = labels(&h);
|
||||
assert!(rows.iter().any(|r| r == "Body · Outer") && rows.iter().any(|r| r == "Body · Inner"), "{rows:?}");
|
||||
let faces = rows.iter().filter(|r| r.starts_with("Face ")).count();
|
||||
assert_eq!(faces, 2, "the nearest face of each body: {rows:?}");
|
||||
let i = rows.iter().position(|r| r == "Body · Inner").unwrap();
|
||||
h.call("ui.selectOther", json!({"pick": i}));
|
||||
assert_eq!(h.app.session.selection, [Sel::Body { name: "Inner".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_selection_input_takes_its_pick_from_the_list() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"home"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.step(&json!({"start": "solid.fillet"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"hold": {"world": [5, 0, 10]}})).unwrap();
|
||||
let rows = crate::select_other::describe(&h.app)["rows"].clone();
|
||||
let sels: Vec<&Value> = rows.as_array().into_iter().flatten().map(|r| &r["sel"]).collect();
|
||||
assert!(!sels.is_empty(), "{rows}");
|
||||
// The edge in front, then the face behind it; no bodies, which the fillet doesn't take.
|
||||
assert_eq!(sels[0]["type"], "edge", "{rows}");
|
||||
assert!(sels.iter().all(|s| s["type"] == "edge" || s["type"] == "face"), "only what the fillet's input accepts: {rows}");
|
||||
h.call("ui.selectOther", json!({"pick": 0}));
|
||||
let d = h.app.dialog.as_ref().expect("the fillet dialog stays open");
|
||||
assert!(d.inputs.iter().any(|i| i.items.iter().any(|x| matches!(x, Sel::Edge { .. }))), "{:?}", d.inputs);
|
||||
assert!(h.app.session.selection.is_empty(), "the pick went to the dialog, not the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_name_points_by_their_curves() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":3})).unwrap();
|
||||
h.app.cam.distance = 60.0;
|
||||
h.settle();
|
||||
let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone();
|
||||
h.call("ui.selectOther", json!({"x": at[0], "y": at[1]}));
|
||||
let rows = labels(&h);
|
||||
assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}");
|
||||
}
|
||||
@@ -1045,7 +1045,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
match k {
|
||||
Key::Escape => {
|
||||
app.esc_handled = true;
|
||||
if app.tool.is_some() {
|
||||
if crate::select_other::is_open(app) {
|
||||
crate::select_other::close(app);
|
||||
} else if app.tool.is_some() {
|
||||
crate::tools::finish(app);
|
||||
} else if app.dialog.is_some() {
|
||||
crate::dialogs::cancel(app);
|
||||
|
||||
+184
-74
@@ -82,6 +82,8 @@ pub struct ViewportState {
|
||||
pub build_ms: f64,
|
||||
/// The right-click menu, open at this screen position.
|
||||
pub context_menu: Option<Pos2>,
|
||||
/// Select Other: its list, a press being held, and click cycling.
|
||||
pub select_other: crate::select_other::State,
|
||||
/// A sketch point or curve being dragged (its id, the undo depth when the drag began: the
|
||||
/// whole drag is one undo step, and for a curve where it was last under the cursor).
|
||||
pub point_drag: Option<(String, usize, Option<Vec2>)>,
|
||||
@@ -720,7 +722,68 @@ pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
pick_with_sketch_points(app, proj, pos, true)
|
||||
}
|
||||
|
||||
/// Everything under `pos` (Select Other): like [`pick`], but every sketch point, sketch curve,
|
||||
/// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the
|
||||
/// ray, instead of the nearest of each kind. Each kind is nearest first.
|
||||
pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
pick_hits(app, proj, pos, false, true)
|
||||
}
|
||||
|
||||
fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec<Hit> {
|
||||
pick_hits(app, proj, pos, other_sketch_points, false)
|
||||
}
|
||||
|
||||
/// The same thing picked twice (one row per target in [`pick_all`]).
|
||||
pub fn same_target(a: &Hit, b: &Hit) -> bool {
|
||||
match (a, b) {
|
||||
(Hit::SketchPoint { sketch: s, id: i, .. }, Hit::SketchPoint { sketch: t, id: j, .. })
|
||||
| (Hit::SketchCurve { sketch: s, id: i, .. }, Hit::SketchCurve { sketch: t, id: j, .. }) => s == t && i == j,
|
||||
(Hit::Vertex { body: a, point: p }, Hit::Vertex { body: b, point: q }) => a == b && p.dist(*q) < 1e-9,
|
||||
(Hit::Edge { body: a, index: i, .. }, Hit::Edge { body: b, index: j, .. })
|
||||
| (Hit::Face { body: a, index: i, .. }, Hit::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||
(Hit::Profile { sketch: s, index: i }, Hit::Profile { sketch: t, index: j }) => s == t && i == j,
|
||||
(Hit::Plane { name: a, .. }, Hit::Plane { name: b, .. }) | (Hit::Axis { name: a }, Hit::Axis { name: b }) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidates of one kind: the nearest only, or with `all` every target (nearest first).
|
||||
struct Near<T> {
|
||||
all: bool,
|
||||
v: Vec<(T, Hit)>,
|
||||
}
|
||||
|
||||
impl<T: PartialOrd + Copy> Near<T> {
|
||||
fn new(all: bool) -> Self {
|
||||
Near { all, v: Vec::new() }
|
||||
}
|
||||
/// Whether a candidate at `d` would be kept.
|
||||
fn wants(&self, d: T) -> bool {
|
||||
self.all || self.v.first().is_none_or(|(b, _)| d < *b)
|
||||
}
|
||||
fn offer(&mut self, d: T, h: Hit) {
|
||||
if !self.all {
|
||||
if self.wants(d) {
|
||||
self.v = vec![(d, h)];
|
||||
}
|
||||
return;
|
||||
}
|
||||
match self.v.iter_mut().find(|(_, x)| same_target(x, &h)) {
|
||||
Some(e) if d < e.0 => *e = (d, h),
|
||||
Some(_) => {}
|
||||
None => self.v.push((d, h)),
|
||||
}
|
||||
}
|
||||
fn sorted(mut self) -> Vec<(T, Hit)> {
|
||||
self.v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
self.v
|
||||
}
|
||||
fn hits(self) -> impl Iterator<Item = Hit> {
|
||||
self.sorted().into_iter().map(|x| x.1)
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
|
||||
let s = &app.session;
|
||||
let st = s.world_state();
|
||||
let bounds = body_bounds(app, &st);
|
||||
@@ -733,23 +796,21 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let external_points =
|
||||
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
|
||||
let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
|
||||
let mut best: Option<(f32, Hit)> = None;
|
||||
let mut best = Near::new(all);
|
||||
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
|
||||
let sid = ss.feature;
|
||||
for p in &ss.sketch.points {
|
||||
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
|
||||
let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||||
if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }));
|
||||
if dd < 8.0 && best.wants(dd) {
|
||||
best.offer(dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = best {
|
||||
hits.push(h);
|
||||
}
|
||||
hits.extend(best.hits());
|
||||
// Sketch curves (the active sketch wins ties).
|
||||
let mut bestc: Option<(f32, Hit)> = None;
|
||||
let mut bestc = Near::new(all);
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||||
@@ -762,8 +823,8 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for w in pts.windows(2) {
|
||||
let dd = seg_dist(pos, w[0], w[1]) - bonus;
|
||||
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight }));
|
||||
if dd < 6.0 && bestc.wants(dd) {
|
||||
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -773,17 +834,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let pts: Vec<Pos2> = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for seg in pts.windows(2) {
|
||||
let dd = seg_dist(pos, seg[0], seg[1]) - bonus;
|
||||
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }));
|
||||
if dd < 6.0 && bestc.wants(dd) {
|
||||
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = bestc {
|
||||
hits.push(h);
|
||||
}
|
||||
// Nearest face along the ray (hides what is behind it).
|
||||
hits.extend(bestc.hits());
|
||||
// Nearest face along the ray (hides what is behind it); with `all`, every body's nearest.
|
||||
let mut bestf: Option<(f64, Hit)> = None;
|
||||
let mut faces = Near::new(all);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
@@ -793,23 +853,27 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let pad = bb.diagonal() * 1e-6 + 1e-9;
|
||||
match ray_box(o, d, &bb, pad) {
|
||||
None => continue,
|
||||
Some(t0) if bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
|
||||
Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let m = b.mesh();
|
||||
if let Some((t, ti)) = m.raycast(o, d)
|
||||
&& bestf.as_ref().is_none_or(|(bt, _)| t < *bt)
|
||||
{
|
||||
if let Some((t, ti)) = m.raycast(o, d) {
|
||||
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
|
||||
bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }));
|
||||
let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t };
|
||||
if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) {
|
||||
bestf = Some((t, hit.clone()));
|
||||
}
|
||||
if all {
|
||||
faces.offer(t, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let slack = scene_radius(app) * 0.02;
|
||||
let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack);
|
||||
// Vertices (edge end points) and edges.
|
||||
let mut bestv: Option<(f32, Hit)> = None;
|
||||
let mut beste: Option<(f32, Hit)> = None;
|
||||
let mut bestv = Near::new(all);
|
||||
let mut beste = Near::new(all);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
@@ -828,35 +892,35 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
for v in [e.first(), e.last()].into_iter().flatten() {
|
||||
if let Some(sp) = proj.to_screen(*v) {
|
||||
let dd = sp.distance(pos);
|
||||
if dd < 7.0 && visible(*v) && bestv.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
bestv = Some((dd, Hit::Vertex { body: b.name.clone(), point: *v }));
|
||||
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
|
||||
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for (k, w) in pts.windows(2).enumerate() {
|
||||
let dd = seg_dist(pos, w[0], w[1]);
|
||||
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
if dd < 6.0 && beste.wants(dd) {
|
||||
let mid = polyline_mid(e);
|
||||
// Visible where the cursor is (not just at the middle).
|
||||
let at = e.get(k).copied().unwrap_or(mid);
|
||||
if visible(at) || visible(mid) {
|
||||
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid }));
|
||||
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.extend(bestv.map(|x| x.1));
|
||||
hits.extend(beste.map(|x| x.1));
|
||||
hits.extend(bestv.hits());
|
||||
hits.extend(beste.hits());
|
||||
// Origin axes.
|
||||
let size = origin_size(app.cam.half_height());
|
||||
let mut besta: Option<(f32, Hit)> = None;
|
||||
let mut besta = Near::new(all);
|
||||
for (name, dir) in origin_axes(app) {
|
||||
if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) {
|
||||
let dd = seg_dist(pos, a, b);
|
||||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
besta = Some((dd, Hit::Axis { name: name.into() }));
|
||||
if dd < 5.0 && besta.wants(dd) {
|
||||
besta.offer(dd, Hit::Axis { name: name.into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -864,15 +928,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
for (name, a, b) in construction_axis_segments(app) {
|
||||
if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) {
|
||||
let dd = seg_dist(pos, a, b);
|
||||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
besta = Some((dd, Hit::Axis { name }));
|
||||
if dd < 5.0 && besta.wants(dd) {
|
||||
besta.offer(dd, Hit::Axis { name });
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.extend(besta.map(|x| x.1));
|
||||
hits.extend(besta.hits());
|
||||
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
|
||||
let mut surf: Vec<(f64, Hit)> = Vec::new();
|
||||
let mut bestp: Option<(f64, f64, Hit)> = None;
|
||||
let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new();
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let n = ss.plane.normal();
|
||||
@@ -888,15 +953,24 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
// Show Profile off: the sketch being edited offers no profiles.
|
||||
let profiles = if ss.sketch.view.hide_profile && s.active_sketch == Some(sid) { &[][..] } else { &ss.profiles[..] };
|
||||
for (pi, p) in profiles.iter().enumerate() {
|
||||
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||||
if !p.region.contains(lp) {
|
||||
continue;
|
||||
}
|
||||
if all {
|
||||
profiles_all.push((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||||
} else if bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||||
bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((t, _, h)) = bestp {
|
||||
// Smallest profile first.
|
||||
profiles_all.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
for (t, _, h) in bestp.into_iter().chain(profiles_all) {
|
||||
surf.push((t - slack, h));
|
||||
}
|
||||
if let Some(f) = bestf {
|
||||
if all {
|
||||
surf.extend(faces.sorted());
|
||||
} else if let Some(f) = bestf {
|
||||
surf.push(f);
|
||||
}
|
||||
// Planes give way to a face or profile at the same depth (a floor on the XZ plane).
|
||||
@@ -1103,27 +1177,57 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
|
||||
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
|
||||
return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s)));
|
||||
}
|
||||
hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h))
|
||||
}
|
||||
|
||||
/// Every hit a click could pick, in order, each with its selection (Select Other).
|
||||
pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> {
|
||||
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
|
||||
return hits.iter().filter_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s))).collect();
|
||||
}
|
||||
hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect()
|
||||
}
|
||||
|
||||
/// What the selection filter lets a click pick with no dialog.
|
||||
fn pick_allowed(app: &SolveApp, h: &Hit) -> bool {
|
||||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||||
hits.iter()
|
||||
.filter(|h| match h {
|
||||
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
|
||||
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
|
||||
Hit::Profile { .. } => !off("sketch"),
|
||||
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
|
||||
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
|
||||
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
|
||||
Hit::Plane { .. } | Hit::Axis { .. } => true,
|
||||
})
|
||||
.find_map(|h| match h {
|
||||
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||||
})
|
||||
match h {
|
||||
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
|
||||
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
|
||||
Hit::Profile { .. } => !off("sketch"),
|
||||
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
|
||||
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
|
||||
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
|
||||
Hit::Plane { .. } | Hit::Axis { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection a click on `h` makes with no dialog (sketch entities only belong to the active sketch).
|
||||
fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> {
|
||||
match h {
|
||||
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pick for the open dialog's active input (a click, or a Select Other row).
|
||||
pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
d.pick(&app.session, sel);
|
||||
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
|
||||
*sketch = match hit {
|
||||
Hit::SketchCurve { sketch, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
d.extra.remove("path");
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
|
||||
/// The edge a selection refers to now: by index if it still passes through the stored point,
|
||||
@@ -1855,6 +1959,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||||
// Select Other: a press held still opens the list of everything under it; while the list is
|
||||
// open its row under the pointer is what lights up.
|
||||
let held =
|
||||
crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, primary_nav.is_some() || mode.is_some());
|
||||
crate::select_other::hover_override(app);
|
||||
let painter = ui.painter_at(rect);
|
||||
// Background gradient.
|
||||
let (top, bot) = (t.viewport_top, t.viewport_bottom);
|
||||
@@ -1930,22 +2039,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_hover(app, &proj, p);
|
||||
}
|
||||
if resp.clicked() {
|
||||
if resp.clicked() && !held {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_click(app, &proj, p);
|
||||
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
||||
if let Some((hit, Some(sel))) = cand
|
||||
&& let Some(mut d) = app.dialog.take()
|
||||
{
|
||||
d.pick(&app.session, sel);
|
||||
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
|
||||
*sketch = match hit {
|
||||
Hit::SketchCurve { sketch, .. } => Some(sketch),
|
||||
_ => None,
|
||||
};
|
||||
d.extra.remove("path");
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
if let Some((hit, Some(sel))) = cand {
|
||||
dialog_pick(app, &hit, sel);
|
||||
}
|
||||
} else {
|
||||
// Dimensions and constraint glyphs sit on top of the geometry.
|
||||
@@ -1961,15 +2060,23 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if !crate::sketch3d::click(app, &proj, p) {
|
||||
match on_dim.or(on_glyph) {
|
||||
Some(id) => select(app, Some(Sel::SketchConstraint { id }), add),
|
||||
None => select(app, cand.and_then(|c| c.1), add),
|
||||
// Clicking again on the same spot selects the next thing under it (not
|
||||
// the second click of a double-click, which has its own actions).
|
||||
None if !add && !resp.double_clicked() && crate::select_other::cycle_click(app, &proj, p) => {}
|
||||
None => {
|
||||
let sel = cand.and_then(|c| c.1);
|
||||
select(app, sel.clone(), add);
|
||||
crate::select_other::note_click(app, p, if add { None } else { sel });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Double-click a dimension: edit its value in place.
|
||||
let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||||
let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||||
// Double-click a sketch curve: select the chain joined to it end to end.
|
||||
if resp.double_clicked()
|
||||
&& !held
|
||||
&& !dim_edit
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
@@ -1983,6 +2090,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Double-click an edge: select the edges that continue it smoothly (the loop).
|
||||
if resp.double_clicked()
|
||||
&& !held
|
||||
&& !dim_edit
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
@@ -2006,7 +2114,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
// Dragging a sketch point of the active sketch moves it; the solver keeps the constraints.
|
||||
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() {
|
||||
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() && !held {
|
||||
let on_plane = |app: &SolveApp, p: Pos2| {
|
||||
let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?;
|
||||
let (o, d) = proj.ray(p);
|
||||
@@ -2118,6 +2226,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||||
if app.tool.is_none()
|
||||
&& !held
|
||||
&& primary_nav.is_none()
|
||||
&& mode.is_none()
|
||||
&& app.viewport.point_drag.is_none()
|
||||
@@ -2173,6 +2282,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::canvas::show(app, ui, &painter, &proj);
|
||||
crate::sketch_tools::show(app, ui, &painter, &proj);
|
||||
crate::sketch_dims::show(app, ui, &proj);
|
||||
crate::select_other::show(app, ui.ctx());
|
||||
axis_view(app, ui, rect);
|
||||
nav_bar(app, ui, rect);
|
||||
// Status chip.
|
||||
@@ -2192,7 +2302,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|
||||
/// A click without a command: select the candidate (replacing the selection, or toggling it
|
||||
/// in with Shift/Ctrl); a click on nothing clears.
|
||||
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||||
pub(crate) fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||||
match sel {
|
||||
Some(x) => {
|
||||
let items = grouped_selection(&app.session, &x);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{"note": "Two lines drawn on top of each other: holding the button still lists both, and the one underneath can be picked."},
|
||||
{"start": "sketch.create"},
|
||||
{"click": {"plane": "XY"}},
|
||||
{"call": "ui.set", "params": {"showOrigin": false}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}},
|
||||
{"hold": {"sketch": [10, 10]}},
|
||||
{"expect": {"selection": 0, "sketching": true}},
|
||||
{"move": {"handle": "select_other:1"}},
|
||||
{"click": {"handle": "select_other:1"}},
|
||||
{"expect": {"selection": 1}},
|
||||
{"call": "ui.selectOther", "params": {"pick": 0}, "fail": true},
|
||||
{"note": "Click cycling: a second deliberate click on the same spot selects the next line."},
|
||||
{"click": {"sketch": [10, 10]}},
|
||||
{"expect": {"selection": 1}}
|
||||
]
|
||||
@@ -26,4 +26,13 @@ Keys handled by the window itself:
|
||||
| F6 | fit the view to the model |
|
||||
| Esc | cancel the running command, or clear the selection |
|
||||
|
||||
Mouse and touch:
|
||||
|
||||
| Action | Result |
|
||||
|---|---|
|
||||
| Click | select (Shift or Ctrl adds) |
|
||||
| Click again on the same spot | select the next thing under the cursor (click cycling) |
|
||||
| Hold the left button still (long-press on touch), or right-click › Select Other… | list everything under the cursor to pick from |
|
||||
| Double-click a sketch curve or edge | select its chain or tangent loop |
|
||||
|
||||
Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed.
|
||||
@@ -14,7 +14,14 @@
|
||||
origin, bodies, sketches, construction planes, canvases, joints and components. The eye shows or
|
||||
hides an item, a component's radio button makes it the active component, and right-clicking any
|
||||
row gives that item's menu. Rows drag into groups and other components.
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. To
|
||||
reach something under something else (two lines drawn on top of each other, coincident points,
|
||||
an edge in front of a face, overlapping bodies), hold the left button still for half a second
|
||||
(long-press on a touch screen) or right-click › **Select Other…**: a list of everything under
|
||||
the cursor opens there, the sketch being edited first. Hovering a row lights it up; clicking it
|
||||
selects it (Shift or Ctrl adds), and in a command's dialog it goes to the active selection box.
|
||||
Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed
|
||||
in between, selects the next thing in that list. The
|
||||
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
|
||||
< preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a
|
||||
trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans;
|
||||
|
||||
@@ -56,6 +56,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) |
|
||||
| `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.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list everything pickable there (as holding the left button does); `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `rows`: label, icon, sel) |
|
||||
| `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened |
|
||||
| `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`) |
|
||||
| `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points |
|
||||
|
||||
Reference in new issue
Block a user