From 21e0e99c866284e9a6bf60c8b90093a4d8b43f7f Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:16:49 +0200 Subject: [PATCH] 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 --- crates/engine/src/view.rs | 74 ++++++++++++++++++++++- crates/mcp/src/tools.rs | 3 +- crates/render/src/lib.rs | 2 +- crates/render/src/raster.rs | 105 ++++++++++++++++++++++++++++----- crates/ui-egui/src/control.rs | 49 ++++++++++++++- crates/ui-egui/src/viewport.rs | 28 +-------- docs/control-protocol.md | 2 +- 7 files changed, 217 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 52b3a675..19fa34cb 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. @@ -119,10 +121,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). @@ -172,6 +187,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); @@ -185,6 +206,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 { @@ -315,4 +358,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 938e0de7..78f5762d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -148,7 +148,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 7e8e2cb0..2d755af8 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; @@ -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 { + 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 300f3521..1dc3cc3a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 35a82c20..ad35cbb5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,7 +43,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `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 |