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:
3 files changed
+116
-14
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user