diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 1b9a729..6f67f67 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -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 }); diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index 298079b..e95c773 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -198,6 +198,15 @@ pub fn symbol_unit(length_per_pixel: f64, size: f64) -> Option { (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 { + 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(); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index b03d91d..2073500 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -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 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1914177..556f74c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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}")), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1719438..a1ef29c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, 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, 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. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ca738f..0fcd8a3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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