Keep large STEP assemblies responsive during 3D navigation (#1762)
Thanks @crono2250.
This commit is contained in:
1 parent
da65b4fc8c
commit
e2667b749d
6 files changed
+516
-78
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user