mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 19:45:54 +00:00
dc38349cbf
* Kernel: the B-rep record and the native body file come from solidcore-record SolidCraft's portable B-rep record (`solidcraft_geom::brep`: brep.rs, topology, history, envelope, part, curve2) and the native body file (`solidcraft_kernel::native`: the tagged binary codec and the container) move to solidcore's `solidcore-record` crate: geometry as data, with structural validation and no maths (solidcore's docs/layers.md, stage 1). Every caller uses `solidcore_record::brep` and `solidcore_record::native`; the old copies are deleted. What computes from coordinates stays with the kernel, in geom's `brep`: - `brep::check`: the record's geometric checks (orthonormal frames, non-degenerate planes, unit revolution axes, non-singular placements). `check::validate` and `check::validate_built` are the record's validation and these together, and every caller of `Brep::validate` uses them, so what is accepted is unchanged; - `brep::label`: a surface's natural ranges (a cone's apex), `curve_interval`, `edge_curve_interval` and `form_turns`; - `brep::pcurve::eval`: a pcurve's point; - `geometry::check`, `placement::moved` and `split_edges` become functions on the record. The kernel's native module keeps the conversion between a body and its saved record (metadata.rs) and checks the decoded record's geometry. `native/analytic.rs`, which no module declared, is deleted. solidcore-record is pinned to its rev on solidcore's `layer-record` branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT * Geometry: Vec2, Vec3, Plane and the profile types come from solidcore-record The plain data of vectors and points, sketch planes and planar profiles (`Seg2`, `Loop2`, `Region2`) moves to `solidcore-record`, with its constants and component-wise operators only (owner decision, solidcore layer split). Their geometry stays in geom as traits: `Vec2Geom`, `Vec3Geom`, `PlaneGeom`, `Seg2Geom`, `Loop2Geom`, `Region2Geom`, `Seg2ArcFit`, `Loop2ArcFit` and `Region2ArcFit`, in scope with `use solidcraft_geom::prelude::*`. Every caller names the types from `solidcore_record`; geom no longer defines or re-exports them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT * Kernel: pin solidcore-record to solidcore main f5e8e9a29 solidcore-record is merged on solidcore main (tangency-labs/solidcore#2519); its crate there is identical to the rev pinned before but for its clippy configuration. The lock is regenerated against the merged rev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
749 lines
42 KiB
Rust
749 lines
42 KiB
Rust
//! Depth in headless pictures, drawn with the viewport's pipelines (#1434, #1739): an edge behind
|
||
//! a face stays hidden however large the design and however far the camera, and whatever lies on
|
||
//! a face (its edges, a thin label) draws over it at any angle, with a section shown or not, and a
|
||
//! section's cap hides what lies behind it (#6328). Each check runs with the depth the
|
||
//! device draws with (a 32-bit float buffer, reversed, where it has one) and with the 24-bit
|
||
//! fallback. Skipped, with the reason printed, without a GPU adapter (`SOLIDCRAFT_REQUIRE_GPU=1`
|
||
//! makes that a failure).
|
||
|
||
use serde_json::json;
|
||
use solidcore_record::Vec3;
|
||
use solidcraft_engine::Session;
|
||
use solidcraft_engine::geom::Mesh;
|
||
use solidcraft_engine::render::{Camera, Rgb, Scene, SceneLine, SceneMesh, StandardView};
|
||
use solidcraft_engine::view::{self, Guides};
|
||
use solidcraft_geom::prelude::*;
|
||
use solidcraft_gpu::GpuError;
|
||
use solidcraft_gpu::capture::{self, Capturer};
|
||
use std::sync::{Arc, OnceLock};
|
||
|
||
fn or_skip(c: Result<&'static Capturer, &'static GpuError>) -> Option<&'static Capturer> {
|
||
match c {
|
||
Ok(c) => Some(c),
|
||
Err(e @ GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||
eprintln!("skipping: {e}");
|
||
None
|
||
}
|
||
Err(e) => panic!("{e}"),
|
||
}
|
||
}
|
||
|
||
/// The device's own depth, then the 24-bit fallback (the same device kind, its own instance).
|
||
fn capturers() -> Vec<(&'static str, &'static Capturer)> {
|
||
static FALLBACK: OnceLock<solidcraft_gpu::Result<Capturer>> = OnceLock::new();
|
||
let Some(own) = or_skip(capture::shared()) else { return Vec::new() };
|
||
let Some(fallback) = or_skip(FALLBACK.get_or_init(|| Capturer::headless_with_depth(wgpu::TextureFormat::Depth24PlusStencil8)).as_ref()) else {
|
||
return Vec::new();
|
||
};
|
||
eprintln!("GPU: {}, depth {:?} and {:?}", own.adapter_name(), own.depth_format(), fallback.depth_format());
|
||
vec![("own", own), ("24-bit", fallback)]
|
||
}
|
||
|
||
fn reversed(c: &Capturer) -> bool {
|
||
solidcraft_gpu::view::reversed_depth(Some(c.depth_format()))
|
||
}
|
||
|
||
/// RGBA pixels as `[r, g, b, a]`.
|
||
fn pixels(img: &[u8]) -> impl Iterator<Item = [u8; 4]> + '_ {
|
||
img.as_chunks::<4>().0.iter().copied()
|
||
}
|
||
|
||
fn red(p: [u8; 4]) -> bool {
|
||
p[0] > 150 && p[1] < 90 && p[2] < 90
|
||
}
|
||
|
||
fn session(script: &[(&str, serde_json::Value)]) -> Session {
|
||
let mut s = Session::default();
|
||
for (c, p) in script {
|
||
s.execute(c, p).unwrap();
|
||
}
|
||
s
|
||
}
|
||
|
||
fn picture(c: &Capturer, s: &Session, cam: &Camera, w: u32, h: u32) -> Vec<u8> {
|
||
c.rgba(&view::scene_with_guides(s, cam, None, Guides { grid: false, axes: false }).unwrap(), cam, w, h).unwrap()
|
||
}
|
||
|
||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||
pixels(a).zip(pixels(b)).filter(|(p, q)| p.iter().zip(q).any(|(x, y)| x.abs_diff(*y) > 24)).count()
|
||
}
|
||
|
||
/// The reproduction of #1434: a 20 mm plate with a washer 22–25 mm behind its front face, and a
|
||
/// 30 m slab far away that makes the design 34 m across. The washer never shows: the picture is
|
||
/// the same without it. (Lines were pulled toward the eye by 2e-4 of the depth range, about
|
||
/// 27 mm here in orthographic.)
|
||
#[test]
|
||
fn a_washer_behind_a_plate_stays_hidden_on_a_large_design() {
|
||
let plate = [
|
||
("solid.box", json!({"length": 30000, "width": 8000, "height": 100, "corner": [-15000, 3000, -3000], "body_name": "slab"})),
|
||
("solid.box", json!({"length": 200, "width": 20, "height": 200, "corner": [0, 0, 0], "body_name": "plate"})),
|
||
("solid.cylinder", json!({"radius": 8, "height": 60, "base": [100, -20, 100], "axis": "Y", "body_name": "bolt"})),
|
||
];
|
||
let washer = ("solid.cylinder", json!({"radius": 15, "height": 3, "base": [100, 22, 100], "axis": "Y", "body_name": "washer"}));
|
||
let without = session(&plate);
|
||
let with = session(&[plate[0].clone(), plate[1].clone(), plate[2].clone(), washer]);
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
let cam = Camera { target: Vec3::new(100.0, 10.0, 100.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 450.0, fov, ..Default::default() };
|
||
let n = differing(&picture(c, &with, &cam, 400, 300), &picture(c, &without, &cam, 400, 300));
|
||
assert_eq!(n, 0, "{mode} depth, fov {fov}: the washer shows through the plate in {n} pixels");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #1434's second reproduction, in perspective at 10 m: a 2 m plate, a 600 mm washer 12–15 mm
|
||
/// behind its front face, and a red label 0.3 mm thick on it. The washer stays hidden in both
|
||
/// depths; with reversed float depth the label is red all over (in 24 bits one depth step there
|
||
/// is about 0.6 mm, so the label speckled).
|
||
#[test]
|
||
fn at_ten_metres_a_thin_label_is_whole_and_a_washer_behind_stays_hidden() {
|
||
let base = [
|
||
("solid.box", json!({"length": 2000, "width": 10, "height": 2000, "corner": [0, 0, 0], "body_name": "plate"})),
|
||
("solid.box", json!({"length": 1000, "width": 0.3, "height": 400, "corner": [500, -0.3, 1400], "body_name": "label"})),
|
||
("appearance.assign", json!({"bodies": ["label"], "color": [204, 34, 34]})),
|
||
];
|
||
let washer = ("solid.cylinder", json!({"radius": 300, "height": 3, "base": [1000, 12, 1000], "axis": "Y", "body_name": "washer"}));
|
||
let without = session(&base);
|
||
let with = session(&[base[0].clone(), base[1].clone(), base[2].clone(), washer]);
|
||
let cam =
|
||
Camera { target: Vec3::new(1000.0, 5.0, 1000.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 10_000.0, fov: 0.6, ..Default::default() };
|
||
let (w, h) = (800, 600);
|
||
for (mode, c) in capturers() {
|
||
let img = picture(c, &with, &cam, w, h);
|
||
let n = differing(&img, &picture(c, &without, &cam, w, h));
|
||
assert_eq!(n, 0, "{mode} depth: the washer shows through the plate in {n} pixels");
|
||
if !reversed(c) {
|
||
continue;
|
||
}
|
||
// The label's front face (y = -0.3), sampled inside its outline.
|
||
let radius = view::scene(&with, &cam).unwrap().radius;
|
||
let mut grey = Vec::new();
|
||
for i in 1..20 {
|
||
for j in 1..20 {
|
||
let p = Vec3::new(500.0 + 1000.0 * f64::from(i) / 20.0, -0.3, 1400.0 + 400.0 * f64::from(j) / 20.0);
|
||
let Some((x, y, _)) = cam.to_screen(p, f64::from(w), f64::from(h), radius) else { continue };
|
||
let at = (y as usize * w as usize + x as usize) * 4;
|
||
if let Some(px) = img.get(at..at + 4).and_then(|p| <[u8; 4]>::try_from(p).ok())
|
||
&& !red(px)
|
||
{
|
||
grey.push((x as u32, y as u32, px));
|
||
}
|
||
}
|
||
}
|
||
assert!(grey.is_empty(), "{mode} depth: the label speckles at {} of 361 points: {:?}", grey.len(), &grey[..grey.len().min(5)]);
|
||
}
|
||
}
|
||
|
||
/// A 30 × 40 mm sheet at y = z·tan(`tilt`) (facing the front camera when `tilt` is 0).
|
||
fn sheet(tilt: f64) -> Mesh {
|
||
let t = tilt.tan();
|
||
let c = [(-15.0, -20.0), (15.0, -20.0), (15.0, 20.0), (-15.0, 20.0)].map(|(x, z)| Vec3::new(x, z * t, z));
|
||
Mesh {
|
||
positions: c.to_vec(),
|
||
normals: vec![Vec3::new(0.0, -tilt.cos(), tilt.sin()); 4],
|
||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||
tri_face: vec![0, 0],
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// A front view of `mesh` with `lines`, as part of a design of `radius`, 40 mm high on 200 px.
|
||
/// `section`: with a section that cuts nothing shown, so the section's own passes draw the faces.
|
||
fn front(mesh: Mesh, lines: Vec<SceneLine>, radius: f64, fov: f64, section: bool) -> (Scene, Camera) {
|
||
let clip = section.then_some((Vec3::X, 1e3));
|
||
let scene = Scene { meshes: vec![SceneMesh::solid(Arc::new(mesh), Rgb(160, 165, 175))], lines, radius, clip, ..Default::default() };
|
||
let mut cam = Camera { distance: if fov > 0.0 { 25.0 / (fov * 0.5).tan() } else { 62.5 }, fov, ..Default::default() };
|
||
cam.set_view(StandardView::Front);
|
||
(scene, cam)
|
||
}
|
||
|
||
fn red_line(a: Vec3, b: Vec3, on_top: bool) -> SceneLine {
|
||
SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 1.2, on_top }
|
||
}
|
||
|
||
fn red_pixels(c: &Capturer, scene: &Scene, cam: &Camera) -> usize {
|
||
pixels(&c.rgba(scene, cam, 200, 200).unwrap()).filter(|p| red(*p)).count()
|
||
}
|
||
|
||
/// An edge lying on a face draws its whole width over it, on a face seen square and on one turned
|
||
/// to 80°, in orthographic and perspective, in a small design and a large one, with a section shown
|
||
/// or not (#6328: the section's marking pass draws the faces unpushed, then pushes them).
|
||
#[test]
|
||
fn an_edge_on_a_face_draws_whole_at_any_angle() {
|
||
for (mode, c) in capturers() {
|
||
for tilt in [0.0_f64, 60.0, 80.0] {
|
||
let t = tilt.to_radians().tan();
|
||
// Across the sheet, at z = 5: y = 5·tan(tilt).
|
||
let (a, b) = (Vec3::new(-12.0, 5.0 * t, 5.0), Vec3::new(12.0, 5.0 * t, 5.0));
|
||
for radius in [30.0, 17_000.0] {
|
||
for (fov, section) in [0.0, 0.6].into_iter().flat_map(|fov| [(fov, false), (fov, true)]) {
|
||
let (on, cam) = front(sheet(tilt.to_radians()), vec![red_line(a, b, false)], radius, fov, section);
|
||
let (top, _) = front(sheet(tilt.to_radians()), vec![red_line(a, b, true)], radius, fov, section);
|
||
let (shown, all) = (red_pixels(c, &on, &cam), red_pixels(c, &top, &cam));
|
||
assert!(all > 50, "{mode}: the line itself ({all} px)");
|
||
assert!(
|
||
shown * 10 >= all * 9,
|
||
"{mode} depth, tilt {tilt}°, radius {radius}, fov {fov}, section {section}: {shown} of {all} px drawn"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A selected edge's highlight from `a` to `b` on a face of normal `n`, in a highlight scene of
|
||
/// its own: a 3 px core in a 6 px halo (`GpuScene::lifted_line`).
|
||
fn highlight(a: Vec3, b: Vec3, n: Vec3) -> solidcraft_gpu::view::GpuScene {
|
||
let mut sc = solidcraft_gpu::view::GpuScene::default();
|
||
let red = sc.color([255, 0, 0, 255]);
|
||
sc.lifted_line(a.to_f32(), b.to_f32(), red, 6.0, [n.to_f32(), n.to_f32()]);
|
||
sc
|
||
}
|
||
|
||
/// A selected edge's highlight (a 3 px core in a 6 px halo, drawn by `GpuScene::lifted_line`
|
||
/// in the highlight scene) lying on a face seen almost edge on draws its whole width: the face
|
||
/// no longer buries part of the band, which showed as a thin wedge instead of a line. The same
|
||
/// band 5 mm behind a face seen square, beside faces seen square, stays hidden.
|
||
#[test]
|
||
fn a_selected_edge_on_a_grazing_face_draws_whole() {
|
||
let reference = |a: Vec3, b: Vec3| vec![SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 6.0, on_top: true }];
|
||
for (mode, c) in capturers() {
|
||
for tilt in [60.0_f64, 80.0, 85.0, 88.0] {
|
||
let tr = tilt.to_radians();
|
||
let t = tr.tan();
|
||
let n = Vec3::new(0.0, -tr.cos(), tr.sin());
|
||
let (a, b) = (Vec3::new(-12.0, 5.0 * t, 5.0), Vec3::new(12.0, 5.0 * t, 5.0));
|
||
// The small design holds the edge, 5·tan(tilt) behind the sheet's middle: at 88° that
|
||
// is 143 mm, past the far plane of a design of radius 30, where the reference band drew
|
||
// nothing (the check only passed by counting a band left from another picture).
|
||
for radius in [(5.0 * t).max(30.0), 17_000.0] {
|
||
for fov in [0.0, 0.6] {
|
||
let (plain, cam) = front(sheet(tr), Vec::new(), radius, fov, false);
|
||
let (top, _) = front(sheet(tr), reference(a, b), radius, fov, false);
|
||
let shown = pixels(&c.rgba_highlighted(&plain, Some(highlight(a, b, n)), &cam, 200, 200).unwrap()).filter(|p| red(*p)).count();
|
||
let all = red_pixels(c, &top, &cam);
|
||
assert!(all > 100, "{mode}: the band itself ({all} px)");
|
||
assert!(shown * 10 >= all * 9, "{mode} depth, tilt {tilt}°, radius {radius}, fov {fov}: {shown} of {all} px drawn");
|
||
}
|
||
}
|
||
}
|
||
for radius in [30.0, 17_000.0] {
|
||
for fov in [0.0, 0.6] {
|
||
let (a, b) = (Vec3::new(-12.0, 5.0, 5.0), Vec3::new(12.0, 5.0, 5.0));
|
||
let (plain, cam) = front(sheet(0.0), Vec::new(), radius, fov, false);
|
||
let img = c.rgba_highlighted(&plain, Some(highlight(a, b, Vec3::new(0.0, -1.0, 0.0))), &cam, 200, 200).unwrap();
|
||
let n = pixels(&img).filter(|p| red(*p)).count();
|
||
assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}: the band behind shows in {n} px");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A picture drawn without a highlight shows none of the highlight of the picture before it. The
|
||
/// capturer kept its last highlight scene, so a band drawn for one check showed in the next
|
||
/// picture on the same capturer, and the checks here, which share their capturers across test
|
||
/// threads, failed at random: an edge "192 of 612 px drawn", a washer through its plate.
|
||
#[test]
|
||
fn a_picture_without_a_highlight_shows_none_of_the_one_before() {
|
||
for (mode, c) in capturers() {
|
||
let (plain, cam) = front(sheet(0.0), Vec::new(), 30.0, 0.0, false);
|
||
let (a, b) = (Vec3::new(-12.0, 0.0, 5.0), Vec3::new(12.0, 0.0, 5.0));
|
||
let highlighted = c.rgba_highlighted(&plain, Some(highlight(a, b, Vec3::new(0.0, -1.0, 0.0))), &cam, 200, 200).unwrap();
|
||
let band = pixels(&highlighted).filter(|p| red(*p)).count();
|
||
assert!(band > 100, "{mode}: the band itself ({band} px)");
|
||
let after = red_pixels(c, &plain, &cam);
|
||
assert_eq!(after, 0, "{mode}: the picture after the highlighted one shows its band in {after} px");
|
||
}
|
||
}
|
||
|
||
/// An edge 0.5 mm behind a face seen square is hidden, however large the design, with a section
|
||
/// shown or not: lines were pulled toward the eye by a share of the depth range, about 27 mm in a
|
||
/// design 34 m across.
|
||
#[test]
|
||
fn an_edge_just_behind_a_face_stays_hidden_on_a_large_design() {
|
||
for (mode, c) in capturers() {
|
||
for radius in [30.0, 17_000.0] {
|
||
for (fov, section) in [0.0, 0.6].into_iter().flat_map(|fov| [(fov, false), (fov, true)]) {
|
||
// The sheet is at y = 0; the camera looks along +y, so behind is +y.
|
||
let (a, b) = (Vec3::new(-12.0, 0.5, 5.0), Vec3::new(12.0, 0.5, 5.0));
|
||
let (scene, cam) = front(sheet(0.0), vec![red_line(a, b, false)], radius, fov, section);
|
||
let n = red_pixels(c, &scene, &cam);
|
||
assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}, section {section}: the edge behind shows in {n} px");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The application's device draws with reversed float depth where it can: every desktop backend
|
||
/// this runs on (D3D12, Vulkan, Metal) has `Depth32FloatStencil8`.
|
||
#[test]
|
||
fn the_device_draws_with_float_depth_where_it_has_it() {
|
||
let Some(c) = or_skip(capture::shared()) else { return };
|
||
eprintln!("GPU: {}, depth {:?}", c.adapter_name(), c.depth_format());
|
||
let float = c.depth_format() == wgpu::TextureFormat::Depth32FloatStencil8;
|
||
assert_eq!(float, reversed(c));
|
||
if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_some() {
|
||
assert!(float, "a desktop GPU without Depth32FloatStencil8: {:?}", c.depth_format());
|
||
}
|
||
}
|
||
|
||
/// What lies on a section's cut face draws over the cap, which writes its own depth (so the GPU's
|
||
/// push does not apply to it; the shader pushes it the same way).
|
||
#[test]
|
||
fn an_edge_on_the_cut_face_draws_over_the_cap() {
|
||
let mut s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]);
|
||
// Cut away the front (y < 15), so the iso camera looks at the cut face at y = 15.
|
||
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
let mut cam = view::home_camera(&s).unwrap();
|
||
cam.fov = fov;
|
||
let count = |on_top: bool| {
|
||
// The session's section (`scene_with_guides` takes its own). The line is 2 µm on
|
||
// the kept side: exactly on the plane, rounding cuts parts of it away with the rest.
|
||
let mut sc = view::scene(&s, &cam).unwrap();
|
||
sc.lines.push(red_line(Vec3::new(5.0, 15.002, 10.0), Vec3::new(35.0, 15.002, 10.0), on_top));
|
||
pixels(&c.rgba(&sc, &cam, 300, 220).unwrap()).filter(|p| red(*p)).count()
|
||
};
|
||
let (shown, all) = (count(false), count(true));
|
||
assert!(all > 50, "{mode}: the line itself ({all} px)");
|
||
assert!(shown * 10 >= all * 9, "{mode} depth, fov {fov}: {shown} of {all} px drawn over the cap");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A cut body's cap covers the body it touches behind the cut. A block whose far face (y = 30)
|
||
/// lies on a larger plate's front face, cut at y = 15: through the cut the block's far wall and
|
||
/// the plate's face are at one depth. When the cap went to the nearer of the two by depth, the
|
||
/// plate showed through 2721 of its 4350 px here (an engine's sleeves in their bores, pins in
|
||
/// their bosses); counted by crossings, the cap is the same with the plate as without it. An
|
||
/// open body behind it (a box without its far face, whose near face alone the ray crosses)
|
||
/// takes nothing from the cap either: only closed bodies are counted.
|
||
#[test]
|
||
fn a_cap_covers_the_body_its_cut_body_touches() {
|
||
let block = ("solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "block"}));
|
||
let plate = ("solid.box", json!({"length": 70, "width": 10, "height": 50, "corner": [-15, 30, -15], "operation": "new", "body_name": "plate"}));
|
||
let shell = ("solid.box", json!({"length": 70, "width": 10, "height": 50, "corner": [-15, 35, -15], "operation": "new", "body_name": "shell"}));
|
||
let far_face = ("face.delete", json!({"faces": [[20, 45, 10]], "heal": false}));
|
||
let cut = ("inspect.section", json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}}));
|
||
let alone = session(&[block.clone(), cut.clone()]);
|
||
let touching = session(&[block.clone(), plate, cut.clone()]);
|
||
let open = session(&[block, shell, far_face, cut]);
|
||
assert!(open.world_state().unwrap().body("shell").unwrap().body.is_surface());
|
||
let (w, h) = (360, 280);
|
||
for (mode, c) in capturers() {
|
||
for (what, other) in [("the touching plate", &touching), ("an open shell behind", &open)] {
|
||
for fov in [0.0, 0.6] {
|
||
let mut cam = view::home_camera(&touching).unwrap();
|
||
(cam.fov, cam.yaw, cam.pitch) = (fov, -0.3, 0.2);
|
||
let radius = view::scene(&touching, &cam).unwrap().radius;
|
||
let shot = |s: &Session| {
|
||
c.rgba(&Scene { grid: Vec::new(), lines: Vec::new(), radius, ..view::scene(s, &cam).unwrap() }, &cam, w, h).unwrap()
|
||
};
|
||
let (a, b) = (shot(&alone), shot(other));
|
||
// The cap on screen: the cut's rectangle (x 0..40, z 0..20 at y = 15), 2 px in.
|
||
let corner = |x: f64, z: f64| cam.to_screen(Vec3::new(x, 15.0, z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y));
|
||
let cap: Vec<(f64, f64)> = [(0.0, 0.0), (40.0, 0.0), (40.0, 20.0), (0.0, 20.0)].iter().filter_map(|(x, z)| corner(*x, *z)).collect();
|
||
assert_eq!(cap.len(), 4, "fov {fov}: the cut is on screen");
|
||
let turn = (0..4).map(|i| cap[i].0 * cap[(i + 1) % 4].1 - cap[(i + 1) % 4].0 * cap[i].1).sum::<f64>().signum();
|
||
let inside = |x: f64, y: f64| {
|
||
(0..4).all(|i| {
|
||
let ((ax, ay), (bx, by)) = (cap[i], cap[(i + 1) % 4]);
|
||
((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay) * turn >= 2.0
|
||
})
|
||
};
|
||
let (mut shown, mut all) = (0, 0);
|
||
for (i, (p, q)) in pixels(&a).zip(pixels(&b)).enumerate() {
|
||
if inside((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5) {
|
||
all += 1;
|
||
shown += usize::from(p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 24));
|
||
}
|
||
}
|
||
assert!(all > 3000, "{mode}, fov {fov}: the cap ({all} px)");
|
||
assert_eq!(shown, 0, "{mode} depth, fov {fov}: {what} shows through {shown} of {all} cap px");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pixels of the model's edges lying behind the section plane (recoloured red) that draw over
|
||
/// the cap: the cap's pixels are where the picture without lines shows the hatch (warm, with
|
||
/// warm pixels 3 px around, so the cut's outline doesn't count).
|
||
fn edges_through_the_cap(c: &Capturer, s: &Session, cam: &Camera, (origin, normal): (Vec3, Vec3), (w, h): (u32, u32)) -> usize {
|
||
let mut sc = view::scene(s, cam).unwrap();
|
||
let behind = |p: &Vec3| (*p - origin).dot(normal) < -0.5;
|
||
let segments = sc
|
||
.lines
|
||
.iter()
|
||
.flat_map(|l| l.points.windows(2).filter(|w| w.iter().all(behind)).map(|w| SceneLine { points: w.to_vec(), color: Rgb(255, 0, 0), ..*l }))
|
||
.collect();
|
||
sc.lines = segments;
|
||
let with = c.rgba(&sc, cam, w, h).unwrap();
|
||
sc.lines.clear();
|
||
let without = c.rgba(&sc, cam, w, h).unwrap();
|
||
let warm = |x: i64, y: i64| {
|
||
let at = (y * i64::from(w) + x) as usize * 4;
|
||
without.get(at..at + 3).is_some_and(|p| p[0] > 60 && p[0] > p[2].saturating_add(15))
|
||
};
|
||
let mut n = 0;
|
||
for y in 3..i64::from(h) - 3 {
|
||
for x in 3..i64::from(w) - 3 {
|
||
let at = (y * i64::from(w) + x) as usize * 4;
|
||
let cap = [(0, 0), (3, 0), (-3, 0), (0, 3), (0, -3)].iter().all(|(dx, dy)| warm(x + dx, y + dy));
|
||
// Redder than the cap there: a red line, whole or blended at its sides.
|
||
let redness = |img: &[u8]| img.get(at..at + 3).map_or(0, |p| i32::from(p[0]) - i32::from(p[1]));
|
||
if cap && redness(&with) > redness(&without) + 30 {
|
||
n += 1;
|
||
}
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
/// The cap hides the edges inside a cut body (#6328). Where a face seen through the cut meets a
|
||
/// face turned toward the eye along an edge (a pocket's front wall and its side wall or floor),
|
||
/// the faces' push left the cap out beside the edge, and the edge showed through it. A pocket
|
||
/// whose front wall is 2 mm behind the cut, in perspective and orthographic.
|
||
#[test]
|
||
fn edges_of_a_pocket_behind_the_cut_stay_under_the_cap() {
|
||
let mut s = session(&[
|
||
("solid.box", json!({"length": 40, "width": 40, "height": 30, "corner": [-20, -20, 0]})),
|
||
("solid.box", json!({"length": 16, "width": 16, "height": 18, "corner": [-14, -14, 12], "operation": "cut"})),
|
||
]);
|
||
let plane = (Vec3::new(0.0, -16.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
|
||
s.execute("inspect.section", &json!({"plane": {"origin": [0, -16, 0], "normal": [0, -1, 0]}})).unwrap();
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
let mut cam = view::home_camera(&s).unwrap();
|
||
cam.fov = fov;
|
||
let n = edges_through_the_cap(c, &s, &cam, plane, (360, 300));
|
||
assert!(n <= 2, "{mode} depth, fov {fov}: the pocket's edges show through the cap in {n} px");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The same while the view turns: a filleted hub with a bore and a cross hole, cut through the
|
||
/// middle, seen from twelve directions. Its fillets' and holes' edges came and went through the
|
||
/// cap from one step to the next.
|
||
#[test]
|
||
fn edges_of_a_cut_hub_stay_under_the_cap_as_the_view_turns() {
|
||
let mut s = session(&[
|
||
("solid.cylinder", json!({"radius": 20, "height": 40, "body_name": "hub"})),
|
||
("solid.fillet", json!({"edges": [[20, 0, 40], [20, 0, 0]], "radius": 4})),
|
||
("solid.cylinder", json!({"radius": 8, "height": 30, "base": [0, 0, 10], "operation": "cut"})),
|
||
("solid.cylinder", json!({"radius": 3, "height": 50, "base": [-25, 0, 22], "axis": "X", "operation": "cut"})),
|
||
("solid.fillet", json!({"edges": [[8, 0, 40]], "radius": 1.5})),
|
||
]);
|
||
let plane = (Vec3::new(0.0, -2.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
|
||
s.execute("inspect.section", &json!({"plane": {"origin": [0, -2, 0], "normal": [0, -1, 0]}})).unwrap();
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
let mut shown = Vec::new();
|
||
for i in 0..12 {
|
||
let mut cam = view::home_camera(&s).unwrap();
|
||
(cam.fov, cam.yaw, cam.pitch) = (fov, -1.2 + 0.075 * f64::from(i), 0.35);
|
||
shown.push(edges_through_the_cap(c, &s, &cam, plane, (900, 760)));
|
||
}
|
||
let total: usize = shown.iter().sum();
|
||
assert!(total <= 6, "{mode} depth, fov {fov}: edges show through the cap, px per view {shown:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #6328, bherbruck's block: 40 × 40 × 30 mm with a hole and a pocket, cut on XZ at y = -16, 2 mm in
|
||
/// front of the pocket and 12 mm in front of the hole, seen from the home view. The cap hides them:
|
||
/// it matches the plain block's cap. With the faces' slope push on the section's marking pass
|
||
/// (#1768), the pocket's and the hole's edges showed through it as dashed lines in perspective, 84
|
||
/// of the 18,328 cap pixels here (both depths); before #1768 and with the fix, none. Away from the
|
||
/// cut the plain block's picture is a box of just its kept part's: the push also let back faces win
|
||
/// a band along the far edges, which the cap hatched (58 px here in orthographic, 534 in
|
||
/// perspective; now 0 and 1). The limits leave room for an edge's exact tie, which rounding
|
||
/// settles per sample (one pixel on WARP).
|
||
#[test]
|
||
fn a_cut_hides_the_edges_behind_it() {
|
||
let block = [
|
||
("sketch.create", json!({"plane": "XY", "name": "Base"})),
|
||
("sketch.line", json!({"points": [[-20, -20], [20, -20], [20, 20], [-20, 20]], "closed": true})),
|
||
("sketch.finish", json!({})),
|
||
("solid.extrude", json!({"distance": 30, "body_name": "Block"})),
|
||
];
|
||
let behind = [
|
||
("sketch.create", json!({"plane": "XY", "name": "Hole"})),
|
||
("sketch.circle.center", json!({"center": [10, 0], "diameter": 8})),
|
||
("sketch.finish", json!({})),
|
||
("solid.extrude", json!({"distance": 30, "operation": "cut"})),
|
||
("sketch.create", json!({"plane": "XY", "name": "Pocket", "offset": 30})),
|
||
("sketch.line", json!({"points": [[-14, -14], [2, -14], [2, 2], [-14, 2]], "closed": true})),
|
||
("sketch.finish", json!({})),
|
||
("solid.extrude", json!({"distance": 18, "direction": "negative", "operation": "cut"})),
|
||
];
|
||
let cut = ("inspect.section", json!({"plane": "XZ", "at": -16, "flip": true}));
|
||
let plain = session(&[&block[..], std::slice::from_ref(&cut)].concat());
|
||
let cut_through = session(&[&block[..], &behind[..], &[cut]].concat());
|
||
let (w, h) = (400, 300);
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
let mut cam = view::home_camera(&plain).unwrap();
|
||
cam.fov = fov;
|
||
let radius = view::scene(&plain, &cam).unwrap().radius;
|
||
// The cap on screen: the cut's rectangle, its outline left out (2 px in).
|
||
let corner = |x: f64, z: f64| cam.to_screen(Vec3::new(x, -16.0, z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y));
|
||
let cap: Vec<(f64, f64)> = [(-20.0, 0.0), (20.0, 0.0), (20.0, 30.0), (-20.0, 30.0)].iter().filter_map(|(x, z)| corner(*x, *z)).collect();
|
||
assert_eq!(cap.len(), 4, "fov {fov}: the cut is on screen");
|
||
// Which way the corners run on screen, so a point's distance inside each edge is positive.
|
||
let turn = (0..4).map(|i| cap[i].0 * cap[(i + 1) % 4].1 - cap[(i + 1) % 4].0 * cap[i].1).sum::<f64>().signum();
|
||
let inside_by = |x: f64, y: f64, by: f64| {
|
||
let side = |i: usize| {
|
||
let ((ax, ay), (bx, by)) = (cap[i], cap[(i + 1) % 4]);
|
||
((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay)
|
||
};
|
||
(0..4).all(|i| side(i) * turn >= by)
|
||
};
|
||
let inside = |x: f64, y: f64| inside_by(x, y, 2.0);
|
||
// Without the ground grid, whose extent follows the design.
|
||
let shot = |s: &Session| c.rgba(&Scene { grid: Vec::new(), ..view::scene(s, &cam).unwrap() }, &cam, w, h).unwrap();
|
||
let (a, b) = (shot(&plain), shot(&cut_through));
|
||
let (mut shown, mut all) = (0, 0);
|
||
for (i, (p, q)) in pixels(&a).zip(pixels(&b)).enumerate() {
|
||
if inside((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5) {
|
||
all += 1;
|
||
shown += usize::from(p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 24));
|
||
}
|
||
}
|
||
assert!(all > 5000, "{mode}, fov {fov}: the cap ({all} px)");
|
||
assert!(shown <= 8, "{mode} depth, fov {fov}: {shown} of {all} cap px show what lies behind the cut");
|
||
// Away from the cut, the picture is a box of just the kept part's (no cap along the far
|
||
// edges either).
|
||
let kept = shot(&session(&[("solid.box", json!({"length": 40, "width": 36, "height": 30, "corner": [-20, -16, 0]}))]));
|
||
let fringe = pixels(&a)
|
||
.zip(pixels(&kept))
|
||
.enumerate()
|
||
.filter(|(i, (p, q))| {
|
||
!inside_by((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5, -8.0)
|
||
&& p.iter().zip(q).any(|(u, v)| u.abs_diff(*v) > 24)
|
||
})
|
||
.count();
|
||
assert!(fringe <= 8, "{mode} depth, fov {fov}: {fringe} px away from the cut differ from the kept part alone");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The hatch a picture shows over the pixels `at`: the colour most of them have (the fill,
|
||
/// rounded to 8 levels a channel), and whether the darker lines through it run at 135 degrees
|
||
/// (down to the right on screen) rather than 45.
|
||
fn hatch_seen(img: &[u8], w: u32, at: &[(i64, i64)]) -> ([u8; 3], bool) {
|
||
let px = |x: i64, y: i64| {
|
||
let i = (y * i64::from(w) + x) as usize * 4;
|
||
img.get(i..i + 3).map(|p| [p[0], p[1], p[2]])
|
||
};
|
||
let mut counts = std::collections::HashMap::new();
|
||
for (x, y) in at {
|
||
if let Some(p) = px(*x, *y) {
|
||
*counts.entry(p.map(|c| c / 8)).or_insert(0usize) += 1;
|
||
}
|
||
}
|
||
let fill = counts.into_iter().max_by_key(|(_, n)| *n).map_or([0; 3], |(c, _)| c);
|
||
let line = |x: i64, y: i64| {
|
||
px(x, y).is_some_and(|p| p.iter().map(|c| u32::from(*c)).sum::<u32>() * 8 < fill.iter().map(|c| u32::from(*c) * 8 + 4).sum::<u32>() * 7)
|
||
};
|
||
// Along a 45 degree line (x + y the same) the pixels agree; across it they alternate.
|
||
let agree = |dy: i64| at.iter().filter(|(x, y)| line(*x, *y) && line(x + 1, y + dy)).count();
|
||
(fill, agree(1) > agree(-1))
|
||
}
|
||
|
||
/// Section hatch by part: two blocks side by side, cut through both, touch along the cut, so they
|
||
/// get different hatches (in angle or colour) and the picture shows them apart; the next frame
|
||
/// and a plane moved a little keep each block's hatch. With one hatch for every body (the
|
||
/// preference's old behaviour) both caps are the cap colour at 45 degrees.
|
||
#[test]
|
||
fn touching_caps_are_hatched_apart_and_keep_their_hatch() {
|
||
let left = ("solid.box", json!({"length": 20, "width": 30, "height": 20, "body_name": "left"}));
|
||
let right = ("solid.box", json!({"length": 20, "width": 30, "height": 20, "corner": [20, 0, 0], "operation": "new", "body_name": "right"}));
|
||
let cut = |y: f64| ("inspect.section", json!({"plane": {"origin": [0, y, 0], "normal": [0, -1, 0]}}));
|
||
let s = session(&[left.clone(), right.clone(), cut(15.0)]);
|
||
let moved = session(&[left, right, cut(15.5)]);
|
||
let mut cam = view::home_camera(&s).unwrap();
|
||
(cam.fov, cam.yaw, cam.pitch) = (0.0, -0.3, 0.2);
|
||
let hatches = |s: &Session| view::scene(s, &cam).unwrap().meshes.iter().map(|m| m.hatch).collect::<Vec<_>>();
|
||
let first = hatches(&s);
|
||
assert_eq!(first.len(), 2);
|
||
assert_ne!(first[0], first[1], "touching caps share a hatch");
|
||
assert_eq!(hatches(&s), first, "the next frame keeps them");
|
||
assert_eq!(hatches(&moved), first, "a plane moved a little keeps them");
|
||
let (w, h) = (360, 280);
|
||
// Each block's cap on screen, 4 px in: x 0..20 and 20..40, z 0..20 at y = 15.
|
||
let radius = view::scene(&s, &cam).unwrap().radius;
|
||
let cap_pixels = |x0: f64| -> Vec<(i64, i64)> {
|
||
let corner = |x: f64, z: f64| cam.to_screen(Vec3::new(x, 15.0, z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y));
|
||
let q: Vec<(f64, f64)> = [(x0, 0.0), (x0 + 20.0, 0.0), (x0 + 20.0, 20.0), (x0, 20.0)].iter().filter_map(|(x, z)| corner(*x, *z)).collect();
|
||
assert_eq!(q.len(), 4, "the cut is on screen");
|
||
let turn = (0..4).map(|i| q[i].0 * q[(i + 1) % 4].1 - q[(i + 1) % 4].0 * q[i].1).sum::<f64>().signum();
|
||
let inside = |x: f64, y: f64| {
|
||
(0..4).all(|i| {
|
||
let ((ax, ay), (bx, by)) = (q[i], q[(i + 1) % 4]);
|
||
((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay) * turn >= 4.0
|
||
})
|
||
};
|
||
(0..i64::from(h)).flat_map(|y| (0..i64::from(w)).map(move |x| (x, y))).filter(|(x, y)| inside(*x as f64 + 0.5, *y as f64 + 0.5)).collect()
|
||
};
|
||
let (a, b) = (cap_pixels(0.0), cap_pixels(20.0));
|
||
assert!(a.len() > 1500 && b.len() > 1500, "the caps ({} and {} px)", a.len(), b.len());
|
||
for (mode, c) in capturers() {
|
||
let by_part = c.rgba(&Scene { grid: Vec::new(), ..view::scene(&s, &cam).unwrap() }, &cam, w, h).unwrap();
|
||
let (la, lb) = (hatch_seen(&by_part, w, &a), hatch_seen(&by_part, w, &b));
|
||
assert_ne!(la, lb, "{mode} depth: the touching caps look alike");
|
||
let expect = |hatch: solidcraft_engine::render::hatch::Hatch| hatch.turned();
|
||
assert_eq!((la.1, lb.1), (expect(first[0]), expect(first[1])), "{mode} depth: the caps' angles");
|
||
let one = c.rgba(&Scene { grid: Vec::new(), part_hatch: None, ..view::scene(&s, &cam).unwrap() }, &cam, w, h).unwrap();
|
||
let (oa, ob) = (hatch_seen(&one, w, &a), hatch_seen(&one, w, &b));
|
||
assert_eq!(oa, ob, "{mode} depth: one hatch for every body");
|
||
assert!(!oa.1, "{mode} depth: one hatch runs at 45 degrees");
|
||
}
|
||
}
|
||
|
||
/// A 40 × 20 × 30 mm box centred on the origin, a flat-shaded face per side, and its 12 edges
|
||
/// with the outward normals of the two faces beside each.
|
||
fn shaded_box() -> (Mesh, Vec<(Vec3, Vec3, [Vec3; 2])>) {
|
||
let h = Vec3::new(20.0, 10.0, 15.0);
|
||
let corner = |i: usize| Vec3::new(if i & 1 == 0 { -h.x } else { h.x }, if i & 2 == 0 { -h.y } else { h.y }, if i & 4 == 0 { -h.z } else { h.z });
|
||
// Each side: its normal and its corners, counter-clockwise seen from outside.
|
||
let sides = [
|
||
(Vec3::new(-1.0, 0.0, 0.0), [0, 4, 6, 2]),
|
||
(Vec3::new(1.0, 0.0, 0.0), [1, 3, 7, 5]),
|
||
(Vec3::new(0.0, -1.0, 0.0), [0, 1, 5, 4]),
|
||
(Vec3::new(0.0, 1.0, 0.0), [2, 6, 7, 3]),
|
||
(Vec3::new(0.0, 0.0, -1.0), [0, 2, 3, 1]),
|
||
(Vec3::new(0.0, 0.0, 1.0), [4, 5, 7, 6]),
|
||
];
|
||
let mut mesh = Mesh::default();
|
||
for (f, (n, c)) in sides.iter().enumerate() {
|
||
let base = mesh.positions.len() as u32;
|
||
mesh.positions.extend(c.iter().map(|i| corner(*i)));
|
||
mesh.normals.extend([*n; 4]);
|
||
mesh.triangles.extend([[base, base + 1, base + 2], [base, base + 2, base + 3]]);
|
||
mesh.tri_face.extend([f as u32; 2]);
|
||
}
|
||
let mut edges = Vec::new();
|
||
for (i, (n, c)) in sides.iter().enumerate() {
|
||
for k in 0..4 {
|
||
let (a, b) = (c[k], c[(k + 1) % 4]);
|
||
// Each edge once: from the side that runs it from the lower corner.
|
||
if a > b {
|
||
continue;
|
||
}
|
||
let other = sides.iter().enumerate().find(|(j, (_, d))| *j != i && (0..4).any(|m| d[m] == b && d[(m + 1) % 4] == a));
|
||
if let Some((_, (m, _))) = other {
|
||
edges.push((corner(a), corner(b), [*n, *m]));
|
||
}
|
||
}
|
||
}
|
||
(mesh, edges)
|
||
}
|
||
|
||
/// The shaded-with-hidden-edges style: every edge drawn depth-tested and, in a light colour,
|
||
/// where the model hides it (`GpuScene::hidden_line`). A box turned so one side is seen almost
|
||
/// edge on (yaw 1.565): the side is under a pixel wide, so the edges along it lie within a pixel
|
||
/// of the nearer face beside it, which hides part of their width. Their hidden copy shows nowhere:
|
||
/// drawn after the visible edges, it covered the visible edge of that nearer face with a light
|
||
/// line along some 100 px. The box's back edges, which are hidden, still show.
|
||
#[test]
|
||
fn a_visible_edge_beside_a_face_seen_edge_on_has_no_hidden_copy() {
|
||
let (mesh, edges) = shaded_box();
|
||
let (w, h) = (400, 300);
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
for yaw in [1.565_f64, 0.35] {
|
||
let cam = Camera { yaw, distance: 120.0, fov, ..Default::default() };
|
||
let toward = |p: Vec3| if fov > 0.0 { cam.eye() - p } else { cam.back() };
|
||
let back = |(a, b, n): &(Vec3, Vec3, [Vec3; 2])| n.iter().all(|n| n.dot(toward((*a + *b) * 0.5)) < 0.0);
|
||
let lines = edges
|
||
.iter()
|
||
.map(|(a, b, _)| SceneLine { points: vec![*a, *b], color: Rgb(20, 20, 20), alpha: 1.0, width: 1.3, on_top: false })
|
||
.collect();
|
||
let scene =
|
||
Scene { meshes: vec![SceneMesh::solid(Arc::new(mesh.clone()), Rgb(160, 165, 175))], lines, radius: 40.0, ..Default::default() };
|
||
let hidden = |keep: &dyn Fn(&(Vec3, Vec3, [Vec3; 2])) -> bool| {
|
||
let mut sc = solidcraft_gpu::view::GpuScene::default();
|
||
let col = sc.color([0, 255, 0, 255]);
|
||
for e in edges.iter().filter(|e| keep(e)) {
|
||
sc.hidden_line(e.0.to_f32(), e.1.to_f32(), col, 1.0);
|
||
}
|
||
c.rgba_highlighted(&scene, Some(sc), &cam, w, h).unwrap()
|
||
};
|
||
let green = |p: [u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
|
||
let (all, back_only, none) = (hidden(&|_| true), hidden(&back), hidden(&|_| false));
|
||
let doubled = differing(&all, &back_only);
|
||
assert_eq!(doubled, 0, "{mode} depth, fov {fov}, yaw {yaw}: a visible edge's hidden copy shows in {doubled} px");
|
||
let behind = pixels(&back_only).filter(|p| green(*p)).count();
|
||
assert!(behind > 100, "{mode} depth, fov {fov}, yaw {yaw}: the back edges show in {behind} px");
|
||
assert_eq!(pixels(&none).filter(|p| green(*p)).count(), 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #9407: a shaft in a bore it fits exactly, cut along its axis. Seen through the hub's cut, the
|
||
/// bore's wall (a back face: the hub's cap) and the shaft's side (a front face) are the same
|
||
/// cylinder, each faceted its own way, so neither is always in front: the shaft's side showed
|
||
/// through the hub's cap in streaks along the axis, changing as the view turned. The hub's cap is
|
||
/// the same as without the shaft, from several directions, in orthographic and perspective.
|
||
#[test]
|
||
fn a_shaft_in_its_bore_leaves_the_hubs_cap_whole() {
|
||
let hub = [
|
||
("solid.cylinder", json!({"radius": 20, "height": 40, "body_name": "Hub"})),
|
||
("solid.cylinder", json!({"radius": 8, "height": 40, "operation": "cut"})),
|
||
];
|
||
let shaft = ("solid.cylinder", json!({"radius": 8, "height": 60, "base": [0, 0, -10], "body_name": "Shaft", "operation": "new"}));
|
||
let cut = ("inspect.section", json!({"plane": {"origin": [0, 0, 0], "normal": [0, -1, 0]}}));
|
||
let alone = session(&[&hub[..], std::slice::from_ref(&cut)].concat());
|
||
let fitted = session(&[&hub[..], &[shaft, cut]].concat());
|
||
let (w, h) = (480, 400);
|
||
for (mode, c) in capturers() {
|
||
for fov in [0.0, 0.6] {
|
||
let mut shown = Vec::new();
|
||
for i in 0..6 {
|
||
let mut cam = view::home_camera(&fitted).unwrap();
|
||
(cam.fov, cam.yaw, cam.pitch) = (fov, -1.1 + 0.3 * f64::from(i), 0.3);
|
||
let with = view::scene(&fitted, &cam).unwrap();
|
||
let radius = with.radius;
|
||
// The hub alone, framed as the design with the shaft.
|
||
let without = Scene { radius, ..view::scene(&alone, &cam).unwrap() };
|
||
let shot = |sc: Scene| c.rgba(&Scene { grid: Vec::new(), ..sc }, &cam, w, h).unwrap();
|
||
let (a, b) = (shot(without), shot(with));
|
||
// The hub's cut on screen: a rectangle each side of the bore, its outline left out.
|
||
let rects: Vec<Vec<(f64, f64)>> = [(8.0, 20.0), (-20.0, -8.0)]
|
||
.iter()
|
||
.map(|(x0, x1)| {
|
||
[(*x0, 0.0), (*x1, 0.0), (*x1, 40.0), (*x0, 40.0)]
|
||
.iter()
|
||
.filter_map(|(x, z)| cam.to_screen(Vec3::new(*x, 0.0, *z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y)))
|
||
.collect()
|
||
})
|
||
.collect();
|
||
let inside = |x: f64, y: f64| {
|
||
rects.iter().any(|q| {
|
||
q.len() == 4 && {
|
||
let turn = (0..4).map(|i| q[i].0 * q[(i + 1) % 4].1 - q[(i + 1) % 4].0 * q[i].1).sum::<f64>().signum();
|
||
(0..4).all(|i| {
|
||
let ((ax, ay), (bx, by)) = (q[i], q[(i + 1) % 4]);
|
||
((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay) * turn >= 2.0
|
||
})
|
||
}
|
||
})
|
||
};
|
||
let (mut differ, mut all) = (0, 0);
|
||
for (i, (p, q)) in pixels(&a).zip(pixels(&b)).enumerate() {
|
||
if inside((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5) {
|
||
all += 1;
|
||
differ += usize::from(p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 24));
|
||
}
|
||
}
|
||
assert!(all > 3000, "{mode}, fov {fov}, view {i}: the hub's cap ({all} px)");
|
||
shown.push(differ);
|
||
}
|
||
let total: usize = shown.iter().sum();
|
||
assert!(total <= 12, "{mode} depth, fov {fov}: the shaft shows through the hub's cap, px per view {shown:?}");
|
||
}
|
||
}
|
||
}
|