Compare commits

..

4 Commits

Author SHA1 Message Date
bherbruck 133569e695 A modelled thread hovers, lights and selects as one surface
A modelled thread is thousands of small faces (26,518 for an M8 x 100), and the
pointer over it found the vertices and edges between them, lit one sliver at a
time, and a click selected one sliver. Now the thread is one surface to point at:

- Which faces belong to a thread comes from what made them: a face made by a
  Thread feature is named after it (F<id>:...), a piece of a face the thread
  cut names it among the faces around it. The pieces of one face of an
  imported file group the same way. The names of a big body take seconds, so
  the groups are worked out on a worker, once per body
  (doc::face_groups::face_groups_soon), and nothing waits for them.
- Hover: over a thread the pointer lands on the surface (the edges and
  vertices between the thread's own faces are no boundary to pick), and the
  whole thread lights up, outlined where it meets the rest of the body. The
  highlight is kept while the pointer moves over the thread. A face small on
  screen (a finely faceted surface) wins over vertices and edges further than
  two points away.
- Click: the thread is one item in the selection; shown, and given to a
  command that takes faces, it stands for every face of the thread.
- Thousands of selected faces are drawn in one pass per body and hashed by
  index, not written out, every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:15:51 -04:00
bherbruck 7e05c8b23c Hovering a long helical edge no longer freezes (#3745) (#3818)
Hovering an edge lights its circular rim, which starts from the edge's
tangent chain. That looked at every edge of the mesh for each edge in the
chain (and searched the chain itself each time), so a long helical edge in
many pieces, a thread's crest, took seconds: 7.6 s inside the frame on the
first hover of the stem's thread in a real watch-movement STEP file. The
edges are now found by the grid cells of their ends, two join tolerances
across, so each step looks only at the edges that end near it: the same chain
in the same order, in 29 ms for that thread.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:08:50 -04:00
DuBearte 7dfbaf2a2c Parameter autocomplete matches anywhere in the name, prefix matches first (#2757) (#2849)
* claim: #2757

* fix(params): autocomplete offers a parameter for any part of its name (#2757)

Typing `side` now offers `cab_side_thickness`: parameters match the word
anywhere in their name, after those that start with it, both in the expression
boxes and in parameters.complete. Functions, constants and units still match
from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 20:39:14 +00:00
phanoob 1e0e8f78dc Shift+R repeats the last tool or dialog (#3045) (#3160)
* claim: #3045

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shift+R repeats the last tool or dialog (#3045)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:29:22 +00:00
13 changed files with 534 additions and 46 deletions
+126
View File
@@ -0,0 +1,126 @@
//! Faces that are picked as one (#3745 follow-up): a modelled thread is thousands of small faces
//! (its flanks, and the pieces of the surface it was cut into), but it is one surface to point at,
//! light and select; so are the pieces of one face an imported file had (a face wrapping all the
//! way round, split in two for the kernel).
//!
//! The groups come from what made the faces, never from their shape: a face made by a Thread
//! feature is named after it (`F<id>:…`), and a piece of a face the thread cut names it among the
//! faces around it (`…[F<id>:…]`). Working the names out for a large body takes a while, so it is
//! done on a worker thread, once per body, and asked for without waiting.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::{Document, FeatureKind, ModelBody};
/// Each face's group (an index into `groups`), and the groups: faces picked together, in order.
#[derive(Debug, Default)]
pub struct FaceGroups {
of_face: Vec<Option<u32>>,
groups: Vec<Arc<Vec<usize>>>,
}
impl FaceGroups {
/// The group of `face` (`None`: it is picked alone).
pub fn group_of(&self, face: usize) -> Option<u32> {
self.of_face.get(face).copied().flatten()
}
/// The faces picked together with `face` (`None`: it is picked alone).
pub fn of(&self, face: usize) -> Option<Arc<Vec<usize>>> {
let g = self.of_face.get(face).copied().flatten()?;
self.groups.get(g as usize).cloned()
}
/// Work out the groups of `b` (the names of its faces are worked out here: slow for a big body).
pub fn of_body(threads: &[u64], b: &ModelBody) -> FaceGroups {
let mut keyed: HashMap<(u8, u64), Vec<usize>> = HashMap::new();
if !threads.is_empty() {
let names = crate::naming::face_names(b);
let marks: Vec<(u64, String, String)> = threads.iter().map(|id| (*id, format!("F{id}:"), format!("[F{id}:"))).collect();
for (i, name) in names.iter().enumerate() {
// Made by the thread, or a piece of a face the thread cut (it names the thread's faces
// around it).
let own = |p: &str, inside: &str| name.starts_with(p) || name.contains(inside) || name.contains(&format!(",{p}"));
if let Some((id, _, _)) = marks.iter().find(|(_, p, inside)| own(p, inside)) {
keyed.entry((0, *id)).or_default().push(i);
}
}
}
// Pieces of one face of an imported file.
if let Some(sources) = b.body.face_sources() {
for (i, s) in sources.iter().enumerate() {
if let Some(solidcraft_kernel::FaceSource::Imported { face, .. }) = s
&& !keyed.values().any(|faces| faces.contains(&i))
{
keyed.entry((1, *face)).or_default().push(i);
}
}
}
let mut out = FaceGroups { of_face: vec![None; b.body.face_count()], groups: Vec::new() };
let mut groups: Vec<Vec<usize>> = keyed.into_values().filter(|faces| faces.len() > 1).collect();
groups.sort();
for faces in groups {
let g = u32::try_from(out.groups.len()).unwrap_or(u32::MAX);
for &f in &faces {
if let Some(slot) = out.of_face.get_mut(f) {
*slot = Some(g);
}
}
out.groups.push(Arc::new(faces));
}
out
}
}
/// The modelled threads of `doc`, by feature id.
pub fn thread_features(doc: &Document) -> Vec<u64> {
doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::Thread { modeled: true, .. })).map(|f| f.id).collect()
}
/// Groups worked out (or being worked out: `None`), by the identity of a body's names and the
/// threads they were grouped by.
type Cache = HashMap<(usize, Vec<u64>), Option<Arc<FaceGroups>>>;
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
/// Bodies whose groups are kept; the oldest go first.
const KEPT: usize = 256;
/// The groups of `b` in `doc` if they are worked out; otherwise they are worked out on a worker
/// thread (once) and `None` comes back meanwhile. The browser build has no threads: there they
/// are worked out now.
pub fn face_groups_soon(doc: &Document, b: &ModelBody) -> Option<Arc<FaceGroups>> {
let threads = thread_features(doc);
if threads.is_empty() && b.body.face_sources().is_none() {
return Some(Arc::new(FaceGroups::default()));
}
let key = (b.names.id(), threads.clone());
{
let Ok(mut cache) = CACHE.lock() else { return None };
let cache = cache.get_or_insert_with(HashMap::new);
if let Some(entry) = cache.get(&key) {
return entry.clone();
}
if cache.len() >= KEPT {
cache.clear();
}
cache.insert(key.clone(), None);
}
let work = move |b: ModelBody| {
let groups = Arc::new(FaceGroups::of_body(&threads, &b));
if let Ok(mut cache) = CACHE.lock()
&& let Some(slot) = cache.as_mut().and_then(|c| c.get_mut(&key))
{
*slot = Some(groups.clone());
}
groups
};
#[cfg(not(target_arch = "wasm32"))]
{
let body = b.clone();
let work = work.clone();
if std::thread::Builder::new().name("face-groups".into()).spawn(move || drop(work(body))).is_ok() {
return None;
}
}
Some(work(b.clone()))
}
+1
View File
@@ -22,6 +22,7 @@ mod document;
mod eval;
pub mod expr;
pub mod face_anchor;
pub mod face_groups;
mod feature_geometry;
mod feature_refs;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
+9 -4
View File
@@ -348,11 +348,16 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_(p, "prefix").unwrap_or("");
// The identifier being typed: trailing letters, digits and underscores.
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&word.to_ascii_lowercase());
let lower = word.to_ascii_lowercase();
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&lower);
// A parameter matches wherever the word appears in its name (`side` finds
// `cab_side_thickness`, #2757); functions, constants and units match from the start.
let within = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
let mut out: Vec<Value> = Vec::new();
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| starts(&r.name)).collect();
// Favourites, then user parameters, then the rest.
rows.sort_by_key(|r| (!r.favorite, r.source != "user"));
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
// Names that start with the word, then the rest; within each, favourites, then user
// parameters, then the others.
rows.sort_by_key(|r| (!starts(&r.name), !r.favorite, r.source != "user"));
for r in rows.into_iter().take(200) {
let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
let mut detail = format!("{} = {v}", r.expression);
+21
View File
@@ -212,6 +212,27 @@ fn favorites_comments_and_completion() {
assert!(c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "atan2("), "{c}");
}
/// #2757: a parameter is suggested for a word anywhere in its name, not only at its start;
/// names that start with the word come first. Functions still match from the start.
#[test]
fn completion_matches_parameters_anywhere_in_their_name() {
let mut s = Session::default();
for (name, e) in [("cab_side_thickness", "18 mm"), ("side", "40 mm"), ("door_width", "300 mm")] {
run(&mut s, "parameters.add", json!({"name": name, "expression": e}));
}
let texts = |c: &Value| -> Vec<String> {
c["suggestions"].as_array().unwrap().iter().filter(|x| x["kind"] == "parameter").map(|x| x["text"].as_str().unwrap().to_string()).collect()
};
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * side"}));
assert_eq!(texts(&c), ["side", "cab_side_thickness"], "the name starting with it first: {c}");
let c = run(&mut s, "parameters.complete", json!({"prefix": "THICK"}));
assert_eq!(texts(&c), ["cab_side_thickness"], "any case: {c}");
assert!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "xyz"}))).is_empty());
// Functions keep matching from the start: `in` is not `sin(`.
let c = run(&mut s, "parameters.complete", json!({"prefix": "in"}));
assert!(!c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "sin("), "{c}");
}
#[test]
fn csv_and_json_round_trip() {
let mut s = Session::default();
+34 -2
View File
@@ -504,11 +504,42 @@ impl Mesh {
if self.smooth_seam(e, cos) {
return chain;
}
// The edges by the grid cell of each end, so each step looks only at the edges that end
// near it (#3745: scanning every edge for every edge of a long helical chain held a hover
// for seconds). Cells are twice the join tolerance: two ends within it share a cell or
// touch neighbouring ones.
let cell = tol * 2.0;
let key = |p: Vec3| [(p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64];
let mut at_end: std::collections::HashMap<[i64; 3], Vec<usize>> = std::collections::HashMap::new();
for (i, p) in self.edges.iter().enumerate() {
for q in [p.first(), p.last()].into_iter().flatten().filter(|q| q.is_finite()) {
let slot = at_end.entry(key(*q)).or_default();
if slot.last() != Some(&i) {
slot.push(i);
}
}
}
let mut in_chain: std::collections::HashSet<usize> = chain.iter().copied().collect();
let mut todo = vec![e];
while let Some(cur) = todo.pop() {
let Some(ce) = ends(cur) else { continue };
for (i, _) in self.edges.iter().enumerate() {
if chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
// The edges with an end near one of `cur`'s, in edge order (as a scan of every edge
// meets them).
let mut near: Vec<usize> = Vec::new();
for (p, _) in &ce {
let [x, y, z] = key(*p);
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
near.extend(at_end.get(&[x + dx, y + dy, z + dz]).into_iter().flatten());
}
}
}
}
near.sort_unstable();
near.dedup();
for i in near {
if in_chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
continue;
}
let Some(ne) = ends(i) else { continue };
@@ -516,6 +547,7 @@ impl Mesh {
let smooth = ce.iter().any(|(p, d)| ne.iter().any(|(q, dn)| p.dist(*q) < tol && d.dot(-*dn) > cos));
if smooth && chain.len() < 10_000 {
chain.push(i);
in_chain.insert(i);
todo.push(i);
}
}
+26
View File
@@ -164,6 +164,32 @@ fn tangent_chains_follow_smooth_edges() {
assert_eq!(seam.tangent_chain(0, 5f64.to_radians()), vec![0]);
}
/// #3745: the tangent chain of a long helix (a thread's crest, in many edge pieces) is found
/// without scanning every edge for every edge of it: hovering a threaded part's edge looked for
/// its rim this way, and took seconds inside the frame. Every piece joins, in edge order, and
/// a mesh of 3 000 pieces takes well under a frame.
#[test]
fn a_long_helical_tangent_chain_is_found_quickly() {
let pieces = 3_000usize;
let at = |t: f64| Vec3::new(4.0 * t.cos(), 4.0 * t.sin(), 0.2 * t);
let step = 0.05;
let mut m = Mesh::default();
for k in 0..pieces {
let t0 = k as f64 * step * 4.0;
m.edges.push((0..=4).map(|j| at(t0 + j as f64 * step)).collect());
m.edge_faces.push(vec![0]);
}
// The mesh's points (its size sets the join tolerance).
m.positions = m.edges.iter().flatten().copied().collect();
let began = std::time::Instant::now();
let chain = m.tangent_chain(pieces / 2, 2f64.to_radians());
let ms = began.elapsed().as_secs_f64() * 1000.0;
let mut sorted = chain.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..pieces).collect::<Vec<_>>(), "every piece of the helix is in the chain");
assert!(ms < 200.0, "{ms:.1} ms for {pieces} pieces");
}
#[test]
fn circular_rims_group_only_closed_cocircular_edges() {
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
+4 -6
View File
@@ -307,8 +307,8 @@ pub fn component_bodies(app: &SolveApp, id: u64) -> Vec<String> {
/// The eight radial commands, clockwise from the top.
pub fn radial_items(app: &SolveApp) -> Vec<Item> {
let repeat = match &app.last_command {
Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo"),
None => act("ui.repeat", "Repeat", "redo").on(false),
Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo").key(crate::keymap::REPEAT_KEY),
None => act("ui.repeat", "Repeat", "redo").key(crate::keymap::REPEAT_KEY).on(false),
};
let delete = act("ui.delete", "Delete", "delete").key("Del").on(!app.session.selection.is_empty());
if app.session.active_sketch.is_some() {
@@ -464,7 +464,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
let has = |f: fn(&Sel) -> bool| sel.iter().any(f);
let mut v = Vec::new();
if let Some((_, label)) = &app.last_command {
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo").key(crate::keymap::REPEAT_KEY));
v.push(Item::sep());
}
// Everything under the cursor, for what lies beneath something else.
@@ -975,9 +975,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.repeat" => {
if let Some((id, _)) = app.last_command.clone() {
app.start(&id);
}
app.repeat_last();
}
"ui.selectOther" => {
crate::select_other::open(app, at);
+31
View File
@@ -11,8 +11,12 @@ use solidcraft_engine::command_specs;
use crate::SolveApp;
use crate::ui_kit;
/// Repeats the last tool or dialog started (`SolveApp::repeat_last`, #3045).
pub const REPEAT_KEY: &str = "Shift+R";
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &[
REPEAT_KEY,
"Escape",
"S",
"V",
@@ -379,6 +383,33 @@ mod tests {
assert_eq!(effective(&b, "solid.move").as_deref(), Some("M"));
assert_eq!(key_name(egui::Key::K, egui::Modifiers { ctrl: true, shift: true, ..Default::default() }), "Ctrl+Shift+K");
}
/// #3045: Shift+R starts the last tool again (here a sketch's Line after Escape put it
/// down), R alone is still the rectangle; the menus show the key on Repeat, and no command
/// can take it.
#[test]
fn shift_r_repeats_the_last_tool() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.frames(2);
h.step(&json!({"start": "sketch.line"})).unwrap();
h.step(&json!({"key": "Escape"})).unwrap();
h.frames(1);
assert!(h.app.tool.is_none());
h.step(&json!({"key": "R", "shift": true})).unwrap();
h.frames(1);
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd.to_string()).as_deref(), Some("sketch.line"), "Shift+R starts Line again");
h.step(&json!({"key": "Escape"})).unwrap();
h.step(&json!({"key": "R"})).unwrap();
h.frames(1);
let rectangle = h.app.tool.as_ref().map(|t| t.cmd.to_string()).unwrap_or_default();
assert!(rectangle.starts_with("sketch.rectangle"), "R alone: {rectangle}");
let repeat = crate::context_menu::radial_items(&h.app).into_iter().find(|i| i.id == "ui.repeat").unwrap();
assert_eq!(repeat.shortcut, REPEAT_KEY);
let mut a = app();
assert!(bind(&mut a, "solid.extrude", REPEAT_KEY, true).is_err(), "Shift+R is the app's");
}
}
#[cfg(test)]
+8
View File
@@ -810,6 +810,14 @@ impl SolveApp {
workspace::sync_context(self);
}
/// Start the last tool or dialog started again (Repeat in the menus, Shift+R; #3045).
/// `false` when none was started yet.
pub fn repeat_last(&mut self) -> bool {
let Some((id, _)) = self.last_command.clone() else { return false };
self.start(&id);
true
}
/// Start a command the way a toolbar click does: interactive tools and dialogs for commands
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
+22 -5
View File
@@ -634,11 +634,12 @@ pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -
return Vec::new();
}
let w = word.to_ascii_lowercase();
all.iter()
.filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && (explicit || s.text.trim_end_matches('(') != word))
.take(8)
.cloned()
.collect()
let offered = |s: &&Suggestion| explicit || s.text.trim_end_matches('(') != word;
let starts = |s: &&Suggestion| s.text.to_ascii_lowercase().starts_with(&w);
// A parameter is also offered for a word anywhere in its name (`side` finds
// `cab_side_thickness`, #2757), after the names that start with it.
let within = |s: &&Suggestion| s.kind == "parameter" && !starts(s) && s.text.to_ascii_lowercase().contains(&w);
all.iter().filter(|s| starts(s) && offered(s)).chain(all.iter().filter(|s| within(s) && offered(s))).take(8).cloned().collect()
}
/// Offer keyboard and pointer completions under a focused expression box.
@@ -672,6 +673,22 @@ mod tests {
assert!(check(&mut app, "width + 1 deg", "mm").is_err());
}
/// #2757: typing part of a parameter's name offers it, not only its start; names that start
/// with the word come first, and functions still match from the start.
#[test]
fn a_parameter_is_offered_for_any_part_of_its_name() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
for (name, e) in [("cab_side_thickness", "18 mm"), ("sides", "4")] {
let _ = app.run("parameters.add", json!({"name": name, "expression": e}));
}
publish(&mut app, &ctx);
let texts = |w: &str| suggestions(&ctx, w).into_iter().map(|s| s.text).collect::<Vec<_>>();
assert_eq!(texts("side"), ["sides", "cab_side_thickness"]);
assert_eq!(texts("Thick"), ["cab_side_thickness"]);
assert!(!texts("in").iter().any(|t| t == "sin("), "functions match from the start");
}
/// A favourite is listed twice, under Favorites and in its own table; typing into either of its
/// expression boxes enters each key once (#1767: `1.1` came out `1.11.1`).
#[test]
+64
View File
@@ -2173,3 +2173,67 @@ fn hovering_a_large_body_does_not_wait_for_its_pick_index() {
assert!(matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. })), "{:?}", h.app.viewport.hover);
assert!(hover_ms < index_ms / 3.0, "the first hover took {hover_ms:.1} ms; making the index takes {index_ms:.1} ms");
}
/// A modelled thread is thousands of small faces but one surface to point at: the pointer over
/// it lands on a face (not on the vertices and edges between the thread's own faces), the face
/// lights up with the whole thread, and a click selects the thread as one item, which stands for
/// every one of its faces when it is shown and when a command takes faces. No hover waits for the
/// thread's faces to be worked out.
#[test]
fn a_modelled_thread_hovers_and_selects_as_one_surface() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"diameter": 8, "height": 16, "base": [0, 0, 0], "axis": [0, 0, 1]})).unwrap();
h.app.run("solid.thread", json!({"face": [4, 0, 8], "designation": "M8", "length": 10, "modeled": true})).unwrap();
let st = h.app.session.world_state();
let b = &st.bodies[0];
let names = solidcraft_engine::doc::naming::face_names(b);
let thread = h.app.session.doc.features.iter().find(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::Thread { .. })).unwrap().id;
let made = names.iter().filter(|n| n.starts_with(&format!("F{thread}:"))).count();
assert!(made > 500, "{made} thread faces");
// Close in on the threaded part, side on.
h.app.cam.target = Vec3::new(0.0, 0.0, 5.0);
h.app.cam.distance = 30.0;
h.frames(3);
let mut slowest = 0.0f64;
let mut grouped = None;
for k in 0..40 {
let at = h.at(&json!({"world": [4.0 * (0.3 * k as f64).cos(), 4.0 * (0.3 * k as f64).sin(), 2.0 + (k % 6) as f64]})).unwrap();
let t = std::time::Instant::now();
h.step(&json!({"move": at})).unwrap();
slowest = slowest.max(t.elapsed().as_secs_f64() * 1000.0);
if let Some(crate::viewport::Hit::Face { body, index, point }) = h.app.viewport.hover.clone() {
let sel = Sel::Face { body, index, point };
let pieces = crate::viewport::grouped_selection(&h.app.session, &sel);
if pieces.len() >= made {
grouped = Some(sel);
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(slowest < 250.0, "a hover took {slowest:.0} ms");
let sel = grouped.expect("hovering the thread lands on its faces, and they are worked out as one surface");
// Over the thread, the pointer finds the surface, not the vertices and edges inside it.
let faces = (0..12)
.filter(|k| {
let a = 0.5 * *k as f64;
let at = h.at(&json!({"world": [4.0 * a.cos(), 4.0 * a.sin(), 3.0 + (*k % 4) as f64]})).unwrap();
h.step(&json!({"move": at})).unwrap();
matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. }))
})
.count();
assert!(faces >= 9, "{faces} of 12 hovers over the thread landed on its surface");
// A click selects it as one item, which stands for every face of the thread.
let point = match &sel {
Sel::Face { point, .. } => *point,
_ => Vec3::ZERO,
};
let at = h.at(&json!({"world": [point.x, point.y, point.z]})).unwrap();
let t = std::time::Instant::now();
h.step(&json!({"click": at})).unwrap();
let click_ms = t.elapsed().as_secs_f64() * 1000.0;
assert_eq!(h.app.session.selection.len(), 1, "one item: {:?}", h.app.session.selection.len());
let pieces = crate::viewport::grouped_selection(&h.app.session, &h.app.session.selection[0]);
assert!(pieces.len() >= made, "the thread stands for {} faces of {made}", pieces.len());
assert!(click_ms < 250.0, "the click took {click_ms:.0} ms");
}
+3
View File
@@ -1845,6 +1845,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
app.start("ui.palette");
}
Key::V => crate::context_menu::toggle_visibility(app),
Key::R if mods.shift && !mods.alt => {
app.repeat_last();
}
Key::F2 => {
let at = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(ctx.content_rect().center());
crate::context_menu::rename_selection(app, at);
+185 -29
View File
@@ -1365,12 +1365,41 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
// The edges seen near the cursor, when the GPU pick buffer has them for this view (Select
// Other wants hidden ones too).
let seen = if all { None } else { crate::pick_buffer::edges_near(app, proj, pos, &st) };
// The face under the pointer wins over the vertices and edges inside it when they are not
// the boundary the pointer means: the edges between faces picked as one with it (a modelled
// thread's thousands of faces), and, when the face is small on screen (a finely faceted
// surface), any vertex or edge further than a couple of points away.
let inside = match bestf.as_ref().map(|(_, h)| h) {
Some(Hit::Face { body, index, .. }) if !all => st.body(body).map(|b| {
let m = b.mesh();
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&app.session.doc, b);
let group = groups.as_ref().and_then(|g| g.group_of(*index));
// Small on screen: only worth the look for a face picked alone.
let small = group.is_none() && {
let mut lo = egui::pos2(f32::INFINITY, f32::INFINITY);
let mut hi = egui::pos2(f32::NEG_INFINITY, f32::NEG_INFINITY);
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize == *index {
for p in m.tri(t).into_iter().flatten().filter_map(|p| proj.to_screen(p)) {
lo = lo.min(p);
hi = hi.max(p);
}
}
}
lo.x <= hi.x && (hi - lo).length() < 21.0
};
(body.clone(), groups.zip(group), small)
}),
_ => None,
};
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for (i, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
let inside = inside.as_ref().filter(|(body, _, _)| *body == b.name);
let (vertex_px, edge_px) = if inside.is_some_and(|(_, _, small)| *small) { (2.0, 2.0) } else { (7.0, 6.0) };
// Only bodies whose outline on screen is near the cursor can have an edge under it.
if let Some((_, bb)) = bounds.get(i)
&& screen_box(proj, bb, 8.0).is_some_and(|r| !r.contains(pos))
@@ -1391,12 +1420,18 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
if !m.edge_pickable(ei) {
continue;
}
// Between two faces picked as one with the face under the pointer: not a boundary.
if let Some((_, Some((groups, group)), _)) = inside
&& m.edge_faces.get(ei).is_some_and(|fs| !fs.is_empty() && fs.iter().all(|f| groups.group_of(*f as usize) == Some(*group)))
{
continue;
}
if last_edge != Some(ei) {
last_edge = Some(ei);
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.wants(dd) {
if dd < vertex_px && visible(*v) && bestv.wants(dd) {
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
}
}
@@ -1407,7 +1442,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let dd = seg_dist(pos, a, c);
// Visible where the cursor is: the edge's point under the cursor, not an end or
// the middle, which can lie deeper than the face the cursor is on.
if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
if dd < edge_px && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) });
}
}
@@ -1832,6 +1867,7 @@ fn edge_of(m: &solidcraft_engine::geom::Mesh, index: usize, point: Vec3) -> Opti
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).filter(|e| near(e) < tol * 1e3)
}
#[cfg(test)]
fn face_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) {
mesh_tris(sc, m, col, lit, |f| f as usize == face);
}
@@ -1866,9 +1902,25 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3
}
/// The outline of a highlighted face where the model hides it (dimmed).
fn hidden_face_outline(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: Color32) {
/// Whether an edge (between `faces`) lies inside one thread's faces: no boundary to draw or pick.
fn inside_group(groups: Option<&solidcraft_engine::doc::face_groups::FaceGroups>, faces: &[u32]) -> bool {
let Some(groups) = groups else { return false };
let mut of = faces.iter().map(|f| groups.group_of(*f as usize));
match of.next() {
Some(Some(g)) => faces.len() > 1 && of.all(|x| x == Some(g)),
_ => false,
}
}
fn hidden_faces_outline(
sc: &mut GpuScene,
m: &solidcraft_engine::geom::Mesh,
faces: &std::collections::HashSet<usize>,
groups: Option<&solidcraft_engine::doc::face_groups::FaceGroups>,
col: Color32,
) {
for (ei, e) in m.edges.iter().enumerate() {
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| faces.contains(&(*x as usize))) && !inside_group(groups, f));
if on && m.edge_pickable(ei) {
for p in e.windows(2) {
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
@@ -1936,8 +1988,32 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 {
let selected = app.highlighted();
app.session.revision.hash(&mut h);
(std::sync::Arc::as_ptr(&crate::open_meshing::shown_world(app)) as usize).hash(&mut h);
serde_json::to_string(&selected).unwrap_or_default().hash(&mut h);
format!("{:?}", app.viewport.hover).hash(&mut h);
// A thread selected is thousands of faces: they are hashed by body and index, not written
// out every frame.
selected.len().hash(&mut h);
for item in &selected {
match item {
Sel::Face { body, index, .. } => (0u8, body, index).hash(&mut h),
other => serde_json::to_string(other).unwrap_or_default().hash(&mut h),
}
}
// A face hovered stands for the faces lit with it: moving over them (or within one) lights
// the same, and the highlight is not built again.
let group = match &app.viewport.hover {
Some(Hit::Face { body, index, .. }) => app
.session
.world_state()
.body(body)
.and_then(|b| solidcraft_engine::doc::face_groups::face_groups_soon(&app.session.doc, b))
.and_then(|g| g.of(*index))
.and_then(|g| g.first().copied())
.map(|first| (body.clone(), first)),
_ => None,
};
match group {
Some(group) => group.hash(&mut h),
None => format!("{:?}", app.viewport.hover).hash(&mut h),
}
app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.hash(&mut h);
// Which mesh lights a face follows how the model draws its body (see `shown_mesh`).
@@ -1993,9 +2069,15 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
// Faces lit over the model use the triangles the model shows them with.
let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies);
let mut sel = Vec::new();
// Faces by body and index: a selected thread stands for thousands of them.
let mut seen_faces: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
for item in app.highlighted() {
for piece in displayed_selection(app, &item) {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
let fresh = match &piece {
Sel::Face { body, index, .. } => seen_faces.insert((body.clone(), *index)),
_ => !sel.iter().any(|x| crate::selection::same_item(x, &piece)),
};
if fresh {
sel.push(piece);
}
}
@@ -2076,16 +2158,31 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
return sc;
}
let mut feedback = GpuScene::default();
// Selected faces, a pass per body however many of its faces are selected (a modelled
// thread is thousands).
let mut faces_of: Vec<(&str, std::collections::HashSet<usize>)> = Vec::new();
for x in &sel {
if let Sel::Face { body, index, .. } = x {
match faces_of.iter_mut().find(|(b, _)| *b == body.as_str()) {
Some((_, faces)) => {
faces.insert(*index);
}
None => faces_of.push((body.as_str(), std::collections::HashSet::from([*index]))),
}
}
}
for (body, faces) in &faces_of {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.effective_replaced().iter().any(|r| r == *body)) {
mesh_tris(&mut feedback, &shown(b), c4(t.sel_face), false, |f| faces.contains(&(f as usize)));
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b);
hidden_faces_outline(&mut feedback, &drawn_mesh(app, b), faces, groups.as_deref(), t.sel_edge);
}
}
// Selected things.
for x in &sel {
match x {
Sel::Face { body, index, .. } => {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.effective_replaced().contains(body)) {
face_tris(&mut feedback, &shown(b), *index, c4(t.sel_face), false);
hidden_face_outline(&mut feedback, &drawn_mesh(app, b), *index, t.sel_edge);
}
}
Sel::Face { .. } => {}
Sel::Body { name } => {
if let Some(b) = st.body(name)
&& app.session.body_visible(name)
@@ -2211,12 +2308,21 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
let m = drawn_mesh(app, b);
let patch = m.surface_patch(*index);
let lit = shown(b);
// The face with the faces picked as one with it (a modelled thread), in one pass.
let patch = face_patch(s, b, &m, *index);
let mut in_patch = vec![false; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0).max(*index + 1)];
for &f in &patch {
face_tris(&mut feedback, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
if let Some(slot) = in_patch.get_mut(f) {
*slot = true;
}
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
let lit = shown(b);
let col = [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255];
mesh_tris(&mut feedback, &lit, col, true, |f| in_patch.get(f as usize).copied().unwrap_or(false));
// Its edges, but not those between two faces of one thread: no boundary there.
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b);
let lit_face = |f: &u32| in_patch.get(*f as usize).copied().unwrap_or(false);
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(lit_face) && !inside_group(groups.as_deref(), fs)) {
if let Some(p) = m.edges.get(e)
&& m.edge_pickable(e)
{
@@ -3137,12 +3243,49 @@ pub(crate) fn picked_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec
{
return letters.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
}
grouped_selection(s, sel)
selection_pieces(s, sel)
}
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
/// face pick keeps its point (used by tools such as Hole and construction geometry).
/// The faces picked as one with `face` of `b` (`m` its mesh): the pieces of its surface joined
/// across hidden seams, and every face of a modelled thread (or of one imported face) it is part
/// of. The thread's faces are worked out on a worker; until then the face goes with its surface.
fn face_patch(s: &solidcraft_engine::Session, b: &solidcraft_engine::doc::ModelBody, m: &solidcraft_engine::geom::Mesh, face: usize) -> Vec<usize> {
face_pieces(s, b, m, face, true)
}
/// [`face_patch`], with the faces of the thread it is part of when `groups`.
fn face_pieces(
s: &solidcraft_engine::Session,
b: &solidcraft_engine::doc::ModelBody,
m: &solidcraft_engine::geom::Mesh,
face: usize,
groups: bool,
) -> Vec<usize> {
let mut patch = m.surface_patch(face);
if !groups {
return patch;
}
if let Some(group) = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b).and_then(|g| g.of(face)) {
let mut seen: std::collections::HashSet<usize> = patch.iter().copied().collect();
patch.extend(group.iter().copied().filter(|f| seen.insert(*f)));
}
patch
}
pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
pieces(s, sel, true)
}
/// What a click or a box puts in the selection for `sel`: as [`grouped_selection`], but a face
/// of a modelled thread stays one item (the thread's thousands of faces are what it stands for
/// when shown or given to a command).
pub(crate) fn selection_pieces(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
pieces(s, sel, false)
}
fn pieces(s: &solidcraft_engine::Session, sel: &Sel, groups: bool) -> Vec<Sel> {
let st = s.world_state();
match sel {
Sel::Edge { body, index, .. } => {
@@ -3158,21 +3301,28 @@ pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Ve
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let mut patch = m.surface_patch(*index);
let mut patch = face_pieces(s, b, &m, *index, groups);
patch.sort_by_key(|i| usize::from(i != index));
// A point on each face, from one pass over the triangles.
let wanted: std::collections::HashSet<usize> = patch.iter().copied().collect();
let mut point: std::collections::HashMap<usize, Vec3> = std::collections::HashMap::new();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if f != *index
&& !point.contains_key(&f)
&& wanted.contains(&f)
&& let Some([a, b, c]) = m.tri(t)
{
point.insert(f, (a + b + c) / 3.0);
}
}
return patch
.into_iter()
.filter_map(|i| {
if i == *index {
return Some(sel.clone());
}
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != i {
return None;
}
let [a, b, c] = m.tri(t)?;
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
})
point.get(&i).map(|p| Sel::Face { body: body.clone(), index: i, point: *p })
})
.collect();
}
@@ -3347,9 +3497,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
let mut grouped = Vec::new();
// Faces by body and index: a box over a thread takes thousands of them.
let mut faces: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
for item in out {
for piece in grouped_selection(&app.session, &item) {
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
for piece in selection_pieces(&app.session, &item) {
let fresh = match &piece {
Sel::Face { body, index, .. } => faces.insert((body.clone(), *index)),
_ => !grouped.iter().any(|x| crate::selection::same_item(x, &piece)),
};
if fresh {
grouped.push(piece);
}
}