Merge pull request #65 from N3MI-DG/fix/view-cube-edge-on-flares

View cube, move triad and Offset Plane preview: no flashing lines while orbiting
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-10 09:32:27 -04:00
commit cf1ab075ec
3 files changed
+116 -14

No files matched your search

+34 -10
View File
@@ -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<Vec<Pos2>> {
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<Vec3>) {
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<Pos2> =
[(-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);
+23 -2
View File
@@ -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));
+59 -2
View File
@@ -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<Shape> {
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<Item = Vec3> {
(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<Shape>) -> Vec<Pos2> {
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();