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"] #[path = "occurrence_ui_tests.rs"]
mod occurrence_ui_tests; 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 crate::icons::glyphs::badge;
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -42,6 +49,8 @@ pub fn drawn_occurrences() -> Vec<(Vec<u64>, String, Rect, bool)> {
/// Browser state kept between frames. /// Browser state kept between frames.
#[derive(Default)] #[derive(Default)]
pub struct TreeState { 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`). /// Folders closed by the user (keys like `c0/bodies`).
pub collapsed: BTreeSet<String>, pub collapsed: BTreeSet<String>,
/// Folders opened by the user (folders that start closed). /// 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); 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) { fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
prepare_model(app);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) .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 { if id == 0 {
crate::dialogs_assembly::browser_joints(app, ui, d); 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 { for occurrence in children {
occurrence_rows(app, ui, vec![occurrence], d, acts); 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 /// A placement, rather than a component definition: repeated child IDs remain distinct
/// because their complete paths are the selection and row identity. /// 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) { 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 key = occurrence_key(&path);
let open = is_open(app, &key, true); let open = is_open(app, &key, true);
let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice()) 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"] { for folder in ["bodies", "sketches", "construction"] {
occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts); 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 { if path.len() < 64 {
for occurrence in children { for occurrence in children {
let mut child = path.clone(); let mut child = path.clone();
@@ -1246,14 +1308,12 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
let t = Tokens::get(); let t = Tokens::get();
let doc = &app.session.doc; let doc = &app.session.doc;
let sel = &app.session.selection; let sel = &app.session.selection;
let state = app.session.model.state();
match folder { match folder {
"bodies" => app "bodies" => model_body_indices(app, comp)
.session .into_iter()
.model .filter_map(|i| state.bodies.get(i))
.state() .filter(|b| !app.session.body_removed_later(&b.name))
.bodies
.iter()
.filter(|b| doc.model_body_component(b) == comp && !app.session.body_removed_later(&b.name))
.map(|b| Entry { .map(|b| Entry {
key: b.name.clone(), key: b.name.clone(),
row_key: format!("b:{}", b.name), 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()], bodies: vec![b.name.clone()],
}) })
.collect(), .collect(),
"sketches" => doc "sketches" => model_feature_indices(app, comp, true)
.features .into_iter()
.iter() .filter_map(|i| doc.features.get(i))
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. }))
.map(|f| { .map(|f| {
let active = app.session.active_sketch == Some(f.id); 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()) 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(), .collect(),
_ => doc _ => model_feature_indices(app, comp, false)
.features .into_iter()
.iter() .filter_map(|i| doc.features.get(i))
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
.map(|f| Entry { .map(|f| Entry {
key: f.id.to_string(), key: f.id.to_string(),
row_key: format!("p:{}", f.id), 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 mut all = entries(app, comp, folder);
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}")); let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}"));
if let Some(path) = path { 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 // Each row's placed body by its definition's name (a search per row was quadratic in a
// component's bodies, every frame). // component's bodies, every frame).
let mut placed: std::collections::HashMap<&str, &_> = std::collections::HashMap::with_capacity(placement.bodies.len()); 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>, pub primary_nav: Option<NavMode>,
/// The navigation this frame's pointer input drives. /// The navigation this frame's pointer input drives.
pub mode: Option<NavMode>, 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 /// 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); crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
middle_double_click(app, ui, inside); middle_double_click(app, ui, inside);
// The 3D mouse's motion this frame, with what this page's camera may do. // 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 ---- // ---- navigation ----
let trackpad_pan = app.preferences.trackpad_pans(); 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() { if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint(); 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 /// 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 /// 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 /// 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`]). /// 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) { pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) -> bool {
let Some(queued) = app.viewport.device_motion.take() else { return }; 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 turn = caps.orbit && app.session.active_sketch.is_none();
let height = f64::from(rect.height().max(1.0)); let height = f64::from(rect.height().max(1.0));
device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse); 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 /// `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>, pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
hl_slot: SceneSlot, hl_slot: SceneSlot,
hl_key: u64, 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>, pub nav: Option<NavMode>,
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken /// 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. /// 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, /// past it either side), at least the origin axes' length around the axis's origin: (name,
/// from, to). /// from, to).
pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> {
let h = origin_size(app.cam.half_height()) * AXIS_LEN; let axes: Vec<_> = solidcraft_engine::view::construction_axes(&app.session)
let bb = view_bounds(app);
solidcraft_engine::view::construction_axes(&app.session)
.into_iter() .into_iter()
.filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n)) .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)| { .map(|(_, name, o, d)| {
let (mut lo, mut hi) = (-h, h); let (mut lo, mut hi) = (-h, h);
if !bb.is_empty() { 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 s = &app.session;
let st = s.world_state(); let st = s.world_state();
let bounds = body_bounds(app, &st); 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 mut hits = Vec::new();
let (o, d) = proj.ray(pos); let (o, d) = proj.ray(pos);
// Sketch points of the active sketch (of every shown sketch while a command takes points, // 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. // 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 bestf: Option<(f64, Hit)> = None;
let mut faces = Near::new(all); 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) { if !app.session.body_visible(&b.name) {
continue; continue;
} }
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. // 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; let pad = bb.diagonal() * 1e-6 + 1e-9;
match ray_box(o, d, &bb, pad) { match ray_box(o, d, bb, pad) {
None => continue, None => continue,
Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => 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. // Vertices (edge end points) and edges.
let mut bestv = Near::new(all); let mut bestv = Near::new(all);
let mut beste = 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) { if !app.session.body_visible(&b.name) {
continue; continue;
} }
// Only bodies whose outline on screen is near the cursor can have an edge under it. // Only bodies whose outline on screen is near the cursor can have an edge under it.
if let Some(bb) = bound(&b.name) if let Some((_, bb)) = bounds.get(i)
&& screen_box(proj, &bb, 8.0).is_some_and(|r| !r.contains(pos)) && screen_box(proj, bb, 8.0).is_some_and(|r| !r.contains(pos))
{ {
continue; 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) { 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) { for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize != face { if !takes(*f) {
continue; continue;
} }
for k in t { 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 mut h = std::collections::hash_map::DefaultHasher::new();
let selected = app.highlighted();
app.session.revision.hash(&mut h); 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); format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h); app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.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.session.active_occurrence_path.hash(&mut h);
app.ui.visual_style.hash(&mut h); app.ui.visual_style.hash(&mut h);
app.ui.pick_bodies.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::is_dark().hash(&mut h);
crate::theme::vision().hash(&mut h); crate::theme::vision().hash(&mut h);
app.preview.replaced.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_origin.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_occurrences.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() 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. /// The highlight scene: origin widget, construction planes, selection and hover.
fn build_highlight(app: &SolveApp) -> GpuScene { fn build_highlight(app: &SolveApp) -> GpuScene {
let t = Tokens::get(); 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); 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. // Selected things.
for x in &sel { for x in &sel {
match x { match x {
Sel::Face { body, index, .. } => { Sel::Face { body, index, .. } => {
// A live preview stands in for the body: its old faces would fight with it. // 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)) { 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); face_tris(&mut feedback, &shown(b), *index, c4(t.sel_face), false);
hidden_face_outline(&mut sc, &drawn_mesh(app, b), *index, t.sel_edge); hidden_face_outline(&mut feedback, &drawn_mesh(app, b), *index, t.sel_edge);
} }
} }
Sel::Body { name } => { Sel::Body { name } => {
@@ -1939,9 +1992,8 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
&& app.session.body_visible(name) && app.session.body_visible(name)
{ {
let m = shown(b); let m = shown(b);
for f in 0..b.body.face_count() { let count = b.body.face_count();
face_tris(&mut sc, &m, f, c4(t.body_selected), true); mesh_tris(&mut feedback, &m, c4(t.body_selected), true, |f| (f as usize) < count);
}
} }
} }
Sel::Edge { body, index, point } => { Sel::Edge { body, index, point } => {
@@ -1949,10 +2001,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
&& app.session.body_visible(body) && app.session.body_visible(body)
&& let Some(e) = edge_of(&drawn_mesh(app, b), *index, *point) && 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 } => { Sel::ExternalSketchCurve { sketch, id } => {
if let Some(ss) = st.sketch(*sketch) if let Some(ss) = st.sketch(*sketch)
&& let Some(ci) = ss.sketch.curve_index(id) && 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 { 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) }); 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) { 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() { for ss in st.sketches.iter() {
if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) { if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) {
for q in w.pts.windows(2) { 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 { 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) }); 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) { 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 c = body_rgb(app, &b.name);
let m = drawn_mesh(app, b); let m = drawn_mesh(app, b);
let lit = shown(b); let lit = shown(b);
for &f in &faces { let selected_body = sel.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
if !sel.iter().any(|x| { if !selected_body {
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name) let selected_faces: std::collections::HashSet<usize> = sel
}) { .iter()
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); .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() { 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))))) { 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 k = t.hover_face_lift;
let c = body_rgb(app, body); let c = body_rgb(app, body);
let m = shown(b); let m = shown(b);
for f in 0..b.body.face_count() { let count = 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); 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, .. }) Some(Hit::Face { body, index, .. })
@@ -2050,13 +2115,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let patch = m.surface_patch(*index); let patch = m.surface_patch(*index);
let lit = shown(b); let lit = shown(b);
for &f in &patch { 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)))) { 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) if let Some(p) = m.edges.get(e)
&& m.edge_pickable(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) { 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)); 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) } { 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 }) Some(Hit::Profile { sketch, index })
if !sel.iter().any(|x| matches!(x, Sel::Profile { sketch: s2, index: i2 } if s2 == sketch && i2 == 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 sc
} }
@@ -2179,6 +2266,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
pan, pan,
primary_nav, primary_nav,
mode, mode,
device_moved,
} = input; } = input;
app.viewport.physical_scale = ui.ctx().pixels_per_point(); app.viewport.physical_scale = ui.ctx().pixels_per_point();
let proj = projection(app, rect); 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 // Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused. // 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. // 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 || pinch != 1.0
|| pan != egui::Vec2::ZERO || pan != egui::Vec2::ZERO
|| middle || middle
@@ -3570,7 +3659,7 @@ mod tests {
let mut render = |tess: bool, hover: Option<usize>| { 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.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 }); 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"); 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 proj = projection(&app, rect);
let e = proj.cam.eye().to_f32(); let e = proj.cam.eye().to_f32();
@@ -3787,14 +3876,14 @@ mod tests {
let p = crate::scenario::handle_at("timeline:Blend").unwrap(); let p = crate::scenario::handle_at("timeline:Blend").unwrap();
h.step(&json!({"move":[p.x,p.y]})).unwrap(); h.step(&json!({"move":[p.x,p.y]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, Some(id)); assert_eq!(h.app.viewport.hover_feature, Some(id));
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);
assert!(!build_highlight(&h.app).lines.is_empty()); assert!(!build_highlight(&h.app).merged().lines.is_empty());
h.step(&json!({"click":[p.x,p.y]})).unwrap(); h.step(&json!({"click":[p.x,p.y]})).unwrap();
assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]); assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]);
// Moving away removes hover but leaves the selected blend highlighted. // Moving away removes hover but leaves the selected blend highlighted.
h.step(&json!({"move":[5,5]})).unwrap(); h.step(&json!({"move":[5,5]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, None); 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(); let bounds = selection_bounds(&h.app).unwrap();
assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}"); 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. // 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()); h.app.session.visibility.hidden_bodies.push(body.clone());
assert!(feature_geometry(&h.app, id).is_empty()); assert!(feature_geometry(&h.app, id).is_empty());
assert!(selection_bounds(&h.app).is_none()); 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] #[test]
fn hover_stays_off_during_spin_and_animated_views() { fn hover_stays_off_during_spin_and_animated_views() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
@@ -4401,7 +4523,7 @@ mod tests {
let mut lit_faces = 0; let mut lit_faces = 0;
for index in 0..body.body.face_count() { for index in 0..body.body.face_count() {
app.viewport.hover = Some(Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO }); 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.is_empty(), "face {index} lights");
assert!(lit.iter().all(|t| drawn.contains(t)), "face {index}: every lit triangle is a drawn one"); assert!(lit.iter().all(|t| drawn.contains(t)), "face {index}: every lit triangle is a drawn one");
lit_faces += 1; lit_faces += 1;
@@ -4418,6 +4540,71 @@ mod tests {
app 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] #[test]
fn smooth_wall_display_preserves_wireframe_and_edge_selection() { fn smooth_wall_display_preserves_wireframe_and_edge_selection() {
let mut s = solidcraft_engine::Session::default(); let mut s = solidcraft_engine::Session::default();
@@ -4453,7 +4640,7 @@ mod tests {
assert!(wire.tris.is_empty()); assert!(wire.tris.is_empty());
app.ui.visual_style = 0; app.ui.visual_style = 0;
app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }]; 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(); let mut without_display_mask = (*mesh).clone();
without_display_mask.smooth_edges.clear(); 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)); 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.app.ui.show_origin = true;
h.frames(2); h.frames(2);
assert!(section_plane(&h.app).is_some()); 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(); let planes = origin_planes(&h.app).len();
assert!(planes > 0); assert!(planes > 0);
assert_eq!(sc.guide_trans.len(), planes * 6 * crate::gpu::TRI_SIZE); assert_eq!(sc.guide_trans.len(), planes * 6 * crate::gpu::TRI_SIZE);
@@ -4626,12 +4813,12 @@ mod tests {
for &i in &rim { for &i in &rim {
let mid = polyline_mid(&m.edges[i]); let mid = polyline_mid(&m.edges[i]);
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid }); 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(); let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false); select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2); assert_eq!(app.session.selection.len(), 2);
// Selecting both pieces must not draw each highlight twice. // 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. // Ctrl/Shift clicking either half deselects the entire rim.
select(&mut app, Some(item.clone()), true); select(&mut app, Some(item.clone()), true);
assert!(app.session.selection.is_empty()); assert!(app.session.selection.is_empty());
@@ -4675,11 +4862,11 @@ mod tests {
let [a, c, d] = m.tri(t).unwrap(); let [a, c, d] = m.tri(t).unwrap();
let point = (a + c + d) / 3.0; let point = (a + c + d) / 3.0;
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point }); 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(); let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false); select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2); 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"); 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(); let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
assert_eq!(dialog.items().len(), 2); assert_eq!(dialog.items().len(), 2);
@@ -5189,10 +5376,10 @@ mod tests {
let mut s = solidcraft_engine::Session::default(); let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default()); 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. // 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) }]; 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] #[test]
+16
View File
@@ -12,6 +12,22 @@ Every import (STEP, IGES, 3MF, STL, OBJ; inserted, dropped, opened as a new desi
A STEP **assembly** keeps its structure: each product under the file's root becomes a component nested under the import's, named after the product, at the place the file gives it. A part (or sub-assembly) the file places more than once becomes **one component with an occurrence per placement**: the Import feature builds the part once and skips the file's later copies (`skip` in the feature, format 22), and the occurrences show it everywhere else. The reply adds `subcomponents` (how many components were made below the import's) and `rigid_joints`. A STEP **assembly** keeps its structure: each product under the file's root becomes a component nested under the import's, named after the product, at the place the file gives it. A part (or sub-assembly) the file places more than once becomes **one component with an occurrence per placement**: the Import feature builds the part once and skips the file's later copies (`skip` in the feature, format 22), and the occurrences show it everywhere else. The reply adds `subcomponents` (how many components were made below the import's) and `rigid_joints`.
The Browser reuses body, sketch and construction-plane membership, child lists and resolved placements while the
camera moves. Large assemblies keep their tree open without resolving every part and
folder again on each frame. Geometry edits, assembly changes and uncaptured or reverted
positions refresh those queries; selection, visibility and locking are read live.
Hover picking pauses while the mouse or a 3D mouse moves the view and resumes when
navigation stops. Picking uses the cached body bounds directly in model order, so
finding each body's bounds does not scan the assembly again.
Hovering an assembly's Browser row retains its whole-body highlight buffers during
rotation, panning and zoom; the GPU transforms those buffers with the camera. The
origin and construction widgets resize independently. Whole-body
highlight generation scans each mesh once rather than scanning it again for every
face. Sketch profile feedback still updates its projected curves and lift toward
the eye as the view changes.
The contents of an imported assembly are **rigid by default**: rigid joints (a rigid group, its glyphs not drawn) hold each part where the file put it in its parent, so moving the import's component moves everything, and an inner part doesn't move on its own; joint degrees of freedom count the whole import as one body. To joint the parts one by one, release them with **Unlock Contents** on the component's browser menu (`component.unlock {component}`), one undo step. Undo takes a whole import away in one step. The contents of an imported assembly are **rigid by default**: rigid joints (a rigid group, its glyphs not drawn) hold each part where the file put it in its parent, so moving the import's component moves everything, and an inner part doesn't move on its own; joint degrees of freedom count the whole import as one body. To joint the parts one by one, release them with **Unlock Contents** on the component's browser menu (`component.unlock {component}`), one undo step. Undo takes a whole import away in one step.
Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did. Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did.