From 090fcec6ca0356912f8bb2cf24df83318a3e6426 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:40:00 -0400 Subject: [PATCH] A refused face is ringed in the error colour while the window points at it While a feature dialog says that a part's face can't be used for modelling, and while the pointer is over that part's "needs repair" row in the browser, the face is outlined round its edges in the theme's error colour, over the shading and dimmed where the model hides it, like the selection's outline; it goes when the message or the hover does. Show face now frames the face without selecting it (a selection in a dialog becomes its pick and cleared the message). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DtL5Lzv2n8fyKqD61NCHSF --- crates/ui-egui/src/browser.rs | 8 ++++++++ crates/ui-egui/src/dialogs.rs | 3 +++ crates/ui-egui/src/lib.rs | 10 ++++++++++ crates/ui-egui/src/refused_part.rs | 31 +++++++++++++++++++++--------- crates/ui-egui/src/viewport.rs | 23 ++++++++++++++++++++++ 5 files changed, 66 insertions(+), 9 deletions(-) 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 { .. }));