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:
1 parent
1a456d2d1f
commit
8f48544021
5 files changed
+422
-35
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user