ui.render and the CPU viewport draw in the window's theme (#974) (#1142)

The engine's headless scene takes a palette; ui.render and the CPU viewport build it from the window's theme tokens, so a dark window renders a dark picture. CLI snapshots, MCP screenshots and Home thumbnails keep the default colours.
This commit is contained in:
CamWight authored and GitHub committed 2026-10-14 13:39:58 -04:00
1 parent 1a456d2d1f
commit 8f48544021
5 files changed
+422 -35

No files matched your search

+106 -25
View File
@@ -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<Vec<Vec3>> {
/// 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<Vec<Vec3>> {
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<Vec<Vec3>> {
/// 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<Vec3>>,
) -> Vec<(Vec<Vec3>, 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<Vec<Vec3>>,
p: &Palette,
) -> Vec<(Vec<Vec3>, 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<Vec<Vec3>>,
) -> 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<Vec<Vec3>>,
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<String> = 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;
+176
View File
@@ -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<Vec3>> {
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<String> = 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<Rgb> = 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);
}
+37 -5
View File
@@ -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]
+102 -4
View File
@@ -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<Color32> {
+1 -1
View File
@@ -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 |