From 03790f5509b07c71200e1d87a81e8db502cb7800 Mon Sep 17 00:00:00 2001 From: David Gray Date: Sat, 10 Oct 2026 20:28:08 +1100 Subject: [PATCH] View cube, move triad plane squares and the Offset Plane preview no longer flash lines while orbiting: faces turned nearly edge-on are drawn with projected_polygon (an unfeathered fill, outlined edge by edge) instead of egui's convex_polygon, whose miter joins shot spikes hundreds of pixels long at the slivers' sharp ends. Tests check everything drawn stays within each shape from 4050 view directions Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 44 ++++++++++++++++++------ crates/ui-egui/src/gizmo.rs | 25 ++++++++++++-- crates/ui-egui/src/viewport.rs | 61 ++++++++++++++++++++++++++++++++-- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9fc4b37b..ee171eb3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -13,7 +13,7 @@ use solvecraft_engine::geom::{Mesh, Vec3}; use crate::SolveApp; use crate::dialogs::{Dialog, Kind}; use crate::theme::Tokens; -use crate::viewport::Proj; +use crate::viewport::{Proj, projected_polygon}; /// Where the dialog's geometry is: a base point and, for values that can be dragged, the /// direction a growing value points. @@ -145,6 +145,13 @@ fn format_len(v: f64) -> String { format!("{} mm", if s == "-0" { "0" } else { s }) } +/// The offset plane preview: a square of half side `h` around `c`, square to `n`. +fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option> { + let u = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()?; + let v = n.cross(u); + [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect() +} + #[allow(clippy::too_many_arguments)] fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option) { let t = Tokens::get(); @@ -168,16 +175,9 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, // An offset plane shows where it will be. if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir) && let Ok(off) = app.session.doc.eval(offset, ValueKind::Length) - && let Some(u) = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized() + && let Some(q) = offset_plane_quad(proj, base + n * off, n, half_height * 0.25) { - let v = n.cross(u); - let h = half_height * 0.25; - let c = base + n * off; - let q: Vec = - [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().filter_map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect(); - if q.len() == 4 { - painter.add(egui::Shape::convex_polygon(q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge))); - } + painter.extend(projected_polygon(&q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge))); } // Holes: the depth arrow points into the material (empty depth: through all); the // diameter is typed in the box. @@ -412,6 +412,30 @@ fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) { mod tests { use super::*; + #[test] + fn offset_plane_preview_stays_within_its_square() { + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut worst = f32::NEG_INFINITY; + for n in [Vec3::X, Vec3::Y, Vec3::Z] { + for dir in crate::viewport::orbit_directions() { + let cam = solvecraft_engine::render::Camera::default().looking_from(dir); + let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; + let h = cam.half_height() * 0.25; + let (Some(q), Some(c), Some(side)) = + (offset_plane_quad(&proj, Vec3::ZERO, n, h), proj.to_screen(Vec3::ZERO), proj.to_screen(cam.basis().0 * h)) + else { + continue; + }; + // The square's half diagonal on screen (an orthographic view), plus stroke and feathering. + let limit = c.distance(side) * 2f32.sqrt() + 3.0; + for p in crate::viewport::drawn_points(projected_polygon(&q, Color32::GRAY, Stroke::new(1.2, Color32::BLACK))) { + worst = worst.max(p.distance(c) - limit); + } + } + } + assert!(worst <= 0.0, "a vertex {worst} px past the square"); + } + #[test] fn snapping_steps_and_formatting() { assert_eq!(snap_step(40.0), 1.0); diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index a176d1f4..5f1b1eb1 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -10,7 +10,7 @@ use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, vec2}; use solvecraft_engine::geom::Vec3; use crate::theme::Tokens; -use crate::viewport::Proj; +use crate::viewport::{Proj, projected_polygon}; /// A part of the triad. #[derive(Clone, Copy, Debug, PartialEq)] @@ -264,7 +264,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I } for (i, q) in &sh.planes { let c = col(Handle::Plane(*i), *i); - painter.add(Shape::convex_polygon(q.clone(), c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c))); + painter.extend(projected_polygon(q, c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c))); } for (i, a, b) in &sh.arrows { arrow(painter, *a, *b, col(Handle::Axis(*i), *i), hot(Handle::Axis(*i))); @@ -413,6 +413,27 @@ pub fn axis_angle(m: &[[f64; 3]; 3]) -> (Vec3, f64) { mod tests { use super::*; + #[test] + fn plane_squares_stay_within_the_triad() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let t = Triad { center: Vec3::ZERO, translate: true, rotate: false }; + // The squares reach 0.45 √2 of the arrow length from the centre, plus stroke and feathering. + let limit = (0.45 * 2f64.sqrt() * LEN_PX) as f32 + 3.0; + let mut worst = 0.0f32; + for dir in crate::viewport::orbit_directions() { + let cam = solvecraft_engine::render::Camera::default().looking_from(dir); + let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; + let l = mm_per_px(&proj, t.center).unwrap() * LEN_PX; + let sh = shapes(&proj, &t, l).unwrap(); + for (_, q) in &sh.planes { + for p in crate::viewport::drawn_points(projected_polygon(q, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) { + worst = worst.max(p.distance(sh.ball)); + } + } + } + assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})"); + } + #[test] fn rotations_compose_to_one_axis_angle() { let r = mul(&rotation(Vec3::Z, 0.7), &rotation(Vec3::X, 0.4)); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index be9a50bb..62b8aa3e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -125,6 +125,44 @@ impl Proj { } } +/// A projected 3D face (convex): an unfeathered triangle fan, outlined one edge at a time (no +/// joins between the edges). Unlike `Shape::convex_polygon`, it stays inside its outline when +/// the face turns edge-on. +pub(crate) fn projected_polygon(pts: &[Pos2], fill: Color32, stroke: Stroke) -> Vec { + let mut mesh = egui::Mesh::default(); + for (i, p) in (0u32..).zip(pts) { + mesh.colored_vertex(*p, fill); + if i >= 2 { + mesh.add_triangle(0, i - 1, i); + } + } + let mut out = vec![Shape::mesh(mesh)]; + out.extend(pts.iter().zip(pts.iter().skip(1).chain(pts.first())).map(|(a, b)| Shape::line_segment([*a, *b], stroke))); + out +} + +/// View directions all round, a few degrees apart, none exactly along an axis. +#[cfg(test)] +pub(crate) fn orbit_directions() -> impl Iterator { + (0..90).flat_map(|i| { + (0..45).map(move |j| { + let (yaw, pitch) = (f64::from(i) * 4.0f64.to_radians() + 0.003, f64::from(j) * 4.0f64.to_radians() - 1.5); + Vec3::new(pitch.cos() * yaw.cos(), pitch.cos() * yaw.sin(), pitch.sin()) + }) + }) +} + +/// Every vertex egui draws for `shapes`. +#[cfg(test)] +pub(crate) fn drawn_points(shapes: Vec) -> Vec { + let mut tess = egui::epaint::Tessellator::new(1.0, egui::epaint::TessellationOptions::default(), [1, 1], vec![]); + let mut mesh = egui::Mesh::default(); + for shape in shapes { + tess.tessellate_shape(shape, &mut mesh); + } + mesh.vertices.iter().map(|v| v.pos).collect() +} + fn rgba(c: Rgb) -> [u8; 4] { [c.0, c.1, c.2, 255] } @@ -1953,9 +1991,9 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let n = tg.normal(); let shade = (0.75 + 0.25 * n.dot(b)) as f32; let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) }; - painter.add(Shape::convex_polygon(pts.clone(), fill.to_opaque(), Stroke::new(1.0, t.cube_edge))); + painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge))); if hovered == Some(*i) { - painter.add(Shape::convex_polygon(pts.clone(), t.accent_soft, Stroke::new(1.0, t.accent))); + painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent))); } let centre = cube_centroid(pts); crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre); @@ -2279,6 +2317,25 @@ mod tests { assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None); } + /// From any direction, including faces turned nearly edge-on, everything drawn stays within + /// the cube. + #[test] + fn view_cube_faces_never_spike_past_the_cube() { + let targets = cube_targets(); + let c = pos2(100.0, 100.0); + // The cube's circumradius (corner at √3) plus the stroke and feathering. + let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0; + let mut worst = 0.0f32; + for dir in orbit_directions() { + for (_, pts) in cube_projected(&targets, basis_from(dir), c) { + for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) { + worst = worst.max(p.distance(c)); + } + } + } + assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})"); + } + #[test] fn view_cube_edges_are_not_targets() { let targets = cube_targets();