diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index ce5fddb1..6da37897 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -35,6 +35,8 @@ pub mod colors { pub const AXIS_Z: Rgb = Rgb(60, 100, 230); pub const BG_TOP: Rgb = Rgb(240, 243, 247); pub const BG_BOTTOM: Rgb = Rgb(203, 210, 221); + /// What the section cap is tinted toward. + pub const SECTION_CAP: Rgb = solvecraft_render::SECTION_CAP; } /// Bounds of everything visible. @@ -126,10 +128,23 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool { } } -/// The scene for a headless render. +/// The scene for a headless render, cut by the session's section. pub fn scene(s: &Session, cam: &Camera) -> Scene { + scene_with_section(s, cam, s.section) +} + +/// The scene for a headless render, cut by `section` (origin, normal) instead of the session's: +/// the app passes the cut its viewport shows (an open Section Analysis dialog's preview, a +/// sketch's Slice), so model renders match the window. +pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene { let st = s.world_state(); - let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..Default::default() }; + 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 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). @@ -188,6 +203,12 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { } sc.lines.push(SceneLine { points: e.clone(), color: colors::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 }); + } + } } for ss in &st.sketches { let active = s.active_sketch == Some(ss.feature); @@ -201,6 +222,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { sc } +/// 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 fn section_segments(m: &solvecraft_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 +} + /// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden /// once a feature uses them, like Fusion's default, unless shown one by one. pub fn sketch_shown(s: &Session, id: u64) -> bool { @@ -331,4 +374,31 @@ mod tests { let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count(); assert!(changed > 500, "{changed} pixels changed"); } + + /// The section's cut face hides what lies inside the body behind it (it is drawn at the + /// plane's depth, not on the far wall like a pane of glass), shows in the cap colour, and + /// the cut's outline is drawn. + #[test] + fn snapshot_section_caps_hide_the_inside() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + // Cut away the front half (y < 15), so the iso camera looks into the cut. + s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); + for fov in [0.0, 0.6] { + let mut cam = home_camera(&s); + cam.fov = fov; + let mut sc = scene(&s, &cam); + assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty()); + // A marker inside the body, behind the cut face. + let red = Rgb(255, 0, 0); + sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false }); + let img = solvecraft_render::render(&sc, &cam, 300, 220); + let px = || img.rgba.chunks(4); + let marker = px().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face"); + // The cap: warm (red over blue), unlike the grey-blue body and background. + let cap = px().filter(|p| p[0] as i32 - p[2] as i32 > 20).count(); + assert!(cap > 500, "fov {fov}: cap pixels {cap}"); + } + } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f458ed38..72ed25d1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -151,7 +151,8 @@ pub fn tool_definitions() -> Vec { "Look at the design", "Render the design and return it as a PNG image. Headless: a CPU render of the model, fitted to the view. \ Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \ - with the app's camera (source \"model\"). `view` first turns the camera to a standard view.", + with the app's camera (source \"model\"). Both show an active section (inspect.section) with its cut face \ + capped. `view` first turns the camera to a standard view.", obj( json!({ "view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"}, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 38e7a0aa..56d940d0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,4 +8,4 @@ mod camera; mod raster; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; -pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; +pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 69ac29aa..3b049533 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -43,8 +43,13 @@ pub struct Scene { /// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside /// shows as a hatched cap. pub clip: Option<(Vec3, f64)>, + /// The colour the section cap is tinted toward (default: [`SECTION_CAP`]). + pub cap: Option, } +/// The section cap's default tint, as in the viewport. +pub const SECTION_CAP: Rgb = Rgb(214, 128, 72); + /// RGBA pixels plus depth. pub struct Canvas { pub w: usize, @@ -105,16 +110,28 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]]) }; let view_dir = cam.back(); + // Where the section cap is the visible surface: the depth of the section plane on that + // pixel's view ray. Written into the depth buffer once the solids are drawn, so the cut face + // hides edges inside the body (holes, far corners) instead of showing them like a pane of + // glass, while holes through the cut stay open (as the viewport does with its stencil). + let cap_z = scene.clip.and_then(|(n, d)| cap_plane(cam, n, d, scene.radius.max(1.0), &project)); + let mut cap_depth = if cap_z.is_some() { vec![f32::INFINITY; sw * sh] } else { Vec::new() }; + let cap_tint = scene.cap.unwrap_or(SECTION_CAP).f(); for sm in &scene.meshes { let m = &sm.mesh; let body = sm.color.f(); let pts: Vec> = m.positions.iter().map(|p| project(*p)).collect(); + // 1 / clip w per vertex, for perspective-correct interpolation of world positions (so + // the section cuts along the plane, not a curve, in perspective views). + let inv_w: Vec = m.positions.iter().map(|p| 1.0 / vp.apply(*p)[3].max(1e-9)).collect(); for (ti, t) in m.triangles.iter().enumerate() { let face = m.tri_face.get(ti).copied(); let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f()); let wp = t.map(|k| m.positions.get(k as usize).copied().unwrap_or_default()); // Seen from inside (only where a section cuts the body open). - let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(view_dir) < 0.0; + let to_eye = if cam.fov > 0.0 { cam.eye() - wp[0] } else { view_dir }; + let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(to_eye) < 0.0; + let iw = t.map(|k| inv_w.get(k as usize).copied().unwrap_or(1.0)); let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else { continue; }; @@ -140,8 +157,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { continue; } + let (q0, q1, q2) = (w0 * iw[0], w1 * iw[1], w2 * iw[2]); + let qs = q0 + q1 + q2; + let (p0, p1, p2) = if qs > 1e-12 { (q0 / qs, q1 / qs, q2 / qs) } else { (w0, w1, w2) }; if let Some((cn, cd)) = scene.clip - && cn.dot(wp[0] * w0 + wp[1] * w1 + wp[2] * w2) > cd + && cn.dot(wp[0] * p0 + wp[1] * p1 + wp[2] * p2) > cd { continue; } @@ -151,11 +171,20 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { if let Some(d) = c.depth.get_mut(i) { *d = z; } - let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z); - if scene.clip.is_some() && inside { - // The cut: the body's colour, hatched at 45 degrees. - let k = if ((x + y) / SS) % 9 < 2 { 0.5 } else { 0.8 }; - c.put(x, y, base.map(|v| v * k), 1.0); + let n = (ns[0] * p0 + ns[1] * p1 + ns[2] * p2).normalized().unwrap_or(Vec3::Z); + let cut = scene.clip.is_some() && inside; + if let Some(d) = cap_depth.get_mut(i) { + *d = match cap_z { + Some([a, b, k]) if cut => ((a * px + b * py + k) as f32).min(z), + _ => f32::INFINITY, + }; + } + if cut { + // The cut: the body's colour tinted toward the cap colour, hatched + // at 45 degrees (as the viewport draws it). + let tinted: [f64; 3] = std::array::from_fn(|k| base[k] + (cap_tint[k] - base[k]) * 0.35); + let k = if ((x + y) / SS) % 9 < 2 { 0.6 } else { 1.0 }; + c.put(x, y, tinted.map(|v| v * k), 1.0); continue; } let mut col = shade(n, view_dir, base); @@ -172,21 +201,24 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { } } } + for (d, cz) in c.depth.iter_mut().zip(&cap_depth) { + if cz.is_finite() { + *d = *cz; + } + } for l in &scene.lines { let col = l.color.f(); let half = (l.width * SS as f64 * 0.5).max(0.5); for seg in l.points.windows(2) { - let (Some(p), Some(q)) = (seg.first(), seg.get(1)) else { continue }; - let (Some(a), Some(b)) = (project(*p), project(*q)) else { continue }; + let (Some(&p), Some(&q)) = (seg.first(), seg.get(1)) else { continue }; + // The section cuts the segment in 3D (screen space is not linear in depth in + // perspective views). + let Some((p, q)) = clip_segment(p, q, scene.clip) else { continue }; + let (Some(a), Some(b)) = (project(p), project(q)) else { continue }; let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); let steps = (len.ceil() as usize).clamp(1, 20_000); for k in 0..=steps { let t = k as f64 / steps as f64; - if let Some((cn, cd)) = scene.clip - && cn.dot(*p + (*q - *p) * t) > cd + 1e-6 - { - continue; - } let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t); let r = half.ceil() as i64; for dy in -r..=r { @@ -234,6 +266,39 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { out } +/// The part of segment `p`–`q` the section keeps (where normal·x <= d). +fn clip_segment(p: Vec3, q: Vec3, clip: Option<(Vec3, f64)>) -> Option<(Vec3, Vec3)> { + let Some((n, d)) = clip else { return Some((p, q)) }; + let (dp, dq) = (n.dot(p) - d - 1e-6, n.dot(q) - d - 1e-6); + match (dp > 0.0, dq > 0.0) { + (true, true) => None, + (false, false) => Some((p, q)), + (true, false) => Some((p + (q - p) * (dp / (dp - dq)), q)), + (false, true) => Some((p, p + (q - p) * (dp / (dp - dq)))), + } +} + +/// Screen depth of the section plane `n·p = d` as `z = a·x + b·y + k` in canvas pixels (a plane +/// stays a plane under projection), set a hair behind the plane so the cut's own outline stays +/// drawn. None when the plane is seen edge-on or lies behind the eye. +fn cap_plane(cam: &Camera, n: Vec3, d: f64, radius: f64, project: &dyn Fn(Vec3) -> Option<[f64; 3]>) -> Option<[f64; 3]> { + let margin = 1e-3 * (1.0 + radius); + let o = cam.target - n * (n.dot(cam.target) - d - margin); + let (u, v) = (n.any_perp(), n.cross(n.any_perp())); + let s = radius * 0.1; + let (p0, p1, p2) = (project(o)?, project(o + u * s)?, project(o + v * s)?); + let (ax, ay, az) = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]); + let (bx, by, bz) = (p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]); + let det = ax * by - ay * bx; + if det.abs() < 1e-9 { + return None; + } + let a = (az * by - ay * bz) / det; + let b = (ax * bz - az * bx) / det; + let k = p0[2] - a * p0[0] - b * p0[1]; + [a, b, k].iter().all(|x| x.is_finite()).then_some([a, b, k]) +} + /// Render to PNG bytes. pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option> { let c = render(scene, cam, w, h); @@ -274,6 +339,17 @@ mod tests { use super::*; use crate::StandardView; + /// Lines are cut by the section in 3D: the kept piece ends on the plane. + #[test] + fn section_clips_lines_at_the_plane() { + let clip = Some((Vec3::X, 2.0)); + let (p, q) = clip_segment(Vec3::ZERO, Vec3::new(10.0, 4.0, 0.0), clip).unwrap(); + assert_eq!(p, Vec3::ZERO); + assert!((q.x - 2.0).abs() < 1e-5 && (q.y - 0.8).abs() < 1e-5, "{q:?}"); + assert!(clip_segment(Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0), clip).is_none()); + assert!(clip_segment(Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0), None).is_some()); + } + #[test] fn renders_a_triangle() { let mesh = Mesh { @@ -291,6 +367,7 @@ mod tests { background: None, radius: 20.0, clip: None, + cap: None, }; let mut cam = Camera { distance: 50.0, ..Default::default() }; cam.set_view(StandardView::Front); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7b68414a..e4b23a41 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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; @@ -523,7 +524,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) { @@ -563,3 +566,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 { + 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); + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 9cd55559..b752919c 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -342,7 +342,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.body_visible(&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); } } @@ -437,28 +437,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(); @@ -3016,7 +2994,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; @@ -3026,7 +3004,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] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a1211295..e833f5a4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -55,7 +55,7 @@ rolls the numbering back with the design), so they can go straight into later co | `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) | +| `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.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 |