Model renders (screenshot with source model, ui.render) show the section the window shows: the CPU renderer drew the cut face on the far walls at their own depth in a plain grey hatch, so the inside of the body, its edges and the grid showed through it and the cut barely read as a section, it had no outline, perspective views cut along a curve, and an open Section Analysis dialog's preview (or a sketch's Slice) was ignored. The cut face now writes the section plane's depth where it is the visible surface (the CPU counterpart of the viewport's stencil cap), takes the viewport's cap tint and hatch, the cut outline is drawn (section_segments moved from the UI to engine::view and shared), world positions and lines are clipped perspective-correctly, and ui.render in the app uses the viewport's section plane

This commit is contained in:
CypherNaugh_0x committed 2026-10-11 12:23:57 +02:00
1 parent 22672585c9
commit 21e0e99c86
7 files changed
+217 -46

No files matched your search

+47 -2
View File
@@ -15,7 +15,8 @@
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
//! pointer input in screen points; `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 (no window needed)
//! CPU render of the model with the current camera and 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;
@@ -512,7 +513,9 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"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;
let scene = solvecraft_engine::view::scene(&app.session, &app.cam);
// 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 = solvecraft_engine::view::scene_with_section(&app.session, &app.cam, crate::viewport::section_plane(app));
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
match s("path") {
Some(path) => match std::fs::write(path, &png) {
@@ -552,3 +555,45 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
Err(e) => json!({"ok": false, "error": e.to_string()}),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use solvecraft_engine::{Sel, Session};
use super::*;
use crate::Services;
fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec<u8> {
let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140}));
match handle(app, &egui::Context::default(), &req) {
Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"),
Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"),
}
std::fs::read(path).unwrap()
}
/// A model render shows the section the window shows: the open Section Analysis dialog's
/// preview too, not only a committed `inspect.section`.
#[test]
fn model_render_shows_the_section_dialog_preview() {
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!("solvecraft-render-section-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let whole = render(&mut app, &dir.join("whole.png"));
app.session.selection = vec![Sel::Plane { name: "XZ".into() }];
app.start("inspect.section");
let Some(crate::dialogs::Kind::Section { offset, .. }) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("no section dialog") };
*offset = "15 mm".into();
assert!(app.session.section.is_none() && crate::viewport::section_plane(&app).is_some());
let preview = render(&mut app, &dir.join("preview.png"));
assert!(whole != preview, "the model render ignores the section the window shows");
// Committed, it renders the same.
app.run("inspect.section", json!({"plane": "XZ", "offset": 15})).unwrap();
app.dialog = None;
assert!(render(&mut app, &dir.join("committed.png")) == preview, "the committed section renders differently");
let _ = std::fs::remove_dir_all(&dir);
}
}
+3 -25
View File
@@ -330,7 +330,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
if let Some((o, n)) = section_plane(app) {
let col = c4(tk.text.gamma_multiply(0.9));
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)) {
for (a, c) in section_segments(&b.mesh(), o, n) {
for (a, c) in solvecraft_engine::view::section_segments(&b.mesh(), o, n) {
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
}
}
@@ -419,28 +419,6 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc:
}
}
/// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle,
/// nudged a hair to the kept side (so the clip leaves them drawn).
pub(crate) fn section_segments(m: &solvecraft_engine::geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> {
let eps = 1e-4 * (1.0 + m.bounds().diagonal());
let mut out = Vec::new();
for t in &m.triangles {
let Some(p) = m.tri(t) else { continue };
let d = p.map(|q| n.dot(q - o));
let mut pts = Vec::with_capacity(2);
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
if (d[i] < 0.0) != (d[j] < 0.0) {
let s = d[i] / (d[i] - d[j]);
pts.push(p[i] + (p[j] - p[i]) * s - n * eps);
}
}
if let [a, b] = pts[..] {
out.push((a, b));
}
}
out
}
/// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths).
pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> {
let mut out = Vec::new();
@@ -2969,7 +2947,7 @@ mod tests {
let st = s.model.state();
let m = st.bodies[0].mesh();
let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
let segs = section_segments(&m, o, n);
let segs = solvecraft_engine::view::section_segments(&m, o, n);
assert!(segs.len() >= 4, "{segs:?}");
// On the plane (a hair to the kept side) and around the 40 x 20 cut.
let mut len = 0.0;
@@ -2979,7 +2957,7 @@ mod tests {
}
assert!((len - 120.0).abs() < 0.5, "outline length {len}");
// A plane that misses the body cuts nothing.
assert!(section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
assert!(solvecraft_engine::view::section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
}
#[test]