diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index f87379a0..3bafe47d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -39,6 +39,65 @@ pub mod colors { pub const SECTION_CAP: Rgb = solidcraft_render::SECTION_CAP; } +/// The colours a headless scene is drawn in. [`Palette::DEFAULT`] is [`colors`], the light +/// theme's look, which `solidcraft-cli snapshot`, MCP screenshots and thumbnails keep; the app +/// passes its theme's for `ui.render` and its CPU viewport, so they match the window. Only these +/// defaults change: a body's appearance and a face's own look are the design's, drawn as they are. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Palette { + /// The background gradient, top and bottom. + pub background: (Rgb, Rgb), + pub grid: Rgb, + pub grid_major: Rgb, + /// Body edges, and the outline where the section cuts a body. + pub edge: Rgb, + /// A body without an appearance of its own. + pub body: Rgb, + pub body_selected: Rgb, + /// Sketch curves: under-constrained, fully constrained (in the active sketch), in a finished + /// sketch, fixed, projected, with a lost link, construction, centerline. + pub sketch: Rgb, + pub sketch_fixed: Rgb, + pub sketch_done: Rgb, + pub sketch_locked: Rgb, + pub sketch_projected: Rgb, + pub sketch_lost: Rgb, + pub sketch_construction: Rgb, + pub sketch_centerline: Rgb, + /// The origin axes: X, Y, Z. + pub axes: [Rgb; 3], + /// What the section cap is tinted toward. + pub cap: Rgb, +} + +impl Palette { + /// [`colors`]. + pub const DEFAULT: Palette = Palette { + background: (colors::BG_TOP, colors::BG_BOTTOM), + grid: colors::GRID, + grid_major: colors::GRID_MAJOR, + edge: colors::EDGE, + body: colors::BODY, + body_selected: colors::BODY_SELECTED, + sketch: colors::SKETCH, + sketch_fixed: colors::SKETCH_FIXED, + sketch_done: colors::SKETCH_DONE, + sketch_locked: colors::SKETCH_LOCKED, + sketch_projected: colors::SKETCH_PROJECTED, + sketch_lost: colors::SKETCH_LOST, + sketch_construction: colors::SKETCH_CONSTRUCTION, + sketch_centerline: colors::SKETCH_CENTERLINE, + axes: [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], + cap: colors::SECTION_CAP, + }; +} + +impl Default for Palette { + fn default() -> Self { + Palette::DEFAULT + } +} + /// Bounds of everything visible now, in world space: shown bodies where their occurrences, /// joints and moves place them, and the sketches the view draws (not ones used by a feature, /// hidden ones, or ones in hidden components). What Home, Fit and the orbit pivot frame. @@ -88,7 +147,8 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool sketch_lines_with_sampler(sk, plane, active, determined, world_curve_parts) } -fn world_curve_parts(sk: &Sketch, i: usize, plane: &Plane) -> Vec> { +/// A sketch curve as world polylines: the sampler of [`scene`] and [`sketch_lines`]. +pub fn world_curve_parts(sk: &Sketch, i: usize, plane: &Plane) -> Vec> { sk.segs(i).into_iter().map(|seg| seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect()).collect() } @@ -99,11 +159,23 @@ fn world_curve_parts(sk: &Sketch, i: usize, plane: &Plane) -> Vec> { /// gaps without drawing a line across them. Already placed 3D wires retain their /// existing paths. Sampling changes only display geometry, never the sketch. pub fn sketch_lines_with_sampler( + sk: &Sketch, + plane: &Plane, + active: bool, + determined: &[bool], + sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>, +) -> Vec<(Vec, Rgb, bool)> { + sketch_lines_in(sk, plane, active, determined, sampler, &Palette::DEFAULT) +} + +/// [`sketch_lines_with_sampler`] in the colours of `p`. +fn sketch_lines_in( sk: &Sketch, plane: &Plane, active: bool, determined: &[bool], mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>, + p: &Palette, ) -> Vec<(Vec, Rgb, bool)> { let mut out = Vec::new(); for (i, c) in sk.curves.iter().enumerate() { @@ -111,19 +183,19 @@ pub fn sketch_lines_with_sampler( continue; } let col = if c.link.is_some() && !sk.is_text_curve(i) { - if sk.is_lost_curve(i) { colors::SKETCH_LOST } else { colors::SKETCH_PROJECTED } + if sk.is_lost_curve(i) { p.sketch_lost } else { p.sketch_projected } } else if c.fixed { - colors::SKETCH_LOCKED + p.sketch_locked } else if c.centerline { - colors::SKETCH_CENTERLINE + p.sketch_centerline } else if c.construction { - colors::SKETCH_CONSTRUCTION + p.sketch_construction } else if !active { - colors::SKETCH_DONE + p.sketch_done } else if determined.get(i).copied().unwrap_or(false) { - colors::SKETCH_FIXED + p.sketch_fixed } else { - colors::SKETCH + p.sketch }; for part in sampler(sk, i, plane) { out.push((part, col, c.construction || c.centerline)); @@ -133,18 +205,18 @@ pub fn sketch_lines_with_sampler( // curves to the corner, like construction geometry. for (_, ext) in sk.virtual_corners() { for (a, b) in ext { - out.push((vec![plane.to_world(a), plane.to_world(b)], colors::SKETCH_CONSTRUCTION, true)); + out.push((vec![plane.to_world(a), plane.to_world(b)], p.sketch_construction, true)); } } // 3D curves are in world coordinates already. for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) { // Curves drawn in a 3D sketch look like the sketch's own; included ones are references. let col = if w.fit.is_empty() { - colors::SKETCH_PROJECTED + p.sketch_projected } else if active { - colors::SKETCH + p.sketch } else { - colors::SKETCH_DONE + p.sketch_done }; out.push((w.pts.clone(), col, false)); } @@ -227,19 +299,25 @@ pub fn scene_with_sketch_sampler(s: &Session, cam: &Camera, sampler: impl FnMut( /// A headless scene with an explicit section and supplied sketch display paths. pub fn scene_with_section_and_sketch_sampler( + s: &Session, + cam: &Camera, + section: Option<(Vec3, Vec3)>, + sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>, +) -> Scene { + scene_with_palette(s, cam, section, sampler, &Palette::DEFAULT) +} + +/// [`scene_with_section_and_sketch_sampler`] in the colours of `p` (the app's theme). +pub fn scene_with_palette( s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>, mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>, + p: &Palette, ) -> Scene { let st = s.world_state(); let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9); - let mut sc = Scene { - background: Some((colors::BG_TOP, colors::BG_BOTTOM)), - clip: section.map(|(o, n)| (n, n.dot(o))), - cap: Some(colors::SECTION_CAP), - ..Default::default() - }; + let mut sc = Scene { background: Some(p.background), clip: section.map(|(o, n)| (n, n.dot(o))), cap: Some(p.cap), ..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). @@ -251,7 +329,7 @@ pub fn scene_with_section_and_sketch_sampler( let n = ((ext / minor) as i64).clamp(1, 400); for i in -n..=n { let t = i as f64 * minor; - let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID }; + let col = if (t / major).fract().abs() < 1e-9 { p.grid_major } else { p.grid }; sc.lines.push(SceneLine { points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], color: col, @@ -266,7 +344,7 @@ pub fn scene_with_section_and_sketch_sampler( }); } let axis = cam.half_height() * 0.25; - for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { + for (d, col) in [Vec3::X, Vec3::Y, Vec3::Z].into_iter().zip(p.axes) { sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false }); } let selected_occurrences: Vec = s @@ -284,8 +362,7 @@ pub fn scene_with_section_and_sketch_sampler( // 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 color = if selected { p.body_selected } else { s.doc.body_color(body).map_or(p.body, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) }; let face_colors = if selected { Vec::new() } else { @@ -296,12 +373,12 @@ pub fn scene_with_section_and_sketch_sampler( if !mesh.shaded_edge_visible(i) { continue; } - sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false }); + sc.lines.push(SceneLine { points: e.clone(), color: p.edge, width: 1.2, on_top: false }); } // The outline of the body where the section cuts it. if let Some((o, n)) = section { for (a, b) in section_segments(&mesh, o, n) { - sc.lines.push(SceneLine { points: vec![a, b], color: colors::EDGE, width: 1.6, on_top: false }); + sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, width: 1.6, on_top: false }); } } } @@ -310,7 +387,7 @@ pub fn scene_with_section_and_sketch_sampler( if !sketch_shown(s, ss.feature) { continue; } - for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, &mut sampler) { + for (pts, col, cons) in sketch_lines_in(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, &mut sampler, p) { sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active }); } } @@ -531,3 +608,7 @@ mod tests { #[cfg(test)] #[path = "view_display_tests.rs"] mod display_tests; + +#[cfg(test)] +#[path = "view_palette_tests.rs"] +mod palette_tests; diff --git a/crates/engine/src/view_palette_tests.rs b/crates/engine/src/view_palette_tests.rs new file mode 100644 index 00000000..52c9dce8 --- /dev/null +++ b/crates/engine/src/view_palette_tests.rs @@ -0,0 +1,176 @@ +//! A scene's colours come from its [`Palette`] (#974): the app draws `ui.render` in its theme, +//! while `solidcraft-cli snapshot`, MCP screenshots and thumbnails keep [`Palette::DEFAULT`]. + +use super::*; +use serde_json::json; +use solidcraft_geom::Vec2; +use solidcraft_sketch::{LinkGeom, LinkKind, LinkSource}; + +/// No colour here is one of [`colors`] or an appearance below, and each field has its own. +const MARK: Palette = Palette { + background: (Rgb(1, 2, 3), Rgb(4, 5, 6)), + grid: Rgb(7, 8, 9), + grid_major: Rgb(10, 11, 12), + edge: Rgb(13, 14, 15), + body: Rgb(16, 17, 18), + body_selected: Rgb(19, 20, 21), + sketch: Rgb(22, 23, 24), + sketch_fixed: Rgb(25, 26, 27), + sketch_done: Rgb(28, 29, 30), + sketch_locked: Rgb(31, 32, 33), + sketch_projected: Rgb(34, 35, 36), + sketch_lost: Rgb(37, 38, 39), + sketch_construction: Rgb(40, 41, 42), + sketch_centerline: Rgb(43, 44, 45), + axes: [Rgb(46, 47, 48), Rgb(49, 50, 51), Rgb(52, 53, 54)], + cap: Rgb(55, 56, 57), +}; + +/// Each curve's lines at x = its index, so a line tells which curve it draws. +fn by_index(_: &Sketch, i: usize, _: &Plane) -> Vec> { + vec![vec![Vec3::new(i as f64, 0., 0.), Vec3::new(i as f64, 1., 0.)]] +} + +#[test] +fn the_default_palette_is_the_colour_table() { + let p = Palette::default(); + assert_eq!(p, Palette::DEFAULT); + assert_eq!(p.background, (colors::BG_TOP, colors::BG_BOTTOM)); + assert_eq!( + (p.grid, p.grid_major, p.edge, p.body, p.body_selected), + (colors::GRID, colors::GRID_MAJOR, colors::EDGE, colors::BODY, colors::BODY_SELECTED) + ); + assert_eq!( + (p.sketch, p.sketch_fixed, p.sketch_done, p.sketch_locked), + (colors::SKETCH, colors::SKETCH_FIXED, colors::SKETCH_DONE, colors::SKETCH_LOCKED) + ); + assert_eq!( + (p.sketch_projected, p.sketch_lost, p.sketch_construction, p.sketch_centerline), + (colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE) + ); + assert_eq!((p.axes, p.cap), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP)); +} + +#[test] +fn each_sketch_state_takes_its_palette_colour() { + let line = |sk: &mut Sketch, y: f64| sk.add_line(Vec2::new(0., y), Vec2::new(2., y), None, None, None).unwrap(); + let mut sk = Sketch::new(); + let ordinary = line(&mut sk, 0.); + let fixed = line(&mut sk, 1.); + sk.curves[fixed].fixed = true; + let centerline = line(&mut sk, 2.); + sk.curves[centerline].centerline = true; + let construction = line(&mut sk, 3.); + sk.curves[construction].construction = true; + let determined = line(&mut sk, 4.); + sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 5.), Vec2::new(2., 5.))]).unwrap(); + let projected = sk.curves.len() - 1; + let lost_link = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 6.), Vec2::new(2., 6.))]).unwrap(); + sk.set_link_lost(&lost_link, true); + let lost = sk.curves.len() - 1; + let text = LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None, style: Default::default() }; + sk.add_link(LinkKind::Text, text, &[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))]).unwrap(); + let text = sk.curves.len() - 1; + let mut flags = vec![false; sk.curves.len()]; + flags[determined] = true; + let colour_of = |active: bool, i: usize| { + let lines = sketch_lines_in(&sk, &Plane::XY, active, &flags, by_index, &MARK); + let mut of_curve = lines.iter().filter(|(pts, _, _)| pts.first().is_some_and(|p| p.x == i as f64)).map(|(_, c, _)| *c); + let c = of_curve.next(); + assert!(of_curve.all(|other| Some(other) == c), "curve {i} drawn in two colours"); + c + }; + for (i, want) in [ + (ordinary, MARK.sketch), + (determined, MARK.sketch_fixed), + (fixed, MARK.sketch_locked), + (centerline, MARK.sketch_centerline), + (construction, MARK.sketch_construction), + (projected, MARK.sketch_projected), + (lost, MARK.sketch_lost), + (text, MARK.sketch), + ] { + assert_eq!(colour_of(true, i), Some(want), "curve {i} in the active sketch"); + } + for i in [ordinary, determined, text] { + assert_eq!(colour_of(false, i), Some(MARK.sketch_done), "curve {i} in a finished sketch"); + } + // The public function is the default palette: the colours the GPU viewport reads as tags. + assert_eq!( + sketch_lines_in(&sk, &Plane::XY, true, &flags, by_index, &Palette::DEFAULT), + sketch_lines_with_sampler(&sk, &Plane::XY, true, &flags, by_index) + ); +} + +/// Every colour of a scene comes from its palette, except what the design says: a body's +/// appearance (even one equal to the default grey) and a face's own colour. +#[test] +fn every_scene_colour_comes_from_its_palette_and_appearances_stay() { + let mut s = Session::default(); + let new_body = |s: &mut Session, name: &str, x: f64| { + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "body_name": name})) + .unwrap(); + }; + new_body(&mut s, "Grey", 0.); + new_body(&mut s, "Picked", 20.); + new_body(&mut s, "Plain", 40.); + // Exactly the default body colour, as an appearance: the design's, so it stays. + s.execute("appearance.assign", &json!({"bodies": ["Grey"], "color": "#b0bac6"})).unwrap(); + s.execute("appearance.assign", &json!({"body": "Plain", "faces": [[45, 5, 10]], "color": "#00ff00"})).unwrap(); + s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); + s.execute("sketch.line", &json!({"points": [[0, -20], [30, -20]]})).unwrap(); + s.execute("sketch.finish", &json!({})).unwrap(); + s.selection = vec![Sel::Body { name: "Picked".into() }]; + s.section = Some((Vec3::new(0., 0., 5.), Vec3::Z)); + let cam = home_camera(&s); + let names: Vec = s.world_state().bodies.iter().filter(|b| s.body_visible(&b.name)).map(|b| b.name.clone()).collect(); + let mesh = |sc: &Scene, name: &str| { + names.iter().position(|n| n == name).and_then(|i| sc.meshes.get(i)).map(|m| (m.color, m.face_colors.clone())).unwrap() + }; + + let marked = scene_with_palette(&s, &cam, s.section, world_curve_parts, &MARK); + assert_eq!((marked.background, marked.cap), (Some(MARK.background), Some(MARK.cap))); + let allowed = [ + MARK.grid, + MARK.grid_major, + MARK.edge, + MARK.axes[0], + MARK.axes[1], + MARK.axes[2], + MARK.sketch, + MARK.sketch_fixed, + MARK.sketch_done, + MARK.sketch_locked, + MARK.sketch_projected, + MARK.sketch_lost, + MARK.sketch_construction, + MARK.sketch_centerline, + ]; + let stray: Vec = marked.lines.iter().map(|l| l.color).filter(|c| !allowed.contains(c)).collect(); + assert!(stray.is_empty(), "line colours not from the palette: {stray:?}"); + for want in [MARK.grid, MARK.grid_major, MARK.edge, MARK.axes[0], MARK.axes[1], MARK.axes[2], MARK.sketch_done] { + assert!(marked.lines.iter().any(|l| l.color == want), "no line in {want:?}"); + } + assert_eq!(mesh(&marked, "Grey").0, colors::BODY, "an appearance equal to the default grey is the design's"); + assert_eq!(mesh(&marked, "Picked").0, MARK.body_selected); + let (plain, faces) = mesh(&marked, "Plain"); + assert_eq!(plain, MARK.body); + assert!(!faces.is_empty() && faces.iter().all(|(_, c)| *c == Rgb(0, 255, 0)), "face colours are the design's: {faces:?}"); + + // The default palette is the scene everyone else gets, line for line. + let default = scene_with_palette(&s, &cam, s.section, world_curve_parts, &Palette::DEFAULT); + let plain_scene = scene(&s, &cam); + assert_eq!( + (default.background, default.cap, default.clip, default.radius), + (plain_scene.background, plain_scene.cap, plain_scene.clip, plain_scene.radius) + ); + assert_eq!(default.lines.len(), plain_scene.lines.len()); + for (a, b) in default.lines.iter().zip(&plain_scene.lines) { + assert_eq!((&a.points, a.color, a.width, a.on_top), (&b.points, b.color, b.width, b.on_top)); + } + for name in &names { + assert_eq!(mesh(&default, name), mesh(&plain_scene, name), "{name}"); + } + assert_eq!(mesh(&default, "Plain").0, colors::BODY); + assert_eq!(mesh(&default, "Picked").0, colors::BODY_SELECTED); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b15f149f..c619da6e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -20,8 +20,8 @@ //! pointer input in screen points; `ui.scroll {x, y, delta}`: mouse-wheel input in //! points (zooms); `ui.key {key, cmd?, shift?}`, `ui.text {text}` //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless -//! CPU render of the model with the current camera and the section the viewport shows (no -//! window needed) +//! CPU render of the model with the current camera, in the window's theme, with the section +//! the viewport shows (no window needed) //! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking //! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point, //! the open menu and its items. `ui.menuPick {item}` runs an item by id or label; @@ -620,9 +620,15 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> "ui.render" => { let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize; let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize; - // Cut by the section the viewport shows (an open Section Analysis dialog's preview, - // a sketch's Slice), not only the committed one. - let scene = solidcraft_engine::view::scene_with_section(&app.session, &app.cam, crate::viewport::section_plane(app)); + // In the window's theme, and cut by the section the viewport shows (an open Section + // Analysis dialog's preview, a sketch's Slice), not only the committed one. + let scene = solidcraft_engine::view::scene_with_palette( + &app.session, + &app.cam, + crate::viewport::section_plane(app), + solidcraft_engine::view::world_curve_parts, + &crate::viewport::scene_palette(app.ui.dark), + ); let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") }; match s("path") { Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png)) { @@ -680,6 +686,32 @@ mod tests { std::fs::read(path).unwrap() } + /// `ui.render` draws in the window's theme (#974): its background is the viewport's, light + /// or dark. + #[test] + fn model_render_is_in_the_windows_theme() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let mut app = SolveApp::new(s, Services::default()); + let dir = std::env::temp_dir().join(format!("solidcraft-render-theme-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for dark in [false, true, false] { + app.ui.dark = dark; + let png = render(&mut app, &dir.join(format!("dark-{dark}.png"))); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + // The top row is the background's top: its most common colour. + let mut counts = std::collections::HashMap::new(); + for x in 0..img.width() { + *counts.entry(img.get_pixel(x, 0).0).or_insert(0) += 1; + } + let top = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap(); + let want = if dark { crate::theme::Tokens::dark() } else { crate::theme::Tokens::light() }.viewport_top; + let near = top.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2); + assert!(near, "dark {dark}: the render's top row is {top:?}, the window's background {want:?}"); + } + std::fs::remove_dir_all(&dir).ok(); + } + /// A model render shows the section the window shows: the open Section Analysis dialog's /// preview too, not only a committed `inspect.section`. #[test] diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 707d0ea1..765a867f 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2858,13 +2858,18 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, v.to_bits().hash(&mut h); } (rect.width() as u32, rect.height() as u32).hash(&mut h); + app.ui.dark.hash(&mut h); let key = h.finish(); if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) { let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576); - let scene = - solidcraft_engine::view::scene_with_section_and_sketch_sampler(&app.session, &proj.cam, section_plane(app), |sk, index, plane| { - crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts - }); + let palette = scene_palette(app.ui.dark); + let scene = solidcraft_engine::view::scene_with_palette( + &app.session, + &proj.cam, + section_plane(app), + |sk, index, plane| crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts, + &palette, + ); let w = (rect.width() as usize).clamp(16, 4096); let hh = (rect.height() as usize).clamp(16, 4096); let c = solidcraft_engine::render::render(&scene, &proj.cam, w, hh); @@ -2877,6 +2882,29 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, } } +/// The colours of a CPU picture of the model (`ui.render` and the CPU viewport) in the dark or +/// the light theme, from the tokens the window draws with, so the picture matches the window. The +/// light theme's are the engine's own colours, so a light render is unchanged. The sketch states +/// and axes the window draws in the engine's colours stay so. +pub(crate) fn scene_palette(dark: bool) -> solidcraft_engine::view::Palette { + let t = if dark { Tokens::dark() } else { Tokens::light() }; + let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b()); + solidcraft_engine::view::Palette { + background: (rgb(t.viewport_top), rgb(t.viewport_bottom)), + grid: rgb(t.grid), + grid_major: rgb(t.grid_major), + edge: rgb(t.body_edge), + body: rgb(t.body), + body_selected: rgb(t.body_selected), + sketch: rgb(t.sketch), + sketch_fixed: rgb(t.sketch_fixed), + sketch_done: rgb(t.sketch_fixed), + sketch_locked: rgb(t.sketch_locked), + cap: rgb(t.section_cap), + ..solidcraft_engine::view::Palette::DEFAULT + } +} + pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { if let Some(rect) = app.viewport.rect { let docks: Vec<_> = @@ -3478,6 +3506,76 @@ mod tests { crate::sketch_palette::set_mode(&mut h.app, 0); } + /// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974). + /// The light theme's tokens are the engine's own colours, so a light picture is unchanged; in + /// the dark theme each part takes the token the window draws it with. Sketch states follow + /// what the window does with them (`sketch_color`), so the two can't drift apart. + #[test] + fn cpu_pictures_are_drawn_in_the_windows_theme() { + use solidcraft_engine::view::Palette; + assert_eq!(scene_palette(false), Palette::DEFAULT, "the light theme is the engine's colours"); + let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b()); + let (p, t) = (scene_palette(true), Tokens::dark()); + assert_eq!(p.background, (rgb(t.viewport_top), rgb(t.viewport_bottom))); + assert_eq!( + [p.grid, p.grid_major, p.edge, p.body, p.body_selected, p.cap], + [t.grid, t.grid_major, t.body_edge, t.body, t.body_selected, t.section_cap].map(rgb) + ); + assert_ne!(p.background, Palette::DEFAULT.background); + for dark in [false, true] { + let (p, t) = (scene_palette(dark), if dark { Tokens::dark() } else { Tokens::light() }); + for (tag, cpu) in [ + (colors::SKETCH, p.sketch), + (colors::SKETCH_FIXED, p.sketch_fixed), + (colors::SKETCH_DONE, p.sketch_done), + (colors::SKETCH_LOCKED, p.sketch_locked), + (colors::SKETCH_PROJECTED, p.sketch_projected), + (colors::SKETCH_LOST, p.sketch_lost), + (colors::SKETCH_CONSTRUCTION, p.sketch_construction), + (colors::SKETCH_CENTERLINE, p.sketch_centerline), + ] { + let [r, g, b, _] = sketch_color(&t, tag); + assert_eq!(cpu, Rgb(r, g, b), "dark {dark}: the window draws {tag:?} in another colour"); + } + } + } + + /// The CPU viewport follows a theme change at once: the theme is part of its picture's key. + #[test] + fn cpu_viewport_redraws_in_the_new_theme() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(160.0, 120.0)); + let cam = solidcraft_engine::view::home_camera(&h.app.session); + let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; + h.app.viewport.no_pixels = false; + // The new picture's top row, most common colour first (the background's top); None when + // nothing new was drawn. + let top_row = |h: &mut crate::scenario::Harness| { + let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj); + }); + let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id()); + let top = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map(|upload| { + let egui::ImageData::Color(image) = &upload.image; + let mut counts = std::collections::HashMap::new(); + for px in image.pixels.iter().take(image.size[0]) { + *counts.entry(*px).or_insert(0) += 1; + } + counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default() + }); + out.textures_delta.clear(); + top + }; + for dark in [false, true, false] { + h.app.ui.dark = dark; + let Some(px) = top_row(&mut h) else { panic!("dark {dark}: the CPU viewport kept the old picture") }; + let want = if dark { Tokens::dark() } else { Tokens::light() }.viewport_top; + let near = [px.r(), px.g(), px.b()].iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2); + assert!(near, "dark {dark}: top row {px:?}, the window's background {want:?}"); + } + } + #[test] fn cpu_viewport_uses_section_preview_and_sketch_slice() { fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4bf6f03e..305e9b5c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -116,7 +116,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | | | `ui.text` | `text` | typed text | | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | -| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped | +| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped | | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | | `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking | | `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |