Continuity Analysis tool, legend and theme colours on #1642's inspect.continuity; curvature combs on faces (#770) (#1247)
Thanks @jyviko.
This commit is contained in:
1 parent
a000bd69cf
commit
0a66697da8
16 files changed
+400
-17
No files matched your search
@@ -298,6 +298,16 @@ fn groups(t: &Tokens) -> Vec<Group> {
|
||||
),
|
||||
group("section", &[("section_cap", t.section_cap)], &[("body", t.body)]),
|
||||
group("interference", &[("interference", t.interference)], &[("body", t.body)]),
|
||||
group(
|
||||
"continuity seams",
|
||||
&[
|
||||
("continuity[0]", t.continuity[0]),
|
||||
("continuity[1]", t.continuity[1]),
|
||||
("continuity[2]", t.continuity[2]),
|
||||
("continuity[3]", t.continuity[3]),
|
||||
],
|
||||
&[("body", t.body), ("viewport_top", t.viewport_top)],
|
||||
),
|
||||
// The scale's three stops, unlit. Shaded (a stop at one angle to the light, the next at
|
||||
// another), middle and tight merge for a red-green loss (3.3 protan, 5.2 deutan) and flat
|
||||
// and middle for tritan (4.6). That is the scale's own fault, in Off too: #975.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The Continuity Analysis toolbar tool (#770): the button shades every seam, a click on an edge
|
||||
//! pins its numbers, the button again turns the analysis off, and a legend names the colours.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn marks() -> Vec<String> {
|
||||
MARKS.with(|m| m.borrow().iter().map(|(_, label)| label.clone()).collect())
|
||||
}
|
||||
|
||||
/// The button measures every edge of the shown bodies and leaves the tool picking edges; a
|
||||
/// click on an edge pins its class and numbers without dropping the other seams; the button
|
||||
/// again clears the shading and ends the tool. Without bodies it starts nothing.
|
||||
#[test]
|
||||
fn the_button_shades_every_seam_pins_an_edge_and_turns_off() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
assert!(h.app.tool.is_none() && h.app.session.continuity.is_empty(), "nothing to measure");
|
||||
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
assert_eq!(h.app.session.continuity.len(), 12, "every edge of the box");
|
||||
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("inspect.continuity"));
|
||||
h.step(&json!({"click": {"world": [10, 0, 20]}})).unwrap();
|
||||
assert_eq!(marks(), ["G0 90.000\u{b0}"], "{:?}", h.app.status);
|
||||
assert_eq!(h.app.session.continuity.len(), 12, "the clicked edge is measured again, not added twice");
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
assert!(h.app.session.continuity.is_empty() && h.app.tool.is_none());
|
||||
h.frame();
|
||||
assert!(marks().is_empty(), "the markers go with the tool");
|
||||
}
|
||||
|
||||
/// A round's tangent seam reads G1 with its curvature jump (1 / 4 mm against the flat side).
|
||||
#[test]
|
||||
fn a_tangent_seam_is_labelled_with_its_curvature_jump() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 4})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
// The seam where the round meets the front face (y = 0), halfway up.
|
||||
h.step(&json!({"click": {"world": [16, 0, 10]}})).unwrap();
|
||||
let labels = marks();
|
||||
assert_eq!(labels.len(), 1, "{labels:?} {:?}", h.app.status);
|
||||
assert!(labels[0].starts_with("G1 0.000\u{b0} \u{394}k 0.2500/mm"), "{labels:?}");
|
||||
}
|
||||
|
||||
/// The legend shows while seams are shaded and goes with the analysis.
|
||||
#[test]
|
||||
fn the_legend_shows_while_seams_are_shaded() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
h.settle();
|
||||
assert!(crate::scenario::rect_of("continuity_legend").is_none());
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
h.frame();
|
||||
let legend = crate::scenario::rect_of("continuity_legend").expect("the legend is drawn");
|
||||
assert!(legend.height() > 4.0 * 18.0, "four rows: {legend:?}");
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
h.frame();
|
||||
assert!(crate::scenario::rect_of("continuity_legend").is_none(), "gone with the analysis");
|
||||
}
|
||||
|
||||
/// While the tool runs, only what its click takes lights up under the pointer: an edge, never
|
||||
/// the face around it, whose outline would paint over the seam colours.
|
||||
#[test]
|
||||
fn only_edges_light_up_under_the_tool() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start": "inspect.continuity"})).unwrap();
|
||||
h.step(&json!({"move": {"world": [10, 10, 20]}})).unwrap();
|
||||
assert_eq!(h.app.viewport.hover, None, "the top face is not lit");
|
||||
h.step(&json!({"move": {"world": [10, 0, 20]}})).unwrap();
|
||||
assert!(matches!(h.app.viewport.hover, Some(Hit::Edge { .. })), "{:?}", h.app.viewport.hover);
|
||||
}
|
||||
@@ -84,6 +84,44 @@ fn analysis(app: &mut SolveApp, cmd: &str, p: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A seam from an `inspect.continuity` reply as a marker label: its class and the measure that
|
||||
/// decided it (the gap, the angle between the faces' normals, or the curvature jump).
|
||||
fn continuity_label(app: &SolveApp, seam: &Value) -> String {
|
||||
let deg = |key: &str| seam[key]["value"].as_f64().map(|x| format!("{x:.3}\u{b0}")).unwrap_or_default();
|
||||
let jump = seam["curvature_jump_per_mm"]["value"].as_f64().map(|k| format!("\u{394}k {k:.4}/mm")).unwrap_or_default();
|
||||
match seam["verdict"].as_str().unwrap_or_default() {
|
||||
"gap" => format!("Gap {}", seam["gap_mm"]["value"].as_f64().map(|g| crate::prefs::show_mm(app, g, 1)).unwrap_or_default()),
|
||||
"sharp" => format!("G0 {}", deg("normal_angle_deg")),
|
||||
"curvature_jump" => format!("G1 {} {jump}", deg("normal_angle_deg")),
|
||||
"smooth" => format!("G2 {} {jump}", deg("normal_angle_deg")),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Continuity Analysis legend in the viewport's lower left corner: each class in its
|
||||
/// colour, while seams are shaded.
|
||||
fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if app.session.continuity.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let [smooth, jump, sharp, gap] = tk.continuity;
|
||||
let tr = crate::i18n::tr_label;
|
||||
let rows = [(tr("G2 curvature"), smooth), (tr("G1 tangent"), jump), (tr("G0 crease"), sharp), (tr("Gap"), gap)];
|
||||
let font = egui::FontId::proportional(12.0);
|
||||
let (row, pad, swatch) = (18.0, 8.0, 22.0);
|
||||
let text_width = rows.iter().map(|(label, _)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max);
|
||||
let size = egui::vec2(pad + swatch + 8.0 + text_width + pad, rows.len() as f32 * row + 2.0 * pad);
|
||||
let frame = egui::Rect::from_min_size(proj.rect.left_bottom() + egui::vec2(16.0, -(size.y + 16.0)), size);
|
||||
painter.rect_filled(frame, 4.0, tk.panel.gamma_multiply(0.9));
|
||||
crate::scenario::publish_rect("continuity_legend", frame);
|
||||
for (i, (label, col)) in rows.iter().enumerate() {
|
||||
let y = frame.top() + pad + row * (i as f32 + 0.5);
|
||||
painter.line_segment([egui::pos2(frame.left() + pad, y), egui::pos2(frame.left() + pad + swatch, y)], Stroke::new(3.0, *col));
|
||||
painter.text(egui::pos2(frame.left() + pad + swatch + 8.0, y), egui::Align2::LEFT_CENTER, *label, font.clone(), tk.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Isocurve analysis lines.
|
||||
fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.isocurve") {
|
||||
@@ -104,7 +142,7 @@ fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
|
||||
/// Analysis markers: a target with its label.
|
||||
fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass")) {
|
||||
if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.continuity")) {
|
||||
MARKS.with(|m| m.borrow_mut().clear());
|
||||
return;
|
||||
}
|
||||
@@ -149,6 +187,25 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if id == "inspect.continuity" {
|
||||
// Every edge of the shown bodies at once; the tool stays for clicking single edges. With
|
||||
// seams shaded, the button turns the analysis off.
|
||||
MARKS.with(|m| m.borrow_mut().clear());
|
||||
if !app.session.continuity.is_empty() {
|
||||
if app.run(id, json!({"clear": true})).is_ok() {
|
||||
app.set_status("Analysis off", false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let Ok(v) = app.run(id, json!({})) else { return false };
|
||||
let summary = &v["summary"];
|
||||
let counts: Vec<String> = [("smooth", "G2"), ("curvature_jump", "G1"), ("sharp", "G0"), ("gap", "gap")]
|
||||
.iter()
|
||||
.filter_map(|(key, class)| summary[*key].as_u64().filter(|n| *n > 0).map(|n| format!("{n} {class}")))
|
||||
.collect();
|
||||
app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false);
|
||||
return true;
|
||||
}
|
||||
if id == "inspect.center_of_mass" {
|
||||
// The whole model at once; the tool stays for picking single bodies.
|
||||
MARKS.with(|m| m.borrow_mut().clear());
|
||||
@@ -346,11 +403,21 @@ enum Mode {
|
||||
Steps,
|
||||
}
|
||||
|
||||
/// Whether a click with tool `id` acts on `hit`: what is under the pointer lights up only when
|
||||
/// the tool takes it, so the hover shows what a click does and lights nothing over a view the
|
||||
/// tool draws (Continuity Analysis's seam colours on a face the tool ignores).
|
||||
pub fn takes(id: &str, hit: &Hit) -> bool {
|
||||
match id {
|
||||
"inspect.continuity" => matches!(hit, Hit::Edge { .. }),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn mode(id: &str) -> Option<Mode> {
|
||||
Some(match id {
|
||||
"sketch.project" | "sketch.intersect" | "sketch.include_3d" | "sketch.fit_curves_to_section" | "sketch.isoparametric_curve" => Mode::ModelRef,
|
||||
"inspect.curvature_comb" | "canvas.insert" | "canvas.decal" => Mode::ModelRef,
|
||||
"inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" => Mode::ModelRef,
|
||||
"inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" | "inspect.continuity" => Mode::ModelRef,
|
||||
"sketch.trim" | "sketch.extend" | "sketch.break" => Mode::CurveAt,
|
||||
"sketch.line.midpoint" => Mode::Points(2),
|
||||
"sketch.arc.tangent" => Mode::Points(2),
|
||||
@@ -398,6 +465,7 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"inspect.minimum_radius" => "Minimum radius: click sketch curves, edges or faces",
|
||||
"inspect.center_of_mass" => "Center of mass of all bodies; click a body for its own",
|
||||
"inspect.isocurve" => "Isocurves: click faces",
|
||||
"inspect.continuity" => "Continuity: click an edge for its numbers (the button again turns the colours off)",
|
||||
"canvas.insert" => "Canvas: click a plane or a planar face where the image's centre goes",
|
||||
"canvas.decal" => "Decal: click a planar face where the image's centre goes",
|
||||
"sketch.trim" => "Trim: click the piece of a curve to remove",
|
||||
@@ -596,6 +664,17 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::ModelRef if cmd == "inspect.continuity" => {
|
||||
// The edge measured again, added to the seams shown, and its numbers pinned there.
|
||||
let edge = hits.iter().filter(|h| takes(cmd, h)).find_map(|h| if let Hit::Edge { mid, .. } = h { Some(*mid) } else { None });
|
||||
if let Some(mid) = edge
|
||||
&& let Ok(v) = app.run(cmd, json!({"edges": [[mid.x, mid.y, mid.z]], "add": true}))
|
||||
&& let Some(seam) = v["bodies"][0]["edges"].get(0)
|
||||
{
|
||||
let label = continuity_label(app, seam);
|
||||
MARKS.with(|m| m.borrow_mut().push((mid, label)));
|
||||
}
|
||||
}
|
||||
Mode::ModelRef if matches!(cmd, "inspect.minimum_radius" | "inspect.center_of_mass") => {
|
||||
let params = hits.iter().find_map(|h| match (cmd, h) {
|
||||
("inspect.minimum_radius", Hit::SketchCurve { sketch, id, .. }) => Some(json!({"sketch": sketch, "curves": [id]})),
|
||||
@@ -1134,8 +1213,13 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
}
|
||||
marks(app, painter, proj);
|
||||
iso_lines(app, painter, proj);
|
||||
continuity_legend(app, painter, proj);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_corner_ui_tests.rs"]
|
||||
mod corner_ui_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_continuity_tests.rs"]
|
||||
mod continuity_tests;
|
||||
@@ -288,6 +288,10 @@ pub struct Tokens {
|
||||
pub bend_down: Color32,
|
||||
/// Interference: the clash fill, before its shading.
|
||||
pub interference: Color32,
|
||||
/// Continuity Analysis seams, best to worst as `inspect.continuity` judges them: smooth (G2),
|
||||
/// tangent with a curvature jump (G1), a crease (G0) and a gap. Bluish green, blue, yellow
|
||||
/// and reddish purple stay apart in every colour vision mode, so no mode changes them.
|
||||
pub continuity: [Color32; 4],
|
||||
pub analysis: AnalysisColors,
|
||||
pub icons: IconColors,
|
||||
/// The application bar's buttons: ink, fill and accent of their icons.
|
||||
|
||||
+138
-10
@@ -695,7 +695,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
// Continuity analysis: each seam `inspect.continuity` measured, drawn over the model in the
|
||||
// colour of its worst verdict.
|
||||
for seam in &s.continuity {
|
||||
let col = continuity_color(seam.verdict);
|
||||
let col = c4(continuity_color(&tk, seam.verdict));
|
||||
for w in seam.points.windows(2) {
|
||||
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
|
||||
sc.line(a.to_f32(), b.to_f32(), col, 2.6, true);
|
||||
@@ -706,16 +706,17 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
sc
|
||||
}
|
||||
|
||||
/// The colour of a measured seam: green within every tolerance, yellow where the faces only
|
||||
/// meet at an angle (over G1), red where they do not meet at all or the curvature jumps across
|
||||
/// them (over G2, the defect no chord tolerance shows). The greens, yellows and reds are the
|
||||
/// ones the shader's own analyses use (`assets/shaders/viewport.wgsl`).
|
||||
fn continuity_color(verdict: solidcraft_engine::kernel::SeamVerdict) -> [u8; 4] {
|
||||
/// The colour of a measured seam, one per verdict from the theme's `continuity` tokens: within
|
||||
/// every tolerance (G2), tangent with a curvature jump (G1), a crease (G0), and faces that do
|
||||
/// not meet.
|
||||
fn continuity_color(t: &Tokens, verdict: solidcraft_engine::kernel::SeamVerdict) -> Color32 {
|
||||
use solidcraft_engine::kernel::SeamVerdict as V;
|
||||
let [smooth, jump, sharp, gap] = t.continuity;
|
||||
match verdict {
|
||||
V::Smooth => [51, 191, 77, 255],
|
||||
V::Sharp => [242, 204, 38, 255],
|
||||
V::CurvatureJump | V::Gap => [217, 56, 51, 255],
|
||||
V::Smooth => smooth,
|
||||
V::CurvatureJump => jump,
|
||||
V::Sharp => sharp,
|
||||
V::Gap => gap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1668,6 +1669,12 @@ pub fn hit_sel(h: &Hit) -> Option<Sel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What lights up under the pointer while a tool runs: the nearest hit its click takes.
|
||||
fn tool_hover(app: &SolveApp, hits: &[Hit]) -> Option<Hit> {
|
||||
let cmd = app.tool.as_ref()?.cmd;
|
||||
hits.iter().find(|h| crate::sketch_tools::takes(cmd, h)).cloned()
|
||||
}
|
||||
|
||||
/// What a click would pick: the first hit the open dialog's active input accepts, or with no
|
||||
/// dialog the first hit (sketch entities only belong to the active sketch).
|
||||
pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
|
||||
@@ -2314,7 +2321,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// corner drawn from the origin is the fixed origin, which ends no two curves.
|
||||
let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p));
|
||||
let cand = if app.tool.is_some() {
|
||||
hits.first().cloned().map(|h| (h, None))
|
||||
tool_hover(app, &hits).map(|h| (h, None))
|
||||
} else if let Some((hit, sel)) = corner {
|
||||
Some((hit, Some(sel)))
|
||||
} else {
|
||||
@@ -3507,6 +3514,127 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::navigation::*;
|
||||
|
||||
/// Every tangent seam Continuity Analysis reports keeps its colour while the pointer rests on
|
||||
/// a face (#1247): a 60 x 40 x 20 box with its four top edges rounded (6 mm) has eight, four
|
||||
/// against the sides and four against the top face. The tool lit the face under the pointer,
|
||||
/// though its click takes only edges, and the face's outline covered the colours of its
|
||||
/// edges. Rendered offscreen with bodies built on the CPU and evaluated on the GPU (skipped
|
||||
/// without a GPU adapter); with `SOLIDCRAFT_CONTINUITY_TOP_SHOT` set, the pictures are
|
||||
/// written there (`-cpu.png`, `-gpu.png`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn continuity_colours_stay_on_the_edges_of_the_face_under_the_pointer() {
|
||||
use solidcraft_engine::kernel::SeamVerdict;
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 20, "body_name": "B"})).unwrap();
|
||||
s.execute("solid.fillet", &json!({"edges": [[30, 0, 20], [30, 40, 20], [0, 20, 20], [60, 20, 20]], "radius": 6})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.fit_view();
|
||||
// The toolbar button: every seam shaded, the tool left picking edges.
|
||||
app.start("inspect.continuity");
|
||||
let tangent = |app: &SolveApp| app.session.continuity.iter().filter(|seam| seam.verdict == SeamVerdict::CurvatureJump).count();
|
||||
assert_eq!(tangent(&app), 8);
|
||||
const SIZE: u32 = 512; // rows of 256-byte multiples (the copy alignment)
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
|
||||
// The pointer rests in the middle of the top face, away from its edges: the tool takes
|
||||
// only edges, so nothing there lights up over the analysis.
|
||||
let proj = projection(&app, rect);
|
||||
let middle = proj.to_screen(Vec3::new(30.0, 20.0, 20.0)).unwrap();
|
||||
let hits = pick_cached(&mut app, &proj, middle);
|
||||
assert!(matches!(hits.first(), Some(Hit::Face { .. })), "{hits:?}");
|
||||
app.viewport.hover = tool_hover(&app, &hits);
|
||||
assert_eq!(app.viewport.hover, None);
|
||||
let t = Tokens::get();
|
||||
let g1 = t.continuity[1];
|
||||
// Each tangent seam lies in one flat face (the top, or a side under its round): that
|
||||
// face's outward normal, and the seam shows where it faces the eye (the body is convex).
|
||||
let flat_normal = |e: &[Vec3]| {
|
||||
let on = |f: fn(&Vec3) -> f64, at: f64| e.iter().all(|p| (f(p) - at).abs() < 1e-6);
|
||||
[
|
||||
(on(|p| p.z, 20.0), Vec3::Z),
|
||||
(on(|p| p.y, 0.0), -Vec3::Y),
|
||||
(on(|p| p.y, 40.0), Vec3::Y),
|
||||
(on(|p| p.x, 0.0), -Vec3::X),
|
||||
(on(|p| p.x, 60.0), Vec3::X),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(lies, n)| lies.then_some(n))
|
||||
};
|
||||
for tess in [false, true] {
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||||
let proj = projection(&app, rect);
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
let Some(img) = crate::gpu::render_offscreen(
|
||||
build_scene(&app).merged(),
|
||||
Some(build_highlight(&app)),
|
||||
proj.vp.to_f32(),
|
||||
proj.cam.back().to_f32(),
|
||||
eye,
|
||||
SIZE,
|
||||
tess.then_some(solidcraft_gpu::SHADER),
|
||||
[0.0; 8],
|
||||
crate::gpu::analysis_colors(&t.analysis),
|
||||
) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
if let Ok(path) = std::env::var("SOLIDCRAFT_CONTINUITY_TOP_SHOT")
|
||||
&& let Some(png) = image::RgbaImage::from_raw(SIZE, SIZE, img.clone())
|
||||
{
|
||||
png.save(path.replace(".png", if tess { "-gpu.png" } else { "-cpu.png" })).unwrap();
|
||||
}
|
||||
let lit = |x: i64, y: i64| {
|
||||
(0..SIZE as i64).contains(&x)
|
||||
&& (0..SIZE as i64).contains(&y)
|
||||
&& img[((y * SIZE as i64 + x) * 4) as usize..][..3].iter().zip([g1.r(), g1.g(), g1.b()]).all(|(a, b)| a.abs_diff(b) <= 12)
|
||||
};
|
||||
let mut seen = 0;
|
||||
for seam in app.session.continuity.iter().filter(|seam| seam.verdict == SeamVerdict::CurvatureJump) {
|
||||
let (a, b) = (seam.points[0], seam.points[seam.points.len() - 1]);
|
||||
let normal = flat_normal(&seam.points).unwrap_or_else(|| panic!("a tangent seam off the flat faces: {a:?}..{b:?}"));
|
||||
if normal.dot(proj.cam.eye() - a.lerp(b, 0.5)) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
// In its colour a quarter, half and three quarters along.
|
||||
for f in [0.25, 0.5, 0.75] {
|
||||
let p = a.lerp(b, f);
|
||||
let q = proj.to_screen(p).unwrap();
|
||||
let (x, y) = (q.x.round() as i64, q.y.round() as i64);
|
||||
let drawn = (-2..=2).any(|dy| (-2..=2).any(|dx| lit(x + dx, y + dy)));
|
||||
assert!(drawn, "tess {tess}: tangent seam {a:?}..{b:?} is not drawn in its colour at {p:?} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, 6, "the four top seams and the two sides facing the eye");
|
||||
}
|
||||
// On an edge, the edge lights up: what a click measures.
|
||||
let proj = projection(&app, rect);
|
||||
let on_edge = proj.to_screen(Vec3::new(30.0, 6.0, 20.0)).unwrap();
|
||||
let hits = pick_cached(&mut app, &proj, on_edge);
|
||||
assert!(matches!(tool_hover(&app, &hits), Some(Hit::Edge { .. })), "{hits:?}");
|
||||
}
|
||||
|
||||
/// Continuity Analysis draws each seam in its verdict's `continuity` token (#770): a box
|
||||
/// with one edge rounded shows the round's two tangent seams (G1) and the creases (G0), and
|
||||
/// nothing in the smooth or gap colours.
|
||||
#[test]
|
||||
fn continuity_seams_take_their_colours_from_the_theme() {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap();
|
||||
s.execute("inspect.continuity", &json!({})).unwrap();
|
||||
let app = SolveApp::new(s, crate::Services::default());
|
||||
let sc = build_scene(&app);
|
||||
// An overlay line is two points, a colour and a width: 32 bytes.
|
||||
let colours: Vec<[u8; 4]> = sc.overlay.as_chunks::<32>().0.iter().map(|r| [r[24], r[25], r[26], r[27]]).collect();
|
||||
let t = Tokens::get();
|
||||
let count = |c: Color32| colours.iter().filter(|x| **x == c4(c)).count();
|
||||
let [smooth, jump, sharp, gap] = t.continuity.map(count);
|
||||
assert!(jump > 0 && sharp > 0, "tangent seams {jump}, creases {sharp} segments");
|
||||
assert_eq!((smooth, gap), (0, 0));
|
||||
}
|
||||
|
||||
/// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies
|
||||
/// built into triangles on the CPU: the sample design rendered both ways through the
|
||||
/// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one).
|
||||
|
||||
Reference in new issue
Block a user