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
+106
-25
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user