From a00153fc99fcbeea4e455af79b522d517d8a9792 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 21:37:21 +0000 Subject: [PATCH] Snapshots show appearances: the CPU renderer colours bodies by their appearance (or imported colour) and faces with looks of their own, as the viewport does Fixes #4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- apps/solvecraft-cli/src/main.rs | 2 +- crates/engine/src/view.rs | 37 ++++++++++++++++++++++++++++++++- crates/render/src/raster.rs | 16 ++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 674f4925..db62942a 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -239,7 +239,7 @@ fn icon_png(size: usize) -> Result, String> { let px = size as f64 / 256.0; for body in &s.world_state().bodies { let mesh = body.mesh(); - scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240) }); + scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() }); for (i, e) in mesh.edges.iter().enumerate() { if !mesh.seams.get(i).copied().unwrap_or(false) { scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false }); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index d9485512..18c7ed72 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -142,7 +142,17 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { for body in &st.bodies { let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); let mesh = body.mesh(); - sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } }); + // The body's appearance (or imported colour) and faces with looks of their own, as in + // the viewport. + let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + let color = + if selected { colors::BODY_SELECTED } else { s.doc.body_color(body).map_or(colors::BODY, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) }; + let face_colors = if selected { + Vec::new() + } else { + s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect() + }; + sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors }); for (i, e) in mesh.edges.iter().enumerate() { if mesh.seams.get(i).copied().unwrap_or(false) { continue; @@ -236,3 +246,28 @@ fn constructs(s: &Session) -> Vec<(u64, String, solvecraft_doc::construct::Const }) .collect() } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and + /// faces with looks of their own. + #[test] + fn snapshot_scene_carries_appearances() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + s.execute("solid.cylinder", &json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})).unwrap(); + s.execute("appearance.assign", &json!({"bodies": ["Body2"], "color": [220, 60, 40]})).unwrap(); + s.execute("appearance.assign", &json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})).unwrap(); + let cam = home_camera(&s); + let sc = scene(&s, &cam); + assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40))); + assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80)))); + // And the rendered picture has the green top. + let img = solvecraft_render::render(&sc, &cam, 200, 140); + assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels"); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 1a260700..82cc6b64 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -19,6 +19,8 @@ impl Rgb { pub struct SceneMesh { pub mesh: Arc, pub color: Rgb, + /// Faces in colours of their own: (B-rep face index, colour). + pub face_colors: Vec<(u32, Rgb)>, } pub struct SceneLine { @@ -102,9 +104,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { let view_dir = cam.back(); for sm in &scene.meshes { let m = &sm.mesh; - let base = sm.color.f(); + let body = sm.color.f(); let pts: Vec> = m.positions.iter().map(|p| project(*p)).collect(); - for t in &m.triangles { + 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 (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; }; @@ -248,8 +252,12 @@ mod tests { seams: vec![], edge_faces: vec![], }; - let scene = - Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 }; + let scene = Scene { + meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30), face_colors: Vec::new() }], + lines: vec![], + background: None, + radius: 20.0, + }; let mut cam = Camera { distance: 50.0, ..Default::default() }; cam.set_view(StandardView::Front); let c = render(&scene, &cam, 64, 64);