Files
solidcraft-upstream/crates/ui-egui/src/sketch_display_preview_tests.rs
T
selune 5ad8b3966f fix(ui): preview actual polygon outlines while drawing (#583)
Inscribed, circumscribed and edge polygon tools preview their actual outline (side count and orientation) while drawing, with the current line type, instead of only the support circle. Preview work is bounded. Fixes #520.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-13 04:26:00 -04:00

153 lines
7.4 KiB
Rust

use super::{Tool, polygon_outline, preview_curve};
use crate::scenario::Harness;
use crate::viewport::Proj;
use serde_json::{Value, json};
use solidcraft_engine::geom::{Plane, Vec2, Vec3};
use solidcraft_engine::render::{Camera, Mat4};
use solidcraft_engine::sketch::Sketch;
fn tool(command: &str, points: &[[f64; 2]]) -> Tool {
let mut tool = Tool::for_command(command).unwrap();
tool.pts = points.iter().map(|p| (Vec2::new(p[0], p[1]), None)).collect();
tool
}
fn spans(sk: &Sketch) -> Vec<solidcraft_engine::geom::Seg2> {
sk.exact_segs(0).unwrap_or_else(|| sk.segs(0))
}
fn projection(scale: f64) -> Proj {
Proj {
cam: Camera::default(),
vp: Mat4([[scale / 700.0, 0.0, 0.0, 0.0], [0.0, scale / 450.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 1.0]]),
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)),
}
}
fn screen_error(paths: &[Vec<Vec3>], point: Vec3, proj: &Proj, ppp: f32) -> f32 {
let point = proj.to_screen(point).unwrap();
paths
.iter()
.flat_map(|part| part.windows(2))
.map(|pair| {
let a = proj.to_screen(pair[0]).unwrap();
let b = proj.to_screen(pair[1]).unwrap();
let ab = b - a;
let t = if ab.length_sq() > 0.0 { ((point - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
point.distance(a + ab * t) * ppp
})
.fold(f32::INFINITY, f32::min)
}
#[test]
fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() {
let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option<f64>, Value)> = vec![
("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, json!({"center":[0,0],"radius":1})),
("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, json!({"p0":[-1,0],"p1":[1,0]})),
("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})),
("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})),
(
"sketch.arc.center_point",
vec![[0., 0.], [1., 0.]],
[0., -1.],
Some(-std::f64::consts::FRAC_PI_2),
json!({"center":[0,0],"start":[1,0],"sweep":-90}),
),
("sketch.ellipse", vec![[0., 0.], [1., 0.]], [0., 0.05], None, json!({"center":[0,0],"major":[1,0],"minor":[0,0.05]})),
("sketch.spline.fit_point", vec![[0., 0.], [0.3, 0.8], [0.6, -0.8]], [1., 0.], None, json!({"points":[[0,0],[0.3,0.8],[0.6,-0.8],[1,0]]})),
(
"sketch.spline.control_point_5",
vec![[0., 0.], [0.2, 0.9], [0.4, -0.9], [0.6, 0.9], [0.8, -0.9]],
[1., 0.],
None,
json!({"points":[[0,0],[0.2,0.9],[0.4,-0.9],[0.6,0.9],[0.8,-0.9],[1,0]]}),
),
("sketch.spline.fit_point", vec![[0., 0.], [1., 0.], [0.5, 1.]], [0., 0.], None, json!({"points":[[0,0],[1,0],[0.5,1]],"periodic":true})),
];
for (command, points, hover, sweep, params) in cases {
let tool = tool(command, &points);
let preview = preview_curve(&tool, Vec2::new(hover[0], hover[1]), sweep).unwrap();
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run(command, params).unwrap();
let state = h.app.session.world_state();
let committed = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let a = spans(&preview);
let b = spans(committed);
assert_eq!(a.len(), b.len(), "{command}");
for (a, b) in a.iter().zip(&b) {
for i in 0..=50 {
assert!(a.point_at(i as f64 / 50.0).dist(b.point_at(i as f64 / 50.0)) < 1e-8, "{command}");
}
}
for ppp in [1.0, 1.5, 2.0] {
for scale in [2.0, 20.0, 220.0, 1000.0] {
let proj = projection(scale / f64::from(ppp));
let temporary = crate::sketch_display::curve_parts(&preview, 0, &Plane::XY, &proj, ppp);
let permanent = crate::sketch_display::curve_parts(committed, 0, &Plane::XY, &proj, ppp);
assert_eq!(temporary, permanent, "{command}");
assert!(temporary.iter().map(Vec::len).sum::<usize>() <= 65_536);
let mut visible = 0;
for segment in &a {
for i in 0..=300 {
let point = Plane::XY.to_world(segment.point_at(i as f64 / 300.0));
if !proj.rect.contains(proj.to_screen(point).unwrap()) {
continue;
}
visible += 1;
let error = screen_error(&temporary, point, &proj, ppp);
assert!(error <= 1.0, "{command}, scale{scale}, ppp{ppp}: {error}");
}
}
if scale <= 220.0 {
assert_eq!(visible, a.len() * 301, "the low/normal zoom fixture must test the entire curve: {command}");
}
}
}
}
}
#[test]
fn pixel_preview_fully_offscreen_circle_is_clipped_without_false_connections() {
let curve = preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::X, None).unwrap();
let proj = projection(1000.0);
// The viewport half diagonal is about 832px: a 1000px circle never enters it.
assert!((proj.rect.width() * 0.5).hypot(proj.rect.height() * 0.5) < 1000.0);
assert!(crate::sketch_display::curve_parts(&curve, 0, &Plane::XY, &proj, 1.0).is_empty());
}
#[test]
fn pixel_preview_rejects_degenerate_and_unbounded_input_without_changing_document() {
assert!(preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::new(f64::NAN, 0.), None).is_none());
assert!(preview_curve(&tool("sketch.arc.three_point", &[[0., 0.], [1., 0.]]), Vec2::new(2., 0.), None).is_none());
let points = vec![[0., 0.]; 500];
assert!(preview_curve(&tool("sketch.spline.fit_point", &points), Vec2::X, None).is_none());
}
#[test]
fn polygon_preview_squares_have_the_expected_rotation_and_size() {
let cases = [
("sketch.polygon.inscribed", [10.0, 0.0], [[10.0, 0.0], [0.0, 10.0], [-10.0, 0.0], [0.0, -10.0], [10.0, 0.0]]),
("sketch.polygon.circumscribed", [10.0, 0.0], [[10.0, 10.0], [-10.0, 10.0], [-10.0, -10.0], [10.0, -10.0], [10.0, 10.0]]),
("sketch.polygon.edge", [20.0, 0.0], [[0.0, 0.0], [20.0, 0.0], [20.0, 20.0], [0.0, 20.0], [0.0, 0.0]]),
];
for (command, hover, expected) in cases {
let t = tool(command, &[[0.0, 0.0]]);
let outline = polygon_outline(&t, Vec2::new(hover[0], hover[1]), 4.0).unwrap();
assert_eq!(outline.len(), expected.len());
for (actual, expected) in outline.iter().zip(expected) {
assert!(actual.dist(Vec2::new(expected[0], expected[1])) < 1e-9, "{command}: {actual:?}");
}
}
}
#[test]
fn polygon_preview_rejects_invalid_or_unbounded_input() {
let t = tool("sketch.polygon.inscribed", &[[0.0, 0.0]]);
for sides in [0.0, 2.0, 3.5, 201.0, f64::INFINITY, f64::NAN] {
assert!(polygon_outline(&t, Vec2::X, sides).is_none());
}
assert!(polygon_outline(&t, Vec2::ZERO, 6.0).is_none());
assert!(polygon_outline(&t, Vec2::new(f64::NAN, 0.0), 6.0).is_none());
assert_eq!(polygon_outline(&t, Vec2::X, 200.0).unwrap().len(), 201);
let t = tool("sketch.polygon.inscribed", &[[f64::INFINITY, 0.0]]);
assert!(polygon_outline(&t, Vec2::X, 6.0).is_none());
}