Keep large STEP assemblies responsive during 3D navigation (#1762)

Thanks @crono2250.
This commit is contained in:
cronos authored and GitHub committed 2026-10-16 12:26:26 -04:00
1 parent da65b4fc8c
commit e2667b749d
6 files changed
+516 -78

No files matched your search

+77 -19
View File
@@ -10,6 +10,13 @@
#[path = "occurrence_ui_tests.rs"]
mod occurrence_ui_tests;
#[path = "browser_model.rs"]
mod model;
#[cfg(test)]
#[path = "browser_model_tests.rs"]
mod model_tests;
use crate::icons::glyphs::badge;
use std::collections::BTreeSet;
@@ -42,6 +49,8 @@ pub fn drawn_occurrences() -> Vec<(Vec<u64>, String, Rect, bool)> {
/// Browser state kept between frames.
#[derive(Default)]
pub struct TreeState {
/// Geometry and assembly queries, independent of the camera, selection and row visibility.
model: Option<model::Model>,
/// Folders closed by the user (keys like `c0/bodies`).
pub collapsed: BTreeSet<String>,
/// Folders opened by the user (folders that start closed).
@@ -547,7 +556,60 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
browser_rows(app, ui, acts);
}
fn prepare_model(app: &mut SolveApp) {
if app.tree.model.as_ref().is_none_or(|model| !model.matches(&app.session)) {
app.tree.model = Some(model::Model::new(&app.session));
}
}
fn model_placement(app: &mut SolveApp, path: &[u64]) -> Option<std::sync::Arc<solidcraft_engine::occurrences::OccurrencePlacement>> {
prepare_model(app);
app.tree.model.as_mut()?.placement(&app.session, path)
}
fn model_children(app: &SolveApp, component: u64) -> Vec<u64> {
match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) {
Some(model) => model.children.get(&component).cloned().unwrap_or_default(),
None => app.session.doc.occurrences.iter().filter(|o| o.parent == component).map(|o| o.id).collect(),
}
}
fn model_body_indices(app: &SolveApp, component: u64) -> Vec<usize> {
match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) {
Some(model) => model.bodies.get(&component).cloned().unwrap_or_default(),
None => app
.session
.model
.state()
.bodies
.iter()
.enumerate()
.filter(|(_, b)| app.session.doc.model_body_component(b) == component)
.map(|(i, _)| i)
.collect(),
}
}
fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec<usize> {
match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) {
Some(model) => (if sketches { &model.sketches } else { &model.planes }).get(&component).cloned().unwrap_or_default(),
None => app
.session
.doc
.features
.iter()
.enumerate()
.filter_map(|(i, f)| {
let kind =
if sketches { matches!(f.kind, FeatureKind::Sketch { .. }) } else { matches!(f.kind, FeatureKind::ConstructionPlane { .. }) };
(f.component == component && kind).then_some(i)
})
.collect(),
}
}
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
prepare_model(app);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
@@ -668,7 +730,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
if id == 0 {
crate::dialogs_assembly::browser_joints(app, ui, d);
}
let children: Vec<u64> = app.session.doc.occurrences.iter().filter(|o| o.parent == id).map(|o| o.id).collect();
let children = model_children(app, id);
for occurrence in children {
occurrence_rows(app, ui, vec![occurrence], d, acts);
}
@@ -681,7 +743,7 @@ fn occurrence_key(path: &[u64]) -> String {
/// A placement, rather than a component definition: repeated child IDs remain distinct
/// because their complete paths are the selection and row identity.
fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth: usize, acts: &mut Actions) {
let Ok(placement) = app.session.occurrence_placement(&path) else { return };
let Some(placement) = model_placement(app, &path) else { return };
let key = occurrence_key(&path);
let open = is_open(app, &key, true);
let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice())
@@ -765,7 +827,7 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
for folder in ["bodies", "sketches", "construction"] {
occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts);
}
let children: Vec<u64> = app.session.doc.occurrences.iter().filter(|o| o.parent == placement.component).map(|o| o.id).collect();
let children = model_children(app, placement.component);
if path.len() < 64 {
for occurrence in children {
let mut child = path.clone();
@@ -1246,14 +1308,12 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
let t = Tokens::get();
let doc = &app.session.doc;
let sel = &app.session.selection;
let state = app.session.model.state();
match folder {
"bodies" => app
.session
.model
.state()
.bodies
.iter()
.filter(|b| doc.model_body_component(b) == comp && !app.session.body_removed_later(&b.name))
"bodies" => model_body_indices(app, comp)
.into_iter()
.filter_map(|i| state.bodies.get(i))
.filter(|b| !app.session.body_removed_later(&b.name))
.map(|b| Entry {
key: b.name.clone(),
row_key: format!("b:{}", b.name),
@@ -1268,10 +1328,9 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
bodies: vec![b.name.clone()],
})
.collect(),
"sketches" => doc
.features
.iter()
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. }))
"sketches" => model_feature_indices(app, comp, true)
.into_iter()
.filter_map(|i| doc.features.get(i))
.map(|f| {
let active = app.session.active_sketch == Some(f.id);
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
@@ -1297,10 +1356,9 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
}
})
.collect(),
_ => doc
.features
.iter()
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
_ => model_feature_indices(app, comp, false)
.into_iter()
.filter_map(|i| doc.features.get(i))
.map(|f| Entry {
key: f.id.to_string(),
row_key: format!("p:{}", f.id),
@@ -1396,7 +1454,7 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option
let mut all = entries(app, comp, folder);
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}"));
if let Some(path) = path {
let Ok(placement) = app.session.occurrence_placement(path) else { return };
let Some(placement) = model_placement(app, path) else { return };
// Each row's placed body by its definition's name (a search per row was quadratic in a
// component's bodies, every frame).
let mut placed: std::collections::HashMap<&str, &_> = std::collections::HashMap::with_capacity(placement.bodies.len());
+64
View File
@@ -0,0 +1,64 @@
//! Model-only browser queries, reused while navigating. Resolving a placement walks the
//! definition's bodies; doing that for every offscreen row and folder made large imports
//! quadratic on every frame. Keep the document and evaluated state alive with the index so
//! neither a new document nor a later allocation can accidentally reuse its answers.
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use solidcraft_engine::Session;
use solidcraft_engine::doc::{Document, FeatureKind, Mat, ModelState};
use solidcraft_engine::occurrences::OccurrencePlacement;
pub(super) struct Model {
doc: Arc<Document>,
state: Arc<ModelState>,
pending_moves: BTreeMap<u64, Mat>,
pub bodies: HashMap<u64, Vec<usize>>,
pub sketches: HashMap<u64, Vec<usize>>,
pub planes: HashMap<u64, Vec<usize>>,
pub children: HashMap<u64, Vec<u64>>,
placements: HashMap<Vec<u64>, Option<Arc<OccurrencePlacement>>>,
}
impl Model {
pub fn new(session: &Session) -> Self {
let state = session.model.state();
let mut bodies: HashMap<u64, Vec<usize>> = HashMap::new();
for (i, body) in state.bodies.iter().enumerate() {
bodies.entry(session.doc.model_body_component(body)).or_default().push(i);
}
let mut sketches: HashMap<u64, Vec<usize>> = HashMap::new();
let mut planes: HashMap<u64, Vec<usize>> = HashMap::new();
for (i, feature) in session.doc.features.iter().enumerate() {
let index = match feature.kind {
FeatureKind::Sketch { .. } => &mut sketches,
FeatureKind::ConstructionPlane { .. } => &mut planes,
_ => continue,
};
index.entry(feature.component).or_default().push(i);
}
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for occurrence in &session.doc.occurrences {
children.entry(occurrence.parent).or_default().push(occurrence.id);
}
Self {
doc: session.doc.clone(),
state,
pending_moves: session.pending_moves.clone(),
bodies,
sketches,
planes,
children,
placements: HashMap::new(),
}
}
pub fn matches(&self, session: &Session) -> bool {
Arc::ptr_eq(&self.doc, &session.doc) && Arc::ptr_eq(&self.state, &session.model.state()) && self.pending_moves == session.pending_moves
}
pub fn placement(&mut self, session: &Session, path: &[u64]) -> Option<Arc<OccurrencePlacement>> {
self.placements.entry(path.to_vec()).or_insert_with(|| session.occurrence_placement(path).ok().map(Arc::new)).clone()
}
}
+93
View File
@@ -0,0 +1,93 @@
use std::sync::Arc;
use serde_json::json;
use solidcraft_engine::Sel;
use crate::scenario::Harness;
fn fixture() -> (Harness, u64, u64) {
let mut h = Harness::new();
let part = h.app.run("component.create", json!({"name":"Block"})).unwrap();
let component = part["component"].as_u64().unwrap();
let occurrence = part["occurrence"].as_u64().unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.run("component.activate", json!({"component":0})).unwrap();
(h, component, occurrence)
}
#[test]
fn navigating_and_selecting_reuse_placements_but_rows_read_live_view_state() {
let (mut h, component, occurrence) = fixture();
let first = super::model_placement(&mut h.app, &[occurrence]).unwrap();
h.app.cam.yaw += 0.25;
h.app.run("select.set", json!({"items":[Sel::Body{name:"Body1".into()}]})).unwrap();
h.app.session.visibility.hidden_bodies.push("Body1".into());
h.app.ui.locked_bodies.push("Body1".into());
let after = super::model_placement(&mut h.app, &[occurrence]).unwrap();
assert!(Arc::ptr_eq(&first, &after), "view-only changes resolved the same placement again");
let rows = super::entries(&h.app, component, "bodies");
assert_eq!(rows.len(), 1);
assert!(rows[0].selected && rows[0].locked && !rows[0].visible);
h.frames(2);
assert!(Arc::ptr_eq(&after, &super::model_placement(&mut h.app, &[occurrence]).unwrap()));
}
#[test]
fn pending_and_reverted_moves_replace_cached_transforms() {
let (mut h, _, occurrence) = fixture();
let first = super::model_placement(&mut h.app, &[occurrence]).unwrap();
h.app.run("occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0],"capture":false})).unwrap();
let moved = super::model_placement(&mut h.app, &[occurrence]).unwrap();
assert!(!Arc::ptr_eq(&first, &moved));
assert!((solidcraft_engine::doc::apply_point(&moved.world, solidcraft_engine::geom::Vec3::ZERO).x - 20.0).abs() < 1e-9);
h.app.run("component.revert_position", json!({"occurrences":[occurrence]})).unwrap();
let reverted = super::model_placement(&mut h.app, &[occurrence]).unwrap();
assert!(!Arc::ptr_eq(&moved, &reverted));
assert_eq!(first.world, reverted.world);
}
#[test]
fn feature_indices_follow_component_edits_and_undo() {
let (mut h, component, _) = fixture();
super::prepare_model(&mut h.app);
assert!(super::model_feature_indices(&h.app, 0, true).is_empty());
h.app.run("sketch.create", json!({"plane":"XY", "name":"Root sketch"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
super::prepare_model(&mut h.app);
assert_eq!(super::entries(&h.app, 0, "sketches")[0].label, "Root sketch");
assert!(super::entries(&h.app, component, "sketches").is_empty());
h.app.run("component.activate", json!({"component":component})).unwrap();
h.app.run("sketch.create", json!({"plane":"YZ", "name":"Part sketch"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
super::prepare_model(&mut h.app);
assert_eq!(super::entries(&h.app, component, "sketches")[0].label, "Part sketch");
assert_eq!(super::entries(&h.app, 0, "sketches").len(), 1);
h.app.run("edit.undo", json!({})).unwrap();
super::prepare_model(&mut h.app);
assert!(super::entries(&h.app, component, "sketches").is_empty());
assert_eq!(super::entries(&h.app, 0, "sketches").len(), 1);
}
#[test]
fn body_index_and_placements_follow_geometry_undo_and_document_identity() {
let (mut h, component, occurrence) = fixture();
let first = super::model_placement(&mut h.app, &[occurrence]).unwrap();
let copy = h.app.run("occurrence.copy", json!({"occurrence":occurrence,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
let second = super::model_placement(&mut h.app, &[copy]).unwrap();
assert_ne!(first.path, second.path);
assert_ne!(first.bodies[0].world, second.bodies[0].world);
assert_eq!(super::model_children(&h.app, 0), [occurrence, copy]);
h.app.run("component.activate", json!({"component":component})).unwrap();
h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0]})).unwrap();
let changed = super::model_placement(&mut h.app, &[occurrence]).unwrap();
assert_eq!(changed.bodies.len(), 2);
assert_eq!(super::entries(&h.app, component, "bodies").len(), 2);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(super::model_placement(&mut h.app, &[occurrence]).unwrap().bodies.len(), 1);
assert_eq!(super::entries(&h.app, component, "bodies").len(), 1);
// A separate session can have the same revision and the same occurrence ids.
h.app.session = solidcraft_engine::Session::default();
assert!(super::model_placement(&mut h.app, &[occurrence]).is_none());
assert!(super::model_children(&h.app, 0).is_empty());
assert!(super::entries(&h.app, component, "bodies").is_empty());
}
+24 -4
View File
@@ -249,6 +249,8 @@ pub(crate) struct CameraInput {
pub primary_nav: Option<NavMode>,
/// The navigation this frame's pointer input drives.
pub mode: Option<NavMode>,
/// The 3D mouse changed this page's camera this frame.
pub device_moved: bool,
}
/// Camera input for one frame over `rect`: the navigation preset's buttons and modifiers, Orbit
@@ -286,7 +288,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
middle_double_click(app, ui, inside);
// The 3D mouse's motion this frame, with what this page's camera may do.
device(app, rect, caps);
let device_moved = device(app, rect, caps);
// ---- navigation ----
let trackpad_pan = app.preferences.trackpad_pans();
@@ -504,18 +506,36 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint();
}
CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode }
CameraInput {
hover,
scroll,
mods,
middle,
secondary_down,
primary_down,
primary_pressed,
delta,
inside,
axes,
pinch,
pan,
primary_nav,
mode,
device_moved,
}
}
/// The 3D mouse's motion this frame (#1336), on a page `rect` whose camera may do `caps`: the cap
/// pans and zooms every page, and turns the view only where the page may orbit and no sketch is
/// being edited (a sketch keeps its view square to it). [`handle`] calls it for its page; the 3D
/// mouse only reads the device and queues the motion ([`crate::space_mouse::navigate_frame`]).
pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) {
let Some(queued) = app.viewport.device_motion.take() else { return };
pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) -> bool {
let Some(queued) = app.viewport.device_motion.take() else { return false };
let before = app.cam;
let turn = caps.orbit && app.session.active_sketch.is_none();
let height = f64::from(rect.height().max(1.0));
device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse);
app.cam != before
}
/// `seconds` of 3D mouse `motion` on `cam`, in a view `height` points tall. A full deflection
+242 -55
View File
@@ -72,6 +72,8 @@ pub struct ViewportState {
pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
hl_slot: SceneSlot,
hl_key: u64,
/// Browser/timeline body feedback, independent of the camera and reference widget scale.
hover_block: std::sync::Mutex<Option<HoverBlock>>,
pub nav: Option<NavMode>,
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken
/// when the button went down, so the drag neither selects nor moves sketch geometry.
@@ -919,11 +921,16 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> {
/// past it either side), at least the origin axes' length around the axis's origin: (name,
/// from, to).
pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> {
let h = origin_size(app.cam.half_height()) * AXIS_LEN;
let bb = view_bounds(app);
solidcraft_engine::view::construction_axes(&app.session)
let axes: Vec<_> = solidcraft_engine::view::construction_axes(&app.session)
.into_iter()
.filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n))
.collect();
if axes.is_empty() {
return Vec::new();
}
let h = origin_size(app.cam.half_height()) * AXIS_LEN;
let bb = view_bounds(app);
axes.into_iter()
.map(|(_, name, o, d)| {
let (mut lo, mut hi) = (-h, h);
if !bb.is_empty() {
@@ -1182,7 +1189,6 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let s = &app.session;
let st = s.world_state();
let bounds = body_bounds(app, &st);
let bound = |name: &str| bounds.iter().find(|(n, _)| n == name).map(|(_, b)| *b);
let mut hits = Vec::new();
let (o, d) = proj.ray(pos);
// Sketch points of the active sketch (of every shown sketch while a command takes points,
@@ -1247,14 +1253,16 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
// 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 {
// Bounds retain the state's body order. Looking each one up by name made both passes
// quadratic for assemblies with many small parts.
for (i, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
if let Some(bb) = bound(&b.name) {
if let Some((_, bb)) = bounds.get(i) {
let pad = bb.diagonal() * 1e-6 + 1e-9;
match ray_box(o, d, &bb, pad) {
match ray_box(o, d, bb, pad) {
None => continue,
Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
_ => {}
@@ -1279,13 +1287,13 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
// Vertices (edge end points) and edges.
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for b in &st.bodies {
for (i, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
// Only bodies whose outline on screen is near the cursor can have an edge under it.
if let Some(bb) = bound(&b.name)
&& screen_box(proj, &bb, 8.0).is_some_and(|r| !r.contains(pos))
if let Some((_, bb)) = bounds.get(i)
&& screen_box(proj, bb, 8.0).is_some_and(|r| !r.contains(pos))
{
continue;
}
@@ -1728,8 +1736,14 @@ fn edge_of(m: &solidcraft_engine::geom::Mesh, index: usize, point: Vec3) -> Opti
}
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);
}
/// Emit a set of faces in one mesh pass. Whole-body feedback must not scan every triangle
/// again for each face of a detailed imported part.
fn mesh_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, col: [u8; 4], lit: bool, takes: impl Fn(u32) -> bool) {
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize != face {
if !takes(*f) {
continue;
}
for k in t {
@@ -1820,10 +1834,12 @@ fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32, through:
}
}
fn highlight_key(app: &SolveApp) -> u64 {
fn highlight_geometry_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let selected = app.highlighted();
app.session.revision.hash(&mut h);
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
serde_json::to_string(&selected).unwrap_or_default().hash(&mut h);
format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.hash(&mut h);
@@ -1833,6 +1849,8 @@ fn highlight_key(app: &SolveApp) -> u64 {
app.session.active_occurrence_path.hash(&mut h);
app.ui.visual_style.hash(&mut h);
app.ui.pick_bodies.hash(&mut h);
app.session.active_sketch.hash(&mut h);
(app.dialog.is_some(), app.tool.is_some()).hash(&mut h);
crate::theme::is_dark().hash(&mut h);
crate::theme::vision().hash(&mut h);
app.preview.replaced.hash(&mut h);
@@ -1841,12 +1859,31 @@ fn highlight_key(app: &SolveApp) -> u64 {
app.session.visibility.hidden_origin.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_occurrences.hash(&mut h);
app.cam.half_height().to_bits().hash(&mut h);
app.cam.back().x.to_bits().hash(&mut h);
crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h);
h.finish()
}
fn highlight_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
highlight_geometry_key(app).hash(&mut h);
let selected = app.highlighted();
app.cam.half_height().to_bits().hash(&mut h);
// Body triangles and outlines live in world space; the GPU applies the new camera.
// Only sketch feedback needs projected tessellation and the profile's lift toward the eye.
let projected = selected.iter().any(|s| matches!(s, Sel::Profile { .. } | Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. }))
|| matches!(app.viewport.hover, Some(Hit::Profile { .. }));
if projected {
crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h);
}
h.finish()
}
struct HoverBlock {
key: u64,
state: std::sync::Arc<solidcraft_engine::doc::ModelState>,
doc: std::sync::Arc<solidcraft_engine::doc::Document>,
block: crate::gpu::SceneBlock,
}
/// The highlight scene: origin widget, construction planes, selection and hover.
fn build_highlight(app: &SolveApp) -> GpuScene {
let t = Tokens::get();
@@ -1924,14 +1961,30 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
sc.line((p - d * r).to_f32(), (p + d * r).to_f32(), c4(t.construction_geom), 2.0, true);
}
}
// Reference widgets follow zoom; the often much larger world-space feedback does not.
let projected = sel.iter().any(|s| matches!(s, Sel::Profile { .. } | Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. }))
|| matches!(hover, Some(Hit::Profile { .. }));
let key = highlight_geometry_key(app);
if !projected
&& let Some(block) = app.viewport.hover_block.lock().ok().and_then(|cache| {
cache
.as_ref()
.filter(|c| c.key == key && std::sync::Arc::ptr_eq(&c.state, &st) && std::sync::Arc::ptr_eq(&c.doc, &s.doc))
.map(|c| c.block.clone())
})
{
sc.blocks.push(block);
return sc;
}
let mut feedback = GpuScene::default();
// 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.replaced.contains(body)) {
face_tris(&mut sc, &shown(b), *index, c4(t.sel_face), false);
hidden_face_outline(&mut sc, &drawn_mesh(app, b), *index, t.sel_edge);
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::Body { name } => {
@@ -1939,9 +1992,8 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
&& app.session.body_visible(name)
{
let m = shown(b);
for f in 0..b.body.face_count() {
face_tris(&mut sc, &m, f, c4(t.body_selected), true);
}
let count = b.body.face_count();
mesh_tris(&mut feedback, &m, c4(t.body_selected), true, |f| (f as usize) < count);
}
}
Sel::Edge { body, index, point } => {
@@ -1949,10 +2001,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
&& app.session.body_visible(body)
&& let Some(e) = edge_of(&drawn_mesh(app, b), *index, *point)
{
edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0);
edge_lines(&mut feedback, e, t.sel_edge, t.sel_edge_rim, 3.0);
}
}
Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge),
Sel::Profile { sketch, index } => profile_fill(&mut feedback, app, *sketch, *index, t.sel_profile, t.sel_profile_edge),
Sel::ExternalSketchCurve { sketch, id } => {
if let Some(ss) = st.sketch(*sketch)
&& let Some(ci) = ss.sketch.curve_index(id)
@@ -1960,7 +2012,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts {
let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) });
for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) {
sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true);
feedback.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true);
}
}
}
@@ -1970,7 +2022,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
for ss in st.sketches.iter() {
if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) {
for q in w.pts.windows(2) {
sc.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true);
feedback.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true);
}
}
}
@@ -1982,7 +2034,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts {
let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) });
for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) {
sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true);
feedback.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true);
}
}
}
@@ -2013,16 +2065,28 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let c = body_rgb(app, &b.name);
let m = drawn_mesh(app, b);
let lit = shown(b);
for &f in &faces {
if !sel.iter().any(|x| {
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
}) {
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
let selected_body = sel.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
if !selected_body {
let selected_faces: std::collections::HashSet<usize> = sel
.iter()
.filter_map(|x| {
if let Sel::Face { body, index, .. } = x
&& body == &b.name
{
Some(*index)
} else {
None
}
})
.collect();
let hovered_faces: std::collections::HashSet<usize> = faces.iter().copied().collect();
mesh_tris(&mut feedback, &lit, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true, |f| {
hovered_faces.contains(&(f as usize)) && !selected_faces.contains(&(f as usize))
});
}
for (ei, e) in m.edges.iter().enumerate() {
if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
edge_lines(&mut feedback, e, t.hover_edge, t.hover_edge_halo, 1.5);
}
}
}
@@ -2034,9 +2098,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
let m = shown(b);
for f in 0..b.body.face_count() {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
let count = b.body.face_count();
mesh_tris(&mut feedback, &m, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true, |f| {
(f as usize) < count
});
}
}
Some(Hit::Face { body, index, .. })
@@ -2050,13 +2115,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let patch = m.surface_patch(*index);
let lit = shown(b);
for &f in &patch {
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
face_tris(&mut feedback, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
if let Some(p) = m.edges.get(e)
&& m.edge_pickable(e)
{
edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5);
edge_lines(&mut feedback, p, t.hover_edge, t.hover_edge_halo, 1.5);
}
}
}
@@ -2068,7 +2133,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
for i in m.circular_rim(*index) {
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i));
if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
edge_lines(&mut feedback, e, t.hover_edge, t.hover_edge_halo, 2.0);
}
}
}
@@ -2076,10 +2141,32 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
Some(Hit::Profile { sketch, index })
if !sel.iter().any(|x| matches!(x, Sel::Profile { sketch: s2, index: i2 } if s2 == sketch && i2 == index)) =>
{
profile_fill(&mut sc, app, *sketch, *index, t.hover_profile, t.hover_profile_edge);
profile_fill(&mut feedback, app, *sketch, *index, t.hover_profile, t.hover_profile_edge);
}
_ => {}
}
if projected {
sc.tris.append(&mut feedback.tris);
sc.trans.append(&mut feedback.trans);
sc.lines.append(&mut feedback.lines);
sc.hidden.append(&mut feedback.hidden);
sc.overlay.append(&mut feedback.overlay);
} else {
let block = crate::gpu::SceneBlock {
key,
bytes: std::sync::Arc::new(crate::gpu::BlockBytes {
tris: feedback.tris,
lines: feedback.lines,
hidden: feedback.hidden,
..Default::default()
}),
};
if let Ok(mut cache) = app.viewport.hover_block.lock() {
*cache = Some(HoverBlock { key, state: st.clone(), doc: s.doc.clone(), block: block.clone() });
}
sc.blocks.push(block);
}
sc
}
@@ -2179,6 +2266,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
pan,
primary_nav,
mode,
device_moved,
} = input;
app.viewport.physical_scale = ui.ctx().pixels_per_point();
let proj = projection(app, rect);
@@ -2188,7 +2276,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused.
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
let navigating = scroll != 0.0
let navigating = device_moved
|| scroll != 0.0
|| pinch != 1.0
|| pan != egui::Vec2::ZERO
|| middle
@@ -3570,7 +3659,7 @@ mod tests {
let mut render = |tess: bool, hover: Option<usize>| {
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
app.viewport.hover = hover.map(|index| Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO });
let (scene, highlight) = (build_scene(&app), build_highlight(&app));
let (scene, highlight) = (build_scene(&app), build_highlight(&app).merged());
assert_eq!(!scene.planned.is_empty(), tess, "the box is drawn from its plan exactly on the GPU path");
let proj = projection(&app, rect);
let e = proj.cam.eye().to_f32();
@@ -3787,14 +3876,14 @@ mod tests {
let p = crate::scenario::handle_at("timeline:Blend").unwrap();
h.step(&json!({"move":[p.x,p.y]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, Some(id));
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
assert!(!build_highlight(&h.app).lines.is_empty());
assert_eq!(build_highlight(&h.app).merged().tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
assert!(!build_highlight(&h.app).merged().lines.is_empty());
h.step(&json!({"click":[p.x,p.y]})).unwrap();
assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]);
// Moving away removes hover but leaves the selected blend highlighted.
h.step(&json!({"move":[5,5]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, None);
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
assert_eq!(build_highlight(&h.app).merged().tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
let bounds = selection_bounds(&h.app).unwrap();
assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}");
// Creator features retain whole-body feedback, while hidden bodies stay hidden.
@@ -3802,7 +3891,7 @@ mod tests {
h.app.session.visibility.hidden_bodies.push(body.clone());
assert!(feature_geometry(&h.app, id).is_empty());
assert!(selection_bounds(&h.app).is_none());
assert!(build_highlight(&h.app).tris.is_empty());
assert!(build_highlight(&h.app).merged().tris.is_empty());
}
}
@@ -4326,6 +4415,39 @@ mod tests {
}
}
#[test]
fn device_navigation_skips_hover_picking_and_resumes_when_motion_stops() {
use crate::space_mouse::{DeviceMotion, Motion};
for gpu in [false, true] {
let ctx = egui::Context::default();
let mut app = hover_app(gpu);
app.session.active_sketch = None;
hover_frame(&mut app, &ctx, vec![]);
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert!(app.viewport.hover.is_some());
let pick = app.viewport.last_pick.as_ref().unwrap().0;
let before = app.cam;
for _ in 0..3 {
app.viewport.device_motion =
Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [0.0, 0.0, 0.1] }, seconds: 0.016 });
hover_frame(&mut app, &ctx, vec![]);
assert_ne!(app.cam, before);
assert!(app.viewport.device_motion.is_none());
assert!(app.viewport.hover.is_none(), "hover must stay off while the device moves the view");
assert_eq!(app.viewport.last_pick.as_ref().unwrap().0, pick, "device navigation ran picking again");
}
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some(), "hover must return as soon as motion stops");
assert_ne!(app.viewport.last_pick.as_ref().unwrap().0, pick);
for motion in [DeviceMotion::default(), DeviceMotion { seconds: f64::NAN, ..DeviceMotion::default() }] {
app.viewport.device_motion = Some(motion);
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some(), "an idle device must not suppress selection feedback");
}
}
}
#[test]
fn hover_stays_off_during_spin_and_animated_views() {
let ctx = egui::Context::default();
@@ -4401,7 +4523,7 @@ mod tests {
let mut lit_faces = 0;
for index in 0..body.body.face_count() {
app.viewport.hover = Some(Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO });
let lit = tris_of(&build_highlight(&app).tris);
let lit = tris_of(&build_highlight(&app).merged().tris);
assert!(!lit.is_empty(), "face {index} lights");
assert!(lit.iter().all(|t| drawn.contains(t)), "face {index}: every lit triangle is a drawn one");
lit_faces += 1;
@@ -4418,6 +4540,71 @@ mod tests {
app
}
#[test]
fn body_highlight_buffers_are_reused_when_the_camera_orbits_or_pans() {
let mut app = cylinder_app();
app.ui.show_origin = true;
app.viewport.hover_bodies = vec!["Body1".into()];
let key = highlight_key(&app);
let before = build_highlight(&app).merged();
assert!(!before.tris.is_empty());
app.cam.orbit(25.0, 10.0);
app.cam.roll_by(0.3);
app.cam.pan(20.0, -15.0, 600.0);
assert_eq!(highlight_key(&app), key, "world-space feedback must stay resident during navigation");
let after = build_highlight(&app).merged();
assert_eq!(before.tris, after.tris);
assert_eq!(before.lines, after.lines);
app.cam.zoom_at(1.2, None);
assert_ne!(highlight_key(&app), key, "reference geometry follows the view's scale");
let mut app = hover_app(true);
let sketch = app.session.active_sketch.unwrap();
app.run("sketch.circle.center", json!({"center":[0,0],"radius":5})).unwrap();
app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
let key = highlight_key(&app);
let before = build_highlight(&app).merged();
assert!(!before.trans.is_empty());
app.cam.orbit(25.0, 10.0);
assert_ne!(highlight_key(&app), key, "profile feedback still follows the projected curves and lift toward the eye");
assert_ne!(before.trans, build_highlight(&app).merged().trans);
}
#[test]
fn world_highlight_block_survives_zoom_and_refreshes_when_bodies_are_hidden() {
let mut app = cylinder_app();
app.ui.show_origin = true;
app.viewport.hover_bodies = vec!["Body1".into()];
let before = build_highlight(&app);
let bytes = before.blocks[0].bytes.clone();
assert!(!bytes.tris.is_empty());
app.cam.zoom_at(4.0, None);
let after = build_highlight(&app);
assert!(std::sync::Arc::ptr_eq(&bytes, &after.blocks[0].bytes), "zoom must not regenerate or upload body feedback");
assert_ne!(before.guide_trans, after.guide_trans, "reference planes still resize with the view");
app.session.visibility.hidden_bodies.push("Body1".into());
let hidden = build_highlight(&app);
assert!(!std::sync::Arc::ptr_eq(&bytes, &hidden.blocks[0].bytes));
assert!(hidden.blocks[0].bytes.tris.is_empty(), "hidden bodies must lose their feedback");
app.session.visibility.hidden_bodies.clear();
let shown = build_highlight(&app);
assert_eq!(shown.blocks[0].bytes.tris, bytes.tris);
}
#[test]
fn whole_body_highlight_preserves_each_triangle_and_selected_face_once() {
let mut app = cylinder_app();
let body = app.session.world_state().bodies[0].clone();
let expected = shown_mesh(&app, &body, &None).triangles.len() * 3 * crate::gpu::TRI_SIZE;
app.viewport.hover_bodies = vec![body.name.clone()];
for selected in
[vec![], vec![Sel::Face { body: body.name.clone(), index: 0, point: Vec3::ZERO }], vec![Sel::Body { name: body.name.clone() }]]
{
app.session.selection = selected;
assert_eq!(build_highlight(&app).merged().tris.len(), expected, "whole-body feedback must neither omit nor duplicate selected faces");
}
}
#[test]
fn smooth_wall_display_preserves_wireframe_and_edge_selection() {
let mut s = solidcraft_engine::Session::default();
@@ -4453,7 +4640,7 @@ mod tests {
assert!(wire.tris.is_empty());
app.ui.visual_style = 0;
app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }];
assert!(!build_highlight(&app).lines.is_empty());
assert!(!build_highlight(&app).merged().lines.is_empty());
let mut without_display_mask = (*mesh).clone();
without_display_mask.smooth_edges.clear();
assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize));
@@ -4604,7 +4791,7 @@ mod tests {
h.app.ui.show_origin = true;
h.frames(2);
assert!(section_plane(&h.app).is_some());
let sc = build_highlight(&h.app);
let sc = build_highlight(&h.app).merged();
let planes = origin_planes(&h.app).len();
assert!(planes > 0);
assert_eq!(sc.guide_trans.len(), planes * 6 * crate::gpu::TRI_SIZE);
@@ -4626,12 +4813,12 @@ mod tests {
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid });
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2);
// Selecting both pieces must not draw each highlight twice.
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
// Ctrl/Shift clicking either half deselects the entire rim.
select(&mut app, Some(item.clone()), true);
assert!(app.session.selection.is_empty());
@@ -4675,11 +4862,11 @@ mod tests {
let [a, c, d] = m.tri(t).unwrap();
let point = (a + c + d) / 3.0;
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point });
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
assert_eq!(build_highlight(&app).merged().tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2);
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
assert_eq!(build_highlight(&app).merged().tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first");
let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
assert_eq!(dialog.items().len(), 2);
@@ -5189,10 +5376,10 @@ mod tests {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
assert!(build_highlight(&app).hidden.is_empty());
assert!(build_highlight(&app).merged().hidden.is_empty());
// The back face: its outline is drawn (dimmed) where the box hides it.
app.session.selection = vec![Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(20.0, 30.0, 10.0) }];
assert!(!build_highlight(&app).hidden.is_empty());
assert!(!build_highlight(&app).merged().hidden.is_empty());
}
#[test]