diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index aa5d7b63e3..b96351cc03 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -2387,6 +2387,14 @@ fn entry_row( ..Default::default() }, ); + // A part that needs repair, under the pointer: its refused face is ringed in the view. + if r.hovered + && folder == "bodies" + && let Some(Sel::Body { name }) = &e.sel + && let Some(face) = app.session.model.state().body(&e.key).and_then(|b| b.body.smlib_refused()).and_then(|r| r.face) + { + crate::refused_part::ring(app, name, face); + } if r.fold && let Some(key) = pattern_key { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 89f7d707da..707723bde3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2598,6 +2598,9 @@ fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut // An operation on a part the kernel refuses: the part, what is wrong with it, and what // to do, instead of the kernel's words and an issue report. if let Some(part) = refused_operand(app, d) { + if let Some(face) = part.face { + crate::refused_part::ring(app, &part.body, face); + } let operation = dialog_commands(app, d).ok().and_then(|c| c.iter().find_map(|(_, p)| p.get("operation").and_then(Value::as_str).map(str::to_string))); ui.vertical(|ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d4093cb57..15df696e95 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1925,6 +1925,15 @@ impl SolveApp { self.pending_fit = request; } + /// Frame `bounds` as Fit frames the selection, leaving the selection as it is. + pub(crate) fn frame_bounds(&mut self, bounds: solidcraft_engine::geom::Aabb3) { + let from = self.cam; + let to = self.fitted_bounds(from, &bounds); + self.pending_fit = None; + self.animate_to(to); + self.pending_fit = Some((from, bounds, true)); + } + /// Move the viewport to the camera a command asked for (`view.apply`): animated like a view /// change, or at once (control requests, so a screenshot right after shows it). pub fn follow_view_request(&mut self, animate: bool) { @@ -1976,6 +1985,7 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { frame_watch::begin(self); + refused_part::next_frame(self, ctx); file_picker::tick(self, ctx); dialogs_save_mesh::tick_wait(self, ctx); easyprint_ui::tick(self, ctx); diff --git a/crates/ui-egui/src/refused_part.rs b/crates/ui-egui/src/refused_part.rs index c179e2b1d5..ee5b2c2dbb 100644 --- a/crates/ui-egui/src/refused_part.rs +++ b/crates/ui-egui/src/refused_part.rs @@ -86,6 +86,22 @@ fn why_key(part: &RefusedPart) -> &'static str { } } +/// Ring `body`'s refused `face` in the viewport while this is called each frame. +pub(crate) fn ring(app: &mut SolveApp, body: &str, face: usize) { + if !app.viewport.refused_ring_next.iter().any(|(b, f)| b == body && *f == face) { + app.viewport.refused_ring_next.push((body.to_string(), face)); + } +} + +/// The faces asked for last frame are the ones ringed now; a change is drawn at once. +pub(crate) fn next_frame(app: &mut SolveApp, ctx: &egui::Context) { + let next = std::mem::take(&mut app.viewport.refused_ring_next); + if next != app.viewport.refused_ring { + app.viewport.refused_ring = next; + ctx.request_repaint(); + } +} + /// Repair the part (the command says when it cannot yet). pub(crate) fn repair(app: &mut SolveApp, body: &str) { if let Err(e) = app.run(REPAIR, json!({ "body": body })) { @@ -93,20 +109,17 @@ pub(crate) fn repair(app: &mut SolveApp, body: &str) { } } -/// Select the face the kernel refuses and frame it. +/// Frame the face the kernel refuses (ringed while the message shows). The selection is left +/// as it is: in a dialog it would become the dialog's pick. pub(crate) fn show_face(app: &mut SolveApp, part: &RefusedPart) { let Some(face) = part.face else { return }; let world = crate::open_meshing::session_world(&app.session); let Some(b) = world.body(&part.body) else { return }; let mesh = b.mesh(); - let point = mesh.tri_face.iter().zip(&mesh.triangles).find(|(f, _)| **f as usize == face).and_then(|(_, t)| { - let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied()); - Some((a? + b? + c?) / 3.0) - }); - let Some(point) = point else { return }; - let item = json!({"type": "face", "body": part.body, "index": face, "point": [point.x, point.y, point.z]}); - if app.run("select.set", json!({ "items": [item] })).is_ok() { - let _ = app.run("view.fit", json!({})); + let corners = mesh.tri_face.iter().zip(&mesh.triangles).filter(|(f, _)| **f as usize == face).flat_map(|(_, t)| t.iter()); + let bounds: solidcraft_engine::geom::Aabb3 = corners.filter_map(|i| mesh.positions.get(*i as usize).copied()).collect(); + if !bounds.is_empty() { + app.frame_bounds(bounds); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e1a0b402eb..abdcef1f8b 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -50,6 +50,12 @@ pub enum NavMode { #[derive(Default)] pub struct ViewportState { + /// The faces the modelling kernel refuses that the window points at (a failed operation's + /// message, a "needs repair" row under the pointer): ringed in the error colour. What this + /// frame asks for ([`crate::refused_part::ring`]) is drawn from the next one on, so the ring + /// goes when nothing asks for it any more. + pub(crate) refused_ring: Vec<(String, usize)>, + pub(crate) refused_ring_next: Vec<(String, usize)>, /// A drag on the handles of the drawing being placed (Insert SVG / DXF). pub(crate) insert_drag: Option, /// A drag on the handles of the Transform window's box. @@ -3030,6 +3036,7 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 { crate::theme_files::color_key(&Tokens::get()).hash(&mut h); app.preview.effective_replaced().hash(&mut h); app.preview.rolled_back.hash(&mut h); + app.viewport.refused_ring.hash(&mut h); app.origin_visible().hash(&mut h); app.picking_plane().hash(&mut h); app.preferences.reference_geometry_through_bodies.hash(&mut h); @@ -3340,6 +3347,22 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { sc.line((p - d * r).to_f32(), (p + d * r).to_f32(), construction, 2.0, true); } } + // A face the modelling kernel refuses, while the window points at it: a ring round its edges + // in the error colour, over the shading (and dimmed where the model hides it), like the + // selection's outline. + for (body, face) in &app.viewport.refused_ring { + let Some(b) = st.body(body).filter(|_| app.session.body_visible(body)) else { continue }; + let m = drawn_mesh(app, b); + let hidden = sc.color(c4(t.error.gamma_multiply(0.45))); + for (ei, e) in m.edges.iter().enumerate() { + if m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == *face)) && m.edge_pickable(ei) { + edge_lines(&mut sc, e, t.error, t.error.gamma_multiply(0.5), 3.0, &edge_normals(&m, e)); + for p in e.windows(2) { + sc.hidden_line(p[0].to_f32(), p[1].to_f32(), hidden, 1.4); + } + } + } + } // 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 { .. }));