Merge pull request #125 from CypherNaught-0x/fix/model-render-section
Model renders show an active section with capped cut faces
This commit is contained in:
7 files changed
+217
-46
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"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)"},
|
||||
|
||||
@@ -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};
|
||||
+91
-14
@@ -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<Rgb>,
|
||||
}
|
||||
|
||||
/// 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<Option<[f64; 3]>> = 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<f64> = 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<Vec<u8>> {
|
||||
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);
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user