diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 3018960a..512d86cc 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -121,7 +121,7 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool { /// The scene for a headless render. pub fn scene(s: &Session, cam: &Camera) -> Scene { let st = s.world_state(); - let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() }; + let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..Default::default() }; let b = bounds(s); sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); // Grid on the ground plane (XY with Z up, XZ with Y up). @@ -293,4 +293,19 @@ mod tests { let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count(); assert!(dark > 40, "silhouette pixels: {dark}"); } + + /// A section shows in snapshots: the cut part is gone and the cut face is drawn. + #[test] + fn snapshots_show_the_section() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let cam = home_camera(&s); + let whole = solvecraft_render::render(&scene(&s, &cam), &cam, 200, 140).rgba; + s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); + let sc = scene(&s, &cam); + assert!(sc.clip.is_some()); + let cut = solvecraft_render::render(&sc, &cam, 200, 140).rgba; + let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count(); + assert!(changed > 500, "{changed} pixels changed"); + } } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 6d433dde..69ac29aa 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -40,6 +40,9 @@ pub struct Scene { pub background: Option<(Rgb, Rgb)>, /// Scene radius for clipping planes. pub radius: f64, + /// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside + /// shows as a hatched cap. + pub clip: Option<(Vec3, f64)>, } /// RGBA pixels plus depth. @@ -109,6 +112,9 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { for (ti, t) in m.triangles.iter().enumerate() { let face = m.tri_face.get(ti).copied(); let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f()); + let wp = t.map(|k| m.positions.get(k as usize).copied().unwrap_or_default()); + // Seen from inside (only where a section cuts the body open). + let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(view_dir) < 0.0; let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else { continue; }; @@ -134,6 +140,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { continue; } + if let Some((cn, cd)) = scene.clip + && cn.dot(wp[0] * w0 + wp[1] * w1 + wp[2] * w2) > cd + { + continue; + } let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32; let i = y * sw + x; if c.depth.get(i).is_some_and(|d| z < *d) { @@ -141,6 +152,12 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { *d = z; } let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z); + if scene.clip.is_some() && inside { + // The cut: the body's colour, hatched at 45 degrees. + let k = if ((x + y) / SS) % 9 < 2 { 0.5 } else { 0.8 }; + c.put(x, y, base.map(|v| v * k), 1.0); + continue; + } let mut col = shade(n, view_dir, base); let ndv = n.dot(view_dir).abs(); if curved && ndv < 0.14 { @@ -165,6 +182,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { let steps = (len.ceil() as usize).clamp(1, 20_000); for k in 0..=steps { let t = k as f64 / steps as f64; + if let Some((cn, cd)) = scene.clip + && cn.dot(*p + (*q - *p) * t) > cd + 1e-6 + { + continue; + } let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t); let r = half.ceil() as i64; for dy in -r..=r { @@ -268,6 +290,7 @@ mod tests { lines: vec![], background: None, radius: 20.0, + clip: None, }; let mut cam = Camera { distance: 50.0, ..Default::default() }; cam.set_view(StandardView::Front); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 669ea899..beef00be 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -328,9 +328,11 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 0.5) { - // The cut face: the cap colour, hatched at 45 degrees on screen. + // The cut face in its body's colour (as Fusion's "from component"), tinted toward the + // cap colour so it reads as a cut, hatched at 45 degrees on screen. + let base = mix(i.c.rgb, u.cap.rgb, 0.35); let h = fract((i.pos.x + i.pos.y) / 9.0); - return out_color(vec4(select(u.cap.rgb, u.cap.rgb * 0.62, h < 0.22), u.cap.a)); + return out_color(vec4(select(base, base * 0.6, h < 0.22), 1.0)); } if (u.ana.x > 0.5 && length(i.n) > 0.5) { return analysis(i, k);