forked from 1b-agent/fieldcraft-upstream
Desktop app: egui/wgpu viewport, model tree, commands and control channel (#9)
* render: display data from the FE model, orbit camera and CPU rasterizer modelcraft-render (L1, no GPU): DisplayMesh (the unshared faces of solid elements, shells, element edges, line elements, nodes), the orbit camera and a CPU rasterizer with PNG output for headless snapshots, adapted from SolidCraft's render crate. Workspace: egui/eframe/egui-wgpu 0.36 and SolidCraft's patched egui-wgpu from the pinned rev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: sample model script (a hex8 block and a tet4 cube) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Desktop app: egui/wgpu viewport, model tree, commands, status bar, control channel modelcraft-ui-egui (L5): ModelApp over the engine Session; GPU viewport (lit faces, element edges, nodes) with a CPU fallback; orbit/pan/zoom, standard views and fit; model tree; commands panel generated from the registry with a JSON parameter form; status bar; control request handling (engine.*, ui.*, ui.screenshot, ui.render). apps/modelcraft: eframe window, --sample, --control PORT loopback JSON-lines server. Adapted from SolidCraft's ui-egui and app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * THIRD-PARTY-LICENSES: the desktop app's dependencies Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: control protocol, desktop app in architecture, roadmap and attribution Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * render: simplify the display mesh (sorted face keys over maps, no unused fields) Interior faces are found by sorting fixed-size face keys instead of a BTreeMap of Vecs, edges are deduplicated by sort, and each element's corners are looked up once. Drops the unused `element` fields on DisplayTri/DisplayLine, the unused Mat4::IDENTITY and corner_count, and hoists the line colour out of the raster loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * viewport: split GPU geometry into batches within the buffer limit; refuse oversized CPU renders A mesh with a few million boundary triangles made one vertex buffer larger than the device's max_buffer_size (256 MiB under the limits eframe requests), and wgpu's default error handler panicked. Geometry is now uploaded in batches of whole primitives, each at most 128 MiB and the device limit, and all are drawn. A device error handler logs errors and shows them in the status bar instead of panicking. The CPU renderer refuses sizes over 8192 per side or 16 MP, and scenes over 20 million primitives, with an error, and allocates its buffers fallibly. ui.render reports a size out of range instead of clamping it; the CPU viewport fits a large window into the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
27 files changed
+13965
-895
No files matched your search
@@ -0,0 +1,327 @@
|
||||
//! An orbit camera about a target point, Z up, with perspective or orthographic projection.
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/render/src/camera.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Aabb3, Vec3};
|
||||
|
||||
/// Column-major 4×4 matrix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4(pub [[f64; 4]; 4]);
|
||||
|
||||
impl Mat4 {
|
||||
pub fn mul(&self, o: &Mat4) -> Mat4 {
|
||||
let mut r = [[0.0; 4]; 4];
|
||||
for (c, col) in r.iter_mut().enumerate() {
|
||||
for (rr, cell) in col.iter_mut().enumerate() {
|
||||
*cell = (0..4).map(|k| self.0[k][rr] * o.0[c][k]).sum();
|
||||
}
|
||||
}
|
||||
Mat4(r)
|
||||
}
|
||||
/// Transform a point; returns (x, y, z, w).
|
||||
pub fn apply(&self, p: Vec3) -> [f64; 4] {
|
||||
let v = [p.x, p.y, p.z, 1.0];
|
||||
let mut out = [0.0; 4];
|
||||
for (r, o) in out.iter_mut().enumerate() {
|
||||
*o = (0..4).map(|c| self.0[c][r] * v[c]).sum();
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn to_f32(&self) -> [[f32; 4]; 4] {
|
||||
self.0.map(|c| c.map(|x| x as f32))
|
||||
}
|
||||
}
|
||||
|
||||
/// Named view orientations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StandardView {
|
||||
Front,
|
||||
Back,
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
Iso,
|
||||
}
|
||||
|
||||
impl StandardView {
|
||||
pub const ALL: [StandardView; 7] = [
|
||||
StandardView::Front,
|
||||
StandardView::Back,
|
||||
StandardView::Left,
|
||||
StandardView::Right,
|
||||
StandardView::Top,
|
||||
StandardView::Bottom,
|
||||
StandardView::Iso,
|
||||
];
|
||||
|
||||
pub fn parse(s: &str) -> Option<StandardView> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"front" => StandardView::Front,
|
||||
"back" => StandardView::Back,
|
||||
"top" => StandardView::Top,
|
||||
"bottom" => StandardView::Bottom,
|
||||
"left" => StandardView::Left,
|
||||
"right" => StandardView::Right,
|
||||
"iso" | "isometric" => StandardView::Iso,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StandardView::Front => "Front",
|
||||
StandardView::Back => "Back",
|
||||
StandardView::Top => "Top",
|
||||
StandardView::Bottom => "Bottom",
|
||||
StandardView::Left => "Left",
|
||||
StandardView::Right => "Right",
|
||||
StandardView::Iso => "Iso",
|
||||
}
|
||||
}
|
||||
/// (yaw, pitch) in radians. Yaw 0 looks along +Y (front view); the iso view looks from the
|
||||
/// front-right-top corner. Pitch is the elevation above the XY plane.
|
||||
pub fn angles(self) -> (f64, f64) {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
match self {
|
||||
StandardView::Front => (0.0, 0.0),
|
||||
StandardView::Back => (PI, 0.0),
|
||||
StandardView::Right => (-FRAC_PI_2, 0.0),
|
||||
StandardView::Left => (FRAC_PI_2, 0.0),
|
||||
StandardView::Top => (0.0, FRAC_PI_2),
|
||||
StandardView::Bottom => (0.0, -FRAC_PI_2),
|
||||
// atan(1/√2): the elevation of the cube diagonal.
|
||||
StandardView::Iso => (-FRAC_PI_4, 0.6154797086703874),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target`, Z up.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Camera {
|
||||
pub target: Vec3,
|
||||
/// Rotation about Z; 0 = looking along +Y (from the front).
|
||||
pub yaw: f64,
|
||||
/// Elevation above the XY plane, in [−π/2, π/2].
|
||||
pub pitch: f64,
|
||||
/// Distance from target to eye.
|
||||
pub distance: f64,
|
||||
/// Vertical field of view (radians); 0 = orthographic.
|
||||
pub fov: f64,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
let (yaw, pitch) = StandardView::Iso.angles();
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Direction from the target toward the eye.
|
||||
pub fn back(&self) -> Vec3 {
|
||||
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
|
||||
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
|
||||
}
|
||||
pub fn eye(&self) -> Vec3 {
|
||||
self.target + self.back() * self.distance
|
||||
}
|
||||
/// Camera basis: right, up, back (orthonormal).
|
||||
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
|
||||
let b = self.back();
|
||||
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
|
||||
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
|
||||
(right, up, b)
|
||||
}
|
||||
pub fn view(&self) -> Mat4 {
|
||||
let (r, u, b) = self.basis();
|
||||
let e = self.eye();
|
||||
Mat4([[r.x, u.x, b.x, 0.0], [r.y, u.y, b.y, 0.0], [r.z, u.z, b.z, 0.0], [-r.dot(e), -u.dot(e), -b.dot(e), 1.0]])
|
||||
}
|
||||
/// Half the visible height at the target distance.
|
||||
pub fn half_height(&self) -> f64 {
|
||||
if self.fov > 0.0 { self.distance * (self.fov * 0.5).tan() } else { self.distance * 0.4 }
|
||||
}
|
||||
/// Near/far planes that keep a scene of radius `radius` around the target visible.
|
||||
pub fn clip(&self, radius: f64) -> (f64, f64) {
|
||||
let r = radius.max(1e-3);
|
||||
if self.fov > 0.0 {
|
||||
((self.distance - r * 2.0).max(self.distance * 1e-3).max(1e-3), self.distance + r * 2.0)
|
||||
} else {
|
||||
(self.distance - r * 4.0 - 1.0, self.distance + r * 4.0 + 1.0)
|
||||
}
|
||||
}
|
||||
/// Projection to clip space (wgpu style, depth 0..1).
|
||||
pub fn proj(&self, aspect: f64, near: f64, far: f64) -> Mat4 {
|
||||
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 };
|
||||
let (n, f) = (near, far.max(near + 1e-6));
|
||||
if self.fov > 0.0 {
|
||||
let t = 1.0 / (self.fov * 0.5).tan();
|
||||
Mat4([[t / aspect, 0.0, 0.0, 0.0], [0.0, t, 0.0, 0.0], [0.0, 0.0, f / (n - f), -1.0], [0.0, 0.0, n * f / (n - f), 0.0]])
|
||||
} else {
|
||||
let h = self.half_height();
|
||||
let w = h * aspect;
|
||||
Mat4([[1.0 / w, 0.0, 0.0, 0.0], [0.0, 1.0 / h, 0.0, 0.0], [0.0, 0.0, 1.0 / (n - f), 0.0], [0.0, 0.0, n / (n - f), 1.0]])
|
||||
}
|
||||
}
|
||||
pub fn view_proj(&self, aspect: f64, radius: f64) -> Mat4 {
|
||||
let (n, f) = self.clip(radius);
|
||||
self.proj(aspect, n, f).mul(&self.view())
|
||||
}
|
||||
/// World point → pixel coordinates (x right, y down) and depth (0 near … 1 far).
|
||||
pub fn to_screen(&self, p: Vec3, w: f64, h: f64, radius: f64) -> Option<(f64, f64, f64)> {
|
||||
let c = self.view_proj(w / h.max(1.0), radius).apply(p);
|
||||
if c[3].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]);
|
||||
(c[3] > 0.0).then_some(((x + 1.0) * 0.5 * w, (1.0 - y) * 0.5 * h, z))
|
||||
}
|
||||
/// Picking ray through pixel (x, y): origin and unit direction.
|
||||
pub fn ray(&self, x: f64, y: f64, w: f64, h: f64) -> (Vec3, Vec3) {
|
||||
let (r, u, b) = self.basis();
|
||||
let hh = self.half_height();
|
||||
let aspect = w / h.max(1.0);
|
||||
let nx = (x / w.max(1.0)) * 2.0 - 1.0;
|
||||
let ny = 1.0 - (y / h.max(1.0)) * 2.0;
|
||||
if self.fov > 0.0 {
|
||||
let d = (r * (nx * hh * aspect) + u * (ny * hh) - b * self.distance).normalized().unwrap_or(-b);
|
||||
(self.eye(), d)
|
||||
} else {
|
||||
let o = self.target + r * (nx * hh * aspect) + u * (ny * hh) + b * (self.distance * 4.0);
|
||||
(o, -b)
|
||||
}
|
||||
}
|
||||
/// The point under pixel (x, y) on the plane through the target facing the eye: the anchor
|
||||
/// that stays put when zooming at the cursor.
|
||||
pub fn target_plane_point(&self, x: f64, y: f64, w: f64, h: f64) -> Option<Vec3> {
|
||||
let (o, d) = self.ray(x, y, w, h);
|
||||
let b = self.back();
|
||||
let dn = d.dot(b);
|
||||
if dn.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let p = o + d * ((self.target - o).dot(b) / dn);
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
/// Zoom by `factor` (< 1 zooms in) toward a world point that stays put on screen.
|
||||
pub fn zoom_at(&mut self, factor: f64, anchor: Option<Vec3>) {
|
||||
let f = if factor.is_finite() { factor.clamp(0.05, 20.0) } else { 1.0 };
|
||||
let nd = (self.distance * f).clamp(1e-3, 1e9);
|
||||
if let Some(a) = anchor.filter(|a| a.is_finite()) {
|
||||
self.target = a + (self.target - a) * (nd / self.distance);
|
||||
}
|
||||
self.distance = nd;
|
||||
}
|
||||
/// Pan by a screen delta in pixels for a viewport of height `h`.
|
||||
pub fn pan(&mut self, dx: f64, dy: f64, h: f64) {
|
||||
if !(dx.is_finite() && dy.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (r, u, _) = self.basis();
|
||||
let k = 2.0 * self.half_height() / h.max(1.0);
|
||||
self.target = self.target - r * (dx * k) + u * (dy * k);
|
||||
}
|
||||
/// Orbit by pixel deltas. The model follows the cursor: dragging right turns the side
|
||||
/// facing the viewer to the right.
|
||||
pub fn orbit(&mut self, dx: f64, dy: f64) {
|
||||
if !(dx.is_finite() && dy.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-4;
|
||||
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
|
||||
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
|
||||
}
|
||||
pub fn set_view(&mut self, v: StandardView) {
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let (y, p) = v.angles();
|
||||
self.yaw = y;
|
||||
self.pitch = p.clamp(-lim, lim);
|
||||
}
|
||||
/// Frame a bounding box.
|
||||
pub fn fit(&mut self, b: &Aabb3) {
|
||||
if b.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.target = b.center();
|
||||
let r = (b.diagonal() * 0.5).max(1e-3);
|
||||
self.distance = if self.fov > 0.0 { r / (self.fov * 0.5).sin() * 1.1 } else { r / 0.4 * 1.15 };
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn front_view_projects_target_to_centre() {
|
||||
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
let (x, y, z) = c.to_screen(c.target, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!((x - 400.0).abs() < 1e-6 && (y - 300.0).abs() < 1e-6 && (0.0..=1.0).contains(&z));
|
||||
// Front view looks along +Y: +X is to the right, +Z up.
|
||||
let (xr, _, _) = c.to_screen(c.target + Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||||
let (_, yu, _) = c.to_screen(c.target + Vec3::Z, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!(xr > 400.0 && yu < 300.0);
|
||||
let (o, d) = c.ray(400.0, 300.0, 800.0, 600.0);
|
||||
assert!(d.dist(Vec3::Y) < 1e-9, "{d:?}");
|
||||
assert!((o - c.target).cross(d).len() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_orbit_follows_the_cursor() {
|
||||
let mut c = Camera { distance: 50.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
let near = Vec3::new(0.0, -1.0, 0.0);
|
||||
let x0 = c.to_screen(near, 800.0, 600.0, 10.0).unwrap().0;
|
||||
c.orbit(20.0, 0.0);
|
||||
let x1 = c.to_screen(near, 800.0, 600.0, 10.0).unwrap().0;
|
||||
assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_view_and_perspective() {
|
||||
let mut c = Camera { distance: 100.0, fov: 0.8, ..Default::default() };
|
||||
c.set_view(StandardView::Top);
|
||||
let (xr, _, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||||
let (_, yu, _) = c.to_screen(Vec3::Y, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!(xr > 400.0 && yu < 300.0, "{xr} {yu}");
|
||||
let near = c.to_screen(Vec3::new(0.0, 0.0, 10.0), 800.0, 600.0, 20.0).unwrap().2;
|
||||
let far = c.to_screen(Vec3::new(0.0, 0.0, -10.0), 800.0, 600.0, 20.0).unwrap().2;
|
||||
assert!(near < far);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_at_the_cursor_keeps_the_point_under_it() {
|
||||
let mut c = Camera { distance: 100.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
let a = c.target_plane_point(600.0, 200.0, 800.0, 600.0).unwrap();
|
||||
c.zoom_at(0.5, Some(a));
|
||||
let (x, y, _) = c.to_screen(a, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!((x - 600.0).abs() < 1e-6 && (y - 200.0).abs() < 1e-6, "{x} {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_hostile() {
|
||||
let mut c = Camera::default();
|
||||
let b = Aabb3 { min: Vec3::ZERO, max: Vec3::new(40.0, 30.0, 20.0) };
|
||||
c.fit(&b);
|
||||
assert!(c.target.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-9);
|
||||
c.zoom_at(f64::NAN, Some(Vec3::new(f64::NAN, 0.0, 0.0)));
|
||||
c.orbit(1e9, -1e9);
|
||||
c.orbit(f64::NAN, 0.0);
|
||||
c.pan(f64::INFINITY, 0.0, 100.0);
|
||||
assert!(c.is_valid());
|
||||
c.fit(&Aabb3::EMPTY);
|
||||
assert!(c.is_valid());
|
||||
for v in StandardView::ALL {
|
||||
assert_eq!(StandardView::parse(v.label()), Some(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! What a finite element model looks like on screen: the faces nobody shares (the skin of the
|
||||
//! solid elements, and every shell), the element edges on those faces, line elements and nodes.
|
||||
//!
|
||||
//! Faces shared by two solid elements are inside the mesh and are left out, so a model of a
|
||||
//! million elements draws its surface, not its interior. Higher-order elements (tet10, hex20,
|
||||
//! tri6, quad8) draw through their corner nodes. The output is deterministic: faces come out in
|
||||
//! element label order, edges sorted.
|
||||
|
||||
use modelcraft_model::{ElementKind, Model};
|
||||
|
||||
use crate::{Aabb3, Vec3};
|
||||
|
||||
/// One drawn triangle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DisplayTri {
|
||||
pub p: [Vec3; 3],
|
||||
/// The face's unit normal, pointing out of its element (a shell's own orientation).
|
||||
pub normal: Vec3,
|
||||
pub kind: ElementKind,
|
||||
}
|
||||
|
||||
/// A line element.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DisplayLine {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
pub kind: ElementKind,
|
||||
}
|
||||
|
||||
/// Display data for a model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DisplayMesh {
|
||||
/// Visible faces as triangles (quads are split in two).
|
||||
pub tris: Vec<DisplayTri>,
|
||||
/// Element edges on the visible faces, each once.
|
||||
pub edges: Vec<[Vec3; 2]>,
|
||||
/// Line elements (rods, beams).
|
||||
pub lines: Vec<DisplayLine>,
|
||||
/// Every node.
|
||||
pub nodes: Vec<Vec3>,
|
||||
pub bounds: Aabb3,
|
||||
}
|
||||
|
||||
/// The faces of an element as corner indices (0-based into its node list), each wound
|
||||
/// counter-clockwise seen from outside for a positively oriented element.
|
||||
fn faces(kind: ElementKind) -> &'static [&'static [usize]] {
|
||||
match kind {
|
||||
ElementKind::Rod2 | ElementKind::Beam2 => &[],
|
||||
ElementKind::Tri3 | ElementKind::Tri6 => &[&[0, 1, 2]],
|
||||
ElementKind::Quad4 | ElementKind::Quad8 => &[&[0, 1, 2, 3]],
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => &[&[0, 2, 1], &[0, 1, 3], &[1, 2, 3], &[2, 0, 3]],
|
||||
ElementKind::Wedge6 => &[&[0, 2, 1], &[3, 4, 5], &[0, 1, 4, 3], &[1, 2, 5, 4], &[2, 0, 3, 5]],
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => &[&[0, 3, 2, 1], &[4, 5, 6, 7], &[0, 1, 5, 4], &[1, 2, 6, 5], &[2, 3, 7, 6], &[3, 0, 4, 7]],
|
||||
}
|
||||
}
|
||||
|
||||
/// Most corners of any element (hex).
|
||||
const MAX_CORNERS: usize = 8;
|
||||
|
||||
/// A face's identity regardless of winding and starting corner: its sorted node labels, a
|
||||
/// triangle padded with `u32::MAX`.
|
||||
fn face_key(e: &[u32], face: &[usize]) -> Option<[u32; 4]> {
|
||||
let mut k = [u32::MAX; 4];
|
||||
for (slot, &i) in k.iter_mut().zip(face) {
|
||||
*slot = *e.get(i)?;
|
||||
}
|
||||
k.sort_unstable();
|
||||
Some(k)
|
||||
}
|
||||
|
||||
/// Newell's normal of a polygon (robust for non-planar quads); `None` when degenerate.
|
||||
fn polygon_normal(p: &[Vec3]) -> Option<Vec3> {
|
||||
let mut n = Vec3::ZERO;
|
||||
for (i, a) in p.iter().enumerate() {
|
||||
let b = p.get((i + 1) % p.len()).copied().unwrap_or(*a);
|
||||
n = n + Vec3::new((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y));
|
||||
}
|
||||
n.normalized()
|
||||
}
|
||||
|
||||
fn centre(p: &[Vec3]) -> Vec3 {
|
||||
p.iter().fold(Vec3::ZERO, |s, q| s + *q) * (1.0 / p.len().max(1) as f64)
|
||||
}
|
||||
|
||||
impl DisplayMesh {
|
||||
pub fn from_model(model: &Model) -> DisplayMesh {
|
||||
let mut out = DisplayMesh::default();
|
||||
let pos = |label: u32| model.node(label).map(|n| Vec3::from(n.xyz));
|
||||
for n in model.nodes().values() {
|
||||
let p = Vec3::from(n.xyz);
|
||||
out.nodes.push(p);
|
||||
out.bounds.add(p);
|
||||
}
|
||||
// Faces two solid elements share are inside the mesh: sort every solid face's key and
|
||||
// keep the ones that occur more than once.
|
||||
let mut keys: Vec<[u32; 4]> = model
|
||||
.elements()
|
||||
.values()
|
||||
.filter(|e| e.kind.dimension() == 3)
|
||||
.flat_map(|e| faces(e.kind).iter().filter_map(|f| face_key(&e.nodes, f)))
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
let mut inner: Vec<[u32; 4]> = keys.windows(2).filter(|w| w.first() == w.get(1)).filter_map(|w| w.first().copied()).collect();
|
||||
inner.dedup();
|
||||
drop(keys);
|
||||
let mut edges: Vec<(u32, u32)> = Vec::new();
|
||||
for e in model.elements().values() {
|
||||
// Corners: one past the highest face index (line elements: their two ends).
|
||||
let corners = faces(e.kind).iter().flat_map(|f| f.iter()).max().map_or(2, |m| m + 1).min(MAX_CORNERS);
|
||||
let mut at = [Vec3::ZERO; MAX_CORNERS];
|
||||
let mut found = 0;
|
||||
for (slot, &n) in at.iter_mut().zip(e.nodes.iter().take(corners)) {
|
||||
if let Some(p) = pos(n) {
|
||||
*slot = p;
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
if found < corners {
|
||||
continue;
|
||||
}
|
||||
let at = at.get(..corners).unwrap_or(&[]);
|
||||
if e.kind.dimension() == 1 {
|
||||
if let (Some(&a), Some(&b)) = (at.first(), at.get(1)) {
|
||||
out.lines.push(DisplayLine { a, b, kind: e.kind });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let solid = e.kind.dimension() == 3;
|
||||
let element_centre = centre(at);
|
||||
for f in faces(e.kind) {
|
||||
if solid && face_key(&e.nodes, f).is_some_and(|k| inner.binary_search(&k).is_ok()) {
|
||||
continue;
|
||||
}
|
||||
let p: Vec<Vec3> = f.iter().filter_map(|&i| at.get(i).copied()).collect();
|
||||
let Some(mut normal) = polygon_normal(&p) else { continue };
|
||||
if solid && normal.dot(centre(&p) - element_centre) < 0.0 {
|
||||
normal = -normal;
|
||||
}
|
||||
for k in 1..p.len().saturating_sub(1) {
|
||||
if let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(k), p.get(k + 1)) {
|
||||
out.tris.push(DisplayTri { p: [a, b, c], normal, kind: e.kind });
|
||||
}
|
||||
}
|
||||
for (j, &i) in f.iter().enumerate() {
|
||||
let i2 = f.get((j + 1) % f.len()).copied().unwrap_or(i);
|
||||
if let (Some(&a), Some(&b)) = (e.nodes.get(i), e.nodes.get(i2)) {
|
||||
edges.push((a.min(b), a.max(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
out.edges = edges.into_iter().filter_map(|(a, b)| Some([pos(a)?, pos(b)?])).collect();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `nx × ny × nz` unit hex8 cells.
|
||||
fn block(nx: u32, ny: u32, nz: u32) -> Model {
|
||||
let mut m = Model::default();
|
||||
let id = |i: u32, j: u32, k: u32| 1 + i + j * (nx + 1) + k * (nx + 1) * (ny + 1);
|
||||
for k in 0..=nz {
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
m.set_node(id(i, j, k), [i as f64, j as f64, k as f64]).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut label = 1;
|
||||
for k in 0..nz {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let n = vec![
|
||||
id(i, j, k),
|
||||
id(i + 1, j, k),
|
||||
id(i + 1, j + 1, k),
|
||||
id(i, j + 1, k),
|
||||
id(i, j, k + 1),
|
||||
id(i + 1, j, k + 1),
|
||||
id(i + 1, j + 1, k + 1),
|
||||
id(i, j + 1, k + 1),
|
||||
];
|
||||
m.set_element(label, ElementKind::Hex8, n).unwrap();
|
||||
label += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_shows_its_skin_only() {
|
||||
let d = DisplayMesh::from_model(&block(3, 2, 2));
|
||||
// Skin quads: 2·(3·2 + 3·2 + 2·2) = 32, two triangles each.
|
||||
assert_eq!(d.tris.len(), 64);
|
||||
assert_eq!(d.nodes.len(), 4 * 3 * 3);
|
||||
assert_eq!(d.bounds.max, Vec3::new(3.0, 2.0, 2.0));
|
||||
// Every normal points out of the block.
|
||||
let c = d.bounds.center();
|
||||
for t in &d.tris {
|
||||
let fc = (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
|
||||
assert!(t.normal.dot(fc - c) > 0.0, "{t:?}");
|
||||
}
|
||||
// Euler on the skin: V - E + F = 2 (two nodes are inside).
|
||||
let skin_nodes = 4 * 3 * 3 - 2;
|
||||
assert_eq!(skin_nodes as i64 - d.edges.len() as i64 + 32, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shells_lines_and_tets_draw() {
|
||||
let mut m = Model::default();
|
||||
for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 1., 0.]].into_iter().enumerate() {
|
||||
m.set_node(i as u32 + 1, p).unwrap();
|
||||
}
|
||||
m.set_element(1, ElementKind::Tet4, vec![1, 2, 3, 4]).unwrap();
|
||||
m.set_element(2, ElementKind::Tri3, vec![2, 5, 3]).unwrap();
|
||||
m.set_element(3, ElementKind::Beam2, vec![4, 5]).unwrap();
|
||||
let d = DisplayMesh::from_model(&m);
|
||||
assert_eq!(d.tris.len(), 5);
|
||||
assert_eq!(d.lines.len(), 1);
|
||||
assert_eq!(d.edges.len(), 6 + 2);
|
||||
let tet_normals: Vec<Vec3> = d.tris.iter().filter(|t| t.kind == ElementKind::Tet4).map(|t| t.normal).collect();
|
||||
assert!(tet_normals.contains(&Vec3::new(0.0, 0.0, -1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_deterministic() {
|
||||
assert_eq!(DisplayMesh::from_model(&Model::default()), DisplayMesh::default());
|
||||
let m = block(2, 2, 1);
|
||||
assert_eq!(DisplayMesh::from_model(&m), DisplayMesh::from_model(&m.clone()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Display data for ModelCraft, with no GPU dependency: what a finite element model looks like
|
||||
//! on screen ([`DisplayMesh`]: the visible faces, element edges, line elements and nodes), an
|
||||
//! orbit camera with perspective or orthographic projection, and a CPU rasterizer for headless
|
||||
//! snapshots. The desktop viewport draws the same [`Scene`] on the GPU.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod camera;
|
||||
mod display;
|
||||
mod raster;
|
||||
mod vec;
|
||||
|
||||
pub use camera::{Camera, Mat4, StandardView};
|
||||
pub use display::{DisplayLine, DisplayMesh, DisplayTri};
|
||||
pub use raster::{
|
||||
Canvas, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_png, shade,
|
||||
};
|
||||
pub use vec::{Aabb3, Vec3};
|
||||
@@ -0,0 +1,395 @@
|
||||
//! A drawable scene (coloured triangles, lines and points) and a CPU rasterizer for headless
|
||||
//! snapshots: z-buffered, lit triangles and depth-tested lines, rendered at 2× and downsampled
|
||||
//! for anti-aliasing. The GPU viewport draws the same [`Scene`].
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/render/src/raster.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use crate::{Camera, Vec3};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||
|
||||
impl Rgb {
|
||||
fn f(self) -> [f64; 3] {
|
||||
[self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SceneTri {
|
||||
pub p: [Vec3; 3],
|
||||
/// Unit normal; lighting is two-sided.
|
||||
pub normal: Vec3,
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SceneLine {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
pub color: Rgb,
|
||||
/// Width in output pixels.
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// A node marker: a square `size` output pixels across.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ScenePoint {
|
||||
pub p: Vec3,
|
||||
pub color: Rgb,
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Scene {
|
||||
pub tris: Vec<SceneTri>,
|
||||
pub lines: Vec<SceneLine>,
|
||||
pub points: Vec<ScenePoint>,
|
||||
/// Background gradient top → bottom.
|
||||
pub background: Option<(Rgb, Rgb)>,
|
||||
/// Scene radius around the camera target, for the clipping planes.
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// The lit colour of a surface with normal `n` seen along `view_dir` (toward the eye). The GPU
|
||||
/// shader computes the same.
|
||||
pub fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
|
||||
let n = if n.dot(view_dir) < 0.0 { -n } else { n };
|
||||
let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir);
|
||||
let diff = n.dot(key).max(0.0);
|
||||
let fill = n.dot(view_dir).max(0.0);
|
||||
let h = (key + view_dir).normalized().unwrap_or(view_dir);
|
||||
let spec = n.dot(h).max(0.0).powi(40) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
[(base[0] * k + spec).min(1.0), (base[1] * k + spec).min(1.0), (base[2] * k + spec).min(1.0)]
|
||||
}
|
||||
|
||||
/// RGBA pixels plus depth.
|
||||
pub struct Canvas {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
depth: Vec<f32>,
|
||||
}
|
||||
|
||||
const SS: usize = 2;
|
||||
/// Largest output side in pixels.
|
||||
pub const MAX_DIM: usize = 8192;
|
||||
/// Most output pixels (`w × h`): 16 MP, drawn at 2× into 64 MP of colour and depth (~512 MB).
|
||||
pub const MAX_PIXELS: usize = 4096 * 4096;
|
||||
/// Most primitives (triangles, lines and points together) one render draws.
|
||||
pub const MAX_PRIMITIVES: usize = 20_000_000;
|
||||
|
||||
/// Why a render was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RenderError {
|
||||
/// The image size is zero or over [`MAX_DIM`] per side or [`MAX_PIXELS`] in all.
|
||||
Size { w: usize, h: usize },
|
||||
/// The scene has more than [`MAX_PRIMITIVES`] primitives.
|
||||
SceneTooLarge { primitives: usize },
|
||||
/// The image buffers could not be allocated.
|
||||
OutOfMemory,
|
||||
/// The PNG encoder failed.
|
||||
Encode,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RenderError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RenderError::Size { w, h } => {
|
||||
write!(f, "image size {w} x {h} is out of range (1..={MAX_DIM} per side, at most {MAX_PIXELS} pixels)")
|
||||
}
|
||||
RenderError::SceneTooLarge { primitives } => {
|
||||
write!(f, "the scene has {primitives} primitives; the CPU renderer draws at most {MAX_PRIMITIVES}")
|
||||
}
|
||||
RenderError::OutOfMemory => f.write_str("not enough memory for the image"),
|
||||
RenderError::Encode => f.write_str("PNG encoding failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RenderError {}
|
||||
|
||||
/// Whether `w × h` is an image size [`render`] accepts.
|
||||
fn size_ok(w: usize, h: usize) -> bool {
|
||||
(1..=MAX_DIM).contains(&w) && (1..=MAX_DIM).contains(&h) && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS)
|
||||
}
|
||||
|
||||
/// The largest size with the aspect of `w × h` that [`render`] accepts (for drawing a window of
|
||||
/// any size: the image is scaled up on screen).
|
||||
pub fn fit_size(w: usize, h: usize) -> (usize, usize) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
if size_ok(w, h) {
|
||||
return (w, h);
|
||||
}
|
||||
let by_side = MAX_DIM as f64 / w.max(h) as f64;
|
||||
let by_area = (MAX_PIXELS as f64 / (w as f64 * h as f64)).sqrt();
|
||||
let k = by_side.min(by_area);
|
||||
let fit = |v: usize| ((v as f64 * k).floor() as usize).clamp(1, MAX_DIM);
|
||||
(fit(w), fit(h))
|
||||
}
|
||||
/// Largest half-width of a line or point, in supersampled pixels.
|
||||
const MAX_MARK: f64 = 64.0;
|
||||
/// Most samples drawn along one line segment (a segment from far off screen is bounded).
|
||||
const MAX_LINE_STEPS: usize = 20_000;
|
||||
|
||||
impl Canvas {
|
||||
/// A white canvas; the buffers are reserved fallibly, so a size the machine cannot hold is
|
||||
/// an error rather than an abort.
|
||||
fn new(w: usize, h: usize) -> Result<Canvas, RenderError> {
|
||||
let n = w.checked_mul(h).ok_or(RenderError::Size { w, h })?;
|
||||
let bytes = n.checked_mul(4).ok_or(RenderError::Size { w, h })?;
|
||||
let mut rgba = Vec::new();
|
||||
rgba.try_reserve_exact(bytes).map_err(|_| RenderError::OutOfMemory)?;
|
||||
rgba.resize(bytes, 255);
|
||||
let mut depth = Vec::new();
|
||||
depth.try_reserve_exact(n).map_err(|_| RenderError::OutOfMemory)?;
|
||||
depth.resize(n, f32::INFINITY);
|
||||
Ok(Canvas { w, h, rgba, depth })
|
||||
}
|
||||
fn put(&mut self, x: usize, y: usize, c: [f64; 3], a: f64) {
|
||||
let i = (y * self.w + x) * 4;
|
||||
if let Some(px) = self.rgba.get_mut(i..i + 3) {
|
||||
for (k, v) in px.iter_mut().enumerate() {
|
||||
let old = *v as f64 / 255.0;
|
||||
*v = ((old * (1.0 - a) + c[k] * a).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a scene with the camera into an RGBA canvas of `w × h` pixels. Sizes outside
|
||||
/// 1..=[`MAX_DIM`] per side or over [`MAX_PIXELS`], and scenes over [`MAX_PRIMITIVES`], are
|
||||
/// refused ([`fit_size`] gives an accepted size).
|
||||
pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Canvas, RenderError> {
|
||||
if !size_ok(w, h) {
|
||||
return Err(RenderError::Size { w, h });
|
||||
}
|
||||
let primitives = scene.tris.len().saturating_add(scene.lines.len()).saturating_add(scene.points.len());
|
||||
if primitives > MAX_PRIMITIVES {
|
||||
return Err(RenderError::SceneTooLarge { primitives });
|
||||
}
|
||||
let (sw, sh) = (w * SS, h * SS);
|
||||
let mut c = Canvas::new(sw, sh)?;
|
||||
let (top, bot) = scene.background.unwrap_or((Rgb(236, 240, 245), Rgb(205, 212, 222)));
|
||||
let (a, b) = (top.f(), bot.f());
|
||||
for y in 0..sh {
|
||||
let t = y as f64 / sh.max(1) as f64;
|
||||
let col = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
for x in 0..sw {
|
||||
c.put(x, y, col, 1.0);
|
||||
}
|
||||
}
|
||||
let vp = cam.view_proj(sw as f64 / sh as f64, scene.radius.max(1e-3));
|
||||
let project = |p: Vec3| -> Option<[f64; 3]> {
|
||||
let q = vp.apply(p);
|
||||
if q[3] <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
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();
|
||||
for t in &scene.tris {
|
||||
let (Some(a), Some(b), Some(cc)) = (project(t.p[0]), project(t.p[1]), project(t.p[2])) else { continue };
|
||||
let col = shade(t.normal, view_dir, t.color.f());
|
||||
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
|
||||
if !(x0 <= x1 && y0 <= y1) {
|
||||
continue;
|
||||
}
|
||||
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
|
||||
if !(area.abs() >= 1e-12) {
|
||||
continue;
|
||||
}
|
||||
for y in y0 as usize..=y1 as usize {
|
||||
for x in x0 as usize..=x1 as usize {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let w0 = ((b[0] - px) * (cc[1] - py) - (b[1] - py) * (cc[0] - px)) / area;
|
||||
let w1 = ((cc[0] - px) * (a[1] - py) - (cc[1] - py) * (a[0] - px)) / area;
|
||||
let w2 = 1.0 - w0 - w1;
|
||||
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32;
|
||||
let i = y * sw + x;
|
||||
if let Some(d) = c.depth.get_mut(i)
|
||||
&& z < *d
|
||||
{
|
||||
*d = z;
|
||||
c.put(x, y, col, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &scene.lines {
|
||||
let (Some(a), Some(b)) = (project(l.a), project(l.b)) else { continue };
|
||||
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
|
||||
let steps = if len.is_finite() { (len.ceil() as usize).clamp(1, MAX_LINE_STEPS) } else { continue };
|
||||
let col = l.color.f();
|
||||
for k in 0..=steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
let p = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
dot(&mut c, p, l.width * SS as f64, col, true);
|
||||
}
|
||||
}
|
||||
for p in &scene.points {
|
||||
if let Some(q) = project(p.p) {
|
||||
dot(&mut c, q, p.size * SS as f64, p.color.f(), false);
|
||||
}
|
||||
}
|
||||
downsample(&c, w, h)
|
||||
}
|
||||
|
||||
/// A depth-tested disc (`round`) or square of diameter `size` at screen point `p` (x, y, depth).
|
||||
fn dot(c: &mut Canvas, p: [f64; 3], size: f64, col: [f64; 3], round: bool) {
|
||||
// Widths are capped so a hostile width cannot make the loop below unbounded.
|
||||
let half = if size.is_finite() { (size * 0.5).clamp(0.5, MAX_MARK) } else { 0.5 };
|
||||
let r = half.ceil() as i64;
|
||||
let (sw, sh) = (c.w as i64, c.h as i64);
|
||||
if !(p[0].is_finite() && p[1].is_finite()) || p[0] < -half || p[1] < -half || p[0] > sw as f64 + half || p[1] > sh as f64 + half {
|
||||
return;
|
||||
}
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (ix, iy) = (p[0].floor() as i64 + dx, p[1].floor() as i64 + dy);
|
||||
if ix < 0 || iy < 0 || ix >= sw || iy >= sh {
|
||||
continue;
|
||||
}
|
||||
let (ox, oy) = (ix as f64 + 0.5 - p[0], iy as f64 + 0.5 - p[1]);
|
||||
let d = if round { (ox * ox + oy * oy).sqrt() } else { ox.abs().max(oy.abs()) };
|
||||
let cov = (half + 0.5 - d).clamp(0.0, 1.0);
|
||||
let i = iy as usize * c.w + ix as usize;
|
||||
// Lines and points sit on faces: a small depth allowance keeps them in front.
|
||||
if cov > 0.0 && c.depth.get(i).is_some_and(|dd| (p[2] as f32) <= *dd + 2e-4) {
|
||||
c.put(ix as usize, iy as usize, col, cov);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn downsample(c: &Canvas, w: usize, h: usize) -> Result<Canvas, RenderError> {
|
||||
let mut out = Canvas::new(w, h)?;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0u32; 4];
|
||||
for sy in 0..SS {
|
||||
for sx in 0..SS {
|
||||
let i = ((y * SS + sy) * c.w + x * SS + sx) * 4;
|
||||
for (k, a) in acc.iter_mut().enumerate() {
|
||||
*a += c.rgba.get(i + k).copied().unwrap_or(0) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = (y * w + x) * 4;
|
||||
for (k, a) in acc.iter().enumerate() {
|
||||
if let Some(v) = out.rgba.get_mut(o + k) {
|
||||
*v = (*a / (SS * SS) as u32) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render to PNG bytes.
|
||||
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Vec<u8>, RenderError> {
|
||||
let c = render(scene, cam, w, h)?;
|
||||
png(c.w, c.h, &c.rgba).ok_or(RenderError::Encode)
|
||||
}
|
||||
|
||||
/// RGBA pixels (`w × h`, 8 bits per channel) to PNG bytes; `None` when the sizes disagree.
|
||||
pub fn png(w: usize, h: usize, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
if w.checked_mul(h)?.checked_mul(4)? != rgba.len() || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut buf, u32::try_from(w).ok()?, u32::try_from(h).ok()?);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = enc.write_header().ok()?;
|
||||
writer.write_image_data(rgba).ok()?;
|
||||
writer.finish().ok()?;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::StandardView;
|
||||
|
||||
fn red_triangle() -> Scene {
|
||||
Scene {
|
||||
tris: vec![SceneTri {
|
||||
p: [Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 0.0, -10.0), Vec3::new(0.0, 0.0, 10.0)],
|
||||
normal: Vec3::new(0.0, -1.0, 0.0),
|
||||
color: Rgb(200, 30, 30),
|
||||
}],
|
||||
radius: 20.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_triangle_lines_and_points() {
|
||||
let mut scene = red_triangle();
|
||||
let mut cam = Camera { distance: 50.0, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
let c = render(&scene, &cam, 64, 64).unwrap();
|
||||
let px = |c: &Canvas, x: usize, y: usize| c.rgba[(y * c.w + x) * 4..(y * c.w + x) * 4 + 4].to_vec();
|
||||
let centre = px(&c, 32, 32);
|
||||
assert!(centre[0] > centre[1] + 50, "centre pixel is red: {centre:?}");
|
||||
// A black line across the middle, in front of the triangle, and a point.
|
||||
scene.lines.push(SceneLine { a: Vec3::new(-10.0, -0.1, 0.0), b: Vec3::new(10.0, -0.1, 0.0), color: Rgb(0, 0, 0), width: 3.0 });
|
||||
scene.points.push(ScenePoint { p: Vec3::new(0.0, -0.1, 5.0), color: Rgb(0, 0, 255), size: 5.0 });
|
||||
let c = render(&scene, &cam, 64, 64).unwrap();
|
||||
assert!(px(&c, 32, 32)[0] < 40, "{:?}", px(&c, 32, 32));
|
||||
let (x, y, _) = cam.to_screen(Vec3::new(0.0, 0.0, 5.0), 64.0, 64.0, 20.0).unwrap();
|
||||
let p = px(&c, x as usize, y as usize);
|
||||
assert!(p[2] > 200 && p[0] < 60, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_bytes_and_hostile_sizes() {
|
||||
let scene = red_triangle();
|
||||
let cam = Camera::default();
|
||||
assert!(render_png(&scene, &cam, 16, 16).unwrap().starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
// Out-of-range sizes are refused before anything is allocated.
|
||||
for (w, h) in [(0, 16), (16, 0), (0, usize::MAX), (MAX_DIM + 1, 16), (100_000, 100_000), (usize::MAX, usize::MAX), (8192, 8192)] {
|
||||
assert_eq!(render(&scene, &cam, w, h).err(), Some(RenderError::Size { w, h }));
|
||||
}
|
||||
assert!(render(&scene, &cam, MAX_DIM, MAX_PIXELS / MAX_DIM).is_ok_and(|c| c.w == MAX_DIM));
|
||||
assert!(png(2, 2, &[0; 3]).is_none());
|
||||
assert!(png(usize::MAX, 2, &[]).is_none());
|
||||
// Far-away and non-finite geometry is skipped or bounded, never a panic.
|
||||
let wild = Scene {
|
||||
lines: vec![SceneLine { a: Vec3::new(-1e12, 0.0, 0.0), b: Vec3::new(1e12, 0.0, 0.0), color: Rgb(0, 0, 0), width: 1e6 }],
|
||||
points: vec![ScenePoint { p: Vec3::new(f64::NAN, 0.0, 0.0), color: Rgb(0, 0, 0), size: 4.0 }],
|
||||
radius: f64::NAN,
|
||||
..scene
|
||||
};
|
||||
let _ = render(&wild, &cam, 32, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_scenes_are_refused() {
|
||||
let line = SceneLine { a: Vec3::ZERO, b: Vec3::X, color: Rgb(0, 0, 0), width: 1.0 };
|
||||
let scene = Scene { lines: vec![line; MAX_PRIMITIVES + 1], radius: 1.0, ..Default::default() };
|
||||
let r = render_png(&scene, &Camera::default(), 16, 16);
|
||||
assert_eq!(r, Err(RenderError::SceneTooLarge { primitives: MAX_PRIMITIVES + 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_size_keeps_the_aspect_within_the_limits() {
|
||||
assert_eq!(fit_size(1280, 800), (1280, 800));
|
||||
assert_eq!(fit_size(0, 0), (1, 1));
|
||||
for (w, h) in [(7680, 4320), (100_000, 100_000), (usize::MAX, 3), (20_000, 10)] {
|
||||
let (fw, fh) = fit_size(w, h);
|
||||
assert!(size_ok(fw, fh), "{w}x{h} -> {fw}x{fh}");
|
||||
}
|
||||
let (fw, fh) = fit_size(7680, 4320);
|
||||
assert!(((fw as f64 / fh as f64) - 16.0 / 9.0).abs() < 1e-2, "{fw}x{fh}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! A small 3D vector and bounding box for view math.
|
||||
|
||||
use std::ops::{Add, Mul, Neg, Sub};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vec3 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
pub const ZERO: Vec3 = Vec3::new(0.0, 0.0, 0.0);
|
||||
pub const X: Vec3 = Vec3::new(1.0, 0.0, 0.0);
|
||||
pub const Y: Vec3 = Vec3::new(0.0, 1.0, 0.0);
|
||||
pub const Z: Vec3 = Vec3::new(0.0, 0.0, 1.0);
|
||||
|
||||
pub const fn new(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3 { x, y, z }
|
||||
}
|
||||
pub fn dot(self, o: Vec3) -> f64 {
|
||||
self.x * o.x + self.y * o.y + self.z * o.z
|
||||
}
|
||||
pub fn cross(self, o: Vec3) -> Vec3 {
|
||||
Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
|
||||
}
|
||||
pub fn len(self) -> f64 {
|
||||
self.dot(self).sqrt()
|
||||
}
|
||||
pub fn dist(self, o: Vec3) -> f64 {
|
||||
(self - o).len()
|
||||
}
|
||||
/// The unit vector, or `None` for a zero or non-finite vector.
|
||||
pub fn normalized(self) -> Option<Vec3> {
|
||||
let l = self.len();
|
||||
(l > 1e-300 && l.is_finite()).then(|| self * (1.0 / l))
|
||||
}
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
pub fn to_f32(self) -> [f32; 3] {
|
||||
[self.x as f32, self.y as f32, self.z as f32]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[f64; 3]> for Vec3 {
|
||||
fn from(a: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(a[0], a[1], a[2])
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn add(self, o: Vec3) -> Vec3 {
|
||||
Vec3::new(self.x + o.x, self.y + o.y, self.z + o.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn sub(self, o: Vec3) -> Vec3 {
|
||||
Vec3::new(self.x - o.x, self.y - o.y, self.z - o.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn mul(self, k: f64) -> Vec3 {
|
||||
Vec3::new(self.x * k, self.y * k, self.z * k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn neg(self) -> Vec3 {
|
||||
Vec3::new(-self.x, -self.y, -self.z)
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis-aligned bounding box; empty until a point is added.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Aabb3 {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Default for Aabb3 {
|
||||
fn default() -> Self {
|
||||
Aabb3::EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
impl Aabb3 {
|
||||
pub const EMPTY: Aabb3 = Aabb3 {
|
||||
min: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
|
||||
max: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
|
||||
};
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!(self.min.x <= self.max.x && self.min.y <= self.max.y && self.min.z <= self.max.z)
|
||||
}
|
||||
pub fn add(&mut self, p: Vec3) {
|
||||
if !p.is_finite() {
|
||||
return;
|
||||
}
|
||||
self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z));
|
||||
self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z));
|
||||
}
|
||||
pub fn center(&self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
pub fn diagonal(&self) -> f64 {
|
||||
if self.is_empty() { 0.0 } else { self.max.dist(self.min) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vector_algebra_and_boxes() {
|
||||
assert_eq!(Vec3::X.cross(Vec3::Y), Vec3::Z);
|
||||
assert_eq!(Vec3::new(3.0, 4.0, 0.0).len(), 5.0);
|
||||
assert!(Vec3::ZERO.normalized().is_none());
|
||||
assert!(Vec3::new(f64::NAN, 0.0, 0.0).normalized().is_none());
|
||||
let mut b = Aabb3::EMPTY;
|
||||
assert!(b.is_empty() && b.diagonal() == 0.0);
|
||||
b.add(Vec3::new(f64::NAN, 1.0, 1.0));
|
||||
assert!(b.is_empty());
|
||||
b.add(Vec3::ZERO);
|
||||
b.add(Vec3::new(2.0, 4.0, 4.0));
|
||||
assert_eq!(b.center(), Vec3::new(1.0, 2.0, 2.0));
|
||||
assert_eq!(b.diagonal(), 6.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user