forked from 1b-agent/fieldcraft-upstream
ui: one symbol-unit helper for the window, ui.render and headless renders (#285)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
b377c882d6
commit
1f36693c8f
6 files changed
+66
-17
No files matched your search
@@ -266,7 +266,7 @@ pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette
|
||||
let mut pic = picture(s, p);
|
||||
let mut cam = Camera::default();
|
||||
let aspect = w as f64 / h.max(1) as f64;
|
||||
let unit = |cam: &Camera| cam.length_per_pixel(h as f64).and_then(|l| glyphs::symbol_unit(l, glyphs::DEFAULT_SIZE)).unwrap_or(f64::NAN);
|
||||
let unit = |cam: &Camera| glyphs::unit_for(cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN);
|
||||
// Framed on the model, then again with its symbols at that camera's size, which reach a
|
||||
// little beyond it.
|
||||
cam.orient(&Orientation { distance: None, ..*at });
|
||||
|
||||
@@ -198,6 +198,15 @@ pub fn symbol_unit(length_per_pixel: f64, size: f64) -> Option<f64> {
|
||||
(unit.is_finite() && unit > 0.0).then_some(unit)
|
||||
}
|
||||
|
||||
/// The symbol unit for symbols `size_pt` points across in a view `height_px` pixels high at
|
||||
/// `pixels_per_point` (1 for a headless image, the display's scale for the desktop), seen with
|
||||
/// `cam`: the one helper the desktop, `ui.render` and headless renders share, so a render at
|
||||
/// device resolution matches the window. A scale that is not a positive number counts as 1.
|
||||
pub fn unit_for(cam: &crate::Camera, height_px: f64, size_pt: f64, pixels_per_point: f64) -> Option<f64> {
|
||||
let ppp = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { pixels_per_point } else { 1.0 };
|
||||
symbol_unit(cam.length_per_pixel(height_px)? * ppp, size_pt)
|
||||
}
|
||||
|
||||
/// A symbol unit rounded to a quarter of a doubling (within 9 %), and the step it is: a viewer
|
||||
/// rebuilds its symbols only when zooming changes the step.
|
||||
pub fn quantise_unit(unit: f64) -> Option<(i32, f64)> {
|
||||
@@ -1414,6 +1423,11 @@ mod tests {
|
||||
assert_eq!(symbol_unit(1.0, 1e9), Some(SIZE_RANGE.1));
|
||||
assert_eq!(symbol_unit(1.0, f64::NAN), Some(DEFAULT_SIZE));
|
||||
assert_eq!(symbol_unit(f64::INFINITY, 10.0), None);
|
||||
// A view at twice the pixels per point has the same unit as at one (#285).
|
||||
let cam = crate::Camera::default();
|
||||
assert_eq!(unit_for(&cam, 1600.0, DEFAULT_SIZE, 2.0), unit_for(&cam, 800.0, DEFAULT_SIZE, 1.0));
|
||||
assert_eq!(unit_for(&cam, 800.0, DEFAULT_SIZE, f64::NAN), unit_for(&cam, 800.0, DEFAULT_SIZE, 1.0));
|
||||
assert_eq!(unit_for(&cam, 0.0, DEFAULT_SIZE, 1.0), None);
|
||||
assert_eq!(Camera::default().length_per_pixel(0.0), None);
|
||||
// Units round to a quarter of a doubling.
|
||||
let (k, q) = quantise_unit(1.1).unwrap();
|
||||
|
||||
@@ -76,6 +76,20 @@ impl Scene {
|
||||
self.labels.extend(other.labels);
|
||||
self.on_top.extend(other.on_top);
|
||||
}
|
||||
|
||||
/// Line widths and point sizes (given in points) in pixels at `pixels_per_point`, as the GPU
|
||||
/// viewport draws them: a CPU render at device resolution then matches the window.
|
||||
pub fn scale_marks(&mut self, pixels_per_point: f64) {
|
||||
if !(pixels_per_point.is_finite() && pixels_per_point > 0.0) {
|
||||
return;
|
||||
}
|
||||
for l in self.lines.iter_mut().chain(self.on_top.iter_mut()) {
|
||||
l.width *= pixels_per_point;
|
||||
}
|
||||
for p in &mut self.points {
|
||||
p.size *= pixels_per_point;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lit colour of a surface with normal `n` seen along `view_dir` (toward the eye). The GPU
|
||||
|
||||
@@ -600,12 +600,25 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
(Ok(w), Ok(h)) => (w, h),
|
||||
(Err(e), _) | (_, Err(e)) => return err(e),
|
||||
};
|
||||
let png = match app.render_png(w, h) {
|
||||
// Sizes on screen are in points: at the window's pixels per point unless given, so a
|
||||
// render at device resolution matches the window (#285).
|
||||
let scale =
|
||||
match p.get("scale") {
|
||||
None | Some(Value::Null) => Ok(window_size(ctx).scale),
|
||||
Some(v) => v.as_f64().map(|x| x as f32).filter(|x| crate::capture::SCALE_RANGE.contains(x)).ok_or_else(|| {
|
||||
format!("`scale` must be from {} to {}", crate::capture::SCALE_RANGE.start(), crate::capture::SCALE_RANGE.end())
|
||||
}),
|
||||
};
|
||||
let scale = match scale {
|
||||
Ok(s) => s,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let png = match app.render_png(w, h, f64::from(scale)) {
|
||||
Ok(png) => png,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
match std::fs::write(path, &png) {
|
||||
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "bytes": png.len()})),
|
||||
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "scale": scale, "bytes": png.len()})),
|
||||
Err(e) => err(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
+21
-13
@@ -1109,18 +1109,18 @@ impl ModelApp {
|
||||
/// The setup's symbols for the viewport as drawn last frame (its height in points).
|
||||
pub fn symbols(&mut self) -> (u64, &Scene) {
|
||||
let height = self.viewport.rect.map_or(DEFAULT_VIEW_HEIGHT, |r| f64::from(r.height()));
|
||||
self.symbols_for(height)
|
||||
self.symbols_for(height, 1.0)
|
||||
}
|
||||
|
||||
/// The setup's symbols at the size setting on screen, for a view `height` points (or
|
||||
/// pixels) high with the current camera: a layer of their own, rebuilt when the model, what
|
||||
/// The setup's symbols at the size setting on screen (in points), for a view `height` pixels
|
||||
/// high at `pixels_per_point` ([`glyphs::unit_for`]) with the current camera: a layer of their own, rebuilt when the model, what
|
||||
/// is shown, the plot, the highlighted definition or the size changes, and while zooming
|
||||
/// only when the symbol unit moves to another quarter of a doubling (the mesh's scene is
|
||||
/// never rebuilt for it). Their nodes follow the drawn shape (a results plot's deformed one).
|
||||
pub fn symbols_for(&mut self, height: f64) -> (u64, &Scene) {
|
||||
pub fn symbols_for(&mut self, height: f64, pixels_per_point: f64) -> (u64, &Scene) {
|
||||
self.display();
|
||||
self.plot();
|
||||
let unit = self.cam.length_per_pixel(height).and_then(|l| glyphs::symbol_unit(l, self.ui.symbol_size)).and_then(glyphs::quantise_unit);
|
||||
let unit = glyphs::unit_for(&self.cam, height, self.ui.symbol_size, pixels_per_point).and_then(glyphs::quantise_unit);
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize);
|
||||
let show = self.ui.show();
|
||||
@@ -1170,13 +1170,21 @@ impl ModelApp {
|
||||
}
|
||||
|
||||
/// A CPU render of the viewport's scene with the current camera and the selection, as PNG
|
||||
/// bytes.
|
||||
pub fn render_png(&mut self, w: usize, h: usize) -> Result<Vec<u8>, fieldcraft_render::RenderError> {
|
||||
/// bytes, `w` × `h` pixels at `pixels_per_point`: symbols, lines and nodes are as large as
|
||||
/// in a window of that scale (#285).
|
||||
pub fn render_png(&mut self, w: usize, h: usize, pixels_per_point: f64) -> Result<Vec<u8>, fieldcraft_render::RenderError> {
|
||||
let cam = self.cam;
|
||||
let (_, overlay) = self.overlay();
|
||||
self.symbols_for(h as f64);
|
||||
self.symbols_for(h as f64, pixels_per_point);
|
||||
self.scene();
|
||||
fieldcraft_render::render_layers_png(&self.scene.1, &[&self.symbols.1, &overlay], &cam, w, h)
|
||||
if pixels_per_point == 1.0 {
|
||||
return fieldcraft_render::render_layers_png(&self.scene.1, &[&self.symbols.1, &overlay], &cam, w, h);
|
||||
}
|
||||
let (mut scene, mut symbols, mut overlay) = (self.scene.1.clone(), self.symbols.1.clone(), (*overlay).clone());
|
||||
for s in [&mut scene, &mut symbols, &mut overlay] {
|
||||
s.scale_marks(pixels_per_point);
|
||||
}
|
||||
fieldcraft_render::render_layers_png(&scene, &[&symbols, &overlay], &cam, w, h)
|
||||
}
|
||||
|
||||
/// The geometry tags behind the drawn mesh, read again when the model changed.
|
||||
@@ -2217,7 +2225,7 @@ mod tests {
|
||||
// Framed when the viewport first draws.
|
||||
draw(&mut app, &egui::Context::default(), 1280.0, 800.0);
|
||||
assert!(app.framed(), "{:?}", app.cam);
|
||||
let png = app.render_png(64, 48).unwrap();
|
||||
let png = app.render_png(64, 48, 1.0).unwrap();
|
||||
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
}
|
||||
|
||||
@@ -2297,7 +2305,7 @@ mod tests {
|
||||
// length (a symbol unit) long.
|
||||
let measure = |app: &mut ModelApp| {
|
||||
let cam = app.cam;
|
||||
let lines = app.symbols_for(h).1.lines.clone();
|
||||
let lines = app.symbols_for(h, 1.0).1.lines.clone();
|
||||
let t = theme::Tokens::get(app.ui.dark);
|
||||
let at = |p: Vec3| cam.to_screen(p, 800.0, h, 1e6).unwrap();
|
||||
lines
|
||||
@@ -2310,14 +2318,14 @@ mod tests {
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
let scene = app.scene_key();
|
||||
let (symbols, _) = app.symbols_for(h);
|
||||
let (symbols, _) = app.symbols_for(h, 1.0);
|
||||
let mut sizes = Vec::new();
|
||||
for distance in [40.0, 150.0, 900.0, 5000.0] {
|
||||
app.cam.distance = distance;
|
||||
sizes.push(measure(&mut app));
|
||||
}
|
||||
assert_eq!(app.scene_key(), scene, "the camera is not in the scene's key");
|
||||
assert_ne!(app.symbols_for(h).0, symbols, "zooming resizes the symbols");
|
||||
assert_ne!(app.symbols_for(h, 1.0).0, symbols, "zooming resizes the symbols");
|
||||
let (lo, hi) = sizes.iter().fold((f64::MAX, 0.0f64), |(lo, hi), s| (lo.min(*s), hi.max(*s)));
|
||||
assert!(lo > 10.0 && hi / lo < 1.2, "{sizes:?}");
|
||||
// The size setting scales them.
|
||||
|
||||
@@ -70,7 +70,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
|
||||
| `ui.fullscreen` | `on?` (default true) | into or out of full screen; on macOS the title bar's left group moves into the traffic lights' room while there are none |
|
||||
| `ui.screenshot` | `path?` | PNG of the window, taken a few frames later; waits for a presented frame; when none comes within 8 seconds (a hidden window, a locked screen) it answers with `ui.capture`'s offscreen picture (`"offscreen": true`). Without `path`: `fieldcraft-shot.png` in the temporary folder |
|
||||
| `ui.capture` | `path?`, `width?`, `height?` (points; default the window's), `scale?` (pixels per point, 0.5–4; default the window's) | PNG of the whole window (title bar, ribbon, open menus and search, panels, forms and the viewport) drawn offscreen, without fade-in animations: the UI pass runs in a separate egui context and is rasterised on the CPU, so it works with the window hidden, minimised or the screen locked. At most 8192 pixels a side and 16 megapixels. Without `path`: `fieldcraft-shot.png` in the temporary folder |
|
||||
| `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all | CPU render of the model with the current camera, view settings and selection highlight (no window or GPU needed). A size out of range, or a scene over 20 million primitives, is an error |
|
||||
| `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all; `scale?` (pixels per point, 0.5–4, default the window's) | CPU render of the model with the current camera, view settings and selection highlight (no window or GPU needed). Symbols, lines and nodes are sized in points, so a render at the window's device resolution matches the window. A size out of range, or a scene over 20 million primitives, is an error |
|
||||
| `app.quit` | — | closes the window |
|
||||
|
||||
## Selection
|
||||
|
||||
Reference in new issue
Block a user