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]
+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`.
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.
Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did.