319 lines
13 KiB
Rust
319 lines
13 KiB
Rust
//! What the viewport shows, independent of the UI toolkit: body meshes, edges, sketches, the
|
|
//! grid and the origin. Used by the headless snapshot renderer; the GPU viewport draws the same
|
|
//! content.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use solvecraft_geom::{Aabb3, Plane, Vec3};
|
|
use solvecraft_render::{Camera, Rgb, Scene, SceneLine, SceneMesh};
|
|
use solvecraft_sketch::Sketch;
|
|
|
|
use crate::{Sel, Session};
|
|
|
|
/// Colours (shared with the GPU viewport).
|
|
pub mod colors {
|
|
use solvecraft_render::Rgb;
|
|
pub const BODY: Rgb = Rgb(176, 186, 198);
|
|
pub const BODY_SELECTED: Rgb = Rgb(110, 170, 235);
|
|
pub const EDGE: Rgb = Rgb(40, 44, 52);
|
|
pub const SKETCH: Rgb = Rgb(30, 90, 200);
|
|
pub const SKETCH_DONE: Rgb = Rgb(20, 20, 20);
|
|
pub const SKETCH_CONSTRUCTION: Rgb = Rgb(230, 130, 40);
|
|
pub const SKETCH_FIXED: Rgb = Rgb(20, 20, 20);
|
|
/// Fixed (locked) geometry.
|
|
pub const SKETCH_LOCKED: Rgb = Rgb(0, 140, 60);
|
|
pub const SKETCH_CENTERLINE: Rgb = Rgb(200, 110, 60);
|
|
/// Linked (projected) reference geometry.
|
|
pub const SKETCH_PROJECTED: Rgb = Rgb(150, 60, 190);
|
|
/// Linked geometry whose reference was lost.
|
|
pub const SKETCH_LOST: Rgb = Rgb(200, 60, 90);
|
|
pub const PROFILE: Rgb = Rgb(255, 196, 120);
|
|
pub const GRID: Rgb = Rgb(196, 202, 212);
|
|
pub const GRID_MAJOR: Rgb = Rgb(170, 178, 190);
|
|
pub const AXIS_X: Rgb = Rgb(220, 60, 60);
|
|
pub const AXIS_Y: Rgb = Rgb(60, 170, 60);
|
|
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);
|
|
}
|
|
|
|
/// Bounds of everything visible.
|
|
pub fn bounds(s: &Session) -> Aabb3 {
|
|
let st = s.world_state();
|
|
let mut b = Aabb3::EMPTY;
|
|
let v = &s.visibility;
|
|
for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) {
|
|
b = b.union(&body.mesh().bounds());
|
|
}
|
|
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
|
|
if let Some((lo, hi)) = ss.sketch.bounds() {
|
|
for p in [lo, hi] {
|
|
b.add(ss.plane.to_world(p));
|
|
}
|
|
}
|
|
}
|
|
b
|
|
}
|
|
|
|
/// Grid spacing (minor, major) for a view of the given half height.
|
|
pub fn grid_step(half_height: f64) -> (f64, f64) {
|
|
let raw = (half_height / 10.0).max(1e-3);
|
|
let p = 10f64.powf(raw.log10().floor());
|
|
let minor = if raw / p < 2.0 {
|
|
p
|
|
} else if raw / p < 5.0 {
|
|
2.0 * p
|
|
} else {
|
|
5.0 * p
|
|
};
|
|
(minor, minor * 5.0)
|
|
}
|
|
|
|
/// Sketch curves as world polylines with their colour.
|
|
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
|
|
let mut out = Vec::new();
|
|
for (i, c) in sk.curves.iter().enumerate() {
|
|
if c.link.is_some() && sk.view.hide_projected && !sk.is_text_curve(i) {
|
|
continue;
|
|
}
|
|
let col = if c.link.is_some() && !sk.is_text_curve(i) {
|
|
if sk.is_lost_curve(i) { colors::SKETCH_LOST } else { colors::SKETCH_PROJECTED }
|
|
} else if c.fixed {
|
|
colors::SKETCH_LOCKED
|
|
} else if c.centerline {
|
|
colors::SKETCH_CENTERLINE
|
|
} else if c.construction {
|
|
colors::SKETCH_CONSTRUCTION
|
|
} else if !active {
|
|
colors::SKETCH_DONE
|
|
} else if determined.get(i).copied().unwrap_or(false) {
|
|
colors::SKETCH_FIXED
|
|
} else {
|
|
colors::SKETCH
|
|
};
|
|
for seg in sk.segs(i) {
|
|
out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline));
|
|
}
|
|
}
|
|
// 3D curves are in world coordinates already.
|
|
for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) {
|
|
// Curves drawn in a 3D sketch look like the sketch's own; included ones are references.
|
|
let col = if w.fit.is_empty() {
|
|
colors::SKETCH_PROJECTED
|
|
} else if active {
|
|
colors::SKETCH
|
|
} else {
|
|
colors::SKETCH_DONE
|
|
};
|
|
out.push((w.pts.clone(), col, false));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Should sketch point `i` be drawn? Text outlines' points are not (they are not picked or
|
|
/// constrained individually).
|
|
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
|
match sk.points.get(i).and_then(|p| p.link.as_deref()) {
|
|
Some(l) => sk.link(l).is_none_or(|l| l.kind != solvecraft_sketch::LinkKind::Text),
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
/// The scene for a headless render.
|
|
pub fn scene(s: &Session, cam: &Camera) -> 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 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).
|
|
let (minor, major) = grid_step(cam.half_height());
|
|
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
|
|
let tf = cam.to_frame(cam.target);
|
|
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
|
|
let w = |p: Vec3| cam.from_frame(p);
|
|
let n = ((ext / minor) as i64).clamp(1, 400);
|
|
for i in -n..=n {
|
|
let t = i as f64 * minor;
|
|
let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID };
|
|
sc.lines.push(SceneLine {
|
|
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
|
|
color: col,
|
|
width: 0.6,
|
|
on_top: false,
|
|
});
|
|
sc.lines.push(SceneLine {
|
|
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
|
|
color: col,
|
|
width: 0.6,
|
|
on_top: false,
|
|
});
|
|
}
|
|
let axis = cam.half_height() * 0.25;
|
|
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
|
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
|
|
}
|
|
for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) {
|
|
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
|
let mesh = body.mesh();
|
|
// The body's appearance (or imported colour) and faces with looks of their own, as in
|
|
// the viewport.
|
|
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
|
let color =
|
|
if selected { colors::BODY_SELECTED } else { s.doc.body_color(body).map_or(colors::BODY, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) };
|
|
let face_colors = if selected {
|
|
Vec::new()
|
|
} else {
|
|
s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect()
|
|
};
|
|
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors });
|
|
for (i, e) in mesh.edges.iter().enumerate() {
|
|
if mesh.seams.get(i).copied().unwrap_or(false) {
|
|
continue;
|
|
}
|
|
sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false });
|
|
}
|
|
}
|
|
for ss in &st.sketches {
|
|
let active = s.active_sketch == Some(ss.feature);
|
|
if !sketch_shown(s, ss.feature) {
|
|
continue;
|
|
}
|
|
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
|
|
sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active });
|
|
}
|
|
}
|
|
sc
|
|
}
|
|
|
|
/// 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 {
|
|
let v = &s.visibility;
|
|
s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id)))
|
|
}
|
|
|
|
/// Is a sketch used by a later feature?
|
|
pub fn sketch_consumed(s: &Session, id: u64) -> bool {
|
|
use solvecraft_doc::FeatureKind;
|
|
s.doc.features.iter().any(|f| matches!(f.kind, FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if sketch == id))
|
|
}
|
|
|
|
/// Camera framing the model (iso view).
|
|
pub fn home_camera(s: &Session) -> Camera {
|
|
let mut c = Camera::default();
|
|
let b = bounds(s);
|
|
if !b.is_empty() {
|
|
c.fit(&b);
|
|
} else {
|
|
c.distance = 150.0;
|
|
}
|
|
c
|
|
}
|
|
|
|
/// Construction planes of the timeline, as evaluated: (feature id, name, plane).
|
|
pub fn construction_planes(s: &Session) -> Vec<(u64, String, Plane)> {
|
|
constructs(s)
|
|
.into_iter()
|
|
.filter_map(|(id, name, g)| match g {
|
|
solvecraft_doc::construct::ConstructGeom::Plane(p) => Some((id, name, p)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Construction axes, as evaluated: (feature id, name, origin, unit direction).
|
|
pub fn construction_axes(s: &Session) -> Vec<(u64, String, Vec3, Vec3)> {
|
|
constructs(s)
|
|
.into_iter()
|
|
.filter_map(|(id, name, g)| match g {
|
|
solvecraft_doc::construct::ConstructGeom::Axis { origin, dir } => Some((id, name, origin, dir)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Construction points, as evaluated: (feature id, name, position).
|
|
pub fn construction_points(s: &Session) -> Vec<(u64, String, Vec3)> {
|
|
constructs(s)
|
|
.into_iter()
|
|
.filter_map(|(id, name, g)| match g {
|
|
solvecraft_doc::construct::ConstructGeom::Point(p) => Some((id, name, p)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Construction geometry of unsuppressed features, shown where its component is placed.
|
|
fn constructs(s: &Session) -> Vec<(u64, String, solvecraft_doc::construct::ConstructGeom)> {
|
|
use solvecraft_doc::construct::ConstructGeom;
|
|
let st = s.model.state();
|
|
st.construct
|
|
.iter()
|
|
.filter_map(|c| {
|
|
let f = s.doc.feature(c.feature).filter(|f| !f.suppressed)?;
|
|
let m = s.doc.component_transform(f.component);
|
|
if solvecraft_doc::is_identity(&m) {
|
|
return Some((c.feature, c.name.clone(), c.geom.clone()));
|
|
}
|
|
let (pt, v) = (|p: Vec3| solvecraft_doc::apply_point(&m, p), |d: Vec3| solvecraft_doc::apply_vector(&m, d));
|
|
let g = match &c.geom {
|
|
ConstructGeom::Plane(p) => ConstructGeom::Plane(Plane::new(pt(p.origin), v(p.x), v(p.y)).unwrap_or(*p)),
|
|
ConstructGeom::Axis { origin, dir } => ConstructGeom::Axis { origin: pt(*origin), dir: v(*dir) },
|
|
ConstructGeom::Point(p) => ConstructGeom::Point(pt(*p)),
|
|
};
|
|
Some((c.feature, c.name.clone(), g))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
|
|
/// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and
|
|
/// faces with looks of their own.
|
|
#[test]
|
|
fn snapshot_scene_carries_appearances() {
|
|
let mut s = Session::default();
|
|
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
|
s.execute("solid.cylinder", &json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})).unwrap();
|
|
s.execute("appearance.assign", &json!({"bodies": ["Body2"], "color": [220, 60, 40]})).unwrap();
|
|
s.execute("appearance.assign", &json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})).unwrap();
|
|
let cam = home_camera(&s);
|
|
let sc = scene(&s, &cam);
|
|
assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40)));
|
|
assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80))));
|
|
// And the rendered picture has the green top.
|
|
let img = solvecraft_render::render(&sc, &cam, 200, 140);
|
|
assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels");
|
|
}
|
|
|
|
/// Curved surfaces get a dark silhouette line in snapshots where they turn away.
|
|
#[test]
|
|
fn snapshots_draw_silhouettes_on_curved_surfaces() {
|
|
let mut s = Session::default();
|
|
s.execute("solid.sphere", &json!({"diameter": 20})).unwrap();
|
|
let cam = home_camera(&s);
|
|
let img = solvecraft_render::render(&scene(&s, &cam), &cam, 400, 400);
|
|
let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count();
|
|
assert!(dark > 40, "silhouette pixels: {dark}");
|
|
}
|
|
|
|
/// A section shows in snapshots: the cut part is gone and the cut face is drawn.
|
|
#[test]
|
|
fn snapshots_show_the_section() {
|
|
let mut s = Session::default();
|
|
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
|
let cam = home_camera(&s);
|
|
let whole = solvecraft_render::render(&scene(&s, &cam), &cam, 200, 140).rgba;
|
|
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
|
|
let sc = scene(&s, &cam);
|
|
assert!(sc.clip.is_some());
|
|
let cut = solvecraft_render::render(&sc, &cam, 200, 140).rgba;
|
|
let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count();
|
|
assert!(changed > 500, "{changed} pixels changed");
|
|
}
|
|
}
|